Compare commits

...
34 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 5 cd4b721d51 release: v0.5.2 — GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
- GenerateUI/LabタブでMiniMax H3等のオールインワン動画ノードのプロンプト・画像を編集可能に(analyzeWorkflow拡張)、生成タイムアウト延長、動画結果プレビュー対応
- バグ修正: Lab index imageが真っ黒になる問題(動画は最終フレームを抽出して描画)
- バグ修正: Lab Ksampler列が動画ワークフローでも表示される問題(内部SamplerCustomAdvancedを編集対象から除外)
- Videoタブに Frame抽出(クライアント側canvas)/ アニメーションGIF変換(サーバー側PyAV+Pillow)を追加、Video Sourceパネルでプレビュー統合
- データ管理: civitai_cache.jsonのエクスポート漏れを修正、ZIP形式のフルバックアップ機能を新規実装(ai_skills/lab_plan/style/tagger.db対応、Zip Slip対策、デフォルトワークフロー/wildcardフォルダのオプトイン・復元除外)
- Image Edit/Galleryタブの画像送信ボタンを「Send to LI node」に統一し、Galleryタブにも追加
- Galleryタブの✕クリアボタンを表示形式切替ボタンの左隣に移動

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:27:12 +09:00
ketle-manandClaude Sonnet 5 46c789c492 release: v0.5.1 — mp4動画の埋め込みメタデータ対応 + Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
Gallery Prompt/JSONタブ・Metadataサブタブ・サイドパネルIタブの3箇所で、ComfyUIのSaveVideoがmp4のmoov/udta/metaに埋め込むworkflow/promptタグ(PNGのtEXtチャンクと同一キー名)を読み取れるようにした。バックエンドはPyAV、Metadataサブタブ/サイドパネルIタブはブラウザ側でMP4ボックス構造を直接パースする実装。副産物として、CLIPTextEncode系を介さず自ノードのprompt入力に直接テキストを持つ動画生成オールインワンノード(MiniMaxH3ImageToVideo等)のプロンプトが抽出できていなかったバグも修正。Gallery Infoタブには画像の幅×高さ・動画の再生時間の表示を追加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:04:48 +09:00
ketle-man ede39ec715 release: v0.5.0 — mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
- Galleryタブでmp4動画の閲覧・再生・サムネイル生成(PyAV)に対応
- 新規Videoタブ(MiniMax H3専用、GenerateUIとは独立)を3ペイン構成で追加
  - first_frame/last_frameは両方optional、LoadImageノードの無いワークフローにも対応
  - Workflowタブから「Load in Video」で読み込み可能
- バグ修正: cleanup_stale_images()が非再帰スキャン時にサブフォルダ内画像/動画のメタデータを誤って全削除していた問題を修正
- バグ修正: Gallery側のタグ/メモ保存がサーバーエラーをサイレントに無視していた問題を修正
2026-08-27 14:46:09 +09:00
ketle-manandClaude Sonnet 5 ad3b380929 Save to Input(旧Send to ComfyUI)ボタンを削除
Send to Workflowと機能が重複していたため、Image Editタブの
「Save to Input」ボタン・uploadToComfyUI()を削除。ヘルプ・README・
i18n(日英中)を更新。次回リリース時にバージョンを上げて反映する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:10:32 +09:00
ketle-manandClaude Sonnet 5 c589e562e4 release: v0.4.8 — LabにPromptグループ一括適用+キーフレーム並べ替え + デフォルトワークフロー上書き保存バグ修正 + StyleテーブルAdd to this file
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 12:16:06 +09:00
ketle-manandClaude Sonnet 5 cb61dc61a7 release: v0.4.7 — Prompt Tab内部整理 + Gallery Promptタブ新設 + Send to Canvas改善 + Presetsソート
- prompt-tab.js(1545行)をprompt-presets/ai-chat/wildcards/styles.jsの4ファイルに分割、
  prompt-tab.jsは薄いオーケストレーターに(prompt-table.js/app.jsは無変更でre-export経由)
- ワイルドカード入力支援ツールバーをPresetsのプロンプト編集モーダルへ追加(Wildcardモーダル
  ではなくプロンプトを書く場所に付けるべきという指摘を受けて再配置)
- Style適用とワイルドカード展開の実行順序を修正: Style内の__file__参照が展開されずに
  送信されていたバグを解消(Style適用→展開の順に変更)
- yamlワイルドカード(ImpactWildcardEncode)を使うサンプルワークフローを追加
- Galleryタブ詳細パネルに「Prompt」タブを新設(既定選択、Metadataタブと同じPOS/NEGリスト
  UIを再利用); Gallery側のプロンプト抽出はAPI形式優先のprompt_workflowを新設し、
  Metadataタブとの抽出結果の食い違いを解消
- Send to Canvasのフォールバックを、Workflow Studio LibraryのWタブを絞り込み表示する
  方式に統一(UI形式のタイトルドラッグ・API形式非対応だった従来方式を置き換え)
- PresetsテーブルツールバーにMarkdown ★/Bソートトグルボタンを追加(複合ソート対応)
- graphifyの運用を一時停止(CLAUDE.md、ユーザー指示による)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 12:19:09 +09:00
ketle-manandClaude Sonnet 5 ad9d06d3e1 graphify: v0.4.6の変更を反映しナレッジグラフを更新
v0.4.6(Metadataタブ移動・GenerateUI Save非表示・Prompt Table表示)で
変更した20ファイルをgraphify --updateで再抽出。
2320ノード・5560エッジ・157コミュニティ(旧グラフから+141ノード/+314エッジ、
重複ノード13件を整理)。DEVLOG.index.md/README.index.mdの2件はOllamaの
出力トークン上限で今回は未反映(次回--updateで再試行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:33:26 +09:00
ketle-manandClaude Sonnet 5 ea408690ff release: v0.4.6 — MetadataタブをGalleryへ統合 + PromptタブにTable表示を新設
- MetadataタブをGalleryタブのサブタブ(Style_Catalog右隣)へ移動
- GenerateUI: Batch/LabサブタブでSaveボタンを非表示化
- Promptタブ: Form/Table切替を追加。Tableには編集可能な
  Presets/Presets Group/Wildcards/Styleテーブルを新設
  - #列クリックで行選択→ツールバーDelete/Deselectで一括操作
  - 番号ダブルクリックでPositive/Negative編集モーダル
  - PresetsのB列/BCボタンでBatchグループ登録
  - PP/NP Copy + グループ選択でAdd to Group(Presets Groupの
    Add Preset選択式を置き換え)
- ヘルプ(index.html + i18n.js 3言語 + helpIdMap)とREADMEを更新

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 15:00:23 +09:00
ketle-man cbb096ec85 release: v0.4.5 — Lab Model列の無意味な空キーフレーム表示を解消(対象ノードなし時は非表示化)
対象ノード(Checkpoint/LoRA/VAE)がワークフローに存在しない列でも
Model列に常にSetting 1のキーフレーム行が表示されていた問題を修正。
Run時には何の効果も持たない死んだコントロールだったため、
_hasNodeForColumn()で判定し、対象ノードが無い列はプレースホルダー
表示にして+ボタンでの追加もブロックするようにした。
2026-08-21 20:27:05 +09:00
ketle-manandClaude Sonnet 5 99f342cc89 release: v0.4.4 — LoRAノードバイパス機能(Lab/GenerateUI Model) + Lab LoRA構文/トリガーワードのプロンプト適用 + AI TOOL Chat生成パラメータ拡張
- GenerateUI ModelタブのLoRA列にバイパストグルボタン(⛔)を追加(strength=0の簡易実装)
- Lab機能のLoRAキーフレームに「LoRAノードをバイパス」「LoRA構文/トリガーワードをポジティブプロンプトに適用」チェックボックスを追加
- AI TOOL Chatのgenerate_imageツールにsteps/cfg/sampler_name/scheduler/denoise/seed/width/height/batch_size/aspect_ratioを追加
- Resolution Selectorノード(ComfyUI組み込み)のaspect_ratio COMBO値対応
- ヘルプ(index.html/i18n.js 3言語/app.js helpIdMap)・README.md更新
- README/DEVLOG索引自動化(index.html PostToolUseフック + generate_doc_index.py)を導入

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:44:33 +09:00
ketle-manandClaude Sonnet 5 9681e76848 release: v0.4.3 — 共有モーダルの☆/リネーム残留バグ修正 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:44:26 +09:00
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
72 changed files with 107513 additions and 5281 deletions
+60
View File
@@ -0,0 +1,60 @@
"""PostToolUse hook: regenerate README.index.md / DEVLOG.index.md right after
Claude Code edits README.md or DEVLOG.md, so graphify's excluded-doc index
stand-ins (see .graphifyignore) never drift from the source files within a
session. A git pre-commit hook covers edits made outside Claude Code.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
TARGETS = {"readme.md": "readme", "devlog.md": "devlog"}
def main() -> None:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return
if payload.get("tool_name") not in ("Edit", "Write"):
return
file_path = payload.get("tool_input", {}).get("file_path", "")
if not file_path:
return
name = Path(file_path).name.lower()
target = TARGETS.get(name)
if target is None:
return
project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or str(Path(file_path).resolve().parent)
script = Path(project_dir) / "tools" / "generate_doc_index.py"
if not script.exists():
return
result = subprocess.run(
[sys.executable, str(script), target],
cwd=project_dir,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
print(json.dumps({"systemMessage": f"[doc-index] {result.stdout.strip()}"}))
else:
print(
json.dumps(
{"systemMessage": f"[doc-index] generate_doc_index.py {target} failed: {result.stderr.strip()}"}
),
file=sys.stderr,
)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
{
"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"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/on_doc_edit.py\"",
"timeout": 30
}
]
}
]
}
}
+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
+10
View File
@@ -0,0 +1,10 @@
# Private/unpublished planning notes — never committed to git, must not leak
# into the published graphify-out/ knowledge graph either.
QWEN_CHAT_INTEGRATION_PLAN.md
# Too large for reliable local-Ollama semantic extraction (large chunks make the
# model summarize in prose instead of emitting JSON, and time out on slower
# models/hardware). README.index.md / DEVLOG.index.md are condensed stand-ins
# that graphify extracts instead — regenerate them after editing the originals.
README.md
DEVLOG.md
+26
View File
@@ -199,3 +199,29 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
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運用は一時停止中(2026-08-23〜、ユーザー指示により凍結)。再開時はこのコメントブロックを解除すること。graphify-out/ 自体は削除しておらずそのまま残っている。
## 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).
### README.md / DEVLOG.md の索引ファイル
`README.md`/`DEVLOG.md`はサイズが大きすぎてローカルOllamaでの意味抽出が不安定になる(大きな塊を渡すとJSONではなく散文の要約を返す、または処理がタイムアウトする)ため、`.graphifyignore`でgraphifyの抽出対象から除外している。代わりに縮小版の`README.index.md`/`DEVLOG.index.md`を抽出対象にしている。
- 生成スクリプト: `python tools/generate_doc_index.py [readme|devlog|all]`
- 自動化: `README.md`/`DEVLOG.md`を編集すると以下の2つのフックが自動でindexを再生成する
- Claude Code PostToolUseフック(`.claude/hooks/on_doc_edit.py`)— Edit/Write直後に即時実行
- git pre-commitフック(`.git/hooks/pre-commit`)— コミット時にstagedファイルを検知して再生成し、同じコミットに含める(他エディタでの編集にも対応)
- 索引ファイルを手動で編集しない(次の生成で上書きされる)
- 索引ファイル再生成後は`graphify update .`を実行してグラフに反映する
-->
+169
View File
@@ -0,0 +1,169 @@
# DEVLOG Index — ComfyUI-Workflow-Studio
Condensed version-by-version index of DEVLOG.md (each line: version + one-line summary of what changed and why). Full rationale, code details, and "How to apply" lessons live in the excluded DEVLOG.md itself; this index exists so graphify can extract a queryable semantic node per release without feeding the full log to the local Ollama model. Regenerate with `python tools/generate_doc_index.py devlog` whenever DEVLOG.md changes.
- **v0.5.2** — 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
- **v0.5.1** — 機能追加: mp4動画の埋め込みメタデータ対応(Gallery Prompt/JSON・Metadataサブタブ・サイドパネルIタブ)+ Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
- **v0.5.0** — 機能追加: mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
- **v0.4.8** — 機能追加: LabタブにPromptグループ一括適用+キーフレーム並べ替え + バグ修正: デフォルトワークフローの上書き保存が反映されない + Styleテーブルの「このファイルへ追加」対応
- **v0.4.7** — 機能追加: Prompt Tab内部リファクタ + Gallery Promptタブ新設 + Send to Canvas改善 + Presetsソート
- **v0.4.6** — 機能追加: MetadataタブをGalleryタブへ統合 + Promptタブに一覧編集可能なTable表示を新設
- **v0.4.5** — 調査: トップバー保存ワークフローのRMBG不具合報告(原因なし) + バグ修正: LabタブModel列の無意味な空キーフレーム表示を解消
- **v0.4.4** — 機能追加: Lab/GenerateUI ModelタブへLoRAバイパス機能 + AI TOOL Chatの画像生成パラメータ拡張
- **v0.4.3** — バグ修正: 共有モーダルのお気に入り☆/リネームがLabモーダルへ残留 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
- **v0.4.2** — 機能追加: Lab「Model」列統合(Checkpoint / LoRA Single / VAE)+ フィールド単位継承 + C/L/V表示切替
- **v0.4.1** — UX改善: Mask Editor One連携ボタンを「選択中ノード優先」方式に再設計
- **v0.4.0** — リファクタリング: image-edit-tab.js / models-tab.js の低凝集度モジュール分割
- **v0.3.99** — 機能追加: AI TOOLタブ + サイドパネルAタブにUnslothバックエンド追加(APIキーは.env管理、SSRF脆弱性修正、reasoning_content対応)
- **v0.3.98** — 機能追加・バグ修正: AI TOOL翻訳の信頼性向上 + GenerateUI Modelタブ再構成(ハイライト色・折りたたみ・並び替え)+ Thinking mode/Max tokens設定
- **v0.3.97** — 機能追加・バグ修正: KREA-2ワークフロー対応 + Labタブ拡張(Clear/Get from Previous/Bypass/ライブ反映)+ Input Imageプレースホルダ機能
- **v0.3.96** — バグ修正: カタログ作成バッチが特定件数で無言停止する問題の修正 + Style Catalog/Promptタブ機能拡張
- **v0.3.95** — 機能追加: Style/Prompt→ImagePrompt改名 + Style_Catalogギャラリー新設 + GenerateUI「カタログ作成」機能
- **v0.3.94** — 機能追加: Gallery タブのサブタブ化 + Style/Prompt ギャラリー新設(視覚的プロンプトビルダー)
- **v0.3.93** — バグ修正・機能改善: Labタブ VAEキーフレーム不発バグの修正+Style適用UI刷新+Plan JSONスクロール同期修正+キーフレーム個別削除+Batch数超過警告+ワイルドカード対応+Save index image デフォルトON
- **v0.3.92** — バグ修正: CLIPTextEncodeEditPlus使用ワークフローがGenerateUI経由だと直接実行と異なる画像になる問題(根本原因はconvertUiToApiのForceInput 未考慮バグ)+LoRA Stack機能の改善一式
- **v0.3.91** — 機能拡張: Labインデックス画像にPlanメタデータをPNG埋め込み+README構成整理(Changelogセクション廃止)
- **v0.3.90** — 機能拡張: Labタブの進化(ワークフロー呼び出し・T2I対応・Run時インデックス画像Output保存+Eagle連携・プラン接頭辞)+Galleryタブのルート表示簡略化
- **v0.3.89** — バグ修正: v0.3.88で追加したLab機能への実動作確認フィードバック3件を反映
- **v0.3.88** — 機能拡張: Labタブ Promptセルにスタイル適用・画像/GenerateUIからのプロンプト取得、Checkpoint/VAEフィルター、Plan JSONサブタブを追加
- **v0.3.87** — 追加機能: GenerateUIタブに実験的な「Lab」サブタブを追加(I2Iバッチ生成・キーフレーム方式)
- **v0.3.86** — バグ修正: 画像を一括生成(I2I)等の連続実行で "GalleryMetadataStore: save error: dictionary changed size during iteration" が発生
- **v0.3.85** — 機能拡張: Generate UI連携ブリッジ(T2I)にデフォルトワークフロー・Negativeプロンプト対応を追加
- **v0.3.84** — バグ修正: ernie_t2i.jsonをGenerateUIタブで実行すると "Prompt outputs failed validation" エラー
- **v0.3.83** — 追加機能: Ernie Image(ernie-image / ernie-image-turbo)ワークフロー対応 + プロンプトがリンク経由のノードから供給されるパターンの修正
- **v0.3.82** — バグ修正: MetadataタブとサイドパネルIタブでLongCat/Boogu/HiDream E1/FireRed等のプロンプトが表示されない不具合
- **v0.3.81** — 追加機能: GenerateUIタブでFlux.2 Klein / LongCat / Boogu / HiDream E1 / FireRedのImage Editワークフローに対応
- **v0.3.80** — 追加機能: Comic Creator「半自動マンガ作成」向けブリッジ(LLMプロンプト下書き・バッチ画像生成)
- **v0.3.79** — 追加機能: サイドパネル(Workflow Studio Library)IタブのMage-Flow等サブグラフワークフロー対応
- **v0.3.78** — 追加機能: Eagle自動保存のSVG対応
- **v0.3.76** — 追加機能: Comic Creator向けI2I実行ブリッジ`_wfmReceiveI2IRunRequest`
- **v0.3.77** — 追加機能: GalleryタブでSVGファイルの表示・管理に対応
- **v0.3.75** — Lemonadeバックエンド追加(3つ目のAIバックエンド)
- **v0.3.74** — AI TOOL Chatペインの画像添付対応 + I2I連携
- **v0.3.73** — AI TOOL Chatペインからの対話式画像生成(Tool Calling)+ Checkpoint関連の安全策
- **v0.3.72** — Comic Creator連携用の外部Inpaintエントリポイントを追加
- **v0.3.71** — Inpaint機能の追加(GenerateUI Imageタブ + Image Edit「Inpaint」タブ)
- **v0.3.70** — Workflow AI要約: Ollama 404エラー修正
- **v0.3.69** — Gallery → ComfyUI Comic Creator 連携(Send CC ボタン)
- **v0.3.68** — MASK EDITOR ONE セクション常時表示化
- **v0.3.67** — Alt+Click Apply & Generate
- **計画: Image Edit 拡張 — ABRブラシ連携 + Mask Editor One 統合** — フェーズ1: Draw ツール — ABRブラシ対応
- **2026-06-29: v0.3.66 — Mask ツール 5サブツール追加(Color / Alpha / Text / Vector / Shape)・Support リンク追加** — 概要
- **2026-06-29: v0.3.65 — Mask Editor One 連携修正・トップバーアイコン修正・Mask ABRブラシ対応** — 概要
- **2026-06-28: v0.3.64 — Image Edit マスク Add/Subtract・BiRefNet BG Remove・SAM3 セグメンテーション・ブラシカーソルバグ修正** — 概要
- **2026-06-28: v0.3.63 — ノードタブ複数選択・全タブフィルタークリア・検索Xボタン** — 概要
- **2026-06-27: WFS_PromptText の GenerateUI 対応・ドロップ時 title 固定** — 概要
- **2026-06-27: v0.3.62 — Send GenUI Image ボタン追加・tagger_settings.json バグ修正・README 更新** — 概要
- **2026-06-27: データ管理バグ修正 — tagger_settings.json をエクスポート対象に追加** — 概要
- **2026-06-27: v0.3.61 — G'MIC バグ修正・セキュリティ・Draw/Mask ブラシ操作性改善** — 概要
- **2026-06-27: v0.3.60 — Image Edit Filter Tool (G'MIC-Qt 連携) 実装** — 概要
- **2026-06-27: v0.3.59 — Image Edit Mask Tool 実装・Draw/Mask 描画バグ修正・クリッピング保存対応** — 概要
- **2026-06-27: v0.3.58 — GenerateUI Model タブ GGUF対応拡張(Diffusion Model / Text Encoder)** — 概要
- **2026-06-26: v0.3.57 — Image Edit Blur Tool / BG Remove Tool 追加** — 概要
- **2026-06-26: v0.3.56 — Style機能追加(GenerateUI ツールバー & Batchタブ)** — 概要
- **2026-06-25: v0.3.55 — グループ機能バグ修正(全タブ)** — 根本原因(データ消失): パス区切り文字の不一致
- **2026-06-25: v0.3.54 — Image Edit Shape ツール追加・Models タブ overlay バグ修正** — Image Edit Tab — Shape ツール(□ / S キー)
- **2026-06-25: v0.3.53 — Image Edit Tab 実装・レイヤーロック・ギャラリー保存** — Image Edit Tab(新規タブ)
- **2026-06-25: GenUI設定タブ LATENT IMAGE にプリセット機能を追加** — 変更内容
- **2026-06-24: GenUI ModelでLoRA適用後、Modelサブタブ再表示時に値がリセットされるバグ修正** — 問題
- **2026-06-24: v0.3.51 — Embedding GenUI PP/NP対応 + GenerateUIプロンプトタブEmbeddingセレクタ追加** — 変更内容
- **2026-06-24: v0.3.51 — GenerateUIタブ Hypernetwork対応 + ModelsタブGenUI Modelボタン追加** — 変更内容
- **2026-06-24: v0.3.51 — Hypernetwork/Embeddingモデル未表示バグ修正** — 原因
- **2026-06-24: v0.3.51 — ModelsタブサイドパネルにGenUI Modelボタン追加** — 変更内容
- **2026-06-22: v0.3.50 — ギャラリータブ プロンプト検索負荷改善(バックグラウンドインデックス)** — 問題
- **2026-06-22: v0.3.49 — 生成UIタブ A1111風ワイルドカード展開・ギャラリータブ バグ修正2件** — 生成UIタブ — A1111風SPAサイドワイルドカード展開
- **2026-06-22: v0.3.48 — ギャラリータブ 画像ダウンロード・一括エクスポート機能追加** — 詳細パネルの画像プレビューにダウンロード機能を追加
- **2026-06-22: v0.3.47 — モデルタブ ファイル移動後グループ状態修正** — モデル移動後のグループ状態不整合を修正
- **2026-06-21: v0.3.46 — プロンプトタブ AI Assistant LM Studio対応・設定タブ AI Assistant設定に変更** — 設定タブ「AI Assistant設定(プロンプトタブ)」
- **2026-06-21: v0.3.45 — グループ孤立エントリ自動クリーンアップ・Galleryタブ Shift+クリック範囲選択** — グループ孤立エントリ自動クリーンアップ
- **2026-06-21: v0.3.44 — Galleryタブ高速化・比較モード・バルク強化・バルクバーUI整理** — フェーズ2-A: サーバーサイドサムネイル生成
- **2026-06-21: ヘルプタブ改善 — Gallery mode 多言語対応・フォント拡大・検索機能追加** — Gallery mode の多言語対応
- **2026-06-21: Send to Canvas — window.opener経由のキャンバス直接ロード対応**
- **2026-06-20: API形式ワークフローのキャンバス読み込み修正・画像メタデータ埋め込み修正** — API形式ワークフローをキャンバスへドラッグすると空になる問題を修正
- **2026-06-20: セキュリティ修正(XSS・パスバリデーション)** — 修正内容
- **2026-06-20: WFS_GalleryFeeder — ComfyUI キャンバス上コントロール追加** — 新規ファイル(`web/comfyui/gallery_feeder_extension.js`)
- **2026-06-20: __Feeder__ グループ保護 + サムネイル F ボタン + seed バグ修正** — `__Feeder__` グループの予約済み保護
- **2026-06-20: Gallery Feeder 機能追加(WFS_GalleryFeeder ノード)** — 概要
- **2026-06-19: Generate UI — ImpactWildcardEncode/Processor プロンプト検出バグ修正(v0.3.41)** — 修正内容(`static/js/comfyui-workflow.js`)
- **2026-06-19: AI TOOL タブ Chat ペイン・ワイルドカード生成追加(v0.3.40)** — Chat ペイン(`templates/index.html`, `static/css/main.css`, `static/js/ai-tab.js`, `web/comfyui/node_sets_menu.js`)
- **2026-06-18: Send to Canvas機能追加(ワークフロータブ・ギャラリータブ→LibraryタイトルDnD)** — ワークフロータブ — Send to Canvas(`static/js/workflow-tab.js`, `static/js/app.js`, `templates/index.html`)
- **2026-06-15: TaggerシングルGenUI:Pボタン追加+生成UIのZITワークフロープロンプト解析修正** — TaggerシングルGenUI:Pボタン(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`)
- **2026-06-15: Taggerバッチ.txt出力追加+Galleryタグ保存バグ修正** — Taggerバッチ.txt出力(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`, `py/routes/tagger_routes.py`, `py/services/tagger_service.py`)
- **2026-06-15: Taggerタブ新規実装+ギャラリー詳細パネルUI改善+パストラバーサル修正** — Taggerタブ新規実装
- **2026-06-15: 全選択ボタン追加+ギャラリーバルクバーi18n対応** — 変更内容
- **2026-06-10: v0.3.36 — コードレビュー修正+リファクタリング+README刷新** — コードレビュー修正(v0.3.35の不具合10件)
- **2026-06-10: v0.3.35 — テーブルソート・Load GenUI・ワークフロー保存** — 変更内容
- **2026-06-09: v0.3.34 — Modelsタブ テーブルビュー強化・バルク操作改善** — 変更内容
- **2026-06-09: v0.3.33 — LoRAペイン・ワークフロー解析バグ修正** — 変更内容
- **2026-06-08: v0.3.32 — Lora Loader (LoraManager) LoRA検出対応・ライブラリ機能拡張** — 変更内容
- **2026-06-08: v0.3.31 — LoRAペイン Single/Stack タブ分割・GenUI Model LoRA対応強化** — 変更内容
- **2026-06-08: v0.3.30 — LoRAスタック トリガーワードバグ修正・ヘルプ更新** — 変更内容
- **2026-06-08: v0.3.29 — LoRAセクション統合・ヘルプタブサイドバー・UI細部改善** — 変更内容
- **2026-06-08: 生成UIタブ 複数改善 — LoRA Stackトリガーワード連携・Reset Workflowボタン・Inputタブタブ化** — 変更内容
- **2026-06-08: Feederタブ UI改善 — (root)自動選択・PREVIEWペイン右移動・RUN中プレビュー反映** — 変更内容
- **2026-06-07: v0.3.28 — Batchタブ Sampler/Scheduler対応 + Modelsサムネイルバグ修正** — 概要
- **2026-06-07: v0.3.27 — model-and-prompt-from-metadata カスタムノード対応 + README修正** — 概要
- **2026-06-07: v0.3.26 — Stack LORA SYNTAX 表示修正 + 全体強度調整 UI** — 概要
- **2026-06-07: v0.3.25 — Lora Loader (LoraManager) 対応 + Stack 有効無効切り替え** — 概要
- **2026-06-06: v0.3.24 — Lora Stack 登録機能 + 生成UI Model タブ 3ペイン化** — 概要
- **2026-06-05: v0.3.23 — バグ修正2件** — バグ修正
- **2026-06-05: v0.3.22 — カードビュー廃止 + Batch タイプ切り替え UI + 4タイプバッチ生成** — 概要
- **2026-06-05: (未リリース) — 生成UIタブ Batch 4タイプ対応(Lora/Prompt/Workflow グループ選択 + BATCH QUEUE 4列化)** — 概要
- **2026-06-05: (未リリース) — Prompt・Workflow Batchグループ登録UI** — 概要
- **2026-06-04: (未リリース) — モデルBatchグループ登録UI(サムネイル/カード/テーブル・フィルター・一括解除)** — 概要
- **2026-06-04: (未リリース) — Batch予約グループ・Presets Save後リセット・グループフィルター種別絞り込み** — 概要
- **2026-05-31: v0.3.21 — CivitAI ホスト設定・Sample サブタブ・URL 修正・ヘルプ i18n 整備** — 概要
- **2026-05-31: v0.3.20 — CivitAI 詳細パネル強化(Type / Hash 表示・URL 修正・画像別タブ)** — 概要
- **2026-05-31: v0.3.19 — CivitAI 連携強化(バッチ高速化・リトライ・APIキー・フィールド拡充)** — 概要
- **2026-05-31: v0.3.18 — Batch タブ追加・Models 複数選択強化** — 概要
- **2026-05-31: v0.3.17 patch — SSLコンテキスト セキュリティ強化** — 概要
- **2026-05-30: v0.3.17 — SSL証明書エラー修正・Lora Manager対応** — 概要
- **2026-05-20: v0.3.16 — CivitAIプレビューフォールバック表示** — 概要
- **2026-05-20: v0.3.15 — サンプルワークフロー同梱・CivitAI自動プレビュー・Create Tagsオプション** — 概要
- **2026-05-20: AI TOOL タブ 英語化(SPA・Libraryパネル)** — 概要
- **2026-05-19: v0.3.14 — AI TOOL タブ 3ペイン化・RAW JSON 文字色カスタマイズ** — 概要
- **2026-05-19: v0.3.13 — AI タブ(A)追加** — 概要
- **2026-05-18: v0.3.12 リリース — README スクリーンショット刷新 / workflow_analyzer 拡張** — 概要
- **2026-05-17: RAW JSON パネルに検索機能を追加** — 概要
- **2026-05-17: v0.3.11 Metadata タブ対応ノードタイプ拡張 / Settings テキストサイズ UI 追加** — 概要
- **2026-05-17: v0.3.10 Information タブ キャンバスドラッグ対応 / ノードタイプ拡張 / バグ修正** — 概要
- **2026-05-16: v0.3.9 サイドパネル I タブ追加 / トップバーアイコン修正** — 概要
- **2026-05-16: v0.3.8 Metadata タブ: data9 対応 / プロンプト判別不能テキスト** — 概要
- **2026-05-15: v0.3.7 Metadata タブ: Flux2 / Qwen / Z-Image 対応** — 概要
- **2026-05-15: v0.3.6 Metadata タブ追加** — 概要
- **2026-05-09: v0.3.5 GenerateUI — Feeder サブタブ・ワークフロー解析精度向上** — 概要
- **2026-04-29: v0.3.4 GenerateUI — Checkpoint Batch 刷新・Settings 横並びレイアウト・ヘルプ i18n 修正** — 概要
- **2026-04-28: v0.3.3 Promptタブ — ワイルドカード支援パネル・Impact Packシンボリックリンク連携・サブディレクトリ対応・GenerateUI修正** — 概要
- **2026-04-28: v0.3.2 生成UIタブ — Checkpoint Batch・Seed UIレイアウト修正** — 概要
- **2026-04-27: v0.3.1 ギャラリー拡張(フォルダ/ファイル操作・ワークフロー保存)** — 概要
- **2026-04-18: v0.3.0 モデルグループ表示改善(全タイプ横断・サイドパネル修正)** — 概要
- **2026-04-18: v0.2.9 モデル有効/無効・複数選択・一括削除** — 概要
- **2026-04-14: v0.2.8 データ保存先変更・エクスポート/インポート機能追加** — 概要
- **2026-04-06: v0.2.6 UI改善・バッジ保存修正・フォルダフィルター追加** — 概要
- **2026-04-04: v0.2.5 追加修正** — 概要
- **2026-04-03: v0.2.5 サイドパネル Category/Package サブタブ・テーマ設定・Workflow修正** — 概要
- **2026-03-29: v0.2.4 GenerateUIタブ レイアウト再設計** — 概要
- **2026-03-29: v0.2.3 バッジ統一・GenUI Model・サイドパネルModels拡充** — 概要
- **2026-03-28: v0.2.2 Modelsタブ・CivitAI連携** — 概要
- **2026-03-27: テーマカスタマイズ機能・設定タブ改善** — 概要
- **2026-03-26: トップバーボタンアイコン修正・Appモードバッジ調査** — 概要
- **2026-03-25: v0.2.0 プロンプトプリセット機能・Workflow Studio Library** — 概要
- **2026-03-24: v0.1.9 サイドパネルUI改善・保存ダイアログ・バグ修正** — 概要
- **2026-03-24: v0.1.8 WF & Node Library サイドパネル拡張** — 概要
- **2026-03-23: v0.1.7 Nodes タブ追加** — 概要
- **2026-03-23: v0.1.6 パストラバーサル脆弱性修正** — 概要
- **2026-03-22: v0.1.5 テーマシステム追加** — 概要
- **2026-03-22: v0.1.4 App形式対応・プリセットコピー・分析バグ修正** — 概要
- **2026-03-21: v0.1.3 ヘルプ&サポートタブ追加** — 概要
- **2026-03-19: v0.1.2 リリース** — 概要
- **2026-03-19: v0.1.1 リリース** — 概要
- **2026-03-18: プロジェクトリネーム & アイコン適用** — 概要
- **2026-03-18: デフォルトビュー設定追加** — 概要
- **2026-03-18: MarkdownNote表示専用ノード除外** — 概要
- **2026-03-18: サブグラフワークフロー対応 & 変換精度修正** — 概要
- **2026-03-18: Raw JSON同期修正** — 概要
- **プロジェクト構成 (現在)**
- **2026-03-18: JSONシンタックスハイライト** — 概要
- **2026-03-18: プロンプトタブ 中国語↔英語翻訳追加** — 概要
- **2026-03-18: v0.1.0 リリース** — 概要
- **2026-04-13: Gallery タブ追加 & 全タブ Group UI 統一** — 概要
+1011 -151
View File
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
# README Index — ComfyUI-Workflow-Studio
Condensed index of README.md: intro summary, feature names per tab (full descriptions live in the excluded README.md), and the installation/usage/requirements/project-structure sections verbatim. Exists so graphify can extract this without feeding the full README (with its screenshot table) to the local Ollama model. Regenerate with `python tools/generate_doc_index.py readme` whenever README.md changes.
# ComfyUI-Workflow-Studio
A comprehensive workflow, asset management, and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
**📁 Management** — organize all your assets in one studio
- Workflow, model, image, and prompt management
- AI-powered prompt writing assistance, translation, and tag generation
- File drop & Gallery-linked metadata viewing — inspect and reuse generation settings instantly
**⚡ GenerateUI** — a productivity-focused generation tab connected to every other tab
- Batch generation across models, samplers, prompts, and workflows
- Image Feeder for continuous folder-based generation
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Model (Checkpoint/LoRA Single/VAE)/Prompt/KSampler
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
- Drag & drop models, nodes, prompts, and workflows straight onto the canvas
- **Send to Canvas**: click "Send to Canvas" (Workflow tab) or "Copy & Send Canvas" (Gallery tab) to send the workflow directly to the ComfyUI canvas — UI and API formats both supported
- View metadata from images / JSON, then drop the detected models and prompts onto the canvas
- Built-in AI tools (translation and more)
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.5.2-green)
## Features (tab -> feature names)
### Workflow Tab
Thumbnail / Table views, Thumbnail side panel, Badge filtering, Search, Side panel tabs, Badge management, AI summary, Import / Export, Side panel toolbar buttons, Default view setting, Search clear (✕), Clear all filters (✕ Clear)
### Canvas Snapshot (v0.1.2)
One-click capture, Auto-save as thumbnail, Embedded workflow metadata, Auto-import
### GenerateUI Tab (v0.3.5)
6-tab layout, Save button, Input tab, Model tab, Settings tab, Always-visible Raw JSON, Bypass/Mute node handling, One-click generation, Seed control, Style selector, Create Catalog / Catalog buttons, Batch type selector, Batch tab, UI-to-API conversion, Video workflow support, Eagle integration
### Feeder subtab (v0.3.5 / v0.3.42)
ImageFeeder node control, Image library, Selection management, Presets, Continuous Run loop, Index sync, Seed, WFS_GalleryFeeder node, Node & group selector, Image grid, After Gen modes, Run / Stop controls
### Lab subtab (v0.3.87 – v0.5.2)
Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column — Checkpoint / LoRA Single / VAE, Empty state when no matching node exists, Wildcards, Prompt cell extras, Keyframe #1 live-reflect, Bypass checkbox, LoRA node bypass & prompt injection, T2I workflow support, Use generated image for next, Workflow recall, Plan files, Plan Load, Plan JSON tab, Save index image to Output on Run, Index images are self-contained plan files, Eagle integration, Select vs. edit, and keyframe reordering, PG — apply a Prompt tab preset group, Video workflow support, Use generated image for next, now video-aware
### Prompt Tab
3-column layout, AI chat assistant, Image attachment, Translation, Prompt presets, Preset Manager, Group management, Clipboard copy, GenUI Set, Wildcard/Style tab bar, Wildcard input toolbar, Wildcard file manager, Style manager, Form / Table toggle, Table view — Presets / Presets Group / Wildcards / Style
### Settings Tab
2-column layout, Collapsible sections, Theme selection, Theme customization, Workflows directory, Gallery output directory, Eagle connection, Ollama connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
### Gallery Tab (v0.3.44)
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send CC button, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, Performance
### ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)
Visual prompt library, 3-column layout, Prompt builder (right pane), Plain-text prompt storage, ponyxlWildcardsVault format support, Seed data importer
### Style_Catalog Gallery subtab (v0.3.95)
Visual Style picker, 3-column layout, Positive/Negative panel, Select as Style button, Load in GenerateUI / Open in Metadata Tab buttons, Double-click to enlarge
### Metadata Gallery subtab (v0.3.8, moved from a top-level tab in v0.4.6)
Now inside Gallery, 3-column layout, File drop, MP4 embedded metadata, Model extraction, LoRA extraction, Prompt extraction, Prompt actions, Format support, Format note
### Nodes Tab (v0.1.7)
Node Browser, Search & Filter, Package badges, Node detail panel, Node Sets, Right-click context menu, Multi-select, Search clear (✕), Clear all filters (✕ Clear)
### Models Tab (v0.2.3)
Model Browser, Thumbnail / Table views, Table column sort, Search & Filter, User-defined badges, Side panel tabs, CivitAI integration, CivitAI panel — Info / Sample sub-tabs, CivitAI panel states, Batch CivitAI fetch, Detail modal, GenUI Model button, Group management, Table view memo, Preview images, Enable / Disable models, Multi-select & bulk operations, Search clear (✕), Clear all filters (✕ Clear)
### Tagger Tab (v0.3.38)
3 sub-tabs, Model support, Threshold sliders, Ollama VLM, Single tab, Single output options, Batch tab, DB tab, Dependencies
### Image Edit Tab (v0.3.65)
Layer-based image editor, Loading images, New button, Tools, Layer panel, Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
### Video Tab (v0.5.0 – v0.5.2)
Dedicated MiniMax H3 video generation UI, independent of GenerateUI, 3-pane layout, First Frame / Last Frame are both optional, Node lookup instead of hardcoded IDs, Load in Video, Preview frame + Video Source panel, Frame tab, GIF tab
### AI TOOL Tab (v0.3.14)
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
Tab layout (W / N / P / M / I / A), W — Workflows tab, N — Nodes tab, M — Models tab, P — Prompts tab, I — Information tab, A — AI TOOL tab, Drag & drop workflows, Drag & drop nodes, Drag & drop prompts, Send to Canvas, Copy prompts, Double-click, Search, ⚙ Theme settings
### Help &amp; Support Tab (v0.1.3)
Sidebar navigation, Support, Feature list, Keyboard Shortcuts
## Installation
### Via ComfyUI Manager (Recommended)
Search for **Workflow Studio** in ComfyUI Manager and install.
### Manual Installation
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/ketle-man/ComfyUI-Workflow-Studio.git
```
Restart ComfyUI after installation.
---
## Sample Workflows
Sample workflows are included in the `workflows/` folder. You can open them directly in ComfyUI via drag & drop, or load them from the Workflow tab.
> **Note:** Some sample workflows require additional custom nodes.
> If a node is shown as missing (red/unknown) after loading, install the required custom nodes via **ComfyUI Manager** or by cloning the repository from GitHub into your `ComfyUI/custom_nodes/` directory.
>
> ```bash
> cd ComfyUI/custom_nodes
> git clone <repository-url>
> ```
>
> After installation, restart ComfyUI to activate the new nodes.
---
## Usage
### Launch
Click the **W** button in the ComfyUI top menu bar, or navigate to:
```
http://127.0.0.1:8188/wfm
```
> **Tip:** Shift+Click the W button to open in a new window.
### Canvas Snapshot
Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture the current workflow canvas as a thumbnail. The image is automatically saved to the workflow data folder and appears in Workflow Studio's workflow list.
### Quick Start
1. **Workflow Tab** — Your workflows from `ComfyUI/user/default/workflows/` are automatically listed
2. **Click a workflow** — View thumbnail, JSON details, and metadata in the side panel
3. **Load in GenerateUI** — Click the button to load a workflow into the generation interface
4. **Adjust parameters** — Modify prompts, models, seeds, and settings via the auto-generated UI
5. **Generate** — Hit the Generate button to queue the prompt
---
## Requirements
- **ComfyUI** — any recent version (v1.33.9+ recommended for action bar integration)
- **Python 3.10+**
- **Jinja2** — `pip install jinja2` (usually included with ComfyUI)
### Optional
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
---
## Supported Languages
| Language | Status |
| -------- | ------ |
| English | Full |
| Japanese | Full |
| Chinese | Full |
---
## Project Structure
```
ComfyUI-Workflow-Studio/
├── __init__.py # ComfyUI entry point
├── py/
│ ├── wfm.py # Main class & route registration
│ ├── config.py # Path configuration
│ ├── nodes/
│ │ ├── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
│ │ └── gallery_feeder_node.py # WFS_GalleryFeeder custom node (gallery group → IMAGE)
│ ├── routes/
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
│ │ ├── models_routes.py # Model metadata, preview, CivitAI API
│ │ ├── prompts_routes.py # Prompt presets CRUD API
│ │ ├── settings_routes.py # Settings API
│ │ ├── ollama_routes.py # Ollama proxy API
│ │ ├── eagle_routes.py # Eagle integration API
│ │ └── gallery_routes.py # Gallery tab API (Output + ImagePrompt + Style_Catalog sub-tabs)
│ └── services/
│ ├── workflow_service.py # Workflow file operations
│ ├── nodes_service.py # Node metadata & node sets
│ ├── models_service.py # Model metadata & preview images
│ ├── civitai_service.py # CivitAI API integration & cache
│ ├── prompts_service.py # Prompt presets persistence
│ ├── workflow_analyzer.py # Model/node detection
│ ├── settings_service.py # Settings persistence
│ ├── png_extractor.py # PNG metadata extraction
│ ├── gallery_service.py # Gallery image scanning, .txt sidecar & ponyxlWildcardsVault prompt resolution
│ └── gallery_metadata.py # Gallery per-image metadata store (favorites/tags/memo/groups)
├── templates/
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Metadata/Gallery/Nodes/Models/Settings/Help/AI)
├── static/
│ ├── favicon.svg # Browser tab icon (W+S Wave)
│ ├── css/main.css # Styles
│ └── js/
│ ├── app.js # App initialization & routing
│ ├── workflow-tab.js # Workflow browser
│ ├── generate-tab.js # Generation UI
│ ├── feeder-tab.js # Feeder subtab (Image Loop + Gallery modes)
│ ├── gallery-tab.js # Gallery tab: Output sub-tab (image browser, groups, metadata) + sub-tab switching
│ ├── image-prompt-tab.js # Gallery tab: ImagePrompt sub-tab (visual prompt builder)
│ ├── style-catalog-tab.js # Gallery tab: Style_Catalog sub-tab (visual Style picker)
│ ├── tagger-tab.js # Tagger tab (WD Tagger, DeepDanbooru, Ollama VLM)
│ ├── prompt-tab.js # AI assistant & presets
│ ├── metadata-tab.js # Metadata extraction & display (PNG/WebP/JSON)
│ ├── settings-tab.js # Settings panel
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
│ ├── nodes-tab.js # Node browser & node sets
│ ├── models-tab.js # Model browser & CivitAI integration
│ ├── ai-tab.js # AI TOOL tab (Translation | Chat | TOOLS/VLM | Settings)
│ ├── comfyui-workflow.js # UI-to-API format conversion
│ ├── comfyui-editor.js # Dynamic parameter editor
│ ├── json-highlight.js # JSON syntax highlighting
│ ├── util.js # Shared utilities (escapeHtml, readJsonStorage, getSettings)
│ └── i18n.js # Internationalization
├── web/comfyui/
│ ├── top_menu_extension.js # ComfyUI menu bar integration
│ ├── node_sets_menu.js # Workflow Studio Library side panel
│ └── gallery_feeder_extension.js # WFS_GalleryFeeder canvas widgets (After Gen / Run / Stop)
├── tools/
│ └── import_style_prompt_seed.py # Dev tool: pre-process a wildcards-vault source into ws_image_prompt (not run by the plugin itself)
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
```
---
## License
MIT License
---
## Acknowledgements
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) by comfyanonymous
- [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss — Canvas snapshot and PNG workflow embedding implementation reference
- [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) — Plugin architecture and UI pattern reference
- [Ollama](https://ollama.com/) for local LLM inference
- [Eagle](https://eagle.cool/) for image management
- [Pillow (PIL Fork)](https://python-pillow.org/) — server-side thumbnail generation (`data/thumb_cache/`)
- [ComfyUI-Gallery](https://github.com/PanicTitan/ComfyUI-Gallery) by PanicTitan — thumbnail grid UX reference
- [infinite-image-browsing](https://github.com/zanllp/sd-webui-infinite-image-browsing) by zanllp — thumbnail caching and index strategy reference
- [Fooocus](https://github.com/lllyasviel/Fooocus) by lllyasviel — style preset JSON format reference
+94 -41
View File
@@ -12,7 +12,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
- Batch generation across models, samplers, prompts, and workflows
- Image Feeder for continuous folder-based generation
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Checkpoint/VAE/Prompt/KSampler
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Model (Checkpoint/LoRA Single/VAE)/Prompt/KSampler
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
@@ -22,7 +22,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
- Built-in AI tools (translation and more)
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.3.97-green)
![Version](https://img.shields.io/badge/version-0.5.2-green)
## Screenshots
@@ -69,6 +69,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
- **AI summary** — generate workflow descriptions using Ollama
- **Import / Export** — import workflows from files or clipboard; **Send to Canvas** button sends the selected workflow directly to the ComfyUI canvas (UI and API formats both supported)
- **Side panel toolbar buttons** (v0.5.0) — selecting a card shows three buttons in the top toolbar: **Load in GenerateUI**, **Load in Video** (new — loads a MiniMax H3 Image-to-Video workflow into the Video tab; shows an error toast for workflows that don't contain that node), and **Send to Canvas**; the detail modal's duplicate copies of these three buttons were removed in favor of this single toolbar location
- **Default view setting** — persist your preferred view mode (Thumbnail / Table)
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, group filter, and badge filter (ALL) in one click
@@ -89,20 +90,24 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
<summary><h3>GenerateUI Tab (v0.3.5)</h3></summary>
- **6-tab layout** — Input / Model / Settings / Feeder / Batch / **Lab** tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
- **Save button** — located at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API
- **Save button** — located at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API; hidden while the Batch or Lab subtab is active (v0.4.6), since those work on workflow copies / their own Plan files rather than the currently loaded workflow
- **Default-workflow snapshot fix** (v0.4.8) — overwriting the loaded workflow under a filename that's currently set as the auto-load **Default workflow** (Settings tab, see below) now also refreshes that cached snapshot; previously only the file on disk was updated, so the old (pre-edit) version — including any checkpoint no longer installed — kept reappearing after every page reload until the default was re-set by hand
- **Input tab** — Prompt and Image inner tabs (drag-and-drop upload); Prompt tab shows Positive Prompt and Negative Prompt textareas, plus an **Embeddings selector** at the bottom (Filter + Select + Weight input + Paste button); Paste inserts `(embedding:Name:weight)` at the cursor position of the last focused textarea (defaults to Positive when neither is focused); Raw JSON (540px) in the right column
- **Revert button** (v0.3.70) — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value (the reverse of Apply)
- **Emphasis weight editing** (v0.3.70) — select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05, A1111/native-ComfyUI style; parentheses are removed automatically when the weight returns to 1.0
- **Mask slot for inpainting** (v0.3.71) — LoadImage slots whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) show an additional Mask drop zone; when a mask is set, image + mask are composited into a single RGBA upload (mask baked into the alpha channel) matching ComfyUI's native alpha→MASK extraction — no extra nodes required
- **Placeholder Image + Clear** (v0.3.97) — each LoadImage card gets a **Clear** button (resets that card's pending selection/preview back to empty; doesn't touch the workflow node itself) and a **Placeholder** button; a collapsible **Placeholder Image** section (collapsed by default) at the top of the Image sub-panel configures one 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) — remembered across sessions; clicking a card's own Placeholder button immediately replaces just that card's image with the configured default, so multiple LoadImage cards can each be replaced individually with one click
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device), **Hypernetwork** (with Strength field) selectors with filter; Raw JSON on the right
- **Model tab** — Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), and Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device) are always shown; **ControlNet** and **Hypernetwork** (with Strength field) are collapsed by default (v0.3.98) since they're used less often — click their header to expand; **LoRA** (Single/Stack tabs) has its own column; all selectors have a filter; Raw JSON on the right
- **Highlight color** (v0.3.98) — the label of each model type above, plus the LoRA column header, turns a distinct color (default bright green, customizable in the Settings tab) when the loaded workflow actually contains that type of node; stays visible even while ControlNet/Hypernetwork are collapsed, so relevance is visible without expanding
- **LoRA Stack Group toggle** (v0.3.92) — for `Lora Loader (LoraManager)` nodes, a **Use Stack Group** checkbox next to the Single/Stack tabs switches what the Stack tab shows and edits: OFF (the default whenever a workflow is loaded) displays and edits the LoRAs actually configured in the workflow's own Lora Manager node — correctly showing every LoRA it holds, not just the first one; ON switches to the LoRA group registered in the Models tab instead; toggling either direction clears the node's LoRA list first, then loads it from the newly selected source, and switching back to OFF restores exactly the list that was present when the workflow was loaded
- **LoRA Bypass button** (v0.4.4) — a ⛔ toggle next to the Refresh Stack group button in the LoRA column header; forces the target LoRA node's strength to 0 (`strength_model`/`strength_clip` for a standard LoraLoader, or every entry's `strength`/`clipStrength` for a Lora Manager node — its LoRA list stays intact, not replaced) without touching the actual ComfyUI node's mode; click again to restore the exact values that were there before toggling; targets whichever node Single/Stack Apply default to (the workflow's Lora Manager node if it has one, else the first LoRA node); clicking a Single or Stack Apply button clears the bypass state so it can't silently reassert itself later
- **Settings tab** — KSampler and Latent Image side by side at 50% width each; Raw JSON on the right
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow; built-in **search bar** (always shown) finds all matches as you type with count display (`3/12`); navigate with ↑/↓ buttons or Enter / Shift+Enter; Escape or ✕ clears; current match highlighted in orange, other matches in yellow
- **Bypass/Mute node handling** (v0.3.75) — loading a UI-format workflow with Bypass-mode nodes now reroutes their wires to the upstream source (matching same-type input/output slots, recursively through chained bypasses) instead of leaving a dangling reference that failed validation; Mute-mode nodes are excluded without rerouting, same as ComfyUI itself; when either is present, a note listing the affected nodes appears above the Raw JSON search bar in the Input/Model/Settings tabs
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
- **Style selector** — checkbox and dropdown next to the Reset Workflow button; enable to apply a Fooocus-style JSON to positive and negative prompts at generation time; style files (`*.json`) are loaded from `user/default/Workflow-Studio/style/`; the style's `{prompt}` placeholder is replaced with the original prompt text, or the style is appended if no placeholder exists; `negative_prompt` is appended to the existing negative prompt; styles are applied to a per-generation copy and do not modify the loaded workflow
- **Wildcards inside a Style now expand** (v0.4.7) — an A1111-style `__wildcard__` reference written into a Style's Positive/Negative prompt is expanded at generation time just like one typed directly into GenerateUI; the Style text is merged into the prompt *before* wildcard expansion runs (previously the order was reversed, so a Style's own wildcard references were sent to ComfyUI unexpanded)
- **Create Catalog / Catalog buttons** (v0.3.95) — next to the Style dropdown. **Create Catalog** generates one preview image per Style checked 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 named after the Style (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. **Catalog** jumps straight to the Gallery tab's Style_Catalog sub-tab to browse the results (see below)
- **Don't keep a copy in the Output folder** (v0.3.96) — checkbox in the Create Catalog modal; each generation still writes a normal file to the Output folder first (the workflow's own Save Image node always does that), but once its copy has been saved into the catalog folder, checking this deletes that Output-folder original right away — keeps a large catalog run (e.g. every Style at once) from flooding the Output folder with near-duplicate files; if a style's catalog copy fails to save, its Output file is left alone rather than deleted
- **Batch type selector** — check one of the column header checkboxes in the Batch Queue pane (Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / Style) to activate that batch type; only one type can be active at a time; the Batch panel below Generate shows the active type, progress, and Pause/Resume/Stop controls
@@ -112,6 +117,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
- **Right pane (Batch Queue)** — 7 columns: Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / **Style**; each column header has an enable checkbox (radio behavior: only one at a time); count shown per column; Style batch applies each selected style sequentially to a workflow copy
- **Lora batch prompt sync** (v0.3.69) — for each selected LoRA, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt (rebuilt from the original prompt each time, not accumulated), then restored to the pre-batch state when the batch completes or is stopped; if a selected LoRA is not yet recognized by ComfyUI's model list, a warning toast suggests a Refresh or restart
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and Guider-based "Advanced Sampling" workflows (`SamplerCustomAdvanced`/`SamplerCustom` + `CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `KSamplerSelect` + `*Scheduler` — Flux.2 Klein, LongCat, Boogu, HiDream E1, etc.)
- **Video workflow support** (v0.5.2) — loading a MiniMax H3 (or other all-in-one video-generation) workflow now works from GenerateUI too, not just the dedicated Video tab: the Input tab's Prompt field picks up its `prompt` widget, the Image tab picks up its LoadImage-based `first_frame` the same way any other I2I workflow does, and generation runs with a longer timeout since video generation takes much longer than a still image; the result preview shows the generated mp4 inline (`<video>`) instead of trying to render it as an image
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
</details>
@@ -145,18 +151,20 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
</details>
<details>
<summary><h3>Lab subtab (v0.3.87 – v0.3.97)</h3></summary>
<summary><h3>Lab subtab (v0.3.87 – v0.5.2)</h3></summary>
An experimental batch generator: runs the workflow currently loaded in GenerateUI N times, letting Checkpoint / VAE / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone. Works with both I2I and T2I workflows (v0.3.90).
An experimental batch generator: runs the workflow currently loaded in GenerateUI N times, letting Model (Checkpoint/LoRA Single/VAE) / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone. Works with both I2I and T2I workflows (v0.3.90).
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 4 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
- **Per-column keyframes** — Checkpoint, VAE, Prompt, KSampler each hold an independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it; lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 3 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
- **Per-column keyframes** — Checkpoint, LoRA, VAE, Prompt, and KSampler each hold their own fully independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it; lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
- **Model column — Checkpoint / LoRA Single / VAE** (v0.4.2, redesigned v0.4.3) — Checkpoint and VAE (and now LoRA) share one visual "Model" column; a `[C][L][V]` toggle in its header picks which of the three independent keyframe lists the shared cell list, its own +/-, and its edit modal currently operate on — each behaves exactly like its own column (same as Prompt/KSampler), just sharing the Model column's screen space, so adding, removing, editing, reverting, or bypassing a keyframe while viewing one never touches the other two. Each toggle button lights up the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. A field left blank in a later keyframe's edit modal shows "(inherits: ...)" instead of "(workflow default)" when an earlier keyframe in that same list already set something. LoRA support is Single only (no Stack, matching the experimental scope of this tab); if a LoRA is set but the loaded workflow has no LoRA node at all, Run warns once and skips it rather than auto-inserting one — unlike VAE (which does auto-inject a VAELoader when missing), wiring in a LoraLoader would require rewiring the Checkpoint's model/clip outputs through it
- **Empty state when no matching node exists** (v0.4.5) — Checkpoint/LoRA/VAE each show "No matching node in this workflow" instead of a keyframe list when the loaded workflow has no node that column could actually override, and the column's +/− is disabled too, so no dead "(workflow default)" keyframe row can be created in the first place; VAE still counts as usable whenever a Checkpoint node exists, since its baked-in VAE can still be overridden via auto-injected VAELoader as above
- **Wildcards** (v0.3.93) — Prompt keyframes' Positive/Negative text supports the same A1111-style `__name__` syntax as the main GenerateUI prompt fields; each keyframe is expanded fresh right before that iteration runs, so reusing the same keyframe across iterations can still draw a different random line each time
- **Checkpoint / VAE cell filtering** (v0.3.88) — a Filter... box above the value dropdown narrows the list live, same as GenerateUI's Model tab; overlay ✕ clear button matches the Models/Nodes/Workflow/Gallery tab search boxes
- **Prompt cell extras** (v0.3.88, refined in v0.3.89, v0.3.93, and v0.3.97) — **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); **Get from Previous** (v0.3.97) copies both Positive and Negative wholesale from the keyframe immediately before this one in the same column (e.g. editing #3 copies #2's prompt; not shown on keyframe #1, which has no earlier row); **Clear** (v0.3.97) empties both fields; **Style** row — an **Apply** button merges whichever style is currently selected in GenerateUI's own top-bar Style dropdown into the Positive/Negative text below, comma-separated after any existing text (never clears it) and works regardless of that top-bar's own Style enable checkbox; a checkbox next to it tracks on/off and shows the applied style's name beside itself (not on the button) — unchecking only clears that name label, the already-merged text stays and is yours to edit/delete by hand
- **Keyframe #1 live-reflect** (v0.3.97) — in every column, keyframe #1 shows the currently loaded workflow's own live setting (Checkpoint/VAE/Prompt/KSampler) instead of a blank placeholder, 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 unchanged — 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
- **Keyframe #1 live-reflect** (v0.3.97) — in every column, keyframe #1 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 unchanged — 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
- **Bypass checkbox** (v0.3.97) — available on every keyframe including #1; skips that row entirely when picking which setting applies at a given iteration, as if it didn't exist, so whatever was active before it keeps running instead; Lab-internal only, never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (mutually exclusive with Revert); the cell shows an amber "⏭ Bypassed" label, taking priority over both the Revert and live-reflect display
- **T2I workflow support** (v0.3.90) — a **T2I workflow (no source image)** checkbox in the Image pane skips the source-image requirement and never touches a LoadImage node, so workflows with no image input (e.g. plain `EmptyLatentImage` txt2img) can also use the Checkpoint/VAE/Prompt/KSampler keyframes; the Image drop zone and "Use generated image for next" are greyed out (not cleared) while it's on; saved with the plan as `t2i_mode`
- **LoRA node bypass & prompt injection** (v0.4.4) — inside the LoRA keyframe's own edit modal, two checkboxes independent of the row-level Bypass above: **Bypass LoRA node** forces that keyframe's `strength_model`/`strength_clip` to 0 at apply-time — the behavioral equivalent of ComfyUI's node Bypass (mode:4), since the workflow used here is already API-format with no `mode` concept; **Apply LoRA syntax + trigger words to Positive prompt** appends `<lora:name:strengthModel:strengthClip>` plus that LoRA's cached CivitAI trigger words to the Positive prompt, comma-separated, at apply-time — the same format GenerateUI's own Model tab Apply button writes. Switching keyframes (a different LoRA, this checkbox turned off, or the row Bypassed) always strips exactly what the previous iteration injected before adding anything new, so a batch never accumulates every LoRA it has used; the prompt checkbox is disabled automatically while Bypass LoRA node is checked
- **T2I workflow support** (v0.3.90) — a **T2I workflow (no source image)** checkbox in the Image pane skips the source-image requirement and never touches a LoadImage node, so workflows with no image input (e.g. plain `EmptyLatentImage` txt2img) can also use the Model/Prompt/KSampler keyframes; the Image drop zone and "Use generated image for next" are greyed out (not cleared) while it's on; saved with the plan as `t2i_mode`
- **Use generated image for next** — optional checkbox that chains iterations: from iteration 2 onward the previous iteration's first output image is fed back in as the I2I source instead of the original dropped image, using ComfyUI's `"name [type]"` annotated-filename reference (no re-upload needed); disabled together with the Image drop zone in T2I mode
- **Workflow recall** (v0.3.90) — every 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 next to it — click to load that exact workflow file back into GenerateUI (asks for confirmation first, since it replaces whatever is currently loaded)
- **Plan files** — Plan Save (silently overwrites the loaded plan) / Save As (always asks for a new name) / Plan Clear; plans are stored as `ws_labplan_<name>.json` in `user/default/Workflow-Studio/lab_plan/` (the `ws_labplan_` prefix is added automatically, v0.3.90 — type just the plain name when prompted), alongside an auto-generated `ws_labplan_<name>.png` index image (a 3-per-row contact sheet of up to 9 result thumbnails)
@@ -165,6 +173,10 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
- **Save index image to Output on Run** (v0.3.90, on by default since v0.3.93) — checkbox in the run controls; when checked, every completed Run also 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, right alongside the images that Run just generated); independent of Plan Save, which always writes its own copy next to the plan file regardless of this checkbox; saved with the plan as `save_index_on_run`
- **Index images are self-contained plan files** (v0.3.91) — every index-image PNG, whether from Plan Save/Save As or from Save index image to Output on Run, carries the entire plan embedded directly in the file as a PNG `wfm_lab_plan` chunk; this is what lets the Output-folder copy above work as a Plan Load source even though it has no matching `.json` anywhere
- **Eagle integration** — every image Lab generates is auto-saved to Eagle the same way as GenerateUI's own Generate button, if enabled in the Settings tab; the Output-folder index image above is included too (v0.3.90) once saved
- **Select vs. edit, and keyframe reordering** (v0.4.8) — a single click on a keyframe cell now only selects it (blue outline); double-click to open its edit modal as before. Each column's header (Model / Prompt / KSampler) also gets **▲ / ▼** buttons that swap the selected keyframe with its immediate neighbor, reordering keyframes without retyping them; keyframe #1 (the live-reflecting baseline) can never move, and nothing can be swapped into its slot
- **PG — apply a Prompt tab preset group** (v0.4.8) — a **PG** button on the Prompt column header opens a picker for the Prompt tab's Table-view preset groups (the same groups the Batch tab's checklist uses); choosing one replaces the entire Prompt column with one keyframe per preset in that group's order, starting at #1 (#1 is overwritten, not appended after). If the group has more presets than the current Batch count, Batch count is raised automatically to fit (capped at 50, the Batch input's own limit); anything still beyond that shows the existing "keyframes beyond Batch count" warning instead of being dropped
- **Video workflow support** (v0.5.2) — loading a MiniMax H3 (or other all-in-one video-generation) workflow into GenerateUI and running Lab now works end to end: the Prompt column writes to its `prompt` widget, the KSampler column auto-hides (same "no matching node" treatment as Model sub-columns) since the sampler wired internally is a fixed part of the model's own preset rather than something meant to be tuned per keyframe, the Results grid plays mp4 results inline, and the contact-sheet index image draws each video result's last frame instead of failing silently onto a black square
- **Use generated image for next, now video-aware** (v0.5.2) — when a Run's output is a video, the "Use generated image for next" chain extracts its last frame client-side (canvas) and uploads it as the next iteration's source image, instead of trying to feed the raw mp4 into a LoadImage node
</details>
@@ -188,21 +200,18 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
- **New styles go to a dedicated file** — created via **+ New**, a style is appended to its own `custom.json` inside the style folder, so the bundled style packs are never modified directly
- **+ Add to this file** — while editing an existing style, this button clears the form for a brand-new entry that gets appended to that *same* file instead of the default `custom.json` — the way to add a style into one of the bundled packs
- Saving, renaming, or deleting a style immediately refreshes GenerateUI's Style dropdown, the Batch tab's checklist, and the Style Catalog's name list — no reload needed
</details>
<details>
<summary><h3>Metadata Tab (v0.3.8)</h3></summary>
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
- **File drop** — drop a ComfyUI-generated PNG / WebP or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2, Mage-Flow, LongCat, Boogu, HiDream E1, FireRed, Krea-2 Turbo); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
- **LoRA extraction** — lists all LoRA models with `strength_model / strength_clip` values
- **Prompt extraction** — lists prompts with POS / NEG badges when positive/negative can be determined; when distinction is not possible (e.g. `SamplerCustomAdvanced`, intermediate nodes, cross-level connections), prompts are shown without a badge as plain **Text**; click any entry to view the full text below
- **CLIP Text Encode edit+ support** (v0.3.92) — `CLIPTextEncodeEditPlus` nodes are resolved using the same RAW / EDIT / front / back combination rule as the node itself, correctly merging the linked `text1` (e.g. a Lora Manager's trigger words) with the editable `text_edit` field instead of showing only one side
- **Prompt actions** — Copy to clipboard, **GenUI:P/N** (set GenerateUI positive/negative prompt), **Prompt:P/N** (set Prompt tab preset positive/negative)
- **Format support** — ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Krea-2 Turbo subgraph workflows), SD WebUI, SD Forge, Fooocus
- **Format note** — supported formats and covered model types are always shown in the left column
- **Form / Table toggle** (v0.4.6) — two top-level tabs at the top of Prompt Tab: **Form** is the one-item-at-a-time editor described above; **Table** shows the same Presets / Wildcards / Style data all at once as editable spreadsheets, useful once there are more than a handful of items to manage
- **Table view — Presets / Presets Group / Wildcards / Style** (v0.4.6) — four sub-tabs, each an editable table built from the exact same data as the Form tab above (changes on either side stay in sync)
- **Row selection & bulk delete** — every table shares a left-most auto-numbered **#** column; click a number to select that row (multiple at once), then use the toolbar's **Delete** button to remove every selected row in one confirm, or **Deselect** to clear the selection — replacing per-row delete buttons, which were too easy to misclick
- **Double-click to edit in a modal** — double-clicking a row's number opens a larger prompt-editing modal instead of the small in-table textarea; Presets/Style show Positive/Negative toggle buttons above one shared textarea (switching preserves unsaved text on the other side), Wildcards shows just the file content; Save writes back and closes the modal, Close discards changes
- **Wildcard syntax toolbar in the Presets modal** (v0.4.7) — the same `{|}` / `__|__` / `<lora>` / file-picker toolbar as the Form tab's Wildcard composer appears above the Presets modal's textarea, since that's where a wildcard reference typically ends up; not shown in the Wildcards or Style modals (the Wildcard modal edits the raw file content itself, and Style prompts have no such wildcard-composer connection)
- **+ New row** — the toolbar's **+ New ...** button adds a blank editable row with small ✓ (Add) / ✕ (Cancel) icons in place of its number
- **Presets table** — **★** (favorite) and **B** (Batch group) columns work as toggle switches, same as the Form tab's Preset Manager; the toolbar's **BC** button clears the whole Batch group at once; **PP Copy** / **NP Copy** copy the single selected row's positive/negative prompt to the clipboard; a **group select + Add to Group** control bulk-adds every selected preset to a chosen group in one click — searching/selecting from the table itself (rather than a long per-group preset picker) keeps group assignment usable once there are many presets
- **★ / B sort toggles** (v0.4.7) — toolbar buttons next to the search box bring favorited and/or batch-selected presets to the top of the list; both can be enabled together (★ takes priority, B breaks ties), and everything else keeps its original relative order; toggling a row's own ★/B while a sort is active re-sorts immediately
- **Presets Group table** — create, rename, and delete groups, with each group's members shown as removable badges; the reserved **Batch** group is not listed here — it's managed entirely through the Presets table's B column and BC button instead
- **Wildcards table** — same files as the Form tab's Wildcard file manager above; editing the File path or the Ext dropdown renames the file (saves the content under the new name, then removes the old file)
- **Style table** — same styles as the Form tab's Style manager above, editable inline the same way as Presets
- **+ Add to this file** (v0.4.8) — select exactly one row whose File badge isn't `custom.json` to enable this toolbar button, bringing the Form tab's same-file-add capability (above) to the Table view too; it adds a blank row targeting that same file (shown as a badge in the File column even before saving); custom.json entries and multi-row selections leave it disabled — use the regular + New Style button for those instead
</details>
@@ -221,9 +230,11 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
- **CivitAI API Key** — optional Bearer token for authenticated CivitAI access; stored in `settings.json` (excluded from data exports); environment variable `CIVITAI_API_KEY` takes priority if set
- **Default workflow** — set a workflow to auto-load on startup
- **Default Checkpoint** (v0.3.73) — optional checkbox to force every checkpoint loader node's model to a chosen default whenever a workflow is loaded into GenerateUI; a safeguard against generation errors or garbled images from a stale/mismatched checkpoint saved in the workflow file
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
- **Data Management** — export core settings/metadata/prompts/CivitAI cache to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
- **Full Backup (ZIP)** (v0.5.2) — additionally covers what the JSON bundle can't represent: folder-based data (AI Skills, Lab Plans, Style presets) and the Tagger SQLite database; two opt-in checkboxes can also include ComfyUI's default workflows folder (`user/default/workflows`) and the Impact Pack's wildcard folder in the export — both of those are export-only and stored under an `_external/` prefix in the ZIP that Restore always skips, since auto-overwriting files a different plugin/ComfyUI core manages would be unsafe; copy them back by hand if needed. Restoring overwrites existing files; restart ComfyUI afterward for either restore path
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
- **GenerateUI Model Tab Highlight** (v0.3.98) — 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; saved to `localStorage` under `wfm_settings.modelTabActiveColor`
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
- **G'MIC-Qt Integration** — set the path to `gmic_qt.exe` for the Image Edit Filter tool; download G'MIC-Qt Standalone from `gmic.eu/download.html` (Windows: `gmic_qt-win64.zip`), extract anywhere, then enter the full path to `gmic_qt.exe` and click Save
- **Language** — English / Japanese / Chinese
@@ -233,13 +244,16 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
<details>
<summary><h3>Gallery Tab (v0.3.44)</h3></summary>
- **Output / Style-Prompt sub-tabs** (v0.3.94) — the Gallery tab has two sub-tabs at the top: **Output** (the image browser described below) and **Style/Prompt** (a separate visual prompt-building library — see its own section below)
- **Output / ImagePrompt / Style_Catalog / Metadata sub-tabs** (v0.3.94, v0.3.95, v0.4.6) — four sub-tabs at the top of the Gallery tab: **Output** (the image browser described below), **ImagePrompt** and **Style_Catalog** (separate visual libraries — see their own sections below), and **Metadata** (embeds the full Metadata tab — see its own section below)
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
- **Folder tree root label** (v0.3.90) — the root entry in the folder tree is labeled simply **[root]**, no longer suffixed with the scanned folder's own directory name (which could be a misleading, environment-specific name — e.g. StabilityMatrix installs where the output folder is a symlink named `Text2Img` — even though the folder holds every kind of generated output, not just text-to-image results)
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To..., Export, and Delete File from the multi-select bar
- **Download** — hover over the image preview in the detail panel to reveal a download icon (⬇); click to download the single image
- **Download** (v0.5.0) — a dedicated orange **Download** button in the detail panel's action-button row (next to Tagger); replaces the old preview-image hover overlay, which blocked video playback controls
- **MP4 video support** (v0.5.0) — `.mp4` files are browsable, previewable (detail panel with native `<video controls>`, lightbox, and Compare), and included in favorites/tags/groups/move/delete/export alongside images; server-side thumbnails are generated from the first video frame via PyAV (falls back to a placeholder if PyAV/ffmpeg decoding isn't available); a small ▶ badge overlays video thumbnails in the grid
- **MP4 embedded metadata** (v0.5.1) — the Prompt and JSON tabs now also work for `.mp4` files: reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4 (via PyAV server-side), including all-in-one video-generation nodes that carry their prompt directly on a `prompt` input rather than a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
- **Info tab: dimensions & duration** (v0.5.1) — the detail panel's Info tab now also shows image dimensions (width×height) or video duration, in addition to filename, size, date, tags, and memo
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations: select group → "Add to Group" / "Remove from Group"; Favorite All / Unfavorite All; Compare (2–4 images); Move To...; **Export** (downloads all selected images as a ZIP file); Delete File
- **Image Compare** — select 2–4 images with Ctrl+click, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; grid adapts to the number of selected images
- **Prompt search** — text search covers filename, tags, memo, and prompt text (A1111 `parameters` field cached on first detail panel open)
@@ -247,14 +261,16 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab; **`__Feeder__`** is a reserved group (🔒 prefix) that cannot be renamed or deleted; used by the Feeder Gallery mode to define the generation queue; **FC** button in the toolbar clears all members without deleting the group
- **Thumbnail F button** — cyan overlay button (top-left of each image card) toggles the image's membership in the `__Feeder__` group; cyan and always-visible when the image is in the group; visible on hover only when inactive
- **Favorites** — star images inline without reopening the detail panel
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel; four tabs — **Info** / **JSON** / **Groups** / **Prompt**
- **Prompt tab** (v0.4.7) — selected by default (the most frequently checked piece of info shouldn't need an extra click); lists every positive/negative prompt candidate found in the embedded workflow with POS/NEG badges, reusing the exact same list→click→full-text UI as the Metadata tab; extracts from the API-format embedding when available rather than the UI-format one, since a workflow with independent top-level and subgraph sampling chains (e.g. Krea-2) can otherwise have some of its prompts missed
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab; **Copy & Send Canvas** button copies the JSON to the clipboard and sends the workflow directly to the ComfyUI canvas (UI and API formats both supported)
- **Load GenUI button** — loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; shows a warning toast if no workflow is embedded or the format is unsupported; Metadata button is styled green, Load GenUI button uses the primary accent color
- **GenUI button** (renamed from "Load GenUI" in v0.5.0 for readability) — loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; shows a warning toast if no workflow is embedded or the format is unsupported; Metadata button is styled green, GenUI button uses the primary accent color
- **Image Edit button** — toolbar button; sends the selected image directly to the Image Edit tab as the base layer
- **Send GenUI Image button** — toolbar button (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
- **Send to LI node button** (v0.5.2) — toolbar button (next to Send GenUI Image); uploads the selected image and writes its filename into the `image` widget of the node currently selected on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination and cross-window bridge as the Image Edit tab's **Send to LI node** button (renamed from "Send to Workflow" in this same release), so both entry points now share one consistent name; requires Workflow Studio to have been opened from ComfyUI's top menu button
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
- **Clear all filters (✕ Clear)** — toolbar button next to the view-mode (Thumbnail/Table) switch (v0.5.2, moved from the toolbar's rightmost position so it sits with the other display-affecting controls); resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
- **Output folder configurable** — set the scanned output folder from Settings tab
- **SVG file support** (v0.3.77) — `.svg` files are browsable, previewable (detail panel and lightbox), and included in favorites/tags/groups/move/delete/export alongside raster formats; served as-is with no rasterized thumbnail, since `<img>` scales vector graphics natively at any size
@@ -286,6 +302,23 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
</details>
<details>
<summary><h3>Metadata Gallery subtab (v0.3.8, moved from a top-level tab in v0.4.6)</h3></summary>
- **Now inside Gallery** (v0.4.6) — this used to be its own top-level tab; it's now the fourth Gallery sub-tab, to the right of Style_Catalog — everything below works exactly the same as before
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
- **File drop** — drop a ComfyUI-generated PNG / WebP / MP4 or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
- **MP4 embedded metadata** (v0.5.1) — reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4's `moov/udta/meta` box (parsed client-side, no server round-trip); also recognizes all-in-one video-generation nodes that carry their prompt directly on a `prompt` input instead of a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2, Mage-Flow, LongCat, Boogu, HiDream E1, FireRed, Krea-2 Turbo); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
- **LoRA extraction** — lists all LoRA models with `strength_model / strength_clip` values
- **Prompt extraction** — lists prompts with POS / NEG badges when positive/negative can be determined; when distinction is not possible (e.g. `SamplerCustomAdvanced`, intermediate nodes, cross-level connections), prompts are shown without a badge as plain **Text**; click any entry to view the full text below
- **CLIP Text Encode edit+ support** (v0.3.92) — `CLIPTextEncodeEditPlus` nodes are resolved using the same RAW / EDIT / front / back combination rule as the node itself, correctly merging the linked `text1` (e.g. a Lora Manager's trigger words) with the editable `text_edit` field instead of showing only one side
- **Prompt actions** — Copy to clipboard, **GenUI:P/N** (set GenerateUI positive/negative prompt), **Prompt:P/N** (set Prompt tab preset positive/negative)
- **Format support** — ComfyUI PNG/WebP/MP4/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Krea-2 Turbo subgraph workflows), SD WebUI, SD Forge, Fooocus
- **Format note** — supported formats and covered model types are always shown in the left column
</details>
<details>
<summary><h3>Nodes Tab (v0.1.7)</h3></summary>
@@ -355,7 +388,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends 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 (same selection logic as **Send to LI node**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
@@ -363,30 +396,48 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load
- **Layer lock** — locked layers show an orange bounding box and 🔒 icon on the canvas; move/resize/rotate are disabled while locked; click the 🔓 button in the layer row to unlock
- **Text quality** — text layers are rendered at their measured bounding-box size; resizing with the Select tool regenerates the canvas at the new display resolution so text stays sharp
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Send to ComfyUI** (uploads to ComfyUI input folder for use in Load Image nodes)
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Send to LI node** (renamed from "Send to Workflow" in v0.5.2) — uploads to ComfyUI's input folder via `/upload/image`, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable; the Gallery tab's toolbar has the same button (v0.5.2, see Gallery Tab below) so a selected Gallery image can be sent the same way without going through Image Edit first
- **Canvas navigation** — scroll-wheel zoom, Space + drag to pan; zoom indicator in the bottom bar
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), Delete (remove selected layer when 2+ exist)
</details>
<details>
<summary><h3>Video Tab (v0.5.0 – v0.5.2)</h3></summary>
- **Dedicated MiniMax H3 video generation UI, independent of GenerateUI** — a purpose-built tab for the MiniMax H3 (Image to Video) workflow; does not share state with the GenerateUI tab, and model selection (UNet/CLIP/VAE/Audio VAE) is fixed to the loaded workflow's own defaults rather than editable here
- **3-pane layout** — left: form (First Frame / Last Frame / Prompt / Aspect Ratio / Megapixels / Multiple / Duration / Noise Seed / Generate); center: preview frame + Video Source panel, plus an Editing pane reserved for future video-editing tools; right: **Frame** / **GIF** property tabs (v0.5.2, see below)
- **First Frame / Last Frame are both optional** — leaving First Frame empty runs the workflow text-only (the node is simply left unconnected — a "bypass"); workflows that don't even include a LoadImage node for First Frame are supported out of the box, and a LoadImage node is only injected into the graph on the fly if an image is actually supplied
- **Node lookup instead of hardcoded IDs** — locates the MiniMax H3 subgraph instance, its linked Resolution Selector, and (if present) its linked LoadImage node by walking the workflow's subgraph definitions and links, so a workflow doesn't need to match the reference `minimax_test.json`'s exact node IDs
- **Load in Video** (Workflow tab) — load any workflow containing a MiniMax H3 Image-to-Video node straight into this tab from the Workflow tab's side-panel toolbar (next to Load in GenerateUI); unsupported workflows show an error toast instead of loading
- **Preview frame + Video Source panel** (v0.5.2, replaces the old Result panel) — the center pane's preview shows either a just-generated video or one loaded independently of generation; a **Video Source** drop zone next to it accepts any local video file (drag-and-drop or click to pick), with a **Clear** button to reset back to empty — the Frame/GIF tools below work on whatever is currently shown here, generated or not; generated results are still saved to Gallery metadata (embedded workflow JSON) automatically, so the Gallery tab's Workflow/Prompt panels work on generated videos the same way they do for images
- **Frame tab** (v0.5.2) — captures the preview video's current playback position as a still frame: play/seek to the position you want, click **Capture Frame** (client-side canvas, no server round-trip), then **Save to Output** to write it as a PNG into ComfyUI's own Output folder (auto-numbered, same pattern as other generated images)
- **GIF tab** (v0.5.2) — converts the preview video to an animated GIF; set Start/End (seconds), FPS, and an optional Max Width, then **Convert to GIF** — the server decodes the video (PyAV) and encodes an animated GIF (Pillow), both dependencies ComfyUI itself already requires, so no ffmpeg binary or extra install is needed; the result is saved into ComfyUI's Output folder and previewed inline
</details>
<details>
<summary><h3>AI TOOL Tab (v0.3.14)</h3></summary>
- **4-pane layout** — Translation | Chat | TOOLS | Settings; all panes always visible simultaneously; no sub-tab switching required
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio and Lemonade use `/v1/chat/completions`
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, Lemonade, or Unsloth; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
- **Reliability fix** (v0.3.98) — switched from a raw single-string prompt to a system-role chat request (Ollama `/api/chat`, LM Studio/Lemonade `/v1/chat/completions` with a `system` message) so small local models follow the "translation only" instruction more reliably; output is cleaned of common LLM preambles/quotes, and a warning toast appears if the model appears to have echoed the source text unchanged instead of translating it
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio, Lemonade, and Unsloth use `/v1/chat/completions`
- **Chat pane — image generation via Tool Calling** (v0.3.73) — if the selected model supports tool calling and the user asks for an image, a `generate_image` tool is invoked and the request runs through the same generation pipeline as the GenerateUI Generate button, with the result shown inline in the chat; an "Allow image generation" checkbox in the input row toggles this on/off (default on); the workflow used is the one currently loaded in GenerateUI, or a dedicated saved workflow configured in the Settings pane; models verified to work well for tool calling and vision: **Gemma 4:e4b**, **Qwen 3.5:9b**, **Ministral 3:3b** (Ollama)
- **Chat pane — generate_image extra parameters** (v0.4.4) — besides `prompt`/`negative_prompt`, the tool call also accepts optional `steps`, `cfg`, `sampler_name`, `scheduler`, `denoise`, `seed`, `width`, `height`, `batch_size` (number of images produced in that one call), and `aspect_ratio` (a ratio preset like `3:2`, resolved against the node's actual COMBO options via `/object_info` and applied only if the workflow has a **Resolution Selector** node feeding width/height — ignored otherwise); anything the model doesn't supply keeps the loaded workflow's own current setting; `batch_size` > 1 shows every generated image inline in the same chat bubble
- **TOOLS pane (VLM)** — drop an image into the 110px drop zone, select a task (Describe Image / Create Prompt / Create Tags), and click Run to analyze with a vision model; result shown in the output area with a Copy button
- **TOOLS pane — shared Chat attachment** (v0.3.74) — an image dropped in the TOOLS drop zone is also usable as the Chat pane's attachment: it's sent to the LLM as vision input, and used as the base image when `generate_image` runs image-to-image; a ✕ button clears it (from either the TOOLS preview or the Chat attachment indicator); it stays attached across turns until explicitly cleared
- **TOOLS pane (Wildcards)** (v0.3.40) — select "Create wildcards" from the task dropdown; enter a category name and count; click Run to generate plain-text wildcard entries one per line (no markdown, no numbering); result can be copied directly into wildcard `.txt` files
- **Chat pane — image-to-image (I2I)** (v0.3.74) — when an image is attached and `generate_image` is invoked, the attachment is uploaded and swapped into the target workflow's LoadImage node instead of running text-to-image; the target workflow is the one currently loaded in GenerateUI, or a dedicated I2I workflow configured in the Settings pane
- **Chat pane — SVG generation, no ComfyUI workflow involved** (v0.3.77) — the LLM can also produce SVG graphics directly as text; if a reply contains SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`), it's rendered inline as a preview and a "Save to Gallery" button writes it as a `.svg` file to the Gallery tab's output folder; unlike the Tool Calling image generation flow above, this is plain text pattern matching, so it works with any model regardless of tool-calling support
- **Chat pane — Skills** (v0.3.79) — select a `.md` skill file from a dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); a pencil button opens an in-pane manager (list + inline editor, same pattern as the Prompt tab's wildcard file manager) to create, edit, or delete skill files stored in `user/default/Workflow-Studio/ai_skills/`; a file's `---`-delimited frontmatter (`name`/`description`) is shown in the dropdown; two starter skills are bundled — **SVG Icon Generator** (explicitly decomposes the subject into parts before drawing, which markedly improves recognizability from small local models) and **Skill Creator** (interviews you conversationally and drafts a new skill as a ```skill code block; a "Save as new skill" button appears under such replies and opens the manager pre-filled with the draft)
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade / Unsloth), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
- **Unsloth backend** (v0.3.99) — Unsloth Desktop's OpenAI-compatible API always requires an API key, even for local access; unlike the other backends it's proxied server-side (`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`) so the key never reaches the browser: set `UNSLOTH_API_KEY` in a `.env` file in the plugin folder (copy `.env.example`, loaded via `python-dotenv` in `prestartup_script.py`) and restart ComfyUI; also requires **"Auto model switching (OpenAI API)"** to be turned on in the Unsloth Desktop app's own Settings screen — with it off, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
- **Settings pane — Generation** (v0.3.98) — **Thinking mode** toggle (off by default; sends Ollama's `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: `options.num_predict`; LM Studio/Lemonade/Unsloth: `max_tokens`; `0` = unlimited)
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade)
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API directly from the browser (`/v1/chat/completions`, `/v1/models`); Unsloth via the same OpenAI-compatible API but relayed through a server-side proxy that attaches the `Authorization: Bearer` header; VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade/Unsloth)
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
- **Lemonade's other endpoints (not integrated)** — Lemonade Server also exposes OpenAI-compatible image generation (`/v1/images/generations`), text-to-speech (`/v1/audio/speech`), and transcription (`/v1/audio/transcriptions`) endpoints; these are not wired up — image generation would duplicate the existing ComfyUI-workflow-based `generate_image` tool call, and audio/TTS has no corresponding UI yet, so both are future considerations rather than a natural extension of the current backend switch
@@ -402,15 +453,16 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
- **Package sub-tab** — dropdown to filter nodes by custom node package name
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs); LoRA groups show an **All N LoRAs** item — drag to canvas to place a `Lora Loader (LoraManager)` node with all LoRAs pre-loaded
- **P — Prompts tab** — browse prompt presets with All / ★ Favorites / Categories sub-tabs; **Groups sub-tab** (row 2) — view presets by group (shared with the Batch tab's `wfm_prompt_preset_groups`)
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP/MP4 or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
- **MP4 embedded metadata** (v0.5.1) — reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4's `moov/udta/meta` box, parsed entirely client-side (same box-walking logic as the Gallery tab's Metadata sub-tab); also recognizes all-in-one video-generation nodes that carry their prompt directly on a `prompt` input rather than a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
- **model sub-tab** — Checkpoint, VAE, Diffusion Model, and Text Encoder; drag items to canvas to place the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`); double-click also places at canvas center
- **lora sub-tab** — detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format `inputs.loras.__value__` supported); shows `strength_model / strength_clip` values; drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (appears for 1+ LoRAs) drags all LoRAs into a single `Lora Loader (LoraManager)` node with LoRA syntax pre-filled
- **Prompts sub-tab** — POS / NEG badge list; drag a prompt to place `CLIPTextEncode` with text pre-filled; click any entry to view full text + Copy button
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
- **Drag & drop nodes** — drag nodes/node sets onto the canvas to place them
- **Drag & drop prompts** — drag a preset onto the canvas to create a WFS_PromptText node with positive/negative prompts
- **Send to Canvas** — clicking "Send to Canvas" (Workflow tab toolbar / detail modal) or "Copy & Send Canvas" (Gallery tab JSON panel) sends the workflow directly to the ComfyUI canvas via `window.opener` (UI and API formats both supported); if Workflow Studio was opened from a bookmark instead of the ComfyUI toolbar, UI-format workflows fall back to title drag (panel title highlights blue with a green ● — drag it onto the canvas to load)
- **Send to Canvas** — clicking "Send to Canvas" (Workflow tab toolbar / detail modal) sends the workflow directly to the ComfyUI canvas via `window.opener` when available (UI and API formats both supported); if Workflow Studio was opened from a bookmark instead of the ComfyUI toolbar (or in another tab), it filters the Library's **W** tab down to that workflow's card instead (v0.4.7) — drag the card onto the canvas yourself; this replaced the old title-drag fallback, which had no thumbnail and didn't work for API-format workflows at all. "Copy & Send Canvas" (Gallery tab JSON panel) still uses only `window.opener` + title-drag for UI-format workflows, since the embedded workflow it sends isn't a saved Library file to filter down to
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
- **Double-click** — load workflows or place nodes without dragging
- **Search** — search within each sub-tab to quickly find items
@@ -500,6 +552,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
+1
View File
@@ -0,0 +1 @@
{"0": "models-tab.js", "1": "prompt-tab.js", "2": "models_routes.py", "3": "generate-tab.js", "4": "lab-tab.js", "5": "gallery_routes.py", "6": "gallery-tab.js", "7": "ImageEditTab", "8": "ai-tab.js", "10": "feeder-tab.js", "11": "NodesService", "12": "settings_routes.py", "13": "metadata-tab.js", "14": "node_sets_menu.js", "15": "GalleryService", "16": "workflow-tab.js", "17": "tagger_routes.py", "18": "WildcardService", "19": "t", "20": "renderContent", "21": "comfyui-editor.js", "23": "TaggerService", "24": "top_menu_extension.js", "25": "settings-tab.js", "26": "setupAiHandlers", "27": "PromptsService", "28": "workflow_routes.py", "29": "GalleryMetadataStore", "30": "WorkflowService", "31": "lab_routes.py", "32": "SkillService", "33": "LayerManager", "34": "SelectTool", "35": "CivitaiService", "36": "DrawTool", "37": "MaskTool", "39": "gallery_service.py", "41": "image-prompt-tab.js", "42": "KSampler Node", "44": "style-catalog-tab.js", "45": "add", "46": "comfyui-workflow.js", "47": "BlurTool", "48": "MaskShapeTool", "49": "MaskVectorTool", "50": "ShapeTool", "51": "gmic_routes.py", "52": "ollama_routes.py", "53": "TextTool", "54": "analyze_workflow", "55": "MaskTextTool", "56": "i18n.js", "57": ".list_images", "58": "MaskAlphaTool", "59": "MaskColorTool", "60": "gallery_feeder_extension.js", "61": "createPanel", "62": "util.js", "63": "BgRemove", "65": "WFS_GalleryFeeder", "66": "app.js", "67": "InpaintI2IActions", "68": "Sam3Segmentation", "69": "fromWorkflow", "70": "_extractPromptsAPI", "71": "generate_doc_index.py", "72": "WS Inpaint Basic Workflow", "73": "._bg_index_folder", "74": "SettingsService", "75": "import_style_prompt_seed.py", "76": "Image Edit (FireRed Image Edit 1.1) node", "77": "Version 0.3.9", "78": "DWPose Image Feeder Workflow", "79": "civitai_service.py", "81": "WFS_PromptText", "82": "workflow_service.py", "84": "config.py", "85": "Tagger Tab requirements.txt", "88": "Chinese Language Support", "89": "Version 0.3.95", "97": "Lab Subtab", "100": "prestartup_script.py", "101": ".get_image_prompt_root", "102": ".get_style_catalog_root", "103": ".start_background_index", "104": ".update_workflows_dir", "105": "Version 0.4.2", "106": "Workflow Studio Banner", "107": "Workflow Studio Icon", "108": "BiRefNet Model", "110": "SDXL 2D Pose Editor I2I Workflow", "111": "ComfyUI Manager", "112": "Tagger Tab Screenshot (WD Tagger/DeepDanbooru)", "113": "Models Tab UI Screenshot", "114": "Workflow Studio Topbar Icon Strip", "115": "GenerateUI LoRA Stack Tab Screenshot", "116": "Workflow Studio Favicon Icon", "117": "2D Pose Editor to Image Edit (FireRed) Workflow", "118": "3D Pose Editor to Image Edit (Flux.2 Klein) Workflow", "119": "SDXL DWPose ControlNet Workflow", "120": "SDXL From Metadata T2I Workflow", "121": "SDXL Img2Img + FaceDetailer Workflow", "122": "SDXL Image Feeder Img2Img Workflow", "123": "SDXL T2I + FaceDetailer Workflow", "124": "GenerateUI Batch Tab Screenshot", "125": "Multiple Select Menu Screenshot", "126": "Image Edit Tab Screenshot", "127": "Workflow Tab Screenshot", "128": "Prompt Tab Input Assistance Screenshot", "129": "GenerateUI Feeder Tab Screenshot", "130": "Workflow Studio Library Panel (Lora Info View)", "131": "Library Panel I Tab (Prompts) Screenshot", "132": "Settings Tab Screenshot (Language, Theme)", "133": "ComfyUI Workflow Studio Thumbnail", "135": "Feeder Subtab", "137": "Help & Support Tab", "140": "Metadata Tab", "144": "comfyui-workflow-studio", "146": "py/__init__.py", "147": "nodes/__init__.py", "148": "routes/__init__.py", "149": "services/__init__.py", "150": "python-dotenv", "155": "SD1.5 Basic Text-to-Image Workflow", "156": "SDXL Text-to-Image Basic Workflow Screenshot", "9": "Backend API Routes", "22": "External Plugin Ecosystem", "38": "Registry Publish & Docs", "40": "Generate & Export Utilities", "43": "ComfyUI Client & Mask Bridge", "64": "Workflow Analyzer Internals", "80": "Doc Index Automation", "83": "Project Directory Layout", "86": "Data Storage Files", "87": "AI Tool Version History", "90": "Windows Long Path Handling", "91": "Route Registration", "92": "Caching & Unique IDs", "93": "Service Layer", "94": "Lazy Input Evaluation", "95": "HuggingFace Config", "96": "Node Cache Invalidation", "98": "Mask Editor One Release", "99": "PowerShell Encoding", "109": "Chinese Locale", "134": "English Locale", "136": "ComfyUI folder_paths API", "138": "i18n System", "139": "Japanese Locale", "141": "Node Class Mappings", "142": "OrderedDict Usage", "143": "Output Directory API", "145": "ComfyUI PromptServer", "151": "Silent Failure Diagnosis", "152": "Temp Directory API", "153": "Path Utility", "154": "Input Validation Hook"}
+546
View File
@@ -0,0 +1,546 @@
# Graph Report - ComfyUI-Workflow-Studio (2026-08-22)
## Corpus Check
- 20 files · ~567,102 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 2320 nodes · 5560 edges · 157 communities (79 shown, 78 thin omitted)
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 328 edges (avg confidence: 0.87)
- Token cost: 22,191 input · 55,365 output
## Community Hubs (Navigation)
- models-tab.js
- prompt-tab.js
- models_routes.py
- generate-tab.js
- lab-tab.js
- gallery_routes.py
- gallery-tab.js
- ImageEditTab
- ai-tab.js
- Backend API Routes
- feeder-tab.js
- NodesService
- settings_routes.py
- metadata-tab.js
- node_sets_menu.js
- GalleryService
- workflow-tab.js
- tagger_routes.py
- WildcardService
- t
- renderContent
- comfyui-editor.js
- External Plugin Ecosystem
- TaggerService
- top_menu_extension.js
- settings-tab.js
- setupAiHandlers
- PromptsService
- workflow_routes.py
- GalleryMetadataStore
- WorkflowService
- lab_routes.py
- SkillService
- LayerManager
- SelectTool
- CivitaiService
- DrawTool
- MaskTool
- Registry Publish & Docs
- gallery_service.py
- Generate & Export Utilities
- image-prompt-tab.js
- KSampler Node
- ComfyUI Client & Mask Bridge
- style-catalog-tab.js
- add
- comfyui-workflow.js
- BlurTool
- MaskShapeTool
- MaskVectorTool
- ShapeTool
- gmic_routes.py
- ollama_routes.py
- TextTool
- analyze_workflow
- MaskTextTool
- i18n.js
- .list_images
- MaskAlphaTool
- MaskColorTool
- gallery_feeder_extension.js
- createPanel
- util.js
- BgRemove
- Workflow Analyzer Internals
- WFS_GalleryFeeder
- app.js
- InpaintI2IActions
- Sam3Segmentation
- fromWorkflow
- _extractPromptsAPI
- generate_doc_index.py
- WS Inpaint Basic Workflow
- ._bg_index_folder
- SettingsService
- import_style_prompt_seed.py
- Image Edit (FireRed Image Edit 1.1) node
- Version 0.3.9
- DWPose Image Feeder Workflow
- civitai_service.py
- Doc Index Automation
- WFS_PromptText
- workflow_service.py
- Project Directory Layout
- config.py
- Tagger Tab requirements.txt
- Data Storage Files
- AI Tool Version History
- Chinese Language Support
- Version 0.3.95
- Windows Long Path Handling
- Route Registration
- Caching & Unique IDs
- Service Layer
- Lazy Input Evaluation
- HuggingFace Config
- Node Cache Invalidation
- Lab Subtab
- Mask Editor One Release
- PowerShell Encoding
- prestartup_script.py
- .get_image_prompt_root
- .get_style_catalog_root
- .start_background_index
- .update_workflows_dir
- Version 0.4.2
- Workflow Studio Banner
- Workflow Studio Icon
- BiRefNet Model
- Chinese Locale
- SDXL 2D Pose Editor I2I Workflow
- ComfyUI Manager
- Tagger Tab Screenshot (WD Tagger/DeepDanbooru)
- Models Tab UI Screenshot
- Workflow Studio Topbar Icon Strip
- GenerateUI LoRA Stack Tab Screenshot
- Workflow Studio Favicon Icon
- 2D Pose Editor to Image Edit (FireRed) Workflow
- 3D Pose Editor to Image Edit (Flux.2 Klein) Workflow
- SDXL DWPose ControlNet Workflow
- SDXL From Metadata T2I Workflow
- SDXL Img2Img + FaceDetailer Workflow
- SDXL Image Feeder Img2Img Workflow
- SDXL T2I + FaceDetailer Workflow
- GenerateUI Batch Tab Screenshot
- Multiple Select Menu Screenshot
- Image Edit Tab Screenshot
- Workflow Tab Screenshot
- Prompt Tab Input Assistance Screenshot
- GenerateUI Feeder Tab Screenshot
- Workflow Studio Library Panel (Lora Info View)
- Library Panel I Tab (Prompts) Screenshot
- Settings Tab Screenshot (Language, Theme)
- ComfyUI Workflow Studio Thumbnail
- English Locale
- Feeder Subtab
- ComfyUI folder_paths API
- Help & Support Tab
- i18n System
- Japanese Locale
- Metadata Tab
- Node Class Mappings
- OrderedDict Usage
- Output Directory API
- comfyui-workflow-studio
- ComfyUI PromptServer
- python-dotenv
- Silent Failure Diagnosis
- Temp Directory API
- Path Utility
- Input Validation Hook
- SD1.5 Basic Text-to-Image Workflow
- SDXL Text-to-Image Basic Workflow Screenshot
## God Nodes (most connected - your core abstractions)
1. `t()` - 226 edges
2. `showToast()` - 186 edges
3. `ImageEditTab` - 53 edges
4. `escapeHtml()` - 53 edges
5. `GalleryService` - 45 edges
6. `ComfyUI-Workflow-Studio` - 41 edges
7. `initPromptTab()` - 33 edges
8. `setup_routes()` - 32 edges
9. `initSettingsTab()` - 31 edges
10. `add()` - 31 edges
## Surprising Connections (you probably didn't know these)
- `ComfyUI-Workflow-Studio` --references--> `ImagePrompt Gallery Subtab` [INFERRED]
CLAUDE.md → README.index.md
- `ComfyUI-Workflow-Studio` --references--> `Prompt Tab` [INFERRED]
CLAUDE.md → README.index.md
- `ComfyUI-Workflow-Studio` --references--> `Gallery Tab` [INFERRED]
CLAUDE.md → README.index.md
- `ComfyUI-Workflow-Studio` --references--> `GenerateUI Tab` [INFERRED]
CLAUDE.md → README.index.md
- `ComfyUI-Workflow-Studio` --references--> `Image Edit Tab` [INFERRED]
CLAUDE.md → README.index.md
## Import Cycles
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/Sam3Segmentation.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/BgRemove.js -> static/js/app.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/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/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/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/comfyui-editor.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/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/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/comfyui-editor.js -> static/js/app.js`
- 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/app.js -> static/js/gallery-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/comfyui-editor.js -> static/js/app.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/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/prompt-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
## Hyperedges (group relationships)
- **Architecture Layers** — workflowstudio, add_routes, py_wfm, py_routes, py_services, business_logic, web_comfyui, static, templates, data, settings_json, metadata_json, node_metadata_json [EXTRACTED 0.80]
- **Workflow JSON Consistency Group** — last_node_id, last_link_id, links, workflow_json [EXTRACTED 0.85]
- **i18n Languages** — i18n, english, japanese, chinese [EXTRACTED 0.90]
- **Graphify Index Generation** — readme_index, devlog_index, generate_doc_index, graphifyignore, readme, devlog, claude_hooks, git_hooks [EXTRACTED 0.85]
- **AI Feature Backends** — ollama, lm_studio, lemonade_server, unsloth [INFERRED 0.80]
- **Image/Gallery Implementation References** — comfyui_custom_scripts, comfyui_gallery, infinite_image_browsing [INFERRED 0.70]
- **Workflow Studio UI Navigation Tabs** — templates_index_html_workflow, templates_index_html_nodes, templates_index_html_models, templates_index_html_generate, templates_index_html_prompt, templates_index_html_gallery, templates_index_html_image_edit, templates_index_html_tagger, templates_index_html_settings, templates_index_html_help, templates_index_html_ai [EXTRACTED 0.85]
- **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 (157 total, 78 thin omitted)
### Community 0 - "models-tab.js"
Cohesion: 0.06
Nodes (120): openModal(), t(), badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange() (+112 more)
### Community 1 - "prompt-tab.js"
Cohesion: 0.06
Nodes (107): closeModal(), refreshStylesList(), addChatMessage(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), applyToGenerateUI(), attachFile() (+99 more)
### Community 2 - "models_routes.py"
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 3 - "generate-tab.js"
Cohesion: 0.06
Nodes (64): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _buildSimpleGroupList(), _ckptBatch (+56 more)
### Community 4 - "lab-tab.js"
Cohesion: 0.08
Nodes (67): _expandWildcardsInWorkflow(), _annotatedImageRef(), _applyLabLoraToWorkflow(), _applyPlanData(), _applyStyleToText(), _buildIndexImageDataUrl(), _buildLoraPromptInjection(), _buildPlanData() (+59 more)
### Community 5 - "gallery_routes.py"
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.js"
Cohesion: 0.09
Nodes (54): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+46 more)
### Community 8 - "ai-tab.js"
Cohesion: 0.09
Nodes (51): appendChatBubble(), _appendSkillSaveButton(), _appendSvgPreview(), _applyGenOptions(), buildTranslationMessages(), callChat(), callLLM(), callVLM() (+43 more)
### Community 9 - "Backend API Routes"
Cohesion: 0.05
Nodes (46): aiohttp, Jinja2, py/routes, _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test() (+38 more)
### Community 10 - "feeder-tab.js"
Cohesion: 0.10
Nodes (49): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+41 more)
### Community 11 - "NodesService"
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 12 - "settings_routes.py"
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 13 - "metadata-tab.js"
Cohesion: 0.11
Nodes (40): buildLoRAItem(), buildModelItem(), buildPromptItem(), collectAllNodes(), collectUnique(), extractAllMetadata(), fromWorkflow(), extractCheckpoints() (+32 more)
### Community 14 - "node_sets_menu.js"
Cohesion: 0.08
Nodes (37): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+29 more)
### Community 15 - "GalleryService"
Cohesion: 0.10
Nodes (13): GalleryService, Path, 許可するルートパスを更新する(Settings変更時に呼ぶ), ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ), パスが許可ルート配下かチェック(パストラバーサル防止)。 Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの…, outputフォルダのフォルダツリーを返す, 画像と同名の.txtサイドカーにプロンプトテキストを保存する, ワークフローを抽出する。 優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow] (+5 more)
### Community 16 - "workflow-tab.js"
Cohesion: 0.13
Nodes (35): readJsonStorage(), badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows() (+27 more)
### Community 17 - "tagger_routes.py"
Cohesion: 0.14
Nodes (24): Connection, handle_batch_start(), handle_batch_status(), handle_batch_stop(), handle_db_delete(), handle_db_export(), handle_db_list(), handle_db_save() (+16 more)
### Community 18 - "WildcardService"
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 19 - "t"
Cohesion: 0.12
Nodes (35): _applyI18n(), _batchStart(), _batchStop(), _dbDelete(), _dbExport(), _dbLoad(), _dbSave(), _dbSearch() (+27 more)
### Community 20 - "renderContent"
Cohesion: 0.14
Nodes (35): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), createModelItem(), esc(), extractPackageName(), fetchModelList(), getNodeCategory() (+27 more)
### Community 21 - "comfyui-editor.js"
Cohesion: 0.08
Nodes (28): _applyLoraToNode(), _attachPromptWeightControl(), _buildLoraManagerSyntax(), _buildLoraSyntax(), _buildPresetOptions(), _compositeImageWithMask(), _I2I_PLACEHOLDER_DEFAULT, _imageInputToDataURL() (+20 more)
### Community 22 - "External Plugin Ecosystem"
Cohesion: 0.06
Nodes (33): Canvas Snapshot, CivitAI, ComfyUI-Custom-Scripts, ComfyUI-Gallery, ComfyUI-Lora-Manager, comfyui-mask-editor-one, README Index — ComfyUI-Workflow-Studio, ComfyUI-Workflow-Studio (+25 more)
### Community 23 - "TaggerService"
Cohesion: 0.14
Nodes (5): Image, Path, WD Tagger / DeepDanbooru / Ollama VLM 推論サービス。, パストラバーサル防止: セパレータ・NUL・相対参照を拒否, TaggerService
### Community 24 - "top_menu_extension.js"
Cohesion: 0.13
Nodes (29): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), injectStyles(), NODE_SETS_TOOLTIP, saveSelectedAsNodeSet(), attachTopMenuButton(), beforeRegisterNodeDef() (+21 more)
### Community 25 - "settings-tab.js"
Cohesion: 0.12
Nodes (29): applyCustomFont(), applyCustomOverrides(), applyTheme(), BG_PATTERNS, buildLangOptions(), clearCustomOverrides(), COLOR_TARGETS, FONT_OPTIONS (+21 more)
### Community 26 - "setupAiHandlers"
Cohesion: 0.11
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
### Community 27 - "PromptsService"
Cohesion: 0.13
Nodes (16): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+8 more)
### Community 28 - "workflow_routes.py"
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 29 - "GalleryMetadataStore"
Cohesion: 0.13
Nodes (9): GalleryMetadataStore, Path, グループ名を変更し、全画像のgroupsフィールドも更新する, gallery_metadata.json の構造: { "images": { "<abs_path>": { "favorite": bool,…, 存在しないパスをグループから一括削除する(1回の保存で完結), グループメンバーのパスをsetで返す(高速フィルタ用), グループ内の全画像を除外する(グループ自体は残す), 画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ) (+1 more)
### Community 30 - "WorkflowService"
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 31 - "lab_routes.py"
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 32 - "SkillService"
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 35 - "CivitaiService"
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 38 - "Registry Publish & Docs"
Cohesion: 0.10
Nodes (22): actions/checkout@v4, Comfy-Org/publish-node-action@main, DEVLOG.md, Publish to Comfy Registry Workflow, graphifyignore, main Branch, pyproject.toml, README.md (+14 more)
### Community 39 - "gallery_service.py"
Cohesion: 0.10
Nodes (13): Gallery Metadata Store - ギャラリー画像のメタデータ永続化, _clean_vault_tags(), _decode_image_data_url(), _FolderCache, _parse_vault_yaml_leaves(), Gallery Service - outputフォルダの画像管理、メタデータ閲覧, フォルダ単位の画像スキャン結果キャッシュ。 フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。, data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。 ヘッダが無ければPNG扱い(旧互換)。 (+5 more)
### Community 40 - "Generate & Export Utilities"
Cohesion: 0.17
Nodes (8): showToast(), _applyDefaultCheckpointIfEnabled(), loadWorkflowIntoEditor(), saveCurrentWorkflow(), FileExport, GmicIntegration, _loadPlanWorkflow(), onLoadGenUIClick()
### Community 41 - "image-prompt-tab.js"
Cohesion: 0.20
Nodes (21): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+13 more)
### Community 42 - "KSampler Node"
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 43 - "ComfyUI Client & Mask Bridge"
Cohesion: 0.16
Nodes (8): comfyUI, comfyEditor, comfyWorkflow, MaskEditorOneBridge, MASK_TEXT_FONTS, fitToCanvas(), TOOL_DEFS, TEXT_FONTS
### Community 44 - "style-catalog-tab.js"
Cohesion: 0.22
Nodes (18): selectStyleByName(), activateStyleCatalogTab(), API, apiFetch(), copyText(), createThumbCard(), debounce(), initStyleCatalogTab() (+10 more)
### Community 45 - "add"
Cohesion: 0.22
Nodes (19): add(), fetchWorkflowRaw(), getCanvasDropPos(), handleInfoFile(), installCanvasDropHandler(), loadWorkflowOnCanvas(), placeClipTextEncodeNode(), placeLoraMgrNode() (+11 more)
### Community 46 - "comfyui-workflow.js"
Cohesion: 0.20
Nodes (13): _CONTROL_AFTER_GENERATE, _findInjectedWidgetIndex(), _flattenSubgraphs(), _getDynamicComboSubNames(), _getWidgetInputNames(), _getWidgetInputTypes(), _isExtraWidgetValue(), _isLinkedWidgetName() (+5 more)
### Community 51 - "gmic_routes.py"
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 52 - "ollama_routes.py"
Cohesion: 0.23
Nodes (14): _get_ollama_config(), handle_chat(), handle_models(), handle_test(), Application, Request, Response, Ollama API proxy routes. (+6 more)
### Community 54 - "analyze_workflow"
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 56 - "i18n.js"
Cohesion: 0.17
Nodes (12): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+4 more)
### Community 57 - ".list_images"
Cohesion: 0.17
Nodes (6): os.scandir() で画像ファイルを1回のシステムコールで列挙。 キャッシュがあればそれを返す。 Returns: [(name,…, os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。 ImagePrompt/Style…, 指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める), 画像のプロンプトテキストを返す。 1. 画像と同名の .txt サイドカーがあればそれを使う 2. 無ければ ponyxlWildcardsVault 形式…, image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ…, ponyxlWildcardsVault形式のフォールバック解決。 thumbnails_option2 の "{leaf}.preview3.ext"…
### Community 60 - "gallery_feeder_extension.js"
Cohesion: 0.36
Nodes (11): beforeRegisterNodeDef(), fetchGroupImages(), getInputWidget(), getOrCreate(), _ns, setWidgetValue(), startLoop(), stopLoop() (+3 more)
### Community 61 - "createPanel"
Cohesion: 0.24
Nodes (12): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), loadModelsData() (+4 more)
### Community 62 - "util.js"
Cohesion: 0.31
Nodes (10): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), getEagleSettings(), getSettings(), _joinBytes(), _n2b() (+2 more)
### Community 64 - "Workflow Analyzer Internals"
Cohesion: 0.20
Nodes (10): EXTRA_PNGINFO, last_link_id, last_node_id, links array, Position Index, PROMPT, widgets_values, workflow_analyzer.py (+2 more)
### Community 65 - "WFS_GalleryFeeder"
Cohesion: 0.27
Nodes (4): Path, WFS_GalleryFeeder – Feeds images from a gallery group into a workflow., Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。, WFS_GalleryFeeder
### Community 66 - "app.js"
Cohesion: 0.22
Nodes (7): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize()
### Community 69 - "fromWorkflow"
Cohesion: 0.36
Nodes (10): _collectAllNodes(), _collectUnique(), fromWorkflow(), _extractCheckpoints(), _extractDiffusionModels(), _extractLoRAs(), _extractMarkdownNoteModels(), addU() (+2 more)
### Community 70 - "_extractPromptsAPI"
Cohesion: 0.33
Nodes (10): _extractPrompts(), _extractPromptsAPI(), _extractPromptsFromNodeSet(), _extractPromptsLiteGraph(), _isPromptStylerNode(), _isSamplerNode(), _isTextEncoderNode(), _resolveEditPlusText() (+2 more)
### Community 71 - "generate_doc_index.py"
Cohesion: 0.31
Nodes (7): main(), PostToolUse hook: regenerate README.index.md / DEVLOG.index.md right after…, Path, generate_devlog_index(), generate_readme_index(), main(), Regenerate README.index.md / DEVLOG.index.md from README.md / DEVLOG.md.…
### Community 72 - "WS 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 73 - "._bg_index_folder"
Cohesion: 0.25
Nodes (5): Event, バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。 10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。, PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す, PNGのtEXtチャンクからメタデータを抽出, JPEGのEXIF/commentからメタデータを抽出(簡易)
### Community 74 - "SettingsService"
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 75 - "import_style_prompt_seed.py"
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 76 - "Image Edit (FireRed Image Edit 1.1) node"
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 77 - "Version 0.3.9"
Cohesion: 0.25
Nodes (8): Models Tab, Nodes Tab, Prompt Tab, Version 0.1.2, Version 0.1.7, Version 0.2.3, Version 0.3.9, Workflow Tab
### Community 78 - "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 79 - "civitai_service.py"
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 80 - "Doc Index Automation"
Cohesion: 0.40
Nodes (6): on_doc_edit.py, DEVLOG.index.md, generate_doc_index.py, pre-commit, graphify, README.index.md
### Community 81 - "WFS_PromptText"
Cohesion: 0.33
Nodes (3): WFS_PromptText – Prompt preset node with positive/negative STRING outputs., Outputs positive and negative prompt strings., WFS_PromptText
### Community 82 - "workflow_service.py"
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 83 - "Project Directory Layout"
Cohesion: 0.40
Nodes (5): ComfyUI, SPA, static, templates, web/comfyui
### Community 84 - "config.py"
Cohesion: 0.40
Nodes (3): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Settings management service.
### Community 85 - "Tagger Tab requirements.txt"
Cohesion: 0.40
Nodes (5): Tagger Tab requirements.txt, onnxruntime (WD Tagger / SwinV2), piexif (JPEG EXIF tag writing), python-dotenv (Unsloth .env loading), tensorflow (optional, DeepDanbooru)
### Community 86 - "Data Storage Files"
Cohesion: 0.50
Nodes (4): data directory, metadata.json, node_metadata.json, settings.json
### Community 87 - "AI Tool Version History"
Cohesion: 0.67
Nodes (3): AI TOOL Tab, Version 0.3.14, Version 0.3.99
### Community 88 - "Chinese Language Support"
Cohesion: 1.00
Nodes (3): Chinese Language Support, English Language Support, Japanese Language Support
### Community 89 - "Version 0.3.95"
Cohesion: 0.67
Nodes (3): ImagePrompt Gallery Subtab, Style_Catalog Subtab, Version 0.3.95
### Community 90 - "Windows Long Path Handling"
Cohesion: 0.67
Nodes (3): LongPathsEnabled, MAX_PATH, Windows
## Knowledge Gaps
- **228 isolated node(s):** `state`, `groups`, `state`, `WF_RESERVED_GROUPS`, `AI_BACKEND_DEFAULT_URLS` (+223 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **78 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `showToast()` connect `Generate & Export Utilities` to `models-tab.js`, `prompt-tab.js`, `generate-tab.js`, `lab-tab.js`, `gallery-tab.js`, `ImageEditTab`, `ai-tab.js`, `feeder-tab.js`, `workflow-tab.js`, `t`, `comfyui-editor.js`, `settings-tab.js`, `LayerManager`, `image-prompt-tab.js`, `ComfyUI Client & Mask Bridge`, `style-catalog-tab.js`, `BlurTool`, `BgRemove`, `app.js`, `InpaintI2IActions`, `Sam3Segmentation`?**
_High betweenness centrality (0.062) - this node is a cross-community bridge._
- **Why does `t()` connect `models-tab.js` to `prompt-tab.js`, `app.js`, `generate-tab.js`, `lab-tab.js`, `gallery-tab.js`, `ai-tab.js`, `Generate & Export Utilities`, `feeder-tab.js`, `image-prompt-tab.js`, `style-catalog-tab.js`, `metadata-tab.js`, `workflow-tab.js`, `t`, `comfyui-editor.js`, `i18n.js`, `settings-tab.js`?**
_High betweenness centrality (0.048) - this node is a cross-community bridge._
- **Why does `GalleryService` connect `GalleryService` to `gallery_routes.py`, `.get_image_prompt_root`, `gallery_service.py`, `.get_style_catalog_root`, `._bg_index_folder`, `.start_background_index`, `.list_images`, `GalleryMetadataStore`?**
_High betweenness centrality (0.027) - this node is a cross-community bridge._
- **What connects `state`, `groups`, `state` to the rest of the system?**
_228 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `models-tab.js` be split into smaller, more focused modules?**
_Cohesion score 0.06185919343814081 - nodes in this community are weakly interconnected._
- **Should `prompt-tab.js` be split into smaller, more focused modules?**
_Cohesion score 0.05572545022086307 - nodes in this community are weakly interconnected._
- **Should `models_routes.py` be split into smaller, more focused modules?**
_Cohesion score 0.0554954954954955 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+866
View File
@@ -0,0 +1,866 @@
{
"CLAUDE.md": {
"mtime": 1787279658.5963283,
"seen": 1787380810.3754587,
"ast_hash": "b2c014309df240903aa90a26914c8c19",
"semantic_hash": "b2c014309df240903aa90a26914c8c19"
},
".claude/settings.json": {
"mtime": 1787279595.344592,
"seen": 1787380810.2925637,
"ast_hash": "cd2c4308b55c5d1aa1b647cdec814b79",
"semantic_hash": "cd2c4308b55c5d1aa1b647cdec814b79"
},
"__init__.py": {
"mtime": 1781939762.254596,
"seen": 1787279199.0011544,
"ast_hash": "72d9ef06026dff7be74da81ff98a306a",
"semantic_hash": "72d9ef06026dff7be74da81ff98a306a"
},
"prestartup_script.py": {
"mtime": 1787036267.332648,
"seen": 1787279199.0011563,
"ast_hash": "7b5d482ef21a480d1b38356e8d82970b",
"semantic_hash": "7b5d482ef21a480d1b38356e8d82970b"
},
"py/__init__.py": {
"mtime": 1773797715.629029,
"seen": 1787279199.0011575,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/config.py": {
"mtime": 1786245317.3352194,
"seen": 1787279199.001159,
"ast_hash": "b013c27bb7cd9a79449961539fdcd30f",
"semantic_hash": "b013c27bb7cd9a79449961539fdcd30f"
},
"py/nodes/__init__.py": {
"mtime": 1774423045.6646452,
"seen": 1787279199.00116,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/nodes/gallery_feeder_node.py": {
"mtime": 1781943269.5205867,
"seen": 1787279199.0011609,
"ast_hash": "d37c520b0f76e70e60ec7c72db904bf4",
"semantic_hash": "d37c520b0f76e70e60ec7c72db904bf4"
},
"py/nodes/prompt_text.py": {
"mtime": 1774423045.6669831,
"seen": 1787279199.0011618,
"ast_hash": "43cf16f182b9eda5fade2b52c2439372",
"semantic_hash": "43cf16f182b9eda5fade2b52c2439372"
},
"py/routes/__init__.py": {
"mtime": 1773797716.663036,
"seen": 1787279199.0011628,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/routes/eagle_routes.py": {
"mtime": 1785464911.9023821,
"seen": 1787279199.0011635,
"ast_hash": "edd32039935a892317c55b434c9839ab",
"semantic_hash": "edd32039935a892317c55b434c9839ab"
},
"py/routes/gallery_routes.py": {
"mtime": 1786682156.6899943,
"seen": 1787279199.001165,
"ast_hash": "59e4c5d34ee2329ab9e0a66f841d1676",
"semantic_hash": "59e4c5d34ee2329ab9e0a66f841d1676"
},
"py/routes/gmic_routes.py": {
"mtime": 1782544784.03979,
"seen": 1787279199.0011659,
"ast_hash": "b909c65af6590c0ca058136cb0b9234e",
"semantic_hash": "b909c65af6590c0ca058136cb0b9234e"
},
"py/routes/lab_routes.py": {
"mtime": 1786322410.571655,
"seen": 1787279199.0011666,
"ast_hash": "48e94f71b11f2e494bb98bb7113e2baa",
"semantic_hash": "48e94f71b11f2e494bb98bb7113e2baa"
},
"py/routes/models_routes.py": {
"mtime": 1782289767.1073005,
"seen": 1787279199.0011675,
"ast_hash": "067aa9c89085fa3e077fdfacbb6be2cd",
"semantic_hash": "067aa9c89085fa3e077fdfacbb6be2cd"
},
"py/routes/nodes_routes.py": {
"mtime": 1774255599.0841086,
"seen": 1787279199.0011685,
"ast_hash": "dc5f7b12dce346631ff945c31bf03ac9",
"semantic_hash": "dc5f7b12dce346631ff945c31bf03ac9"
},
"py/routes/ollama_routes.py": {
"mtime": 1784126582.1613884,
"seen": 1787279199.0011694,
"ast_hash": "80552c0676f7bbd4209eb95cf7a03f5b",
"semantic_hash": "80552c0676f7bbd4209eb95cf7a03f5b"
},
"py/routes/prompts_routes.py": {
"mtime": 1774423045.6696556,
"seen": 1787279199.0011702,
"ast_hash": "53b5641f3ad6f65e8ae665601c4739d7",
"semantic_hash": "53b5641f3ad6f65e8ae665601c4739d7"
},
"py/routes/settings_routes.py": {
"mtime": 1786681377.025748,
"seen": 1787279199.001171,
"ast_hash": "bb2f332c04474ee39319c2d0b8107db9",
"semantic_hash": "bb2f332c04474ee39319c2d0b8107db9"
},
"py/routes/skill_routes.py": {
"mtime": 1785634791.033795,
"seen": 1787279199.0011723,
"ast_hash": "3318c506163c720c5412ec0f8ff3a28a",
"semantic_hash": "3318c506163c720c5412ec0f8ff3a28a"
},
"py/routes/tagger_routes.py": {
"mtime": 1781503496.8879485,
"seen": 1787279199.001173,
"ast_hash": "9c0559b594a69354882da0b1643e11f0",
"semantic_hash": "9c0559b594a69354882da0b1643e11f0"
},
"py/routes/unsloth_routes.py": {
"mtime": 1787036726.267709,
"seen": 1787279199.001174,
"ast_hash": "93d4de97937f472f9618a35a0b4211d6",
"semantic_hash": "93d4de97937f472f9618a35a0b4211d6"
},
"py/routes/wildcard_routes.py": {
"mtime": 1777370036.4121041,
"seen": 1787279199.0011756,
"ast_hash": "1d0ea981a44ffab77d62c214951f4536",
"semantic_hash": "1d0ea981a44ffab77d62c214951f4536"
},
"py/routes/workflow_routes.py": {
"mtime": 1773896239.095392,
"seen": 1787279199.0011766,
"ast_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3",
"semantic_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3"
},
"py/services/__init__.py": {
"mtime": 1773797717.739297,
"seen": 1787279199.0011773,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/services/civitai_service.py": {
"mtime": 1780233122.7587051,
"seen": 1787279199.001178,
"ast_hash": "3b4a2a118bc56dd27e768a2819b401e7",
"semantic_hash": "3b4a2a118bc56dd27e768a2819b401e7"
},
"py/services/gallery_metadata.py": {
"mtime": 1786170828.9941547,
"seen": 1787279199.0011792,
"ast_hash": "9d02a71d56b7ca2a88cdf8c56fadf912",
"semantic_hash": "9d02a71d56b7ca2a88cdf8c56fadf912"
},
"py/services/gallery_service.py": {
"mtime": 1786638406.9058151,
"seen": 1787279199.0011802,
"ast_hash": "c8b9858f015f7f4a934c4b630e905107",
"semantic_hash": "c8b9858f015f7f4a934c4b630e905107"
},
"py/services/lab_plan_service.py": {
"mtime": 1786322396.0192728,
"seen": 1787279199.001181,
"ast_hash": "5d2c928c077ad7b788f15419b39ef51f",
"semantic_hash": "5d2c928c077ad7b788f15419b39ef51f"
},
"py/services/models_service.py": {
"mtime": 1782393318.5989842,
"seen": 1787279199.0011816,
"ast_hash": "0885cc6852199fa45271192ea0d6713a",
"semantic_hash": "0885cc6852199fa45271192ea0d6713a"
},
"py/services/nodes_service.py": {
"mtime": 1774255599.0862236,
"seen": 1787279199.0011828,
"ast_hash": "8819590ffdc136fb2912d9c5696e26e0",
"semantic_hash": "8819590ffdc136fb2912d9c5696e26e0"
},
"py/services/png_extractor.py": {
"mtime": 1773797967.5810304,
"seen": 1787279199.0011837,
"ast_hash": "1282b6032d78cba2a4bbbfb876126bc1",
"semantic_hash": "1282b6032d78cba2a4bbbfb876126bc1"
},
"py/services/prompts_service.py": {
"mtime": 1774423045.6711988,
"seen": 1787279199.0011845,
"ast_hash": "79a98e220d2b556106e8530054547155",
"semantic_hash": "79a98e220d2b556106e8530054547155"
},
"py/services/settings_service.py": {
"mtime": 1773798498.9270506,
"seen": 1787279199.0011854,
"ast_hash": "dade5ea667a2c6408434ad3061c76db7",
"semantic_hash": "dade5ea667a2c6408434ad3061c76db7"
},
"py/services/skill_service.py": {
"mtime": 1785637306.8654232,
"seen": 1787279199.0011866,
"ast_hash": "5ebe579995c1c674e4219e58b3bd5855",
"semantic_hash": "5ebe579995c1c674e4219e58b3bd5855"
},
"py/services/tagger_db_service.py": {
"mtime": 1781503496.8949444,
"seen": 1787279199.0011873,
"ast_hash": "de0ca007db5159484e8ff3b8a36a3b84",
"semantic_hash": "de0ca007db5159484e8ff3b8a36a3b84"
},
"py/services/tagger_service.py": {
"mtime": 1781503496.8969479,
"seen": 1787279199.0011883,
"ast_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9",
"semantic_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9"
},
"py/services/wildcard_service.py": {
"mtime": 1777380000.0054882,
"seen": 1787279199.0011892,
"ast_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd",
"semantic_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd"
},
"py/services/workflow_analyzer.py": {
"mtime": 1785474766.4736228,
"seen": 1787279199.00119,
"ast_hash": "937d6b02a6235795cb22ceb39e81f719",
"semantic_hash": "937d6b02a6235795cb22ceb39e81f719"
},
"py/services/workflow_service.py": {
"mtime": 1775439760.4805496,
"seen": 1787279199.0011911,
"ast_hash": "a2e47c0b8780ec7ca292310b16733a8c",
"semantic_hash": "a2e47c0b8780ec7ca292310b16733a8c"
},
"py/wfm.py": {
"mtime": 1787036294.0749958,
"seen": 1787279199.0011919,
"ast_hash": "cb1c96536f67edb380d1010b6165d74d",
"semantic_hash": "cb1c96536f67edb380d1010b6165d74d"
},
"pyproject.toml": {
"mtime": 1787378263.2900941,
"seen": 1787380810.3265483,
"ast_hash": "f514fae02a3b48726f5073e932cfa1b3",
"semantic_hash": "f514fae02a3b48726f5073e932cfa1b3"
},
"static/js/ai-tab.js": {
"mtime": 1787293482.4888227,
"seen": 1787380810.3265514,
"ast_hash": "960b474155386a36000e6961b0b2e0c3",
"semantic_hash": "960b474155386a36000e6961b0b2e0c3"
},
"static/js/app.js": {
"mtime": 1787377516.2436306,
"seen": 1787380810.3265538,
"ast_hash": "7df994a3e8f6c34d27a1ccd88b207a5c",
"semantic_hash": "7df994a3e8f6c34d27a1ccd88b207a5c"
},
"static/js/comfyui-client.js": {
"mtime": 1786676208.2330563,
"seen": 1787279199.0011961,
"ast_hash": "bb546a6ae7bd35d1c16fee311681be73",
"semantic_hash": "bb546a6ae7bd35d1c16fee311681be73"
},
"static/js/comfyui-editor.js": {
"mtime": 1787293462.7015421,
"seen": 1787380810.3274822,
"ast_hash": "fe27626a41d18f02bdea2b89aaaf740b",
"semantic_hash": "fe27626a41d18f02bdea2b89aaaf740b"
},
"static/js/comfyui-workflow.js": {
"mtime": 1787293410.2500806,
"seen": 1787380810.3274848,
"ast_hash": "255cfe0c2172639ed698a5e9397d7144",
"semantic_hash": "255cfe0c2172639ed698a5e9397d7144"
},
"static/js/feeder-tab.js": {
"mtime": 1781939989.0796032,
"seen": 1787279199.0011988,
"ast_hash": "dcd5977c5fe691b87e2379cd0e018a12",
"semantic_hash": "dcd5977c5fe691b87e2379cd0e018a12"
},
"static/js/gallery-tab.js": {
"mtime": 1787368748.1550386,
"seen": 1787380810.328425,
"ast_hash": "0144bbe48e21eccc498714404e29e29e",
"semantic_hash": "0144bbe48e21eccc498714404e29e29e"
},
"static/js/generate-tab.js": {
"mtime": 1787369336.5249329,
"seen": 1787380810.3284276,
"ast_hash": "14b3ced4743af977596001606be49000",
"semantic_hash": "14b3ced4743af977596001606be49000"
},
"static/js/i18n.js": {
"mtime": 1787377822.448605,
"seen": 1787380810.32843,
"ast_hash": "d925b681cc485326659fbc24f5a87dda",
"semantic_hash": "d925b681cc485326659fbc24f5a87dda"
},
"static/js/image-edit-tab.js": {
"mtime": 1787199901.562169,
"seen": 1787279199.001202,
"ast_hash": "be7b15d85bb185e2fd3492f4d5657f21",
"semantic_hash": "be7b15d85bb185e2fd3492f4d5657f21"
},
"static/js/image-edit/BgRemove.js": {
"mtime": 1787126632.8601232,
"seen": 1787279199.0012033,
"ast_hash": "add6a58068bdadc0c7641202f36ddb27",
"semantic_hash": "add6a58068bdadc0c7641202f36ddb27"
},
"static/js/image-edit/BlurTool.js": {
"mtime": 1787126263.0542421,
"seen": 1787279199.0012043,
"ast_hash": "1e52bc3757110a271ec17d923784a91b",
"semantic_hash": "1e52bc3757110a271ec17d923784a91b"
},
"static/js/image-edit/DrawTool.js": {
"mtime": 1782717315.6436062,
"seen": 1787279199.001205,
"ast_hash": "a97a6a97ae28c804a7c5fa50448a2c87",
"semantic_hash": "a97a6a97ae28c804a7c5fa50448a2c87"
},
"static/js/image-edit/FileExport.js": {
"mtime": 1787192008.741717,
"seen": 1787279199.0012057,
"ast_hash": "cf645b775e537ad276c9e070c7849349",
"semantic_hash": "cf645b775e537ad276c9e070c7849349"
},
"static/js/image-edit/GmicIntegration.js": {
"mtime": 1787125777.3889306,
"seen": 1787279199.0012069,
"ast_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca",
"semantic_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca"
},
"static/js/image-edit/InpaintI2IActions.js": {
"mtime": 1787127273.4351766,
"seen": 1787279199.0012076,
"ast_hash": "9ea57922985b63d4ec8eb44219a8341b",
"semantic_hash": "9ea57922985b63d4ec8eb44219a8341b"
},
"static/js/image-edit/LayerManager.js": {
"mtime": 1782717943.772222,
"seen": 1787279199.0012085,
"ast_hash": "596cd1906208fe72e1c2953d4d7f7135",
"semantic_hash": "596cd1906208fe72e1c2953d4d7f7135"
},
"static/js/image-edit/MaskEditorOneBridge.js": {
"mtime": 1787199887.897264,
"seen": 1787279199.0012095,
"ast_hash": "db2ef8c024c7e72baa0dfdb79b8abd4d",
"semantic_hash": "db2ef8c024c7e72baa0dfdb79b8abd4d"
},
"static/js/image-edit/MaskEditorOneTools.js": {
"mtime": 1782719665.7021136,
"seen": 1787279199.0012105,
"ast_hash": "b6b16c43ce528f7c9921eab0f4eda335",
"semantic_hash": "b6b16c43ce528f7c9921eab0f4eda335"
},
"static/js/image-edit/MaskTool.js": {
"mtime": 1782699721.4711535,
"seen": 1787279199.0012114,
"ast_hash": "116469f33829c5b2e6270f4b278b2b9c",
"semantic_hash": "116469f33829c5b2e6270f4b278b2b9c"
},
"static/js/image-edit/Sam3Segmentation.js": {
"mtime": 1787126893.774897,
"seen": 1787279199.0012121,
"ast_hash": "98c3a330ae7ea3369c00168c54c66d53",
"semantic_hash": "98c3a330ae7ea3369c00168c54c66d53"
},
"static/js/image-edit/SelectTool.js": {
"mtime": 1782368193.2572634,
"seen": 1787279199.0012128,
"ast_hash": "cb8db6bd5ee742b15f3beee4a5173377",
"semantic_hash": "cb8db6bd5ee742b15f3beee4a5173377"
},
"static/js/image-edit/ShapeTool.js": {
"mtime": 1782375200.0253434,
"seen": 1787279199.001214,
"ast_hash": "36d00bf6b80ebea13314d2853528c44f",
"semantic_hash": "36d00bf6b80ebea13314d2853528c44f"
},
"static/js/image-edit/TextTool.js": {
"mtime": 1782368193.2592669,
"seen": 1787279199.0012147,
"ast_hash": "88f9e60251c2f78957e42c33ed5196f8",
"semantic_hash": "88f9e60251c2f78957e42c33ed5196f8"
},
"static/js/image-prompt-tab.js": {
"mtime": 1786638406.9362252,
"seen": 1787279199.0012157,
"ast_hash": "cf39285a30e3f8b79f6f51dfe13f86b5",
"semantic_hash": "cf39285a30e3f8b79f6f51dfe13f86b5"
},
"static/js/json-highlight.js": {
"mtime": 1773834925.1977189,
"seen": 1787279199.0012164,
"ast_hash": "e83e7a7a507d160b9cd503235a79135c",
"semantic_hash": "e83e7a7a507d160b9cd503235a79135c"
},
"static/js/lab-tab.js": {
"mtime": 1787310963.3056493,
"seen": 1787380810.343902,
"ast_hash": "11a5776c4163e2a97ef490e85de7db63",
"semantic_hash": "11a5776c4163e2a97ef490e85de7db63"
},
"static/js/metadata-tab.js": {
"mtime": 1787377525.9536967,
"seen": 1787380810.3439052,
"ast_hash": "5ac004dd3708aa4dc8890256232fd99e",
"semantic_hash": "5ac004dd3708aa4dc8890256232fd99e"
},
"static/js/models-tab.js": {
"mtime": 1787145102.2314196,
"seen": 1787279199.001219,
"ast_hash": "dcee8a68713cd300d0862b1c376d442f",
"semantic_hash": "dcee8a68713cd300d0862b1c376d442f"
},
"static/js/models/badges.js": {
"mtime": 1787143926.3653927,
"seen": 1787279199.00122,
"ast_hash": "0c1901a90a12f1b23df416d9eef11e1d",
"semantic_hash": "0c1901a90a12f1b23df416d9eef11e1d"
},
"static/js/models/detail-panel.js": {
"mtime": 1787144898.6748104,
"seen": 1787279199.0012212,
"ast_hash": "9907a4af0524b35d80e3b9fc7925324e",
"semantic_hash": "9907a4af0524b35d80e3b9fc7925324e"
},
"static/js/models/filters.js": {
"mtime": 1787144201.7602522,
"seen": 1787279199.001222,
"ast_hash": "afd754f68d01f64c51995929b3841e56",
"semantic_hash": "afd754f68d01f64c51995929b3841e56"
},
"static/js/models/grid-view.js": {
"mtime": 1787144745.5522888,
"seen": 1787279199.0012226,
"ast_hash": "492e91fff2b8743a5243f85a736eb689",
"semantic_hash": "492e91fff2b8743a5243f85a736eb689"
},
"static/js/models/helpers.js": {
"mtime": 1787144193.741004,
"seen": 1787279199.0012236,
"ast_hash": "1c7b796f2507f55403fa5d11f304580a",
"semantic_hash": "1c7b796f2507f55403fa5d11f304580a"
},
"static/js/models/selection-bulk.js": {
"mtime": 1787144787.0092065,
"seen": 1787279199.0012245,
"ast_hash": "d445ff4dc4e9810afea73ff53c76b05b",
"semantic_hash": "d445ff4dc4e9810afea73ff53c76b05b"
},
"static/js/models/state.js": {
"mtime": 1787143674.705312,
"seen": 1787279199.0012252,
"ast_hash": "172ced5fdc4c96095f2760d7ef12d77d",
"semantic_hash": "172ced5fdc4c96095f2760d7ef12d77d"
},
"static/js/nodes-tab.js": {
"mtime": 1782619197.3828287,
"seen": 1787279199.0012262,
"ast_hash": "3467d4682a8c20a2b209b5db224ce6e2",
"semantic_hash": "3467d4682a8c20a2b209b5db224ce6e2"
},
"static/js/prompt-tab.js": {
"mtime": 1787373450.5713615,
"seen": 1787380810.352496,
"ast_hash": "6d2e3cd1bbf2e99a5298cfb502a18d9e",
"semantic_hash": "6d2e3cd1bbf2e99a5298cfb502a18d9e"
},
"static/js/settings-tab.js": {
"mtime": 1787227602.6323576,
"seen": 1787279199.001228,
"ast_hash": "85b55cac9901afc9e94e32f0aa220d2b",
"semantic_hash": "85b55cac9901afc9e94e32f0aa220d2b"
},
"static/js/style-catalog-tab.js": {
"mtime": 1786678976.6511285,
"seen": 1787279199.001229,
"ast_hash": "a2956544bf800ac34f8084b8acd0714b",
"semantic_hash": "a2956544bf800ac34f8084b8acd0714b"
},
"static/js/tagger-tab.js": {
"mtime": 1781503496.933307,
"seen": 1787279199.0012298,
"ast_hash": "5e1c858a13e95148eeac1d5b5be5e4f3",
"semantic_hash": "5e1c858a13e95148eeac1d5b5be5e4f3"
},
"static/js/util.js": {
"mtime": 1787036300.7369778,
"seen": 1787279199.001231,
"ast_hash": "29774ed440f2b60a708c70db1b3ae208",
"semantic_hash": "29774ed440f2b60a708c70db1b3ae208"
},
"static/js/workflow-tab.js": {
"mtime": 1785474064.011484,
"seen": 1787279199.001232,
"ast_hash": "65caea21e8e02d81f2e85c75c7231dca",
"semantic_hash": "65caea21e8e02d81f2e85c75c7231dca"
},
"tools/import_style_prompt_seed.py": {
"mtime": 1786616195.405381,
"seen": 1787279199.0012326,
"ast_hash": "02997888c17a6061b7910cddfdd4d89a",
"semantic_hash": "02997888c17a6061b7910cddfdd4d89a"
},
"web/comfyui/gallery_feeder_extension.js": {
"mtime": 1781943835.232655,
"seen": 1787279199.0012345,
"ast_hash": "0048b031e64a433e29a5f7112e82012d",
"semantic_hash": "0048b031e64a433e29a5f7112e82012d"
},
"web/comfyui/node_sets_menu.js": {
"mtime": 1787199930.9575465,
"seen": 1787279199.0012355,
"ast_hash": "a9d7ad5d194ac2f4b125978a7b048042",
"semantic_hash": "a9d7ad5d194ac2f4b125978a7b048042"
},
"web/comfyui/top_menu_extension.js": {
"mtime": 1782696309.9055538,
"seen": 1787279199.0012362,
"ast_hash": "2c74498722b46d42bcc15f61ec35dfc4",
"semantic_hash": "2c74498722b46d42bcc15f61ec35dfc4"
},
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).json": {
"mtime": 1779256606.5720088,
"seen": 1787279199.0012372,
"ast_hash": "52801ad2b42eca5e3a5f8fc51332d2c3",
"semantic_hash": "52801ad2b42eca5e3a5f8fc51332d2c3"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).json": {
"mtime": 1779257129.3572881,
"seen": 1787279199.001238,
"ast_hash": "8260ca467693c21c497f98113abf37f9",
"semantic_hash": "8260ca467693c21c497f98113abf37f9"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).json": {
"mtime": 1779257864.2496417,
"seen": 1787279199.001239,
"ast_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e",
"semantic_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e"
},
"workflows/ws_dwpose_image_feeder.json": {
"mtime": 1779255324.1402953,
"seen": 1787279199.0012398,
"ast_hash": "282ce14b11b8b88ddc91e1d318756c0d",
"semantic_hash": "282ce14b11b8b88ddc91e1d318756c0d"
},
"workflows/ws_inpaint_basic.json": {
"mtime": 1784801838.7836998,
"seen": 1787279199.0012407,
"ast_hash": "43e83496dec4856ab9d97c8b950148ec",
"semantic_hash": "43e83496dec4856ab9d97c8b950148ec"
},
"workflows/ws_inpaint_mask_editor_one.json": {
"mtime": 1784801946.0336668,
"seen": 1787279199.001242,
"ast_hash": "ac0a865c1dacd2b30c489f08f8b4df4d",
"semantic_hash": "ac0a865c1dacd2b30c489f08f8b4df4d"
},
"workflows/ws_sd15_t2i_basic.json": {
"mtime": 1779253098.1947012,
"seen": 1787279199.0012426,
"ast_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51",
"semantic_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51"
},
"workflows/ws_sdxl_2d_pose_editor_i2i.json": {
"mtime": 1779256158.4671156,
"seen": 1787279199.0012436,
"ast_hash": "cfe237775d6fb844ac726639e3ae928b",
"semantic_hash": "cfe237775d6fb844ac726639e3ae928b"
},
"workflows/ws_sdxl_3d pose editor_dwpose.json": {
"mtime": 1779255768.6302803,
"seen": 1787279199.0012448,
"ast_hash": "0e1955da91149ba6280d39db7a54db6f",
"semantic_hash": "0e1955da91149ba6280d39db7a54db6f"
},
"workflows/ws_sdxl_dwpose.json": {
"mtime": 1779255037.8698418,
"seen": 1787279199.0012455,
"ast_hash": "d670222d71df92e262ce8cb7299fd531",
"semantic_hash": "d670222d71df92e262ce8cb7299fd531"
},
"workflows/ws_sdxl_from_metadata_t2i.json": {
"mtime": 1779254360.9712114,
"seen": 1787279199.0012462,
"ast_hash": "463fca53dd0a4030555ff639661f2772",
"semantic_hash": "463fca53dd0a4030555ff639661f2772"
},
"workflows/ws_sdxl_i2i_facedetailer.json": {
"mtime": 1779253668.0067616,
"seen": 1787279199.0012474,
"ast_hash": "e2c677ee6670e37b466829c43ff9cd96",
"semantic_hash": "e2c677ee6670e37b466829c43ff9cd96"
},
"workflows/ws_sdxl_image_feeder_i2i.json": {
"mtime": 1779254714.0149894,
"seen": 1787279199.0012484,
"ast_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79",
"semantic_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79"
},
"workflows/ws_sdxl_t2i_basic.json": {
"mtime": 1779253180.3940487,
"seen": 1787279199.0012493,
"ast_hash": "beacbe857606b44d13aa2fc2f4872b76",
"semantic_hash": "beacbe857606b44d13aa2fc2f4872b76"
},
"workflows/ws_sdxl_t2i_facedetaller.json": {
"mtime": 1779253367.7975926,
"seen": 1787279199.00125,
"ast_hash": "0706c31d93f980b581e0e6eb376c0bcc",
"semantic_hash": "0706c31d93f980b581e0e6eb376c0bcc"
},
"DEVLOG.index.md": {
"mtime": 1787294395.7691903,
"seen": 1787294443.2937605,
"ast_hash": "34f4730b81cef4d6393b5867945e41de",
"semantic_hash": ""
},
"README.index.md": {
"mtime": 1787294395.7601545,
"seen": 1787294443.293762,
"ast_hash": "ccb030b076d60806eaff45744274cdfe",
"semantic_hash": ""
},
"requirements.txt": {
"mtime": 1787036261.1817627,
"seen": 1787279199.0012598,
"ast_hash": "5942e1febf9611227a9145b3f9a91c6e",
"semantic_hash": "5942e1febf9611227a9145b3f9a91c6e"
},
"assets/banner.svg": {
"mtime": 1776081443.419675,
"seen": 1787279199.0012608,
"ast_hash": "059ec317eaba9e353d220d883477927a",
"semantic_hash": "059ec317eaba9e353d220d883477927a"
},
"assets/icon.svg": {
"mtime": 1776081443.4219253,
"seen": 1787279199.0012615,
"ast_hash": "9689aa0178236c19d5240250e08a303e",
"semantic_hash": "9689aa0178236c19d5240250e08a303e"
},
"docs/10_GenUI_Batch.png": {
"mtime": 1781057457.561953,
"seen": 1787279199.0012627,
"ast_hash": "351c1647aa5becfb4a2dbfa7da043b24",
"semantic_hash": "351c1647aa5becfb4a2dbfa7da043b24"
},
"docs/11_multiple_select_menu.png": {
"mtime": 1781057528.0140572,
"seen": 1787279199.0012636,
"ast_hash": "b3082324b8200a4eac8868f94ec4e6c7",
"semantic_hash": "b3082324b8200a4eac8868f94ec4e6c7"
},
"docs/12_Tagger.png": {
"mtime": 1782551031.7107866,
"seen": 1787279199.0012643,
"ast_hash": "42806369c4c08140c87daea348b6fc0f",
"semantic_hash": "42806369c4c08140c87daea348b6fc0f"
},
"docs/13_ImageEdit.png": {
"mtime": 1782550946.6263509,
"seen": 1787279199.0012655,
"ast_hash": "25a313806ba3c2396c9361b42599b063",
"semantic_hash": "25a313806ba3c2396c9361b42599b063"
},
"docs/1_workflowtab.png": {
"mtime": 1779078100.5980048,
"seen": 1787279199.0012662,
"ast_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297",
"semantic_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297"
},
"docs/2_modelstab.png": {
"mtime": 1779077971.8302422,
"seen": 1787279199.0012672,
"ast_hash": "2c89e877f01e8de3151e655f305df6aa",
"semantic_hash": "2c89e877f01e8de3151e655f305df6aa"
},
"docs/3_PromptInputAssistance.png": {
"mtime": 1779077804.0445502,
"seen": 1787279199.001268,
"ast_hash": "6634ec20d561648ce223807d2fb010cc",
"semantic_hash": "6634ec20d561648ce223807d2fb010cc"
},
"docs/4_GenUI_feeder.png": {
"mtime": 1779077729.1484556,
"seen": 1787279199.001269,
"ast_hash": "e6b3607b7c53a2f601aff487c278a3fb",
"semantic_hash": "e6b3607b7c53a2f601aff487c278a3fb"
},
"docs/5_topbar.png": {
"mtime": 1779076905.6012611,
"seen": 1787279199.0012698,
"ast_hash": "bafc74e7452f83ae9c585aa11f76f208",
"semantic_hash": "bafc74e7452f83ae9c585aa11f76f208"
},
"docs/6_ws_library.png": {
"mtime": 1781057870.2308166,
"seen": 1787279199.0012705,
"ast_hash": "6645a6c3d9ea09d75d34255c76cfc68b",
"semantic_hash": "6645a6c3d9ea09d75d34255c76cfc68b"
},
"docs/7_library_Infomation.png": {
"mtime": 1779076410.2629254,
"seen": 1787279199.0012715,
"ast_hash": "acb54f299c244fd41457f8fe4ccfc1d6",
"semantic_hash": "acb54f299c244fd41457f8fe4ccfc1d6"
},
"docs/8_Customize.png": {
"mtime": 1779077388.8494852,
"seen": 1787279199.0012722,
"ast_hash": "c9f979d40753ce41c015b2d594007658",
"semantic_hash": "c9f979d40753ce41c015b2d594007658"
},
"docs/9_GenUI_LoraStack.png": {
"mtime": 1781057821.3736062,
"seen": 1787279199.0012734,
"ast_hash": "e2b08e5f2059554d305563c63286bd74",
"semantic_hash": "e2b08e5f2059554d305563c63286bd74"
},
"docs/thumb.png": {
"mtime": 1778457744.7955222,
"seen": 1787279199.0012743,
"ast_hash": "92a0dcf2c3457777f2945f6df2dff017",
"semantic_hash": "92a0dcf2c3457777f2945f6df2dff017"
},
"static/favicon.svg": {
"mtime": 1773877378.0368707,
"seen": 1787279199.0012753,
"ast_hash": "0cd547c25c922d632890be287d6084ff",
"semantic_hash": "0cd547c25c922d632890be287d6084ff"
},
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).png": {
"mtime": 1779256606.562932,
"seen": 1787279199.0012763,
"ast_hash": "79b9d1e5e1303b523349b539d3dc1af3",
"semantic_hash": "79b9d1e5e1303b523349b539d3dc1af3"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).png": {
"mtime": 1779257129.3453796,
"seen": 1787279199.001277,
"ast_hash": "02c572ab1c659d71dfd56e6b2b0334a5",
"semantic_hash": "02c572ab1c659d71dfd56e6b2b0334a5"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).png": {
"mtime": 1779257864.2375357,
"seen": 1787279199.001278,
"ast_hash": "bb03ed86c7d4460a3568a4a8f90e5f40",
"semantic_hash": "bb03ed86c7d4460a3568a4a8f90e5f40"
},
"workflows/ws_dwpose_image_feeder.png": {
"mtime": 1779255324.1356418,
"seen": 1787279199.0012789,
"ast_hash": "e5db6889f59078e4ead6b89b8c07e91e",
"semantic_hash": "e5db6889f59078e4ead6b89b8c07e91e"
},
"workflows/ws_inpaint_basic.png": {
"mtime": 1784801838.7777002,
"seen": 1787279199.00128,
"ast_hash": "79321edb85aed7c0a8c777accc005486",
"semantic_hash": "79321edb85aed7c0a8c777accc005486"
},
"workflows/ws_inpaint_mask_editor_one.png": {
"mtime": 1784801946.027666,
"seen": 1787279199.001281,
"ast_hash": "bdd62facd6a8176c50e92264994dbbc2",
"semantic_hash": "bdd62facd6a8176c50e92264994dbbc2"
},
"workflows/ws_sd15_t2i_basic.png": {
"mtime": 1779253098.1897035,
"seen": 1787279199.0012817,
"ast_hash": "9582bb698439fff1a748315145538777",
"semantic_hash": "9582bb698439fff1a748315145538777"
},
"workflows/ws_sdxl_2d_pose_editor_i2i.png": {
"mtime": 1779256158.4621174,
"seen": 1787279199.001283,
"ast_hash": "bb7b9e26eae992abe62cd50299da9cb2",
"semantic_hash": "bb7b9e26eae992abe62cd50299da9cb2"
},
"workflows/ws_sdxl_3d pose editor_dwpose.png": {
"mtime": 1779255768.6191878,
"seen": 1787279199.0012836,
"ast_hash": "ac0fc8184ff7e42389fd0d562daa88c4",
"semantic_hash": "ac0fc8184ff7e42389fd0d562daa88c4"
},
"workflows/ws_sdxl_dwpose.png": {
"mtime": 1779255037.8606594,
"seen": 1787279199.0012846,
"ast_hash": "564f58d85a0b960154bd0a30ea111599",
"semantic_hash": "564f58d85a0b960154bd0a30ea111599"
},
"workflows/ws_sdxl_from_metadata_t2i.png": {
"mtime": 1779254360.9671092,
"seen": 1787279199.0012853,
"ast_hash": "98dfdb3ba374b0952c06995843f45171",
"semantic_hash": "98dfdb3ba374b0952c06995843f45171"
},
"workflows/ws_sdxl_i2i_facedetailer.png": {
"mtime": 1779253668.0007572,
"seen": 1787279199.0012865,
"ast_hash": "7285bb2a06aaf8dc7c5b37b84261be61",
"semantic_hash": "7285bb2a06aaf8dc7c5b37b84261be61"
},
"workflows/ws_sdxl_image_feeder_i2i.png": {
"mtime": 1779254714.0099926,
"seen": 1787279199.0012872,
"ast_hash": "13d4e7f87a87fd4c9a78427b38800cc6",
"semantic_hash": "13d4e7f87a87fd4c9a78427b38800cc6"
},
"workflows/ws_sdxl_t2i_basic.png": {
"mtime": 1779253180.3890488,
"seen": 1787279199.0012882,
"ast_hash": "9d8dda899602b8d74c6b23350e58a0d0",
"semantic_hash": "9d8dda899602b8d74c6b23350e58a0d0"
},
"workflows/ws_sdxl_t2i_facedetaller.png": {
"mtime": 1779253367.7925913,
"seen": 1787279199.0012891,
"ast_hash": "51cae742b39316023cf5332f1d794854",
"semantic_hash": "51cae742b39316023cf5332f1d794854"
},
".claude/hooks/on_doc_edit.py": {
"mtime": 1787279578.9362085,
"seen": 1787380810.2925577,
"ast_hash": "1a39a0e2ffaac441e8decccad0e1c42c",
"semantic_hash": "1a39a0e2ffaac441e8decccad0e1c42c"
},
"tools/generate_doc_index.py": {
"mtime": 1787279532.9148839,
"seen": 1787380810.3572273,
"ast_hash": "d0cdaf32b36fdef641a9396fd4ec403f",
"semantic_hash": "d0cdaf32b36fdef641a9396fd4ec403f"
},
".github/workflows/publish.yml": {
"mtime": 1778460176.5699222,
"seen": 1787380810.3754551,
"ast_hash": "a83dab23e8d09fdc8626689e96dfeb0e",
"semantic_hash": "a83dab23e8d09fdc8626689e96dfeb0e"
},
"templates/index.html": {
"mtime": 1787377487.6004047,
"seen": 1787380810.3754609,
"ast_hash": "639c4691eb3439d1707cd972bbed1402",
"semantic_hash": "639c4691eb3439d1707cd972bbed1402"
},
"static/js/prompt-table.js": {
"mtime": 1787376568.1601257,
"seen": 1787380810.3524997,
"ast_hash": "ca7fa878074a6fd12d5a478109c929d9",
"semantic_hash": "ca7fa878074a6fd12d5a478109c929d9"
}
}
+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}")
+9 -3
View File
@@ -163,9 +163,15 @@ async def get_image_workflow(request: web.Request) -> web.Response:
return web.json_response({"error": "path required"}, status=400)
try:
wf = _service.extract_workflow_from_metadata(path)
if wf is None:
return web.json_response({"workflow": None, "has_workflow": False})
return web.json_response({"workflow": wf, "has_workflow": True})
# prompt_workflow: プロンプト抽出専用(PNG[prompt]優先)。トップレベルとサブグラフに
# 独立した複数系統を持つワークフローではUI形式(workflow)からの抽出がトップレベル
# 系統しか拾えないことがあるため、Promptタブはこちらを使う(Metadataタブと揃える)。
prompt_wf = _service.extract_prompt_workflow_from_metadata(path)
return web.json_response({
"workflow": wf,
"has_workflow": wf is not None,
"prompt_workflow": prompt_wf,
})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+152
View File
@@ -1,8 +1,11 @@
"""Settings API routes."""
import asyncio
import io
import json
import logging
import shutil
import zipfile
from pathlib import Path
from aiohttp import web
@@ -24,6 +27,7 @@ _DATA_FILES = [
"model_metadata.json",
"gallery_metadata.json",
"tagger_settings.json",
"civitai_cache.json",
]
@@ -37,6 +41,8 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
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/settings/export-full", handle_export_full)
app.router.add_post("/api/wfm/settings/import-full", handle_import_full)
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)
@@ -248,6 +254,152 @@ async def handle_import(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
# Full-backup (ZIP) export/import: unlike the flat _DATA_FILES JSON bundle above, this
# covers the whole DATA_DIR — directory-shaped data (ai_skills/, lab_plan/, style/) and
# the SQLite tagger.db that the JSON bundle can't represent. Excludes transient/cache
# dirs, and wildcard/ specifically because it's typically a symlink into a *different*
# custom_node's folder (comfyui-impact-pack) rather than this plugin's own data.
_FULL_BACKUP_EXCLUDE_NAMES = {"gmic_temp", "thumb_cache", "wildcard"}
def _add_file_to_zip(zf: zipfile.ZipFile, path: Path, base: Path):
rel = str(path.relative_to(base)).replace("\\", "/")
if path.name == "settings.json":
# Same API-key scrubbing as the JSON-bundle export — fall through to a raw
# copy if the file turns out to be unreadable/corrupt rather than skipping it.
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
data = {k: v for k, v in data.items() if k not in _SETTINGS_EXPORT_EXCLUDE}
zf.writestr(rel, json.dumps(data, ensure_ascii=False, indent=2))
return
except Exception:
pass
zf.write(path, rel)
# Two folders this plugin doesn't own but users often want backed up alongside their
# own data: ComfyUI's own default workflows dir, and the Impact Pack's wildcard
# library (a separate custom_node — the plugin's own wildcard/ is usually just a
# symlink into this same folder, see _FULL_BACKUP_EXCLUDE_NAMES above). Opt-in only
# (see handle_export_full's include_workflows/include_wildcard query params), and
# stored under an "_external/" prefix so _apply_full_backup_zip can recognize and
# skip them on restore — see that function's docstring for why.
def _default_workflows_dir() -> Path:
from ..config import COMFYUI_ROOT
return COMFYUI_ROOT / "user" / "default" / "workflows"
def _impact_pack_wildcard_dir() -> Path:
from ..config import COMFYUI_ROOT
return COMFYUI_ROOT / "custom_nodes" / "comfyui-impact-pack" / "wildcards"
def _add_external_dir_to_zip(zf: zipfile.ZipFile, src_dir: Path, zip_prefix: str):
if not src_dir.is_dir() or src_dir.is_symlink():
return
for path in src_dir.rglob("*"):
if path.is_dir() or path.is_symlink():
continue
rel = f"{zip_prefix}/{path.relative_to(src_dir)}".replace("\\", "/")
zf.write(path, rel)
def _build_full_backup_zip(include_workflows: bool = False, include_wildcard: bool = False) -> bytes:
"""Zips everything under DATA_DIR except the excluded names above. Walks
top-level entries first so an excluded symlinked directory (wildcard/) is never
even opened, rather than filtering after a recursive walk has already followed it.
Optionally also includes the two external folders above under "_external/"."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for top in sorted(DATA_DIR.iterdir()):
if top.name in _FULL_BACKUP_EXCLUDE_NAMES or top.is_symlink():
continue
if top.is_file():
_add_file_to_zip(zf, top, DATA_DIR)
elif top.is_dir():
for path in top.rglob("*"):
if path.is_dir() or path.is_symlink():
continue
_add_file_to_zip(zf, path, DATA_DIR)
if include_workflows:
_add_external_dir_to_zip(zf, _default_workflows_dir(), "_external/default_workflows")
if include_wildcard:
_add_external_dir_to_zip(zf, _impact_pack_wildcard_dir(), "_external/wildcard")
return buf.getvalue()
def _apply_full_backup_zip(zip_bytes: bytes) -> dict:
"""Extracts a full-backup ZIP into DATA_DIR, overwriting existing files. Guards
against Zip Slip (entries whose path resolves outside DATA_DIR via '..' or an
absolute path) by checking each entry before writing it.
Entries under "_external/" (default workflows dir, Impact Pack wildcards — see
_build_full_backup_zip) are always skipped here rather than restored: they live
outside DATA_DIR and are managed by ComfyUI core / a different custom_node, so
auto-restoring them could silently overwrite files this plugin doesn't own. They
stay in the ZIP for the user to copy back manually if they want them."""
summary = {"extracted": [], "skipped": []}
DATA_DIR.mkdir(parents=True, exist_ok=True)
base = DATA_DIR.resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
if info.filename.startswith("_external/"):
summary["skipped"].append(info.filename)
continue
target = (base / info.filename).resolve()
try:
target.relative_to(base)
except ValueError:
summary["skipped"].append(info.filename)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
summary["extracted"].append(info.filename)
return summary
async def handle_export_full(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/export-full - Download a ZIP of the entire data
directory, including data the flat JSON bundle (handle_export) can't represent.
Optional query params include_workflows=1 / include_wildcard=1 additionally bundle
ComfyUI's default workflows dir / the Impact Pack's wildcard dir (see
_build_full_backup_zip) — both restore-excluded, export-only, manual-copy-back."""
try:
include_workflows = request.rel_url.query.get("include_workflows") == "1"
include_wildcard = request.rel_url.query.get("include_wildcard") == "1"
zip_bytes = await asyncio.to_thread(_build_full_backup_zip, include_workflows, include_wildcard)
return web.Response(
body=zip_bytes,
content_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="wfm-full-backup.zip"'},
)
except Exception as e:
logger.error("Error building full backup: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_import_full(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/import-full - Upload and restore a full-backup ZIP."""
try:
reader = await request.multipart()
field = await reader.next()
if field is None or field.name != "file":
return web.json_response({"error": "file field required"}, status=400)
zip_bytes = await field.read(decode=False)
summary = await asyncio.to_thread(_apply_full_backup_zip, zip_bytes)
return web.json_response({"status": "ok", **summary})
except zipfile.BadZipFile:
return web.json_response({"error": "Not a valid ZIP file"}, status=400)
except Exception as e:
logger.error("Error importing full backup: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _style_files() -> list:
style_dir = DATA_DIR / "style"
if not style_dir.is_dir():
+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)
+59
View File
@@ -0,0 +1,59 @@
"""Video tab utility API routes: last-frame capture, animated GIF conversion."""
import asyncio
import logging
from aiohttp import web
from ..services.video_service import VideoService
logger = logging.getLogger(__name__)
_service = VideoService()
def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/video/frame/save-to-output", handle_save_frame)
app.router.add_post("/api/wfm/video/to-gif", handle_to_gif)
async def handle_save_frame(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_frame_to_output, image_base64)
return web.json_response({"status": "ok", **result})
except Exception as e:
logger.error("Error saving video frame to output: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_to_gif(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
subfolder = body.get("subfolder", "")
type_ = body.get("type", "output")
start_time = float(body.get("start_time") or 0)
end_time_raw = body.get("end_time")
end_time = float(end_time_raw) if end_time_raw not in (None, "") else None
fps = float(body.get("fps") or 10)
max_width_raw = body.get("max_width")
max_width = int(max_width_raw) if max_width_raw not in (None, "") else None
if not filename:
return web.json_response({"error": "filename required"}, status=400)
result = await asyncio.to_thread(
_service.convert_to_gif, filename, subfolder, type_, start_time, end_time, fps, max_width
)
return web.json_response({"status": "ok", **result})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error converting video to GIF: %s", e)
return web.json_response({"error": str(e)}, status=500)
+18 -2
View File
@@ -191,17 +191,33 @@ class GalleryMetadataStore:
return self._save_to_disk()
return True
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
def cleanup_stale_images(self, folder_path: str, existing_paths: set, recursive: bool = True) -> int:
"""folder_path 配下の存在しないファイルのメタデータを削除する。
existing_paths: そのフォルダの現在のファイルパス集合(_normalize_path 済み)
recursive: False の場合、folder_path 直下のファイルのみを対象にする。
呼び出し元(list_images)が非recursiveスキャン(フォルダ直下のみ列挙)の
結果を existing_paths として渡してくる場合、サブフォルダ内のファイルは
existing_paths に含まれない。それにもかかわらず対象範囲を「folder_path配下
全階層」のままにすると、サブフォルダ内画像のメタデータ(tags/memo等)を
"存在しない"と誤判定して全削除してしまう(実際に発生した不具合: サブフォルダ
に動画を保存する運用で、ルートフォルダを非recursiveで開くたびに
サブフォルダ内の全メタデータが失われていた)。
"""
folder_key = self._normalize_path(folder_path)
if not folder_key.endswith("/"):
folder_key += "/"
def _in_scope(key: str) -> bool:
if not key.startswith(folder_key):
return False
if recursive:
return True
return "/" not in key[len(folder_key):]
with self._lock:
stale = [
key for key in self._data["images"]
if key.startswith(folder_key) and key not in existing_paths
if _in_scope(key) and key not in existing_paths
]
if not stale:
return 0
+113 -27
View File
@@ -16,8 +16,8 @@ from typing import Any
logger = logging.getLogger(__name__)
# サポートする画像拡張子
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
# サポートする画像拡張子(.mp4は動画だがGalleryでは静止画と同じ一覧・配信経路を共有する)
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".mp4"}
# data URL の MIME タイプ -> 拡張子(save_image_to_gallery / save_image_to_folder共用)
_SAVE_EXT_BY_MIME = {
@@ -385,9 +385,12 @@ class GalleryService:
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
raw_entries = self._scan_folder_recursive(folder) if recursive else self._scan_folder(folder)
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)。
# recursive=False(非再帰スキャン)の場合、existing_pathsにはfolder直下の
# ファイルしか含まれないため、cleanup側にもrecursive=Falseを伝えて対象範囲を
# 直下のみに限定する(でないとサブフォルダ内ファイルのメタデータを誤削除する)。
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
self.metadata_store.cleanup_stale_images(str(folder), existing_paths)
self.metadata_store.cleanup_stale_images(str(folder), existing_paths, recursive=recursive)
results = []
for name, abs_path, size, mtime in raw_entries:
@@ -584,7 +587,10 @@ class GalleryService:
embedded = self._read_png_metadata(path)
elif ext in {".jpg", ".jpeg"}:
embedded = self._read_jpeg_metadata(path)
elif ext == ".mp4":
embedded = self._read_mp4_metadata(path)
dims = self._read_media_dimensions(path, ext)
saved = self.metadata_store.get(str(path))
# workflow キーを除く文字列フィールドをプロンプトキャッシュとして保存(検索用)
@@ -603,6 +609,9 @@ class GalleryService:
"mtime": stat.st_mtime,
"ext": ext,
"embedded": embedded,
"width": dims["width"],
"height": dims["height"],
"duration": dims["duration"],
"favorite": saved.get("favorite", False),
"tags": saved.get("tags", []),
"memo": saved.get("memo", ""),
@@ -682,45 +691,102 @@ class GalleryService:
logger.debug("JPEG metadata read error: %s", e)
return result
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""ワークフローを抽出する。
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
def _read_mp4_metadata(self, path: Path) -> dict:
"""MP4コンテナのformat-levelメタデータタグ(workflow/prompt等)をPyAVで抽出する。
ComfyUIのSaveVideoはPNGのtEXtチャンクと同じキー名(workflow=UI形式, prompt=API形式)で
moov/udta/meta(mdta)にJSON文字列を書き込む。"""
result = {}
try:
import av
with av.open(str(path)) as container:
for k, v in (container.metadata or {}).items():
result[k] = v
except Exception as e:
logger.debug("MP4 metadata read error: %s", e)
return result
def _read_media_dimensions(self, path: Path, ext: str) -> dict:
"""画像は幅高さ、動画は幅高さ+再生時間(秒)を返す。取得できない項目はNone。"""
info: dict = {"width": None, "height": None, "duration": None}
try:
if ext == ".mp4":
import av
with av.open(str(path)) as container:
if container.streams.video:
stream = container.streams.video[0]
info["width"] = stream.width
info["height"] = stream.height
if container.duration:
info["duration"] = container.duration / 1_000_000
elif ext != ".svg":
from PIL import Image
with Image.open(path) as img:
info["width"], info["height"] = img.size
except Exception as e:
logger.debug("dimension read error for %s: %s", path, e)
return info
def _extract_embedded_workflow(self, image_path: str, prefer: str = "workflow") -> dict | None:
"""埋め込みワークフローを抽出する(PNG/MP4の[workflow]/[prompt]の優先順位を選べる)。
prefer="workflow": UI形式(ノード位置・グループ等を保持)優先。GenerateUIへの
ロードやJSON表示などエディタ復元が必要な用途向け。
prefer="prompt": API形式(サブグラフ展開済みでフラット)優先。トップレベルと
サブグラフに独立した複数系統を持つワークフロー(例: Krea2)ではUI形式の
extractPromptsLiteGraphがトップレベル系統しか拾えないことがあるため、
プロンプト抽出専用にAPI形式を優先する(Metadataタブのプロンプト抽出と同じ優先順位)。
"""
path = Path(image_path).resolve()
if not self._check_path_allowed(path):
return None
if path.suffix.lower() == ".png":
ext = path.suffix.lower()
embedded = None
if ext == ".png":
embedded = self._read_png_metadata(path)
# 1. workflow キー (ComfyUI UI形式)
for key in ("workflow", "Workflow"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 2. prompt キー (ComfyUI API形式 — 大多数の生成画像)
for key in ("prompt", "Prompt"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
elif ext == ".mp4":
embedded = self._read_mp4_metadata(path)
# 3. gallery_metadata.json に保存されたworkflow
if embedded:
key_groups = (("workflow", "Workflow"), ("prompt", "Prompt"))
if prefer == "prompt":
key_groups = tuple(reversed(key_groups))
for keys in key_groups:
for key in keys:
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# gallery_metadata.json に保存されたworkflow
saved = self.metadata_store.get(str(path))
return saved.get("workflow") or None
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""ワークフローを抽出する。
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
"""
return self._extract_embedded_workflow(image_path, prefer="workflow")
def extract_prompt_workflow_from_metadata(self, image_path: str) -> dict | None:
"""プロンプト抽出専用: PNG[prompt] > PNG[workflow] > gallery_metadata.json[workflow]"""
return self._extract_embedded_workflow(image_path, prefer="prompt")
# ──────────────────────────────────────────────────────────────
# メタデータ保存
# ──────────────────────────────────────────────────────────────
def save_image_meta(self, image_path: str, data: dict) -> bool:
if not self._check_path_allowed(Path(image_path).resolve()):
resolved = Path(image_path).resolve()
if not self._check_path_allowed(resolved):
logger.warning("save_image_meta: path not allowed: %s", resolved)
return False
return self.metadata_store.save(image_path, data)
# get_image_metadata() 側は resolve() 済みパスをキーにして取得するため、
# ここでも resolve() 後の文字列で統一して保存する(キー不一致による
# 「保存はできるが表示されない」というサイレントな食い違いを防ぐ)。
return self.metadata_store.save(str(resolved), data)
def toggle_favorite(self, image_path: str) -> bool:
if not self._check_path_allowed(Path(image_path).resolve()):
@@ -923,6 +989,8 @@ class GalleryService:
ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。
GIFはアニメーション保持のため元ファイルをそのまま返す。
Pillowが使えない場合は元ファイルにフォールバック。
MP4はPillowで開けないためPyAVで先頭フレームを抽出しJPEGとしてキャッシュする
(元ファイルへのフォールバックはできないため、失敗時はNoneを返す)。
"""
p = Path(image_path).resolve()
if not p.is_file():
@@ -955,6 +1023,24 @@ class GalleryService:
if thumb_path.exists():
return thumb_path
# MP4は動画のためPillowで開けない。PyAV(av)で先頭フレームを抽出しJPEGとしてキャッシュする。
# avが未導入/デコード失敗の場合、元mp4ファイルは<img>的な経路に渡せないため
# (GIF/SVGと違い元ファイルへフォールバックできない) Noneを返しプレースホルダー表示に委ねる。
if p.suffix.lower() == ".mp4":
try:
import av
from PIL import Image
with av.open(str(p)) as container:
stream = container.streams.video[0]
frame = next(container.decode(stream))
img = frame.to_image().convert("RGB")
img.thumbnail((width, width), Image.LANCZOS)
img.save(thumb_path, "JPEG", quality=85, optimize=True)
return thumb_path
except Exception as e:
logger.warning("serve_thumbnail: mp4 frame extraction failed for %s: %s", p, e)
return None
try:
from PIL import Image
with Image.open(p) as img:
+127
View File
@@ -0,0 +1,127 @@
"""Video tab utilities: last-frame capture and animated GIF conversion.
Runs entirely on dependencies ComfyUI itself already requires — PyAV for
decoding (ComfyUI core already needs av>=17.0.0) and Pillow for GIF encoding
— so no ffmpeg binary and no new pip installs, unlike a typical ffmpeg-based
implementation (e.g. Eagle's video2gif/video-to-frame plugins).
"""
import base64
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
try:
from PIL import Image
_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
except Exception: # pragma: no cover - Pillow is a hard ComfyUI dependency
_RESAMPLE = None
class VideoService:
def _resolve_media_path(self, filename: str, subfolder: str, type_: str) -> Path:
"""Resolves a ComfyUI-style {filename, subfolder, type} reference to an
absolute path, guarding against path traversal outside the matching
input/output/temp directory."""
import folder_paths # type: ignore
base_dir = folder_paths.get_directory_by_type(type_)
if not base_dir:
raise ValueError(f"Invalid type: {type_}")
base = Path(base_dir).resolve()
target = (base / (subfolder or "") / filename).resolve()
try:
target.relative_to(base)
except ValueError:
raise ValueError("Access denied: path outside allowed directory")
if not target.is_file():
raise FileNotFoundError(f"File not found: {filename}")
return target
def save_frame_to_output(self, image_base64: str, filename_prefix: str = "video/Frame") -> dict:
"""Saves a client-captured video frame (PNG data URL) into ComfyUI's own
output folder — same auto-numbered pattern as Lab's index-image save
(see LabPlanService.save_index_image_to_output)."""
import folder_paths # type: ignore
b64 = image_base64
if "," in b64 and b64.strip().lower().startswith("data:"):
b64 = b64.split(",", 1)[1]
image_bytes = base64.b64decode(b64)
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 convert_to_gif(
self,
filename: str,
subfolder: str,
type_: str,
start_time: float = 0.0,
end_time=None,
fps: float = 10.0,
max_width=None,
filename_prefix: str = "video/GIF",
) -> dict:
"""Decodes the given video (PyAV), samples it down to `fps`, optionally
downscales to `max_width`, and encodes the result as an animated GIF
(Pillow) into ComfyUI's own output folder."""
import av # type: ignore
import folder_paths # type: ignore
if _RESAMPLE is None:
raise RuntimeError("Pillow is not available")
src_path = self._resolve_media_path(filename, subfolder, type_)
fps = max(0.1, float(fps))
container = av.open(str(src_path))
try:
stream = container.streams.video[0]
source_fps = float(stream.average_rate) if stream.average_rate else 24.0
step = max(1, round(source_fps / fps))
pil_frames = []
idx = 0
for frame in container.decode(stream):
t = float(frame.pts * stream.time_base) if frame.pts is not None else None
if t is not None:
if t < start_time:
idx += 1
continue
if end_time is not None and t > end_time:
break
if idx % step == 0:
im = frame.to_image()
if max_width and im.width > max_width:
ratio = max_width / im.width
im = im.resize((max_width, max(1, round(im.height * ratio))), _RESAMPLE)
pil_frames.append(im.convert("RGB"))
idx += 1
finally:
container.close()
if not pil_frames:
raise ValueError("No frames found in the specified range")
output_dir = folder_paths.get_output_directory()
full_output_folder, out_filename, counter, out_subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
save_name = f"{out_filename}_{counter:05}_.gif"
save_path = Path(full_output_folder) / save_name
duration_ms = max(20, round(1000 / fps))
pil_frames[0].save(
save_path,
format="GIF",
save_all=True,
append_images=pil_frames[1:],
duration=duration_ms,
loop=0,
optimize=True,
)
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": len(pil_frames)}
+3 -1
View File
@@ -80,11 +80,12 @@ class WorkflowStudio:
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, lab_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, video_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)
@@ -95,5 +96,6 @@ class WorkflowStudio:
gmic_routes.setup_routes(app)
skill_routes.setup_routes(app)
lab_routes.setup_routes(app)
video_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.97"
version = "0.5.2"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
+10
View File
@@ -19,3 +19,13 @@ 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
# -- MP4 thumbnail generation (Gallery tab) --
# Uses PyAV (av) to decode the first frame. ComfyUI core already requires av>=17.0.0,
# so it is normally pre-installed in python_embeded and no action is needed here.
# If missing, mp4 thumbnail generation simply fails and falls back to a placeholder
# (Gallery itself keeps working). Not pinned here on purpose — see torch note above.
# av>=17.0.0
+28 -37
View File
@@ -290,6 +290,24 @@
color: #38bdf8;
}
/* 動画ファイル(mp4)の再生アイコンバッジ(サムネイル中央) */
.wfm-gallery-thumb-video-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0,0,0,0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
pointer-events: none;
}
/* テーブルビューのお気に入りボタン */
.wfm-gallery-table-fav-btn {
background: none;
@@ -465,43 +483,6 @@
cursor: pointer;
}
.wfm-gallery-preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.wfm-gallery-preview-wrapper:hover .wfm-gallery-preview-overlay {
opacity: 1;
}
.wfm-gallery-download-btn {
background: var(--wfm-accent, #4f8ef7);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background 0.2s;
}
.wfm-gallery-download-btn:hover {
background: var(--wfm-accent-hover, #3f7fe7);
}
/* タブコンテンツ */
.wfm-gallery-detail-tab-content {
flex: 1;
@@ -836,6 +817,16 @@
border-color: #6a4de0;
}
.wfm-gallery-action-btn-orange {
background: #f59e0b;
border-color: #f59e0b;
color: #1a1a2e;
}
.wfm-gallery-action-btn-orange:hover {
background: #d97f06;
border-color: #d97f06;
}
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
.wfm-gallery-file-actions {
+62
View File
@@ -163,8 +163,10 @@
.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;
@@ -175,6 +177,39 @@
.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;
@@ -235,6 +270,23 @@
color: var(--wfm-warning);
}
/* LoRA node-bypass (strength forced to 0 at apply-time) — red to distinguish from the
amber row-level bypassed above; the row itself still applies, only the LoRA doesn't. */
.wfm-lab-cell.wfm-lab-cell-node-bypassed {
border-color: var(--wfm-danger);
}
.wfm-lab-cell.wfm-lab-cell-node-bypassed .wfm-lab-cell-value {
color: var(--wfm-danger);
}
/* Single-click selection target for the column header's up/down reorder buttons —
layered as a box-shadow (not border-color) so it never conflicts with the
revert/live/bypassed/node-bypassed border-color signaling above. */
.wfm-lab-cell.wfm-lab-cell-selected {
box-shadow: 0 0 0 2px var(--wfm-primary);
}
.wfm-lab-cell-applied {
margin-top: 3px;
padding-top: 3px;
@@ -243,6 +295,16 @@
color: var(--wfm-text-secondary);
}
/* Shown instead of a keyframe list when the loaded workflow has no node this Model
sub-column (checkpoint/lora/vae) could override — see _hasNodeForColumn in lab-tab.js. */
.wfm-lab-cell-empty {
padding: 6px 8px;
font-size: 11px;
font-style: italic;
color: var(--wfm-text-secondary);
cursor: default;
}
.wfm-lab-cell-prompt-line {
display: block;
overflow: hidden;
+258 -6
View File
@@ -653,8 +653,8 @@ body {
align-items: center;
}
.wfm-lora-bypass-btn,
.wfm-lora-refresh-btn {
margin-left: auto;
font-size: 13px;
line-height: 1;
padding: 2px 5px;
@@ -662,8 +662,21 @@ body {
text-transform: none;
letter-spacing: 0;
}
.wfm-lora-bypass-btn {
margin-left: auto;
}
.wfm-lora-bypass-btn:hover,
.wfm-lora-refresh-btn:hover { opacity: 1; }
/* Bypass is active for the current LoRA target node: strength forced to 0 (see
comfyui-editor.js renderLoraPane / _loraNodeBypass). */
.wfm-lora-bypass-btn.active {
opacity: 1;
background: var(--wfm-danger);
border-color: var(--wfm-danger);
color: #fff;
}
.wfm-gen-lora-col-body {
flex: 1;
overflow-y: auto;
@@ -2374,6 +2387,248 @@ body {
overflow: hidden;
}
/* Prompt タブ: Form / Table 切り替え */
.wfm-prompt-toplevel-nav {
display: flex;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-prompt-toplevel-tab {
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-prompt-toplevel-tab:hover { color: var(--wfm-text-primary); }
.wfm-prompt-toplevel-tab.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
.wfm-prompt-panel {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.wfm-prompt-panel.active { display: flex; }
/* Table パネル内サブタブ (Presets / Wildcards / Style) */
.wfm-prompt-table-subnav {
display: flex;
background: var(--wfm-surface);
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-prompt-table-subtab {
padding: 8px 16px;
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-table-subtab:hover { color: var(--wfm-text); }
.wfm-prompt-table-subtab.active { color: var(--wfm-text); border-bottom-color: var(--wfm-primary); }
.wfm-prompt-table-pane {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.wfm-prompt-table-pane.active { display: flex; }
.wfm-prompt-table-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--wfm-border);
}
.wfm-prompt-table-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
/* 編集可能テーブル (Presets / Wildcards / Style 共通) */
.wfm-prompt-edit-table {
width: 100%;
border-collapse: collapse;
}
.wfm-prompt-edit-table th,
.wfm-prompt-edit-table td {
border-bottom: 1px solid var(--wfm-border);
padding: 4px 6px;
vertical-align: top;
}
.wfm-prompt-edit-table th {
position: sticky;
top: 0;
background: var(--wfm-surface);
text-align: left;
font-size: 11px;
color: var(--wfm-text-secondary);
z-index: 1;
}
.wfm-prompt-edit-table tbody tr:hover td {
background: var(--wfm-surface);
}
.wfm-prompt-edit-input,
.wfm-prompt-edit-textarea {
width: 100%;
box-sizing: border-box;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: var(--wfm-text);
font-size: 12px;
padding: 3px 5px;
font-family: inherit;
}
.wfm-prompt-edit-textarea {
min-height: 40px;
resize: vertical;
}
.wfm-prompt-edit-input:hover,
.wfm-prompt-edit-textarea:hover {
border-color: var(--wfm-border);
}
.wfm-prompt-edit-input:focus,
.wfm-prompt-edit-textarea:focus {
outline: none;
border-color: var(--wfm-primary);
background: var(--wfm-bg);
}
@keyframes wfm-prompt-cell-flash {
0% { background: rgba(74, 158, 255, 0.3); }
100% { background: transparent; }
}
.wfm-prompt-edit-cell-saved {
animation: wfm-prompt-cell-flash 0.7s ease;
}
.wfm-prompt-edit-fav-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--wfm-text-secondary);
padding: 2px;
line-height: 1;
}
.wfm-prompt-edit-fav-btn.active { color: #ffd700; }
.wfm-prompt-edit-batch-btn.active { color: #f0c040; }
.wfm-prompt-edit-row-new td { background: rgba(74, 158, 255, 0.06); }
.wfm-prompt-edit-file-badge {
font-size: 10px;
color: var(--wfm-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.wfm-prompt-edit-empty {
padding: 24px;
text-align: center;
color: var(--wfm-text-secondary);
font-size: 12px;
}
/* ダブルクリックで開くプロンプト編集モーダル (Presets / Wildcards / Style 共通) */
.wfm-prompt-modal-toggle {
display: flex;
gap: 6px;
}
.wfm-prompt-modal-textarea {
width: 100%;
box-sizing: border-box;
min-height: 320px;
resize: vertical;
margin-top: 8px;
}
.wfm-prompt-modal-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
margin-top: 10px;
}
/* #(自動採番)列: クリックで行選択 → 上部の Delete ボタンで一括削除 */
.wfm-prompt-edit-num-cell {
text-align: center;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
color: var(--wfm-text-secondary);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.wfm-prompt-edit-num-cell:hover {
color: var(--wfm-text);
background: var(--wfm-surface-2, var(--wfm-surface));
}
.wfm-prompt-edit-table tbody tr.wfm-prompt-edit-row-selected td {
background: rgba(255, 69, 58, 0.14);
}
.wfm-prompt-edit-table tbody tr.wfm-prompt-edit-row-selected .wfm-prompt-edit-num-cell {
color: var(--wfm-danger);
font-weight: bold;
}
.wfm-prompt-edit-num-cell-new {
display: flex;
gap: 2px;
justify-content: center;
}
.wfm-prompt-edit-inline-add-btn,
.wfm-prompt-edit-inline-cancel-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 2px 3px;
line-height: 1;
border-radius: 3px;
}
.wfm-prompt-edit-inline-add-btn { color: var(--wfm-primary); }
.wfm-prompt-edit-inline-add-btn:hover { background: var(--wfm-primary); color: #fff; }
.wfm-prompt-edit-inline-cancel-btn { color: var(--wfm-text-secondary); }
.wfm-prompt-edit-inline-cancel-btn:hover { color: var(--wfm-danger); }
/* Presets Group テーブル: プリセットバッジ */
.wfm-prompt-edit-preset-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: 10px;
padding: 2px 4px 2px 8px;
font-size: 11px;
margin: 2px 3px 2px 0;
}
.wfm-prompt-edit-badge-remove {
background: none;
border: none;
cursor: pointer;
color: var(--wfm-text-secondary);
font-size: 10px;
padding: 1px 3px;
line-height: 1;
}
.wfm-prompt-edit-badge-remove:hover { color: var(--wfm-danger); }
/* Ollama Assistant */
.wfm-ollama-assistant {
display: flex;
@@ -5018,15 +5273,12 @@ body {
Metadata Tab
============================================ */
#wfm-tab-metadata {
overflow: hidden;
}
.wfm-metadata-layout {
display: grid;
grid-template-columns: 220px 1fr 1fr;
gap: 0;
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
}
+185
View File
@@ -0,0 +1,185 @@
/* =============================================
Video Tab (MiniMax H3専用・GenerateUIタブとは独立)
3ペイン構成: 左=フォーム / 中央=プレビュー+編集 / 右=プロパティ
各ペインは独立したカラムとして高さ100%を占め、内部でスクロールする。
中央ペインのコンテンツ量(生成結果の有無・動画の縦横比)が変わっても
左右のペインの位置が動かないよう、意図的に高さを共有させない構造にしている。
============================================= */
#wfm-tab-video {
padding: 0;
overflow: hidden;
}
.wfm-video-layout {
display: flex;
flex-direction: row;
height: 100%;
overflow: hidden;
}
/* ── 左ペイン: フォーム ── */
.wfm-video-form-panel {
width: 380px;
flex: none;
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
overflow-y: auto;
border-right: 1px solid var(--wfm-border);
}
.wfm-video-form-panel label {
display: block;
font-size: 12px;
color: var(--wfm-text-secondary);
margin-bottom: 4px;
}
.wfm-video-header {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
.wfm-video-header h3 {
margin: 0;
}
.wfm-video-wf-name {
color: var(--wfm-text-secondary);
font-size: 13px;
}
.wfm-video-frame-slots {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.wfm-video-frame-slot {
display: flex;
flex-direction: column;
}
.wfm-video-resolution-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
align-items: end;
}
.wfm-video-seed-randomize-wrap {
display: flex;
align-items: center;
padding-bottom: 6px;
}
/* ── 中央ペイン: プレビュー + 編集 ── */
.wfm-video-center-panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 16px;
gap: 16px;
border-right: 1px solid var(--wfm-border);
}
/* 生成結果 or ソース選択エリアで読み込んだ動画を横並びで表示する行。
プレビュー枠が主役、ソースパネルは選択UIのみの固定幅。 */
.wfm-video-preview-row {
flex: none;
display: flex;
flex-direction: row;
gap: 16px;
}
.wfm-video-preview-frame {
flex: 1;
min-width: 0;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--wfm-border);
border-radius: 6px;
background: var(--wfm-bg-hover, #111);
}
.wfm-video-preview-el {
max-width: 100%;
max-height: 60vh;
border-radius: 6px;
}
.wfm-video-source-panel {
width: 220px;
flex: none;
display: flex;
flex-direction: column;
}
.wfm-video-pane-header {
font-weight: 600;
margin-bottom: 4px;
}
.wfm-video-prop-hint {
font-size: 12px;
color: var(--wfm-text-secondary);
margin: 0 0 8px 0;
}
/* 将来の動画編集機能(トリミング等)用のプレースホルダー領域 */
.wfm-video-edit-panel {
flex: 1;
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--wfm-border);
border-radius: 6px;
color: var(--wfm-text-secondary);
font-size: 13px;
}
/* ── 右ペイン: プロパティ (Frame抽出 / GIF変換タブ) ── */
.wfm-video-properties-panel {
width: 240px;
flex: none;
padding: 16px;
overflow-y: auto;
}
.wfm-video-prop-tabnav {
display: flex;
gap: 4px;
margin-bottom: 12px;
border-bottom: 1px solid var(--wfm-border);
}
.wfm-video-prop-tab {
flex: 1;
background: none;
border: none;
padding: 6px 4px;
cursor: pointer;
color: var(--wfm-text-secondary);
border-bottom: 2px solid transparent;
font-size: 13px;
}
.wfm-video-prop-tab.active {
color: var(--wfm-text);
border-bottom-color: var(--wfm-primary);
}
.wfm-video-prop-content {
display: flex;
flex-direction: column;
gap: 6px;
}
+201 -37
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,
});
@@ -423,12 +548,22 @@ const IMAGE_GEN_TOOLS = [{
type: "function",
function: {
name: "generate_image",
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image.",
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image. All parameters besides prompt are optional — omit any the user didn't specify and the workflow's own current setting is kept.",
parameters: {
type: "object",
properties: {
prompt: { type: "string", description: "Positive prompt describing the desired image." },
negative_prompt: { type: "string", description: "What to avoid in the image. Optional." },
steps: { type: "integer", description: "Sampling steps. Optional." },
cfg: { type: "number", description: "Classifier-free guidance scale. Optional." },
sampler_name: { type: "string", description: "ComfyUI sampler name (e.g. euler, dpmpp_2m, ddim). Optional." },
scheduler: { type: "string", description: "ComfyUI scheduler name (e.g. normal, karras, simple). Optional." },
denoise: { type: "number", description: "Denoise strength 0-1 (mainly relevant for image-to-image). Optional." },
seed: { type: "integer", description: "Fixed seed. Omit for a random seed each time. Optional." },
width: { type: "integer", description: "Output image width in pixels. Ignored if the workflow has a Resolution Selector node — use aspect_ratio instead for those. Optional." },
height: { type: "integer", description: "Output image height in pixels. Ignored if the workflow has a Resolution Selector node — use aspect_ratio instead for those. Optional." },
aspect_ratio: { type: "string", enum: ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"], description: "Aspect ratio preset. Only applied if the workflow has a Resolution Selector node (feeding width/height from a ratio + megapixel target); ignored otherwise. Optional." },
batch_size: { type: "integer", description: "Number of images to generate in this single call. Optional." },
},
required: ["prompt"],
},
@@ -453,10 +588,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 +600,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 +619,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 };
}
}
@@ -501,7 +645,7 @@ async function _loadDedicatedChatGenWorkflow(filename) {
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
}
async function generateImageFromChat(prompt, negativePrompt) {
async function generateImageFromChat(prompt, negativePrompt, params = {}) {
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
const settings = loadAiSettings();
@@ -526,12 +670,24 @@ async function generateImageFromChat(prompt, negativePrompt) {
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
const result = await window._wfmGenerateTab.generate(workflow);
const { steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size, aspect_ratio } = params;
let resolvedAspectRatio = null;
const resSelector = analysis.resolution_selector_nodes?.[0];
if (aspect_ratio && resSelector) {
resolvedAspectRatio = await comfyEditor.resolveAspectRatioOption(resSelector.type, aspect_ratio);
}
comfyEditor.setGenerationParams({
steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size,
resolvedAspectRatio, workflow, analysis,
});
const result = await window._wfmGenerateTab.generate(workflow, seed != null ? { seedValue: seed } : {});
const images = result?.images || [];
if (images.length === 0) throw new Error(t("aiChatNoImageProduced"));
const img = images[0];
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
return images.map((img) =>
`/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`
);
}
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
@@ -832,6 +988,14 @@ function appendChatBubble(messagesEl, role, content, opts = {}) {
img.className = "wfm-ai-chat-img";
div.appendChild(img);
}
if (opts.imageUrls?.length) {
opts.imageUrls.forEach((url) => {
const img = document.createElement("img");
img.src = url;
img.className = "wfm-ai-chat-img";
div.appendChild(img);
});
}
if (opts.attachThumb) {
const img = document.createElement("img");
img.src = opts.attachThumb;
@@ -909,7 +1073,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"
);
@@ -921,8 +1085,8 @@ function initChatTab() {
statusEl.textContent = t("aiStatusGeneratingImage");
const args = _parseToolArgs(toolCall);
try {
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
const imageUrls = await generateImageFromChat(args.prompt, args.negative_prompt, args);
appendChatBubble(messagesEl, "assistant", "", { imageUrls });
} catch (genErr) {
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
@@ -1069,7 +1233,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 +1259,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";
+78 -7
View File
@@ -42,11 +42,32 @@ export function showToast(message, type = "info", duration = 3000) {
export function openModal(title, contentHtml) {
const overlay = document.getElementById("wfm-modal-overlay");
const titleEl = document.getElementById("wfm-modal-title");
let titleEl = document.getElementById("wfm-modal-title");
const bodyEl = document.getElementById("wfm-modal-body");
const titleInput = document.getElementById("wfm-modal-title-input");
if (!overlay || !titleEl || !bodyEl) return;
// This modal shell is shared by every caller (Workflow/Models/Nodes detail
// modals, Lab's keyframe editor, etc.). Some callers (e.g. workflow-tab.js's
// openDetailModal) inject extra header chrome after calling openModal() — a
// favorite star button, and a click-to-rename listener + tooltip/cursor on the
// title — but nothing ever tore that down again. The next caller to reuse this
// same DOM inherited it as stale leftovers: a Lab cell modal could show a
// favorite star wired to the PREVIOUS caller's workflow object, and clicking it
// silently toggled that unrelated workflow's favorite state. Clean slate every
// time, before any caller-specific chrome is added back on top.
titleEl.parentElement?.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
const freshTitleEl = titleEl.cloneNode(false); // drops any addEventListener from a prior caller
freshTitleEl.removeAttribute("title");
freshTitleEl.removeAttribute("style"); // also clears any leftover display:none from an interrupted rename
titleEl.replaceWith(freshTitleEl);
titleEl = freshTitleEl;
if (titleInput) {
titleInput.style.display = "none";
titleInput.value = "";
}
titleEl.textContent = title;
bodyEl.innerHTML = contentHtml;
overlay.style.display = "flex";
@@ -91,7 +112,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", "image-edit": "tabImageEdit", settings: "tabSettings", help: "tabHelp", ai: "tabAi", tagger: "tabTagger" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", "image-edit": "tabImageEdit", video: "tabVideo", settings: "tabSettings", help: "tabHelp", ai: "tabAi", tagger: "tabTagger" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -113,6 +134,12 @@ function applyI18nToHtml() {
if (listLoadBtn.disabled) listLoadBtn.title = t("selectCardFirst");
}
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
if (listLoadVideoBtn) {
listLoadVideoBtn.textContent = t("loadInVideo");
if (listLoadVideoBtn.disabled) listLoadVideoBtn.title = t("selectCardFirst");
}
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) {
listOpenComfyBtn.textContent = t("sendToCanvas");
@@ -195,6 +222,7 @@ function applyI18nToHtml() {
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
if (btn.dataset.detailTab === "prompt") btn.textContent = t("gallerySidePrompt");
});
const galleryBulkDeselect = document.getElementById("wfm-gallery-bulk-deselect");
if (galleryBulkDeselect) galleryBulkDeselect.textContent = t("galleryBulkDeselectAll");
@@ -229,6 +257,8 @@ function applyI18nToHtml() {
if (galleryImagePromptSubtab) galleryImagePromptSubtab.textContent = t("galleryTabImagePrompt");
const galleryStyleCatalogSubtab = document.getElementById("wfm-gallery-subtab-styleCatalog");
if (galleryStyleCatalogSubtab) galleryStyleCatalogSubtab.textContent = t("galleryTabStyleCatalog");
const galleryMetadataSubtab = document.getElementById("wfm-gallery-subtab-metadata");
if (galleryMetadataSubtab) galleryMetadataSubtab.textContent = t("galleryTabMetadata");
// ImagePrompt ギャラリーパネル
const imagePromptSearch = document.getElementById("wfm-imageprompt-search");
@@ -364,6 +394,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");
@@ -446,6 +484,9 @@ function applyI18nToHtml() {
"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-gen-29": "helpGen29",
"wfm-help-gen-30": "helpGen30",
"wfm-help-feeder-title": "helpFeederTitle",
"wfm-help-feeder-desc": "helpFeederDesc",
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
@@ -469,7 +510,10 @@ function applyI18nToHtml() {
"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-13": "helpLab13", "wfm-help-lab-14": "helpLab14",
"wfm-help-lab-15": "helpLab15",
"wfm-help-lab-16": "helpLab16", "wfm-help-lab-17": "helpLab17",
"wfm-help-lab-18": "helpLab18", "wfm-help-lab-19": "helpLab19",
"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",
@@ -489,13 +533,23 @@ function applyI18nToHtml() {
"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-prompt-card-toplevel-title": "helpPromptCardToplevel",
"wfm-help-prompt-15": "helpPrompt15",
"wfm-help-prompt-card-table-title": "helpPromptCardTable",
"wfm-help-prompt-16": "helpPrompt16", "wfm-help-prompt-17": "helpPrompt17",
"wfm-help-prompt-18": "helpPrompt18", "wfm-help-prompt-19": "helpPrompt19",
"wfm-help-prompt-20": "helpPrompt20", "wfm-help-prompt-21": "helpPrompt21",
"wfm-help-prompt-22": "helpPrompt22", "wfm-help-prompt-23": "helpPrompt23",
"wfm-help-prompt-24": "helpPrompt24", "wfm-help-prompt-25": "helpPrompt25",
"wfm-help-prompt-26": "helpPrompt26",
"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-settings-15": "helpSettings15", "wfm-help-settings-16": "helpSettings16",
"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",
@@ -515,11 +569,12 @@ function applyI18nToHtml() {
"wfm-help-gallery-11": "helpGallery11", "wfm-help-gallery-12": "helpGallery12",
"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-17": "helpGallery17", "wfm-help-gallery-27": "helpGallery27",
"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-26": "helpGallery26",
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
"wfm-help-tagger-title": "helpTaggerTitle",
@@ -567,7 +622,8 @@ function applyI18nToHtml() {
"wfm-help-imageedit-12": "helpImageEdit12", "wfm-help-imageedit-13": "helpImageEdit13",
"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-18": "helpImageEdit18",
"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",
@@ -593,6 +649,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",
@@ -674,6 +731,12 @@ function applyI18nToHtml() {
"wfm-help-trouble-card-connection-title": "helpTroubleCardConnection",
"wfm-help-trouble-card-workflow-title": "helpTroubleCardWorkflow",
"wfm-help-trouble-card-tagger-title": "helpTroubleCardTagger",
"wfm-help-video-title": "helpVideoTitle",
"wfm-help-video-desc": "helpVideoDesc",
"wfm-help-video-1": "helpVideo1", "wfm-help-video-2": "helpVideo2",
"wfm-help-video-3": "helpVideo3", "wfm-help-video-4": "helpVideo4",
"wfm-help-video-5": "helpVideo5", "wfm-help-video-6": "helpVideo6",
"wfm-help-video-7": "helpVideo7",
};
for (const [id, key] of Object.entries(helpIdMap)) {
const el = document.getElementById(id);
@@ -756,13 +819,15 @@ 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 { initPromptTableTab } from "./prompt-table.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";
import { initAiTab } from "./ai-tab.js";
import { initTaggerTab } from "./tagger-tab.js";
import { imageEditTab } from "./image-edit-tab.js";
import { initVideoTab } from "./video-tab.js";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
@@ -772,6 +837,7 @@ try {
const _s = getSettings();
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
applyJsonColors(_s.jsonColors);
applyModelTabActiveColor(_s.modelTabActiveColor);
} catch {}
function initHelpTab() {
@@ -789,6 +855,9 @@ function initHelpTab() {
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelector(`.wfm-tab[data-tab="${btn.dataset.jumpTab}"]`)?.click();
if (btn.dataset.jumpSubtab) {
document.querySelector(`.wfm-gallery-subtab-btn[data-gallery-subtab="${btn.dataset.jumpSubtab}"]`)?.click();
}
});
});
}
@@ -804,11 +873,13 @@ document.addEventListener("DOMContentLoaded", () => {
initModelsTab();
initGenerateTab();
initPromptTab();
initPromptTableTab();
initMetadataTab();
initGalleryTab();
initAiTab();
initTaggerTab();
imageEditTab.init();
initVideoTab();
console.log("Workflow Studio: App initialized");
});
+223 -39
View File
@@ -6,6 +6,7 @@ import { comfyUI } from "./comfyui-client.js";
import { syncJsonHighlight } from "./json-highlight.js";
import { t } from "./i18n.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
import { showToast } from "./app.js";
// ── Latent Image preset state ─────────────────────────────
const _LATENT_PRESET_KEY = "wfm_latent_presets";
@@ -156,6 +157,16 @@ function _refreshPresetSelect(customPresets) {
let _stackStrengths = {};
// { modelFullPath: boolean } — true = active (default)
let _stackActive = {};
// ── LoRA node-bypass state (GenerateUI Model tab header toggle) ───────────
// Force the LoRA node's strength to 0 without touching the actual ComfyUI node's mode —
// currentWorkflow is already-converted API format (see comfyui-workflow.js
// convertUiToApi), where mode has no effect at execution time, so a real Bypass(mode:4)
// can't be reproduced here; zeroing strength is the behavioral equivalent.
// { nodeId: boolean } — true = currently bypassed
let _loraNodeBypass = {};
// { nodeId: { loraName, strModel, strClip } | { loras, text } } — pre-bypass values to
// restore when toggled back off. Shape depends on isLoraManager at bypass-time.
let _loraBypassSnapshot = {};
// { id: string|null, selStart: number, selEnd: number }
let _lastPromptFocus = { id: null, selStart: 0, selEnd: 0 };
@@ -616,10 +627,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",
@@ -627,46 +642,65 @@ 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>
<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>
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) => {
@@ -719,14 +753,81 @@ export const comfyEditor = {
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
},
// Toggles the GenerateUI Model tab's LoRA column-header bypass button: forces the
// current target LoRA node's strength to 0 (both tabs' strength fields, whichever
// is currently in currentWorkflow), or restores whatever was there before. Targets
// the same node Single/Stack Apply default to (first LoraManager, else first LoRA
// node) so one button covers both tabs without needing its own node picker.
toggleLoraNodeBypass(analysis) {
const loraNodes = analysis?.lora_nodes || [];
let nodeId = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
let isLoraManager = !!loraNodes.find((n) => String(n.id) === String(nodeId))?.is_lora_manager;
if (nodeId == null) {
nodeId = Object.keys(comfyUI.currentWorkflow || {}).find((id) => comfyUI.currentWorkflow[id]?.inputs && "lora_name" in comfyUI.currentWorkflow[id].inputs);
isLoraManager = false;
}
const wfNode = nodeId != null ? comfyUI.currentWorkflow?.[nodeId] : null;
if (!wfNode) {
showToast(t("modelsGenUINoNode", "LoRA"), "warning");
return;
}
if (_loraNodeBypass[nodeId]) {
// Restore whatever was set before bypassing
const snap = _loraBypassSnapshot[nodeId];
if (snap) {
if (isLoraManager) {
wfNode.inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras || [])) };
wfNode.inputs.text = snap.text ?? "";
} else {
wfNode.inputs.lora_name = snap.lora_name;
wfNode.inputs.strength_model = snap.strength_model;
wfNode.inputs.strength_clip = snap.strength_clip;
}
}
delete _loraNodeBypass[nodeId];
delete _loraBypassSnapshot[nodeId];
} else {
// Snapshot current values, then zero the strength (LoraManager: every
// entry in its loras list, keeping the list itself intact so un-bypassing
// restores the exact same Stack — not just a single-LoRA fallback).
if (isLoraManager) {
const loras = Array.isArray(wfNode.inputs.loras?.__value__) ? wfNode.inputs.loras.__value__ : [];
_loraBypassSnapshot[nodeId] = { loras: JSON.parse(JSON.stringify(loras)), text: wfNode.inputs.text ?? "" };
wfNode.inputs.loras = { __value__: loras.map((l) => ({ ...l, strength: 0, clipStrength: 0 })) };
wfNode.inputs.text = "";
} else {
_loraBypassSnapshot[nodeId] = {
lora_name: wfNode.inputs.lora_name,
strength_model: wfNode.inputs.strength_model,
strength_clip: wfNode.inputs.strength_clip,
};
wfNode.inputs.strength_model = 0;
wfNode.inputs.strength_clip = 0;
}
_loraNodeBypass[nodeId] = true;
}
_syncRawJson();
},
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);
// Sync the column-header bypass button (lives outside this container's
// innerHTML, in the static wfm-gen-lora-col-header) to whatever the target
// node's current bypass state is.
const bypassBtn = document.getElementById("wfm-lora-bypass-toggle");
if (bypassBtn) {
bypassBtn.classList.toggle("active", defaultStackTarget != null && !!_loraNodeBypass[defaultStackTarget]);
bypassBtn.disabled = loraNodes.length === 0;
}
// Use Stack Group チェックボックスの現在状態を先読み(Stack表示の取得元判定に使う)。
// resetStackMode(新規読み込み)時は常にOFF扱い。
const stackModeChecked = hasLoraManager && !opts.resetStackMode && (el.querySelector("#wfm-lora-stack-mode-toggle")?.checked ?? false);
@@ -735,6 +836,10 @@ export const comfyEditor = {
// Stackグループ使用チェックボックスをOFFへリセットする(既存のGenUI状態への影響を避けるため
// タブ切替やStack再読込などの単なる再描画では行わない)。
if (opts.resetStackMode) {
// New workflow loaded — bypass state/snapshots belonged to the previous
// workflow's node IDs, so they can't be meaningfully carried forward.
_loraNodeBypass = {};
_loraBypassSnapshot = {};
comfyUI.loraManagerSnapshots = {};
for (const n of loraNodes) {
if (!n.is_lora_manager) continue;
@@ -1060,6 +1165,11 @@ export const comfyEditor = {
const strModel = parseFloat(document.getElementById("wfm-lora-str-model")?.value) || 1.0;
const strClip = parseFloat(document.getElementById("wfm-lora-str-clip")?.value) || 1.0;
_applyLoraToNode(nodeId, select.value, strModel, strClip, node?.is_lora_manager);
// An explicit Apply overwrites whatever bypass had zeroed — clear it so the
// header button (and a later un-bypass) don't stomp this new value.
delete _loraNodeBypass[nodeId];
delete _loraBypassSnapshot[nodeId];
document.getElementById("wfm-lora-bypass-toggle")?.classList.remove("active");
_syncRawJson();
// Sync to Positive prompt
@@ -1192,6 +1302,11 @@ export const comfyEditor = {
comfyUI.currentWorkflow[nodeId].inputs.strength_clip = _stackStrengths[first]?.c ?? 1.0;
}
}
// An explicit Apply overwrites whatever bypass had zeroed — clear it so the
// header button (and a later un-bypass) don't stomp this new value.
delete _loraNodeBypass[nodeId];
delete _loraBypassSnapshot[nodeId];
document.getElementById("wfm-lora-bypass-toggle")?.classList.remove("active");
_syncRawJson();
// Sync Stack LORA SYNTAX + TRIGGER WORDS to Positive prompt
@@ -1966,6 +2081,75 @@ export const comfyEditor = {
if (!workflow) _syncRawJson();
},
// Resolves a user/LLM-supplied aspect ratio (e.g. "3:2" or the full COMBO label
// "3:2 (Photo)") against a ResolutionSelector-type node's actual options via
// /object_info — the workflow analysis only knows the node's CURRENT value, not its
// full option list, so a bare "3:2" needs matching against whatever labeled string
// that node's aspect_ratio COMBO actually accepts. Falls back to the raw input if
// the node type can't be queried or nothing matches, so ComfyUI's own validation
// surfaces a clear error instead of this silently doing nothing.
async resolveAspectRatioOption(nodeType, requested) {
if (!requested) return null;
const ratioMatch = String(requested).match(/(\d+)\s*:\s*(\d+)/);
const wantedRatio = ratioMatch ? `${ratioMatch[1]}:${ratioMatch[2]}` : String(requested).trim();
try {
const info = await comfyUI.fetchObjectInfo(nodeType);
const options = info?.[nodeType]?.input?.required?.aspect_ratio?.[1]?.options || [];
if (options.includes(requested)) return requested;
return options.find((o) => o.startsWith(wantedRatio)) || requested;
} catch {
return requested;
}
},
// AI TOOL Chat's generate_image tool call — writes whichever of KSampler
// (steps/cfg/sampler_name/scheduler/denoise/seed), Latent Image (width/height/
// batch_size), and Resolution Selector (resolvedAspectRatio) fields the LLM
// supplied onto the given workflow/analysis (or comfyUI.currentWorkflow/
// currentAnalysis by default). Mirrors lab-tab.js's _buildWorkflowForIteration
// KSampler branch — same per-field node-id indirection (stepsNodeId/cfgNodeId/...
// falling back to sampler.id) for Advanced-Sampling workflows (Flux.1/2, SD3.5,
// Chroma...) that spread these across several nodes.
// seed is returned rather than written directly — the caller passes it through to
// comfyUI.generate()'s seedMode/seedValue instead, same as every other seed source.
// resolvedAspectRatio must already be one of the node's actual option strings (see
// resolveAspectRatioOption above) — passed in rather than resolved here because
// that lookup is async and this stays a plain synchronous writer.
setGenerationParams({ steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size, resolvedAspectRatio, workflow, analysis } = {}) {
const wf = workflow || comfyUI.currentWorkflow;
const an = analysis || comfyUI.currentAnalysis;
if (!wf || !an) return { seed: null };
const sampler = an.sampler_nodes?.[0];
if (sampler) {
if (steps != null) { const id = sampler.stepsNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.steps = steps; }
if (cfg != null) { const id = sampler.cfgNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.cfg = cfg; }
if (sampler_name) { const id = sampler.samplerNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.sampler_name = sampler_name; }
if (scheduler && sampler.schedulerNodeId != null && wf[sampler.schedulerNodeId]) {
wf[sampler.schedulerNodeId].inputs.scheduler = scheduler;
}
if (denoise != null && sampler.denoiseNodeId != null && wf[sampler.denoiseNodeId]) {
wf[sampler.denoiseNodeId].inputs.denoise = denoise;
}
}
// Resolution Selector takes priority over direct width/height — writing width/
// height straight onto the EmptyLatentImage would just clobber whatever the
// selector computed, silently ignoring the requested aspect ratio.
const resSelector = an.resolution_selector_nodes?.[0];
const latent = an.latent_nodes?.[0];
if (resolvedAspectRatio && resSelector && wf[resSelector.id]) {
wf[resSelector.id].inputs.aspect_ratio = resolvedAspectRatio;
} else if (latent && wf[latent.id]) {
if (width != null) wf[latent.id].inputs.width = width;
if (height != null) wf[latent.id].inputs.height = height;
}
if (latent && wf[latent.id] && batch_size != null) wf[latent.id].inputs.batch_size = batch_size;
if (!workflow) _syncRawJson();
return { seed: seed ?? null };
},
syncToWorkflow() {
// Sync prompt texts before generation
const posSelect = document.getElementById("wfm-prompt-pos-target");
@@ -2092,7 +2276,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>` : ""}
+36
View File
@@ -886,6 +886,7 @@ export const comfyWorkflow = {
prompt_nodes: [],
sampler_nodes: [],
latent_nodes: [],
resolution_selector_nodes: [],
lora_nodes: [],
vae_nodes: [],
save_nodes: [],
@@ -1335,6 +1336,19 @@ export const comfyWorkflow = {
});
}
// Video all-in-one nodes (e.g. MiniMaxH3ImageToVideo) — hold their own "prompt"
// widget directly instead of going through a separate CLIPTextEncode, and have
// no negative counterpart. Matched generically by class name (ImageToVideo /
// TextToVideo) rather than a fixed list so future models sharing this pattern
// (WAN, HunyuanVideo, etc.) work without a code change — same detection metadata-tab.js
// and node_sets_menu.js already use for prompt extraction (see extractPromptsAPI).
if ((ct.includes("ImageToVideo") || ct.includes("TextToVideo")) && typeof inputs.prompt === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: "positive",
text: inputs.prompt, textKey: "prompt",
});
}
// --- Latent nodes ---
// EmptyFlux2LatentImage (Flux.2 templates) often has width/height wired to a
// GetImageSize node (auto-follows the reference image) rather than a direct number —
@@ -1349,6 +1363,20 @@ export const comfyWorkflow = {
});
}
// ResolutionSelector (ComfyUI core, comfy_extras.nodes_resolution): typically
// feeds an EmptyLatentImage's width/height rather than being a latent node
// itself. aspect_ratio is a fixed-choice COMBO (e.g. "3:2 (Photo)") — its exact
// option strings aren't known here, only the node's current value, so callers
// that want to set a new one (AI TOOL Chat's generate_image) must resolve
// against /object_info at call time (see comfyui-editor.js resolveAspectRatioOption).
if (ct === "ResolutionSelector") {
result.resolution_selector_nodes.push({
id, type: ct, title,
aspect_ratio: inputs.aspect_ratio,
megapixels: inputs.megapixels,
});
}
// TextEncodeMageFlowEdit also carries width/height/batch_size directly
// (it builds the latent internally instead of a separate EmptyLatentImage node).
// width/height are commonly wired to a ResolutionSelector-style helper node
@@ -1504,6 +1532,14 @@ export const comfyWorkflow = {
return result;
},
// True if this analysis found a video all-in-one node (see analyzeWorkflow's
// ImageToVideo/TextToVideo prompt_nodes branch). Callers use this to pick a longer
// generation timeout (video generation runs far longer than a still image) — see
// generate-tab.js _coreGenerate and lab-tab.js _runLabBatch.
isVideoWorkflow(analysis) {
return (analysis?.prompt_nodes || []).some((n) => /ImageToVideo|TextToVideo/.test(n.type));
},
applyParams(workflow, params) {
for (const [key, value] of Object.entries(params)) {
// Parse key format: nodeId.paramName
+156 -18
View File
@@ -5,7 +5,7 @@
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { loadFileIntoMetadataTab } from "./metadata-tab.js";
import { loadFileIntoMetadataTab, extractPrompts, buildPromptItem } from "./metadata-tab.js";
import { loadWorkflowIntoEditor } from "./generate-tab.js";
import { escapeHtml, setupSearchClearBtn, getAiBackendDefaultUrl } from "./util.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
@@ -46,6 +46,13 @@ const API = {
export const FEEDER_GROUP = "__Feeder__";
// 拡張子から動画ファイル(mp4)かどうかを判定する。
// 一覧アイテムは path/filename、詳細メタ(get_image_metadata)は ext フィールドを持つため両対応。
function isVideoFile(img) {
const source = img?.ext || img?.filename || img?.path || "";
return source.toLowerCase().endsWith(".mp4");
}
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Generate UIのImage入力スロットへ直接セットする(I2I連携)。
// Comic Creater側の「I2Iへ送る」ボタンから
// iframe.contentWindow._wfmReceiveImageForI2I(blob, name, workflowData?, workflowFilename?) として呼ばれる。
@@ -253,6 +260,13 @@ function formatDate(mtime) {
return new Date(mtime * 1000).toLocaleString();
}
function formatDuration(seconds) {
const total = Math.round(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
async function openImageInMetadataTab(img) {
try {
const res = await fetch(API.serveImage(img.path));
@@ -578,6 +592,14 @@ function createThumbCard(img) {
};
card.appendChild(imgEl);
// 動画ファイルは再生アイコンバッジを重ねて表示(サムネイル自体は先頭フレームのJPEG)
if (isVideoFile(img)) {
const playBadge = document.createElement("div");
playBadge.className = "wfm-gallery-thumb-video-badge";
playBadge.textContent = "▶";
card.appendChild(playBadge);
}
// お気に入りトグルボタン(カード右上)
const favBtn = document.createElement("button");
favBtn.className = `wfm-gallery-thumb-fav-btn${img.favorite ? " active" : ""}`;
@@ -877,18 +899,18 @@ async function loadImageDetail(img) {
if (moveBtn) moveBtn.disabled = false;
if (delBtn) delBtn.disabled = false;
// プレビュー
// プレビュー(mp4は<video controls>、それ以外は<img>)
const preview = document.getElementById("wfm-gallery-detail-preview");
const isVideo = isVideoFile(img);
const mediaHtml = isVideo
? `<video src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" controls title="Double-click to enlarge"></video>`
: `<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${escapeHtml(img.filename)}" title="Double-click to enlarge">`;
preview.innerHTML = `
<div class="wfm-gallery-preview-wrapper">
<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${escapeHtml(img.filename)}" title="Double-click to enlarge">
<div class="wfm-gallery-preview-overlay">
<button class="wfm-gallery-download-btn" title="Download image">⬇</button>
</div>
${mediaHtml}
</div>
`;
preview.querySelector("img").addEventListener("dblclick", () => openLightbox(img));
preview.querySelector(".wfm-gallery-download-btn").addEventListener("click", () => downloadImage(img));
preview.querySelector(isVideo ? "video" : "img").addEventListener("dblclick", () => openLightbox(img));
// ファイル名
document.getElementById("wfm-gallery-detail-filename").textContent = img.filename;
@@ -914,16 +936,32 @@ async function loadImageDetail(img) {
renderWorkflowJson(state.embeddedWorkflow);
_updateCopyCanvasBtn();
renderImagePromptSection(metaRes.image_prompt);
renderDimensionInfo(metaRes);
// Promptタブは prompt_workflow (API形式優先) を使う。トップレベルとサブグラフに
// 独立した複数系統を持つワークフローでは workflow (UI形式) からの抽出だと
// トップレベル系統しか拾えないことがあるため(Metadataタブと同じ優先順位に揃える)。
renderPromptTab(wfRes.prompt_workflow ?? state.embeddedWorkflow);
} catch (e) {
renderWorkflowJson(null);
_updateCopyCanvasBtn();
renderImagePromptSection(null);
renderDimensionInfo(null);
renderPromptTab(null);
}
// グループタブ更新
renderDetailGroup(img);
}
function renderDimensionInfo(metaRes) {
const el = document.getElementById("wfm-gallery-info-dim");
if (!el) return;
const parts = [];
if (metaRes?.width && metaRes?.height) parts.push(`${metaRes.width}×${metaRes.height}`);
if (metaRes?.duration) parts.push(formatDuration(metaRes.duration));
el.textContent = parts.length ? parts.join(" · ") : "";
}
function renderImagePromptSection(imagePrompt) {
const section = document.getElementById("wfm-gallery-image-prompt-section");
const text = document.getElementById("wfm-gallery-image-prompt-text");
@@ -964,6 +1002,44 @@ function renderWorkflowJson(workflow) {
}
}
// 埋め込みワークフローからpositive/negativeプロンプトを抽出してPromptタブに表示する。
// 複数候補があるケース(PromptStyler等で正負が複数生成される場合)に対応するため、
// Metadataタブと同じ POS/NEGリスト → クリックで全文表示のUI(buildPromptItem)をそのまま
// 再利用する。選択画像を切り替えるたびにマウス操作無しで読めるよう、先頭項目を自動選択する。
function renderPromptTab(workflow) {
const listEl = document.getElementById("wfm-gallery-prompt-list");
const fullArea = document.getElementById("wfm-gallery-prompt-full");
const fullLabel = document.getElementById("wfm-gallery-prompt-full-label");
if (!listEl || !fullArea || !fullLabel) return;
listEl.innerHTML = "";
fullArea.value = "";
fullLabel.textContent = "";
if (!workflow) {
listEl.innerHTML = `<div class="wfm-meta-item" style="opacity:0.6;">No workflow embedded in this image.</div>`;
return;
}
const { positives, negatives, texts } = extractPrompts(workflow);
const allPrompts = [
...positives.map(p => ({ type: "positive", text: p })),
...negatives.map(p => ({ type: "negative", text: p })),
...(texts ?? []).map(p => ({ type: "text", text: p })),
];
if (allPrompts.length === 0) {
listEl.innerHTML = `<div class="wfm-meta-item" style="opacity:0.6;">No prompt found.</div>`;
return;
}
for (const { type, text } of allPrompts) {
listEl.appendChild(buildPromptItem(text, type, text, fullArea, fullLabel, listEl));
}
// Auto-select first item (positive prompts come first in allPrompts)
listEl.querySelector(".wfm-meta-item-clickable")?.click();
}
// ── タグ操作 ─────────────────────────────────────────────────
async function addTag(tag) {
@@ -971,7 +1047,8 @@ async function addTag(tag) {
const tags = [...(state.selectedImage.tags || [])];
if (tags.includes(tag.trim())) return;
tags.push(tag.trim());
await saveMetaField({ tags });
const ok = await saveMetaField({ tags });
if (!ok) return;
state.selectedImage.tags = tags;
renderTagsDisplay(tags);
updateTagFilter(state.images);
@@ -980,24 +1057,38 @@ async function addTag(tag) {
async function removeTag(tag) {
if (!state.selectedImage) return;
const tags = (state.selectedImage.tags || []).filter(t => t !== tag);
await saveMetaField({ tags });
const ok = await saveMetaField({ tags });
if (!ok) return;
state.selectedImage.tags = tags;
renderTagsDisplay(tags);
updateTagFilter(state.images);
}
// 戻り値: 保存が実際にサーバーへ永続化されたか(true/false)。呼び出し元はこれを見て
// ローカル状態(state.selectedImage等)の更新可否を判断すること — でないと、サーバーが
// 保存を拒否した場合にもUI上は成功したように見えてしまう(リロードすると消える不具合の原因)。
async function saveMetaField(fields) {
if (!state.selectedImage) return;
if (!state.selectedImage) return false;
try {
await fetch(API.saveImageMeta, {
const res = await fetch(API.saveImageMeta, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.selectedImage.path, ...fields }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
// サーバー側は常にHTTP 200を返し、成否は body.ok (bool) で表す設計
// (gallery_routes.py save_image_meta)。ここをチェックしないと、
// 許可パス外などでサーバーが保存を拒否してもUI上は成功したように見えてしまう。
if (json.error || json.ok === false) {
throw new Error(json.error || "Save rejected by server");
}
const idx = state.images.findIndex(i => i.path === state.selectedImage.path);
if (idx >= 0) Object.assign(state.images[idx], fields);
return true;
} catch (e) {
showToast(t("saveFailed", e.message), "error");
return false;
}
}
@@ -1540,9 +1631,12 @@ async function bulkSetFavorite(favoriteValue) {
function openLightbox(img) {
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox";
const mediaHtml = isVideoFile(img)
? `<video src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" controls autoplay></video>`
: `<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">`;
overlay.innerHTML = `
<div class="wfm-gallery-lightbox-inner">
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
${mediaHtml}
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
<button class="wfm-gallery-lightbox-close">&times;</button>
</div>
@@ -1564,12 +1658,17 @@ function openCompare(paths) {
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox wfm-lightbox-compare";
const itemsHtml = imgs.map(img => `
const itemsHtml = imgs.map(img => {
const mediaHtml = isVideoFile(img)
? `<video src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" controls></video>`
: `<img src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" alt="${escapeHtml(img.filename)}" loading="lazy">`;
return `
<div class="wfm-lightbox-compare-item">
<img src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" alt="${escapeHtml(img.filename)}" loading="lazy">
${mediaHtml}
<div class="wfm-lightbox-compare-label">${escapeHtml(img.filename)}</div>
</div>
`).join("");
`;
}).join("");
overlay.innerHTML = `
<div class="wfm-lightbox-compare-inner">
@@ -1636,7 +1735,7 @@ function onGalleryTabActivated() {
loadGroups();
}
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog) ────────────────────────
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog / Metadata) ────────────
function _initGallerySubtabToggle() {
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((btn) => {
@@ -1647,6 +1746,7 @@ function _initGallerySubtabToggle() {
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");
document.getElementById("wfm-gallery-panel-metadata")?.classList.toggle("active", target === "metadata");
if (target === "imageprompt") activateImagePromptTab();
if (target === "styleCatalog") activateStyleCatalogTab();
});
@@ -1707,6 +1807,34 @@ function bindEvents() {
}
});
// 選択画像をComfyUIキャンバス上で選択中のノード(LoadImage系)のimageウィジェットへ送信。
// Image Editタブの「Send to LI node」と同じ橋渡し(window.opener.wfmSendImageToSelectedNode、
// node_sets_menu.js側で定義)を使う。Galleryの画像はGalleryルート配下の任意フォルダにあり得るため、
// GenUI送信と違いLoadImageノードの有無に依存しない — /upload/imageでinputフォルダへ
// アップロードしてから、そのファイル名を書き込む。
document.getElementById("wfm-gallery-send-li-node-btn")?.addEventListener("click", async () => {
if (!state.selectedImage) {
showToast(t("gallerySelectImageFirst"), "info");
return;
}
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
showToast(t("sendToLiNodeNoOpener"), "error");
return;
}
try {
const res = await fetch(API.serveImage(state.selectedImage.path));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const file = new File([blob], state.selectedImage.filename || "gallery-image.png", { type: blob.type || "image/png" });
const result = await comfyUI.uploadImage(file, file.name);
const filename = result.subfolder ? `${result.subfolder}/${result.name}` : result.name;
window.opener.wfmSendImageToSelectedNode(filename);
showToast(t("sendToLiNodeSuccess", filename), "success");
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
});
// 選択画像を ComfyUI Comic Creator の選択コマ/オーバーレイへ送信
// (ComfyUI Comic CreaterからこのGalleryタブがiframe埋め込みされている場合のみ表示・動作する)
const sendCcBtn = document.getElementById("wfm-gallery-send-cc-btn");
@@ -1840,7 +1968,7 @@ function bindEvents() {
// メモ保存
document.getElementById("wfm-gallery-memo-save-btn")?.addEventListener("click", () => {
const memo = document.getElementById("wfm-gallery-memo").value;
saveMetaField({ memo }).then(() => showToast(t("memoSaved"), "success"));
saveMetaField({ memo }).then((ok) => { if (ok) showToast(t("memoSaved"), "success"); });
});
// ワークフローコピー&キャンバスへ送る
@@ -1973,6 +2101,16 @@ function bindEvents() {
openImageInTaggerTab(state.selectedImage);
});
// Downloadボタン: プレビューのオーバーレイだと動画の再生コントロール操作を妨げるため、
// アクションボタン行に常時表示のボタンとして配置している
document.getElementById("wfm-gallery-download-btn")?.addEventListener("click", () => {
if (!state.selectedImage) {
showToast(t("gallerySelectImageFirst"), "error");
return;
}
downloadImage(state.selectedImage);
});
// Load in GenerateUI ボタン: 埋め込みワークフローをGenerateUIタブに読み込む
document.getElementById("wfm-gallery-load-genui-btn")?.addEventListener("click", async () => {
if (!state.selectedImage) {
+67 -16
View File
@@ -11,6 +11,7 @@ import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
import { initLabTab, refreshLabLiveDefaults } from "./lab-tab.js";
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
import { isVideoFilename } from "./video-utils.js";
// ============================================
// Gallery Metadata - ワークフロー保存
@@ -164,6 +165,17 @@ async function saveCurrentWorkflow() {
nameEl.dataset.filename = filename;
}
}
// このファイル名が「デフォルトワークフロー」(workflow-tab.jsの「デフォルトに設定」で
// localStorageにスナップショットされたAPI形式JSON、ページ起動時に自動読込される)に
// 一致する場合、保存内容と食い違ったまま残らないようスナップショットも更新する。
// 更新しないと、ここで上書き保存しても次回リロード時に古いモデル等が復活する。
const settings = getSettings();
if (settings.defaultWorkflow === filename) {
settings.defaultWorkflowData = workflow;
localStorage.setItem("wfm_settings", JSON.stringify(settings));
}
showToast(t("savedAs", filename), "success");
} catch (err) {
showToast(t("saveFailed", err.message), "error");
@@ -1443,26 +1455,38 @@ export async function _expandWildcardsInWorkflow(workflow) {
// Generation (core — throws on error)
// ============================================
async function _coreGenerate(silent = false, workflowOverride = null) {
async function _coreGenerate(silent = false, workflowOverride = null, genOptions = {}) {
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultVideo = document.getElementById("wfm-gen-result-video");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
// genOptions.seedValue lets a caller (e.g. AI TOOL Chat's generate_image tool)
// force a specific seed on an override workflow without touching the GenerateUI
// seed fields — those still drive normal (non-override) generation as before.
const seedMode = genOptions.seedValue != null ? "fixed" : (document.getElementById("wfm-gen-seed-mode")?.value || "random");
const seedValue = genOptions.seedValue ?? (parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1);
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
// Style must be applied *before* wildcard expansion so a __name__ reference written
// inside a Style's prompt/negative_prompt also gets expanded, not just wildcards
// already present in the base workflow's prompt nodes.
const baseWorkflow = workflowOverride || comfyUI.currentWorkflow;
const workflowExpanded = await _expandWildcardsInWorkflow({ ...baseWorkflow });
const workflowForGenerate = _applyStyleToWorkflow(workflowExpanded);
const workflowStyled = _applyStyleToWorkflow({ ...baseWorkflow });
const workflowForGenerate = await _expandWildcardsInWorkflow(workflowStyled);
// Video workflows (e.g. MiniMax H3) run far longer than a still image — use a longer
// timeout so a multi-minute generation doesn't get force-rejected mid-run (see
// comfyWorkflow.isVideoWorkflow and video-tab.js's own 30-minute timeout).
const isVideo = comfyWorkflow.isVideoWorkflow(comfyUI.currentAnalysis);
const { images, seed, svgOutputs } = await comfyUI.generate(
workflowForGenerate,
{
seedMode,
seedValue,
timeoutMs: isVideo ? 30 * 60 * 1000 : undefined,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
@@ -1508,23 +1532,36 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
});
}
} else if (outputImages.length > 0) {
// Video outputs (SaveVideo/PreviewVideo, e.g. MiniMax H3) come back through the
// same {"images":[...]} shape as still images — dispatch to <video> vs <img>
// per-item by extension rather than assuming the whole batch is one media type.
const showMedia = (blob, filename) => {
const url = URL.createObjectURL(blob);
if (isVideoFilename(filename)) {
if (resultVideo) { resultVideo.src = url; resultVideo.style.display = "block"; }
if (resultImg) resultImg.style.display = "none";
} else {
if (resultImg) { resultImg.src = url; resultImg.style.display = "block"; }
if (resultVideo) { resultVideo.pause(); resultVideo.style.display = "none"; }
}
};
const blob = await comfyUI.getImageBlob(outputImages[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
showMedia(blob, outputImages[0].filename);
if (resultThumbs && outputImages.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < outputImages.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(outputImages[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
const filename = outputImages[i].filename;
const u = URL.createObjectURL(b);
const isVid = isVideoFilename(filename);
const thumb = document.createElement(isVid ? "video" : "img");
thumb.src = u;
if (isVid) thumb.muted = true;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
showMedia(b, filename);
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
@@ -1972,9 +2009,11 @@ async function _runBatchGenerate() {
// ============================================
// workflowOverride: 明示的に渡された場合、comfyUI.currentWorkflow の代わりにこのワークフローで
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行から利用)。
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行や
// AI TOOL Chatのgenerate_imageツールから利用)。
// genOptions: 現状 seedValue のみ(指定時は_coreGenerateがseedMode "fixed"扱いにする)。
// ボタンのclickハンドラから呼ばれる場合は第一引数がMouseEventになるため、その場合は無視する。
async function handleGenerate(workflowOverride = null) {
async function handleGenerate(workflowOverride = null, genOptions = {}) {
if (workflowOverride instanceof Event) workflowOverride = null;
if (!workflowOverride && !comfyUI.currentWorkflow) {
@@ -2003,7 +2042,7 @@ async function handleGenerate(workflowOverride = null) {
await _runBatchGenerate();
} else {
try {
return await _coreGenerate(false, workflowOverride);
return await _coreGenerate(false, workflowOverride, genOptions);
} catch (err) {
const progressText = document.getElementById("wfm-gen-progress-text");
if (progressText) progressText.textContent = "Error";
@@ -2146,6 +2185,10 @@ export async function initGenerateTab() {
// 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" : "";
// Save button writes the currently loaded workflow as-is — not meaningful while
// browsing Batch/Lab (which operate on workflow copies / their own Plan files)
const saveBtn = document.getElementById("wfm-gen-save-btn");
if (saveBtn) saveBtn.style.display = (target === "batch" || target === "lab") ? "none" : "";
});
});
@@ -2156,6 +2199,14 @@ export async function initGenerateTab() {
}
});
// Bypass button in LoRA column header — forces the target LoRA node's strength to
// 0 (or restores it), independent of the Single/Stack tabs' own Apply buttons.
document.getElementById("wfm-lora-bypass-toggle")?.addEventListener("click", () => {
if (!comfyUI.currentAnalysis) return;
comfyEditor.toggleLoraNodeBypass(comfyUI.currentAnalysis);
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
});
// Input inner tab (Prompt / Image)
document.querySelectorAll(".wfm-input-inner-tab").forEach((btn) => {
btn.addEventListener("click", () => {
+414 -60
View File
File diff suppressed because it is too large Load Diff
+101 -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();
}
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 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 the selected node's image widget on the ComfyUI canvas
* (Send to LI node).
*/
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");
}
}
// Uploads the composite via /upload/image, 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 LI node`, "success");
} catch (err) {
showToast(`Send to LI node 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;
});
}
}
+615 -38
View File
@@ -1,14 +1,15 @@
/**
* Lab Tab - Experimental I2I batch generation.
*
* Runs the currently loaded workflow N times, letting Checkpoint / VAE / Prompt /
* KSampler each be overridden independently starting at a chosen iteration
* Runs the currently loaded workflow N times, letting Model (Checkpoint/LoRA/VAE) /
* Prompt / KSampler each be overridden independently starting at a chosen iteration
* ("keyframe"). Never mutates comfyUI.currentWorkflow — everything is applied to a
* fresh deep clone per iteration so it can't interfere with the Input/Model/Settings/
* Batch subtabs.
*/
import { comfyUI } from "./comfyui-client.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
import { comfyEditor } from "./comfyui-editor.js";
import { showToast, openModal, closeModal } from "./app.js";
import { t } from "./i18n.js";
@@ -16,28 +17,94 @@ import { escapeHtml, embedPngTextChunk, getEagleSettings, saveToEagle, setupSear
import { extractAllMetadata, readAllPNGTextChunks } from "./metadata-tab.js";
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
import { _expandWildcardsInWorkflow } from "./generate-tab.js";
import { pmGroups, getPresetsInGroup } from "./prompt-presets.js";
import { isVideoFilename, extractLastFrameBlob } from "./video-utils.js";
const COLUMN_KEYS = ["checkpoint", "vae", "prompt", "ksampler"];
// Checkpoint/LoRA/VAE are three fully independent keyframe timelines — same as
// Prompt/KSampler — each with its own +/-, its own atIteration sequence, its own
// forward-fill resolution (_resolveKeyframe). The Model column header's C/L/V toggle
// only picks which ONE of the three renders into the (single, shared) cell list and
// which one +/- and the cell-click modal operate on; it never bundles them together.
const COLUMN_KEYS = ["checkpoint", "lora", "vae", "prompt", "ksampler"];
const MAX_RESULTS = 9;
const RESULTS_GRID_COLS = 3;
const LAB_PLAN_PREFIX = "ws_labplan_";
const LAB_PLAN_PNG_KEY = "wfm_lab_plan";
function _defaultValueFor(col) {
if (col === "lora") return { name: "", strengthModel: 1.0, strengthClip: 1.0, nodeBypass: false, applyToPrompt: false };
if (col === "prompt") return { positive: "", negative: "", styleApplied: false, styleName: "" };
if (col === "ksampler") return { steps: null, cfg: null, sampler_name: "", scheduler: "", denoise: null, seed: null };
return "";
return ""; // checkpoint, vae
}
// Fills in defaults for a possibly-partial LoRA value — both for legacy plan data
// migrated by _migrateLabColumns and defensively for any hand-edited Plan JSON.
function _normalizeLoraValue(v) {
return {
name: v?.name || "",
strengthModel: v?.strengthModel ?? 1.0,
strengthClip: v?.strengthClip ?? 1.0,
nodeBypass: !!v?.nodeBypass,
applyToPrompt: !!v?.applyToPrompt,
};
}
function _emptyColumns() {
return {
checkpoint: [{ atIteration: 1, value: "", revertToBase: false, bypassed: false }],
vae: [{ atIteration: 1, value: "", revertToBase: false, bypassed: false }],
checkpoint: [{ atIteration: 1, value: _defaultValueFor("checkpoint"), revertToBase: false, bypassed: false }],
lora: [{ atIteration: 1, value: _defaultValueFor("lora"), revertToBase: false, bypassed: false }],
vae: [{ atIteration: 1, value: _defaultValueFor("vae"), revertToBase: false, bypassed: false }],
prompt: [{ atIteration: 1, value: _defaultValueFor("prompt"), revertToBase: false, bypassed: false }],
ksampler: [{ atIteration: 1, value: _defaultValueFor("ksampler"), revertToBase: false, bypassed: false }],
};
}
// Two prior on-disk Plan JSON shapes need upgrading to today's independent
// checkpoint/lora/vae arrays:
// - Pre-v0.4.2: separate top-level `checkpoint`/`vae` columns, no `lora` at all.
// - v0.4.2's brief "merged Model row" design: `columns.model` was a single array of
// {atIteration, value:{checkpoint,lora,vae}, ...} rows bundling all three together.
// That bundling caused its own bugs (+/- and the cell modal affected all three
// fields at once) and was replaced the same release cycle, but a plan saved during
// that window must still load. Values are moved into their own independent arrays;
// iteration #1 is always kept (baseline row) even if a field was blank there.
function _migrateLabColumns(columns) {
if (!columns) return _emptyColumns();
if (!columns.model && Array.isArray(columns.lora)) return columns; // already current shape (independent lora array is the marker)
const defaults = _emptyColumns();
if (Array.isArray(columns.model)) {
const checkpoint = [], lora = [], vae = [];
for (const kf of columns.model) {
const v = kf.value || {};
const base = { atIteration: kf.atIteration, revertToBase: !!kf.revertToBase, bypassed: !!kf.bypassed };
if (kf.atIteration === 1 || v.checkpoint) checkpoint.push({ ...base, value: v.checkpoint || "" });
if (kf.atIteration === 1 || v.lora?.name) lora.push({ ...base, value: _normalizeLoraValue(v.lora) });
if (kf.atIteration === 1 || v.vae) vae.push({ ...base, value: v.vae || "" });
}
const { model, ...rest } = columns;
return {
...rest,
checkpoint: checkpoint.length ? checkpoint : defaults.checkpoint,
lora: lora.length ? lora : defaults.lora,
vae: vae.length ? vae : defaults.vae,
};
}
if (columns.checkpoint || columns.vae) {
return {
...columns,
checkpoint: columns.checkpoint?.length ? columns.checkpoint : defaults.checkpoint,
lora: defaults.lora,
vae: columns.vae?.length ? columns.vae : defaults.vae,
};
}
return columns;
}
function _emptyLabState() {
return {
planFilename: null,
@@ -59,6 +126,18 @@ let _aborted = false;
let _resumeResolve = null;
let _lastGeneratedImageRef = null; // ComfyUI "name [type]" annotated ref of the previous iteration's first output
// Which single field the Model column's cells currently display (C/L/V toggle in its
// header) — purely a display preference, not part of the Plan data, so it's not saved
// with the plan and simply resets to "checkpoint" on page load.
let _modelViewMode = "checkpoint";
// Single-click-selected keyframe index per real column key (checkpoint/lora/vae/prompt/
// ksampler) — used by that column's up/down reorder buttons. A single click selects
// (this object); a double click still opens the edit modal directly, bypassing
// selection. Not part of the Plan data — resets whenever columns are replaced wholesale
// (plan load/clear, Prompt Group apply) so a stale index never points past the new array.
let _selectedIdx = {};
// ============================================
// Init
// ============================================
@@ -68,6 +147,9 @@ export function initLabTab() {
_initPlanDropZone();
_initSubtabToggle();
_initColumnButtons();
_initReorderButtons();
_initPromptGroupButton();
_initModelViewToggle();
_initRunControls();
_initPlanButtons();
_initPlanJsonTab();
@@ -292,10 +374,22 @@ function _initPlanButtons() {
// Column keyframes: render / add / remove / edit
// ============================================
// A click on +/- always targets whichever of checkpoint/lora/vae the Model column
// header's C/L/V toggle currently has selected — "model" is a sentinel dataset.col
// value on those two shared buttons (there's only one +/- pair in the DOM for the
// merged-looking Model column), never a real key into _lab.columns. Resolving it here
// means adding/removing a keyframe only ever touches the ONE field being viewed, never
// the other two — Prompt/KSampler's own +/- buttons carry their real col directly and
// pass through unchanged.
function _resolveColumnKey(col) {
return col === "model" ? _modelViewMode : col;
}
function _initColumnButtons() {
document.querySelectorAll(".wfm-lab-col-add").forEach((btn) => {
btn.addEventListener("click", () => {
const col = btn.dataset.col;
const col = _resolveColumnKey(btn.dataset.col);
if (!_hasNodeForColumn(col)) return; // no matching node to apply an override to
const kfs = _lab.columns[col];
const lastIter = kfs[kfs.length - 1].atIteration;
if (lastIter >= _lab.batchCount) {
@@ -303,38 +397,249 @@ function _initColumnButtons() {
return;
}
kfs.push({ atIteration: lastIter + 1, value: _defaultValueFor(col), revertToBase: false, bypassed: false });
_renderColumn(col);
_renderColumnForKey(col);
_openCellModal(col, kfs.length - 1);
});
});
document.querySelectorAll(".wfm-lab-col-remove").forEach((btn) => {
btn.addEventListener("click", () => {
const col = btn.dataset.col;
const col = _resolveColumnKey(btn.dataset.col);
const kfs = _lab.columns[col];
if (kfs.length <= 1) return; // Row 1 (baseline) cannot be removed
kfs.pop();
_renderColumn(col);
_renderColumnForKey(col);
});
});
}
// Single click on a cell selects it (for the up/down reorder buttons below) instead of
// opening the edit modal — opening now requires a double click (_renderColumn wires
// both listeners on the same cell).
function _selectCell(col, idx) {
_selectedIdx[col] = idx;
_renderColumnForKey(col);
}
// Up/down buttons: swap the selected keyframe with its immediate array neighbor. Row 1
// (idx 0, the baseline) can never move and nothing can be swapped into its slot — it has
// no atIteration input of its own and drives the "live GenerateUI value" display, so
// letting another keyframe take its place would silently change that keyframe's
// behavior. Swapping the two keyframes' atIteration values (rather than reordering the
// array directly) keeps each object's own value permanently paired with its own
// atIteration field; re-deriving the array order from those swapped values below is
// then just "swap array position to match", not a re-sort of the whole column.
function _initReorderButtons() {
document.querySelectorAll(".wfm-lab-col-up").forEach((btn) => {
btn.addEventListener("click", () => _moveSelectedKeyframe(_resolveColumnKey(btn.dataset.col), -1));
});
document.querySelectorAll(".wfm-lab-col-down").forEach((btn) => {
btn.addEventListener("click", () => _moveSelectedKeyframe(_resolveColumnKey(btn.dataset.col), 1));
});
}
function _moveSelectedKeyframe(col, direction) {
const kfs = _lab.columns[col];
const idx = _selectedIdx[col];
if (idx == null || idx >= kfs.length) { // unselected, or stale after a bulk replace/remove
showToast(t("labSelectKeyframeFirst"), "error");
return;
}
const targetIdx = idx + direction;
if (idx <= 0 || targetIdx <= 0 || targetIdx >= kfs.length) return; // baseline immovable; can't swap past either end
const tmpIter = kfs[idx].atIteration;
kfs[idx].atIteration = kfs[targetIdx].atIteration;
kfs[targetIdx].atIteration = tmpIter;
[kfs[idx], kfs[targetIdx]] = [kfs[targetIdx], kfs[idx]];
_selectedIdx[col] = targetIdx;
_renderColumnForKey(col);
}
// "PG" button on the Prompt column header: lets the user pick one of the Prompt tab's
// preset groups (pmGroups, defined in prompt-presets.js) and replace the ENTIRE Prompt
// column with one keyframe per preset in the group, in group order, starting at
// iteration #1 (which is overwritten, not appended to).
function _initPromptGroupButton() {
const btn = document.getElementById("wfm-lab-prompt-group-btn");
if (btn) btn.title = t("labPromptGroupBtnTitle");
btn?.addEventListener("click", () => _openPromptGroupModal());
}
function _openPromptGroupModal() {
const groupNames = Object.keys(pmGroups).sort();
const listHtml = groupNames.length === 0
? `<div class="wfm-pm-empty">${t("labPromptGroupEmpty")}</div>`
: groupNames.map((g) => {
const count = getPresetsInGroup(g).length;
return `<div class="wfm-pm-item wfm-lab-pg-item" data-group="${escapeHtml(g)}">
<div class="wfm-pm-item-body">
<div class="wfm-pm-item-name">${escapeHtml(g)}</div>
<div class="wfm-pm-item-sub">${t("labPromptGroupCount", count)}</div>
</div>
</div>`;
}).join("");
const html = `
<div id="wfm-lab-pg-list">${listHtml}</div>
<div style="display:flex;gap:8px;margin-top:12px;justify-content:flex-end;">
<button id="wfm-lab-pg-apply" class="wfm-btn wfm-btn-primary" disabled>${t("labApply")}</button>
</div>`;
openModal(t("labPromptGroupModalTitle"), html);
let selectedGroup = null;
const applyBtn = document.getElementById("wfm-lab-pg-apply");
document.querySelectorAll(".wfm-lab-pg-item").forEach((el) => {
el.addEventListener("click", () => {
document.querySelectorAll(".wfm-lab-pg-item").forEach((o) => o.classList.remove("active"));
el.classList.add("active");
selectedGroup = el.dataset.group;
if (applyBtn) applyBtn.disabled = false;
});
});
applyBtn?.addEventListener("click", () => {
if (!selectedGroup) { showToast(t("labPromptGroupSelectFirst"), "error"); return; }
_applyPromptGroupToLab(selectedGroup);
});
}
// Full replacement of _lab.columns.prompt: one keyframe per preset in the group, in
// group order, atIteration 1..N — #1 is overwritten (never appended after), matching
// how every other "bulk load" action on Lab (e.g. plan load) replaces rather than merges.
function _applyPromptGroupToLab(groupName) {
const presets = getPresetsInGroup(groupName);
if (presets.length === 0) { showToast(t("labPromptGroupNoPresets"), "error"); return; }
// Batch input caps at 50 (see templates/index.html); raise batchCount to fit the
// group up to that cap, same as the existing keyframe-beyond-batch warning handles
// anything past it (see _warnIfKeyframesExceedBatch below) rather than silently
// dropping presets past #50.
if (presets.length > _lab.batchCount) {
_lab.batchCount = Math.min(presets.length, 50);
const batchEl = document.getElementById("wfm-lab-batch-count");
if (batchEl) batchEl.value = _lab.batchCount;
showToast(t("labPromptGroupBatchRaised", _lab.batchCount), "info");
}
_lab.columns.prompt = presets.map((p, idx) => ({
atIteration: idx + 1,
value: {
positive: p.text || p.posText || "",
negative: p.negText || "",
styleApplied: false,
styleName: "",
},
revertToBase: false,
bypassed: false,
}));
delete _selectedIdx.prompt; // stale selection would point past the freshly replaced array
_renderColumnForKey("prompt");
_warnIfKeyframesExceedBatch();
closeModal();
showToast(t("labPromptGroupApplied", groupName, presets.length), "success");
}
// Wires the Model column header's C/L/V buttons — switches which independent field
// (checkpoint/lora/vae) the shared cell list, +/-, and cell-click modal all operate on.
// Purely a display/routing selector: none of the three keyframe arrays are touched by
// switching it.
function _initModelViewToggle() {
document.querySelectorAll(".wfm-lab-model-view-btn").forEach((btn) => {
btn.addEventListener("click", () => {
_modelViewMode = btn.dataset.view;
document.querySelectorAll(".wfm-lab-model-view-btn").forEach((b) => b.classList.toggle("active", b === btn));
_renderModelColumn();
});
});
}
function _renderAllColumns() {
COLUMN_KEYS.forEach(_renderColumn);
_renderModelColumn();
_renderColumn("prompt");
_renderColumn("ksampler");
}
function _renderColumn(col) {
const container = document.getElementById(`wfm-lab-col-cells-${col}`);
// Renders the Model column's shared cell list from whichever of checkpoint/lora/vae is
// currently toggled (_modelViewMode) — the other two arrays exist in _lab.columns but
// have no DOM of their own to render into; they're only ever shown when toggled to.
function _renderModelColumn() {
_updateModelViewHighlight();
_renderColumn(_modelViewMode, "wfm-lab-col-cells-model");
}
// Routes a keyframe-array key to whichever container renders it: checkpoint/lora/vae
// all share the Model column's single cell list, everything else has its own.
function _renderColumnForKey(col) {
if (col === "checkpoint" || col === "lora" || col === "vae") _renderModelColumn();
else _renderColumn(col);
}
// True when the loaded workflow actually has a node the given Model column (checkpoint/
// lora/vae) could apply an override to. VAE is special-cased: even with no standalone
// VAELoader node, a checkpoint node means _buildWorkflowForIteration can still inject one
// to override the checkpoint's baked-in VAE — so VAE only counts as "absent" when there's
// no checkpoint either. Non-model columns (prompt/ksampler) always have somewhere to
// apply, so they're not gated by this.
function _hasNodeForColumn(col) {
const analysis = comfyUI.currentAnalysis;
if (col === "checkpoint") return (analysis?.checkpoint_nodes?.length || 0) > 0;
if (col === "lora") return (analysis?.lora_nodes?.length || 0) > 0;
if (col === "vae") {
return (analysis?.vae_nodes?.length || 0) > 0 || (analysis?.checkpoint_nodes?.length || 0) > 0;
}
// Video all-in-one nodes (e.g. MiniMaxH3ImageToVideo) DO wire through a
// SamplerCustomAdvanced internally, so sampler_nodes.length alone isn't enough to
// detect "no user-adjustable sampler here" — that internal sampler is a fixed part
// of the model's own preset, not something meant to be edited per keyframe (unlike a
// real Advanced Sampling workflow's SamplerCustomAdvanced, e.g. Flux/SD3.5). Treat
// any video workflow as having no editable Ksampler column, same as the Model
// sub-columns above hide when their node is absent.
if (col === "ksampler") {
if (comfyWorkflow.isVideoWorkflow(analysis)) return false;
return (analysis?.sampler_nodes?.length || 0) > 0;
}
return true;
}
function _renderColumn(col, containerId = `wfm-lab-col-cells-${col}`) {
const container = document.getElementById(containerId);
if (!container) return;
// No matching node in the loaded workflow for this Model sub-column: showing a
// baseline "Setting 1" keyframe here would be a no-op at Run time (_buildWorkflowForIteration
// silently skips writes when the target node id doesn't exist), so skip rendering it
// entirely rather than presenting an override control that can never do anything.
if (!_hasNodeForColumn(col)) {
container.innerHTML = `<div class="wfm-lab-cell-empty">${t("labNoNodeForColumn")}</div>`;
return;
}
const keyframes = _lab.columns[col];
container.innerHTML = keyframes.map((kf, idx) => _cellHtml(col, kf, idx)).join("");
// Single click selects (for the up/down reorder buttons); double click still opens
// the edit modal directly, independent of whatever is currently selected.
container.querySelectorAll(".wfm-lab-cell").forEach((el) => {
el.addEventListener("click", () => _openCellModal(col, parseInt(el.dataset.idx, 10)));
const idx = parseInt(el.dataset.idx, 10);
el.addEventListener("click", () => _selectCell(col, idx));
el.addEventListener("dblclick", () => _openCellModal(col, idx));
});
}
// Colors the header's C/L/V buttons the same "this node type actually exists in the
// loaded workflow" highlight GenerateUI's Model tab uses (wfm-model-label-active,
// colored via settings-tab.js applyModelTabActiveColor) — independent of which one is
// currently selected/active. Re-run on every model column render so it stays in sync
// with whatever's loaded (initial render, keyframe edits, toggle clicks, plan load/
// apply, and refreshLabLiveDefaults() when switching into the Lab subtab).
function _updateModelViewHighlight() {
document.querySelector('.wfm-lab-model-view-btn[data-view="checkpoint"]')?.classList.toggle("wfm-model-label-active", _hasNodeForColumn("checkpoint"));
document.querySelector('.wfm-lab-model-view-btn[data-view="lora"]')?.classList.toggle("wfm-model-label-active", _hasNodeForColumn("lora"));
document.querySelector('.wfm-lab-model-view-btn[data-view="vae"]')?.classList.toggle("wfm-model-label-active", _hasNodeForColumn("vae"));
}
function _cellHtml(col, kf, idx) {
const revertClass = kf.revertToBase ? " wfm-lab-cell-revert" : "";
const bypassClass = kf.bypassed ? " wfm-lab-cell-bypassed" : "";
const nodeBypassClass = (col === "lora" && kf.value?.nodeBypass) ? " wfm-lab-cell-node-bypassed" : "";
const selectedClass = _selectedIdx[col] === idx ? " wfm-lab-cell-selected" : "";
if (idx === 0) {
// Bypassed takes priority over the live-reflect display — an explicit "skip this
// row" toggle shouldn't be masked by Setting 1's usual "show the workflow's
@@ -342,13 +647,13 @@ function _cellHtml(col, kf, idx) {
const live = !kf.bypassed && _isLiveDisplay(col, idx);
const liveClass = live ? " wfm-lab-cell-live" : "";
const label = live ? _cellLabel(col, { value: _liveValueFor(col), revertToBase: false, bypassed: false }) : _cellLabel(col, kf);
return `<div class="wfm-lab-cell${revertClass}${bypassClass}${liveClass}" data-idx="${idx}">
return `<div class="wfm-lab-cell${revertClass}${bypassClass}${nodeBypassClass}${liveClass}${selectedClass}" data-idx="${idx}">
<span class="wfm-lab-cell-index">${kf.atIteration}:</span>
<span class="wfm-lab-cell-value">${label}</span>
</div>`;
}
// Iteration #2 and beyond: value on its own line, applied-at iteration below it
return `<div class="wfm-lab-cell${revertClass}${bypassClass}" data-idx="${idx}">
return `<div class="wfm-lab-cell${revertClass}${bypassClass}${nodeBypassClass}${selectedClass}" data-idx="${idx}">
<div class="wfm-lab-cell-value">${_cellLabel(col, kf)}</div>
<div class="wfm-lab-cell-applied">${t("labAppliedAt", kf.atIteration)}</div>
</div>`;
@@ -360,6 +665,13 @@ function _cellLabel(col, kf) {
if (col === "checkpoint" || col === "vae") {
return kf.value ? escapeHtml(kf.value) : t("labUseWorkflowDefault");
}
if (col === "lora") {
const v = kf.value;
if (!v?.name) return t("labUseWorkflowDefault");
if (v.nodeBypass) return escapeHtml(`${v.name} ${t("labLoraNodeBypassed")}`);
const promptTag = v.applyToPrompt ? ` ${t("labLoraPromptAppliedTag")}` : "";
return escapeHtml(`${v.name} (${v.strengthModel}/${v.strengthClip})${promptTag}`);
}
if (col === "prompt") {
const p = kf.value?.positive || "";
const n = kf.value?.negative || "";
@@ -380,6 +692,7 @@ function _cellLabel(col, kf) {
function _isEmptyValue(col, value) {
if (col === "checkpoint" || col === "vae") return !value;
if (col === "lora") return !value?.name;
if (col === "prompt") return !value || (!value.positive && !value.negative);
if (col === "ksampler") {
return !value || (value.steps == null && value.cfg == null && value.denoise == null &&
@@ -397,12 +710,11 @@ function _isEmptyValue(col, value) {
function _liveValueFor(col) {
const analysis = comfyUI.currentAnalysis;
if (!analysis) return null;
if (col === "checkpoint") {
return analysis.checkpoint_nodes?.[0]?.ckpt_name || null;
}
if (col === "vae") {
return analysis.vae_nodes?.[0]?.vae_name || null;
}
if (col === "checkpoint") return analysis.checkpoint_nodes?.[0]?.ckpt_name || null;
if (col === "vae") return analysis.vae_nodes?.[0]?.vae_name || null;
// LoRA is intentionally never live-reflected — there's no safe way to pick "the"
// active LoRA back out of a workflow node to prefill with.
if (col === "lora") return null;
if (col === "prompt") {
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
@@ -447,6 +759,12 @@ function _openCellModal(col, idx) {
const effectiveValue = live ? _liveValueFor(col) : kf.value;
const hasPrevious = col === "prompt" && idx > 0;
// What this row would inherit if left blank (the latest earlier keyframe in this
// SAME field's own array, not touching the other two) — shown as the blank option's
// label so it's clear leaving it untouched still carries a value forward, rather
// than resetting to the workflow's own default. Only meaningful past #1.
const inherited = isFirst ? null : _resolveKeyframe(col, kf.atIteration - 1);
let valueFieldsHtml = "";
if (col === "checkpoint" || col === "vae") {
const options = comfyEditor.models[col === "checkpoint" ? "checkpoints" : "vaes"] || [];
@@ -458,10 +776,39 @@ function _openCellModal(col, idx) {
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-value-filter-clear" title="Clear search">✕</button>
</div>
<select id="wfm-lab-modal-value" class="wfm-select">
<option value="">${t("labUseWorkflowDefault")}</option>
<option value="">${escapeHtml(inherited ? t("labInheritsFrom", inherited) : t("labUseWorkflowDefault"))}</option>
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === effectiveValue ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
</select>
</div>`;
} else if (col === "lora") {
const v = _normalizeLoraValue(effectiveValue);
const options = comfyEditor.models.loras || [];
valueFieldsHtml = `
<div class="wfm-lab-modal-row">
<label>LoRA</label>
<div class="wfm-search-wrap" style="width:100%;">
<input type="text" id="wfm-lab-modal-lora-filter" class="wfm-input wfm-search-input" placeholder="Filter...">
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-lora-filter-clear" title="Clear search">✕</button>
</div>
<select id="wfm-lab-modal-lora-select" class="wfm-select">
<option value="">${escapeHtml(inherited?.name ? t("labInheritsFrom", inherited.name) : t("labUseWorkflowDefault"))}</option>
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === v.name ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
</select>
<div class="wfm-lora-strength-single" style="margin-top:6px;">
<span>M</span>
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strmodel" value="${v.strengthModel}" step="0.05" min="0" max="2" ${v.nodeBypass ? "disabled" : ""}>
<span>C</span>
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strclip" value="${v.strengthClip}" step="0.05" min="0" max="2" ${v.nodeBypass ? "disabled" : ""}>
</div>
<div class="wfm-lab-modal-checkbox" style="margin-top:6px;">
<input type="checkbox" id="wfm-lab-modal-lora-nodebypass" ${v.nodeBypass ? "checked" : ""}>
<label for="wfm-lab-modal-lora-nodebypass">${t("labLoraNodeBypassCheckboxLabel")}</label>
</div>
<div class="wfm-lab-modal-checkbox">
<input type="checkbox" id="wfm-lab-modal-lora-prompttoggle" ${v.applyToPrompt ? "checked" : ""} ${v.nodeBypass ? "disabled" : ""}>
<label for="wfm-lab-modal-lora-prompttoggle">${t("labLoraApplyToPromptCheckboxLabel")}</label>
</div>
</div>`;
} else if (col === "prompt") {
const v = effectiveValue || {};
valueFieldsHtml = `
@@ -555,6 +902,7 @@ function _openCellModal(col, idx) {
openModal(`${col} #${idx + 1}`, html);
if (col === "prompt") _wireLabPromptModalExtras(idx);
if (col === "checkpoint" || col === "vae") _wireLabValueFilter(col);
if (col === "lora") _wireLabLoraFilter();
// Bypass disables everything else on the modal (value fields AND the Revert
// checkbox) — the two are mutually exclusive since bypass already means "ignore
@@ -563,6 +911,14 @@ function _openCellModal(col, idx) {
const bypassCb = document.getElementById("wfm-lab-modal-bypass");
const fieldsWrap = document.getElementById("wfm-lab-modal-fields");
const revertRowEl = revertCb?.closest(".wfm-lab-modal-checkbox");
// LoRA-only: the node-bypass checkbox (force strength to 0 at apply-time — see
// _applyLabLoraToWorkflow) disables just the strength fields, independent of the
// row-level bypass/revert above (which disable everything in fieldsWrap instead).
const loraNodeBypassCb = document.getElementById("wfm-lab-modal-lora-nodebypass");
const loraStrModelEl = document.getElementById("wfm-lab-modal-lora-strmodel");
const loraStrClipEl = document.getElementById("wfm-lab-modal-lora-strclip");
// Bypassing the LoRA node makes "apply its syntax/triggers to the prompt" moot too.
const loraPromptCb = document.getElementById("wfm-lab-modal-lora-prompttoggle");
const syncDisabled = () => {
const bypassed = !!bypassCb?.checked;
const fieldsDisabled = bypassed || !!revertCb?.checked;
@@ -570,14 +926,20 @@ function _openCellModal(col, idx) {
fieldsWrap?.querySelectorAll("input,select,textarea").forEach((el) => { el.disabled = fieldsDisabled; });
if (revertCb) revertCb.disabled = bypassed;
if (revertRowEl) revertRowEl.style.opacity = bypassed ? "0.4" : "1";
if (!fieldsDisabled && loraNodeBypassCb?.checked) {
if (loraStrModelEl) loraStrModelEl.disabled = true;
if (loraStrClipEl) loraStrClipEl.disabled = true;
if (loraPromptCb) loraPromptCb.disabled = true;
}
};
revertCb?.addEventListener("change", syncDisabled);
bypassCb?.addEventListener("change", syncDisabled);
loraNodeBypassCb?.addEventListener("change", syncDisabled);
syncDisabled();
document.getElementById("wfm-lab-modal-delete")?.addEventListener("click", () => {
_lab.columns[col].splice(idx, 1);
_renderColumn(col);
_renderColumnForKey(col);
closeModal();
});
@@ -594,6 +956,15 @@ function _openCellModal(col, idx) {
if (!revert && !bypassed) {
if (col === "checkpoint" || col === "vae") {
value = document.getElementById("wfm-lab-modal-value")?.value || "";
} else if (col === "lora") {
const loraNodeBypassed = !!document.getElementById("wfm-lab-modal-lora-nodebypass")?.checked;
value = {
name: document.getElementById("wfm-lab-modal-lora-select")?.value || "",
strengthModel: parseFloat(document.getElementById("wfm-lab-modal-lora-strmodel")?.value) || 1.0,
strengthClip: parseFloat(document.getElementById("wfm-lab-modal-lora-strclip")?.value) || 1.0,
nodeBypass: loraNodeBypassed,
applyToPrompt: !loraNodeBypassed && !!document.getElementById("wfm-lab-modal-lora-prompttoggle")?.checked,
};
} else if (col === "prompt") {
value = {
positive: document.getElementById("wfm-lab-modal-positive")?.value || "",
@@ -619,7 +990,7 @@ function _openCellModal(col, idx) {
_lab.columns[col][idx] = { atIteration, value, revertToBase: revert, bypassed };
_lab.columns[col].sort((a, b) => a.atIteration - b.atIteration);
_renderColumn(col);
_renderColumnForKey(col);
closeModal();
});
}
@@ -662,6 +1033,26 @@ function _wireLabValueFilter(col) {
setupSearchClearBtn("wfm-lab-modal-value-filter", "wfm-lab-modal-value-filter-clear", applyFilter);
}
// Same filter behavior as _wireLabValueFilter, for the LoRA modal's own select (it
// has separate element ids since a LoRA keyframe row also carries Strength M/C
// inputs alongside it, unlike the plain single-select Checkpoint/VAE modal).
function _wireLabLoraFilter() {
const filterInput = document.getElementById("wfm-lab-modal-lora-filter");
const select = document.getElementById("wfm-lab-modal-lora-select");
if (!filterInput || !select) return;
const options = comfyEditor.models.loras || [];
const applyFilter = () => {
const filter = filterInput.value.toLowerCase();
select.innerHTML = `<option value="">${t("labUseWorkflowDefault")}</option>` +
options.filter((m) => m.toLowerCase().includes(filter))
.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join("");
};
filterInput.addEventListener("input", applyFilter);
setupSearchClearBtn("wfm-lab-modal-lora-filter", "wfm-lab-modal-lora-filter-clear", applyFilter);
}
// Wires the Prompt-column modal's extra actions: fetch the live GenerateUI prompt,
// extract a prompt from a dropped image/workflow file, copy the previous keyframe's
// prompt, clear the fields, and apply/toggle a saved Style. idx is this keyframe's own
@@ -795,10 +1186,10 @@ function _resolveKeyframe(col, iteration) {
return _isEmptyValue(col, applicable.value) ? null : applicable.value;
}
function _buildWorkflowForIteration(iteration, chainImageRef) {
function _buildWorkflowForIteration(iteration, chainImageRef, prevLoraInjection) {
const wf = JSON.parse(JSON.stringify(comfyUI.currentWorkflow || {}));
const analysis = comfyUI.currentAnalysis;
if (!analysis) return { workflow: wf, seed: null };
if (!analysis) return { workflow: wf, seed: null, loraInjection: null };
const write = (nodeId, key, val) => {
if (nodeId != null && wf[nodeId]) wf[nodeId].inputs[key] = val;
@@ -825,14 +1216,35 @@ function _buildWorkflowForIteration(iteration, chainImageRef) {
}
}
const lora = _resolveKeyframe("lora", iteration);
if (lora?.name) {
_applyLabLoraToWorkflow(wf, analysis, lora);
}
const prompt = _resolveKeyframe("prompt", iteration);
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
if (prompt) {
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
if (posNode && prompt.positive) write(posNode.id, posNode.textKey || "text", prompt.positive);
if (negNode && prompt.negative) write(negNode.id, negNode.textKey || "text", prompt.negative);
}
// LoRA syntax/trigger-word injection into the Positive prompt (opt-in per keyframe
// via lora.applyToPrompt). Always strip whatever the previous iteration injected
// first, whether or not this iteration re-injects — so switching to a different
// LoRA (or a keyframe with the option off) starts from a clean prompt instead of
// accumulating every LoRA the batch has used so far. See _stripLoraPromptInjection.
let loraInjection = null;
if (posNode && wf[posNode.id]) {
const textKey = posNode.textKey || "text";
let posText = _stripLoraPromptInjection(wf[posNode.id].inputs[textKey] || "", prevLoraInjection);
if (lora?.name && lora.applyToPrompt && !lora.nodeBypass) {
loraInjection = _buildLoraPromptInjection(lora);
posText = posText ? `${posText}, ${loraInjection}` : loraInjection;
}
write(posNode.id, textKey, posText);
}
let seed = null;
const ks = _resolveKeyframe("ksampler", iteration);
const sampler = analysis.sampler_nodes?.[0];
@@ -850,7 +1262,108 @@ function _buildWorkflowForIteration(iteration, chainImageRef) {
write(analysis.load_image_nodes[0].id, "image", imageRef);
}
return { workflow: wf, seed };
return { workflow: wf, seed, loraInjection };
}
// True once a "no LoRA node in this workflow" warning has already been shown during the
// current Run — reset at the start of _runLabBatch so the toast fires at most once per
// run instead of once per iteration.
let _labLoraWarned = false;
// metadata.json (sha256 lookup) + CivitAI cache (sha256 -> trainedWords), fetched once
// per Run (see _runLabBatch) and used only to resolve trigger words for the Positive-
// prompt LoRA injection below — same two endpoints comfyui-editor.js's renderLoraPane
// uses for the GenerateUI Model tab's own LORA SYNTAX/TRIGGER WORDS display.
let _labModelMetadata = {};
let _labCivitaiCache = {};
async function _fetchLabLoraMetadataCache() {
try {
const [metaRes, civRes] = await Promise.all([
fetch("/api/wfm/models/metadata"),
fetch("/api/wfm/models/civitai/cache"),
]);
_labModelMetadata = metaRes.ok ? await metaRes.json() : {};
_labCivitaiCache = civRes.ok ? await civRes.json() : {};
} catch {
_labModelMetadata = {};
_labCivitaiCache = {};
}
}
function _labTriggerWordsFor(loraName) {
const sha = (_labModelMetadata[loraName] || {}).sha256;
const civInfo = sha && _labCivitaiCache[sha];
return civInfo?.trainedWords || [];
}
// Builds the exact "<lora:stem:m:c>[, trigger, words...]" string appended to the
// Positive prompt for this LoRA keyframe — the same format comfyui-editor.js's Single
// Apply writes. Returned as-is so _buildWorkflowForIteration can hand it back unchanged
// to the next iteration for removal (see _stripLoraPromptInjection).
function _buildLoraPromptInjection(lora) {
const stem = lora.name.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
const strModel = lora.strengthModel ?? 1.0;
const strClip = lora.strengthClip ?? 1.0;
const syntax = `<lora:${stem}:${strModel}:${strClip}>`;
const triggers = _labTriggerWordsFor(lora.name);
return triggers.length > 0 ? `${syntax}, ${triggers.join(", ")}` : syntax;
}
// Removes an exact previously-injected string (as returned by _buildLoraPromptInjection)
// from a prompt, plus the comma that joined it to whatever came before — so switching
// keyframes/LoRAs mid-batch never accumulates every LoRA the batch has used so far.
// A plain string search (no regex) is safe here since `injection` is always something
// _buildLoraPromptInjection generated, never arbitrary user text.
function _stripLoraPromptInjection(text, injection) {
if (!injection || !text) return text || "";
let out = text.split(`, ${injection}`).join("");
out = out.split(injection).join("");
return out.replace(/,\s*,/g, ",").replace(/^\s*,\s*/, "").replace(/\s*,\s*$/, "").trim();
}
// Applies a Lab-configured LoRA (Single only — no Stack support here) to the given
// per-iteration workflow clone. Mirrors models-tab.js's applyToGenUI LoRA branch: prefer
// analysis.lora_nodes[0] (correctly picks up "Lora Loader (LoraManager)" nodes via
// is_lora_manager), else fall back to any node with a lora_name input. If the workflow
// has no LoRA node at all, this warns once and does nothing — unlike VAE above, Lab does
// NOT auto-inject a LoraLoader node, since that would require rewiring model/clip links
// between the checkpoint and every downstream consumer, not just overwriting one input.
function _applyLabLoraToWorkflow(wf, analysis, lora) {
let nodeId = analysis.lora_nodes?.[0]?.id;
let isLoraManager = !!analysis.lora_nodes?.[0]?.is_lora_manager;
if (nodeId == null) {
nodeId = Object.keys(wf).find((id) => wf[id]?.inputs && "lora_name" in wf[id].inputs);
isLoraManager = false;
}
if (nodeId == null || !wf[nodeId]) {
if (!_labLoraWarned) {
_labLoraWarned = true;
showToast(t("labLoraNoNode"), "warning");
}
return;
}
// nodeBypass: user-requested "bypass the LoRA node" toggle (Lab keyframe-level,
// independent of the row's own bypassed flag above). There's no real node to set
// mode:4 on here — currentWorkflow is already-converted API format (see
// comfyui-workflow.js convertUiToApi) where mode has no effect — so this forces
// strength to 0 instead, which is behaviorally equivalent to bypassing the node.
const bypass = !!lora.nodeBypass;
const strModel = bypass ? 0 : (lora.strengthModel ?? 1.0);
const strClip = bypass ? 0 : (lora.strengthClip ?? 1.0);
if (isLoraManager) {
const stem = lora.name.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
wf[nodeId].inputs.loras = {
__value__: [{ name: stem, strength: strModel, active: !bypass, expanded: false, clipStrength: strClip, locked: false }],
};
wf[nodeId].inputs.text = bypass ? "" : `<lora:${stem}:${strModel}:${strClip}>`;
} else {
wf[nodeId].inputs.lora_name = comfyEditor.resolveLoraName(lora.name);
wf[nodeId].inputs.strength_model = strModel;
wf[nodeId].inputs.strength_clip = strClip;
}
}
// ComfyUI's LoadImage accepts "name [type]" (optionally "subfolder/name [type]") to
@@ -860,6 +1373,20 @@ function _annotatedImageRef(img) {
return `${rel} [${img.type || "output"}]`;
}
// Extracts the last frame of a just-generated video output and uploads it as a plain
// "input" image, returning the filename LoadImage's image widget expects (no [type]
// suffix needed — uploadImage always lands in the input folder). Used only by the
// chain-image path below; the Video tab will reuse extractLastFrameBlob() directly
// once it grows its own batch/chaining feature.
async function _extractAndUploadLastFrame(img) {
const url = _resultImageUrl(img);
const blob = await extractLastFrameBlob(url);
const file = new File([blob], `lab_lastframe_${Date.now()}.png`, { type: "image/png" });
const result = await comfyUI.uploadImage(file, file.name);
if (!result.name) throw new Error("Upload returned no filename");
return result.name;
}
// ============================================
// Batch execution
// ============================================
@@ -893,16 +1420,23 @@ async function _runLabBatch() {
_paused = false;
_aborted = false;
_labLoraWarned = false;
_lastGeneratedImageRef = null;
_lab.results.images = [];
_renderResultsGrid();
_setRunUiState(true);
await _fetchLabLoraMetadataCache();
const progressBar = document.getElementById("wfm-lab-progress-bar");
const progressText = document.getElementById("wfm-lab-progress-text");
const eagleAutoSave = getEagleSettings().autoSave;
// Video workflows (e.g. MiniMax H3) run far longer than a still image — use a longer
// timeout for every iteration so a multi-minute generation doesn't get force-rejected
// mid-batch (see comfyWorkflow.isVideoWorkflow and generate-tab.js's own use of this).
const isVideoWf = comfyWorkflow.isVideoWorkflow(comfyUI.currentAnalysis);
let completed = 0, failed = 0;
let prevLoraInjection = null;
try {
for (let i = 1; i <= _lab.batchCount; i++) {
if (_aborted) break;
@@ -911,13 +1445,15 @@ async function _runLabBatch() {
if (progressText) progressText.textContent = `[${i}/${_lab.batchCount}] ...`;
const chainRef = (!_lab.t2iMode && _lab.chainImage && i > 1) ? _lastGeneratedImageRef : null;
const { workflow, seed } = _buildWorkflowForIteration(i, chainRef);
const { workflow, seed, loraInjection } = _buildWorkflowForIteration(i, chainRef, prevLoraInjection);
prevLoraInjection = loraInjection;
const workflowExpanded = await _expandWildcardsInWorkflow(workflow);
try {
const { images } = await comfyUI.generate(workflowExpanded, {
seedMode: seed != null ? "fixed" : "random",
seedValue: seed ?? -1,
timeoutMs: isVideoWf ? 30 * 60 * 1000 : undefined,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `[${i}/${_lab.batchCount}] ${(pct * 100).toFixed(0)}%`;
@@ -925,7 +1461,20 @@ async function _runLabBatch() {
});
const outputImages = images.filter((img) => img.type !== "temp");
if (outputImages.length > 0) {
_lastGeneratedImageRef = _annotatedImageRef(outputImages[0]);
const first = outputImages[0];
if (isVideoFilename(first.filename)) {
// LoadImage can't take an mp4 directly — extract its last frame
// client-side and upload that as an "input" image, so the chain
// (Use generated image for next) can feed it back in as first_frame.
try {
_lastGeneratedImageRef = await _extractAndUploadLastFrame(first);
} catch (err) {
showToast(t("labLastFrameExtractFailed", err.message), "error");
_lastGeneratedImageRef = null;
}
} else {
_lastGeneratedImageRef = _annotatedImageRef(first);
}
}
for (const img of outputImages) {
if (_lab.results.images.length < MAX_RESULTS) {
@@ -975,14 +1524,23 @@ function _renderResultsGrid() {
grid.innerHTML = `<p class="wfm-placeholder">${t("labNoResultsYet")}</p>`;
return;
}
grid.innerHTML = _lab.results.images.map((img, i) => `
grid.innerHTML = _lab.results.images.map((img, i) => {
const url = _resultImageUrl(img);
const media = isVideoFilename(img.filename)
? `<video class="wfm-lab-result-thumb" data-idx="${i}" src="${url}" muted></video>`
: `<img class="wfm-lab-result-thumb" data-idx="${i}" src="${url}">`;
return `
<div class="wfm-lab-result-item">
<span class="wfm-lab-result-index">${img.iteration}</span>
<img class="wfm-lab-result-thumb" data-idx="${i}" src="${_resultImageUrl(img)}">
</div>`).join("");
${media}
</div>`;
}).join("");
grid.querySelectorAll(".wfm-lab-result-thumb").forEach((el) => {
el.addEventListener("click", () => {
openModal(t("labResultPreview"), `<img src="${el.src}" style="max-width:100%;max-height:80vh;display:block;margin:0 auto;">`);
const content = el.tagName === "VIDEO"
? `<video src="${el.src}" controls style="max-width:100%;max-height:80vh;display:block;margin:0 auto;"></video>`
: `<img src="${el.src}" style="max-width:100%;max-height:80vh;display:block;margin:0 auto;">`;
openModal(t("labResultPreview"), content);
});
});
}
@@ -1029,6 +1587,23 @@ function _loadImageEl(src) {
});
}
// A result item's own URL (e.g. .mp4) can't be loaded into an <img> — extract its
// last frame first so the index/contact-sheet gets a real thumbnail instead of
// silently failing onto the canvas's black background (see _buildIndexImageDataUrl).
async function _loadIndexCellImage(img) {
const url = _resultImageUrl(img);
if (!isVideoFilename(img.filename)) return _loadImageEl(url);
try {
const blob = await extractLastFrameBlob(url);
const blobUrl = URL.createObjectURL(blob);
const im = await _loadImageEl(blobUrl);
URL.revokeObjectURL(blobUrl);
return im;
} catch {
return null;
}
}
// planData, when given, is embedded into the resulting PNG as an iTXt chunk
// (LAB_PLAN_PNG_KEY) so the image alone — dropped onto the Plan drop zone, from
// anywhere, not just lab_plan/ — can be loaded back without needing a same-named
@@ -1048,7 +1623,7 @@ async function _buildIndexImageDataUrl(planData) {
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < imgs.length; i++) {
const im = await _loadImageEl(_resultImageUrl(imgs[i]));
const im = await _loadIndexCellImage(imgs[i]);
if (!im) continue;
const col = i % cols, row = Math.floor(i / cols);
const scale = Math.max(cellSize / im.width, cellSize / im.height);
@@ -1153,8 +1728,9 @@ function _applyPlanData(filename, data) {
_lab.saveIndexOnRun = !!data.save_index_on_run;
_lab.sourceImageFilename = data.source_image || null;
_lab.workflowFilename = data.workflow_filename || null;
_lab.columns = data.columns || _emptyColumns();
_lab.columns = _migrateLabColumns(data.columns);
_lab.results = { images: (data.results?.images || []).slice(0, MAX_RESULTS) };
_selectedIdx = {}; // every column's array was just replaced wholesale
_updatePlanWorkflowUI();
_updateT2IModeUI();
@@ -1234,6 +1810,7 @@ async function _loadPlanFromServer(filename) {
function _clearPlan() {
if (!window.confirm(t("labConfirmClear"))) return;
_lab = _emptyLabState();
_selectedIdx = {};
const noteEl = document.getElementById("wfm-lab-note");
const batchEl = document.getElementById("wfm-lab-batch-count");
+99 -3
View File
@@ -97,6 +97,81 @@ export async function readAllPNGTextChunks(file) {
return chunks;
}
// ── MP4 container metadata (moov/udta/meta の mdta keys/ilst) ──
// ComfyUIのSaveVideoはPNGのtEXtチャンクと同じキー名(workflow=UI形式, prompt=API形式)で
// JSON文字列をisobmff(mp4)のudta/meta(mdta)にコンテナレベルタグとして書き込む(FFmpegの
// movflags=+use_metadata_tags)。ここではPyAV等に頼らずブラウザ側でボックスを直接辿って読む。
function findMP4Box(view, bytes, start, end, targetType) {
let offset = start;
while (offset + 8 <= end) {
const size = view.getUint32(offset);
const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
let boxSize = size;
let bodyStart = offset + 8;
if (size === 1) {
if (offset + 16 > end) break;
const hi = view.getUint32(offset + 8);
const lo = view.getUint32(offset + 12);
boxSize = hi * 4294967296 + lo;
bodyStart = offset + 16;
} else if (size === 0) {
boxSize = end - offset;
}
if (boxSize < 8) break;
if (type === targetType) return { bodyStart, end: offset + boxSize };
offset += boxSize;
}
return null;
}
export async function readMP4MetaTags(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
if (bytes.length < 12) return null;
const ftyp = String.fromCharCode(bytes[4], bytes[5], bytes[6], bytes[7]);
if (ftyp !== "ftyp") return null;
const moov = findMP4Box(view, bytes, 0, bytes.length, "moov");
if (!moov) return null;
const udta = findMP4Box(view, bytes, moov.bodyStart, moov.end, "udta");
let meta = udta ? findMP4Box(view, bytes, udta.bodyStart, udta.end, "meta") : null;
if (!meta) meta = findMP4Box(view, bytes, moov.bodyStart, moov.end, "meta");
if (!meta) return null;
const metaBody = meta.bodyStart + 4; // meta は FullBox (version+flags 4バイト)
const keysBox = findMP4Box(view, bytes, metaBody, meta.end, "keys");
const ilstBox = findMP4Box(view, bytes, metaBody, meta.end, "ilst");
if (!keysBox || !ilstBox) return null;
const utf8 = new TextDecoder("utf-8", { fatal: false });
let off = keysBox.bodyStart + 4; // version+flags をスキップ
const count = view.getUint32(off); off += 4;
const keyNames = [];
for (let i = 0; i < count && off + 8 <= keysBox.end; i++) {
const ksize = view.getUint32(off);
if (ksize < 8) break;
keyNames.push(utf8.decode(bytes.slice(off + 8, off + ksize)));
off += ksize;
}
const result = {};
let ioff = ilstBox.bodyStart;
while (ioff + 8 <= ilstBox.end) {
const itemSize = view.getUint32(ioff);
const keyIndex = view.getUint32(ioff + 4);
if (itemSize < 8) break;
const itemEnd = Math.min(ioff + itemSize, ilstBox.end);
const data = findMP4Box(view, bytes, ioff + 8, itemEnd, "data");
if (data) {
const payload = bytes.slice(data.bodyStart + 8, data.end); // type(4)+locale(4)をスキップ
const name = keyNames[keyIndex - 1];
if (name) { try { result[name] = utf8.decode(payload); } catch { /* skip */ } }
}
ioff = itemEnd;
}
return result;
}
// ── Workflow extraction helpers ───────────────────────────────
function collectUnique(arr) {
const seen = new Set(), out = [];
@@ -203,7 +278,7 @@ function resolveEditPlusText(mode, textEdit, text1, text2) {
}
}
function extractPrompts(wf) {
export function extractPrompts(wf) {
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
}
@@ -459,6 +534,13 @@ function extractPromptsAPI(wf) {
if (combined) textMap.set(id, combined);
continue;
}
// MiniMaxH3ImageToVideo等の動画生成オールインワンノード。CLIPTextEncode等を経由せず
// 自ノードの"prompt"入力に直接テキストを持つ(positive/negativeの区別も無い)ため、
// isTextEncoderNode判定を通さずtextMapへ直接投入する(fallbackのtextsに載る)。
if (typeof n.inputs?.prompt === "string" && /ImageToVideo|TextToVideo/i.test(ct)) {
textMap.set(id, n.inputs.prompt);
continue;
}
if (!isTextEncoderNode(ct)) continue;
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
// model text encoders use this key instead of "text"/"text_g".
@@ -607,6 +689,7 @@ 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");
const isMP4 = file.type === "video/mp4" || name.endsWith(".mp4");
async function fromWorkflow(originalWf, source) {
let wf = originalWf;
@@ -644,6 +727,17 @@ export async function extractAllMetadata(file) {
const wf = extractWorkflowFromEXIF(exif);
return wf ? await fromWorkflow(wf, "comfyui") : null;
}
if (isMP4) {
let tags; try { tags = await readMP4MetaTags(file); } catch { return null; }
if (!tags) return null;
for (const key of ["prompt", "workflow"]) {
const raw = tags[key];
if (!raw) continue;
let wf; try { wf = JSON.parse(sanitizeJSON(raw)); } catch { continue; }
if (wf) return await fromWorkflow(wf, "comfyui");
}
return null;
}
// PNG
const chunks = await readAllPNGTextChunks(file);
if (!chunks) return null;
@@ -693,7 +787,7 @@ function buildLoRAItem(lora) {
return el;
}
function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
export function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
const el = document.createElement("div");
el.className = "wfm-meta-item wfm-meta-item-clickable";
const snippet = label.length > 60 ? label.slice(0, 60) + "…" : label;
@@ -723,7 +817,8 @@ function renderSection(sectionEl, listEl, items, buildFn) {
let _externalHandleFile = null;
export async function loadFileIntoMetadataTab(file) {
document.querySelector('.wfm-tab[data-tab="metadata"]')?.click();
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="metadata"]')?.click();
await new Promise(r => setTimeout(r, 0));
if (_externalHandleFile) await _externalHandleFile(file);
}
@@ -790,6 +885,7 @@ export function initMetadataTab() {
"wfm-help-metadata-3": "helpMetadata3",
"wfm-help-metadata-4": "helpMetadata4",
"wfm-help-metadata-5": "helpMetadata5",
"wfm-help-metadata-6": "helpMetadata6",
};
for (const [id, key] of Object.entries(helpIds)) {
const el = document.getElementById(id);
+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" },
};
+394
View File
@@ -0,0 +1,394 @@
/**
* Prompt Tab - AI Assistant (Ollama / LM Studio chat, translate, apply to GenerateUI)
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { getAiBackendDefaultUrl } from "./util.js";
// ============================================
// State
// ============================================
const ollamaState = {
chatHistory: [],
attachedImage: null,
};
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
function loadPromptAiSettings() {
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
}
// バックエンド変更検知用: モデル一覧を取得した時点のバックエンド名を記録
let _promptAiBackendForModels = null;
export function getPromptAiConfig() {
const s = loadPromptAiSettings();
const backend = s.backend || "ollama";
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
return { backend, url };
}
export async function fetchAiModels() {
const { backend, url } = getPromptAiConfig();
if (backend === "ollama") {
const res = await fetch(`${url}/api/tags`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).models?.map(m => m.name) || [];
} else {
const res = await fetch(`${url}/v1/models`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return ((await res.json()).data || []).map(m => m.id);
}
}
async function chatWithAi(model, messages) {
const { backend, url } = getPromptAiConfig();
if (backend === "ollama") {
const res = await fetch(`${url}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages, stream: false }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).message?.content || "";
} else {
// LM Studio / Lemonade (OpenAI-compatible): convert Ollama-style messages (with .images) to OpenAI content arrays
const openAiMessages = messages.map(msg => {
if (msg.images?.length) {
return {
role: msg.role,
content: [
{ type: "text", text: msg.content },
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
],
};
}
return { role: msg.role, content: msg.content };
});
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages: openAiMessages, stream: false }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).choices?.[0]?.message?.content || "";
}
}
// ============================================
// Chat UI
// ============================================
function addChatMessage(role, content) {
const chat = document.getElementById("wfm-ollama-chat");
if (!chat) return;
const welcome = chat.querySelector(".wfm-ollama-welcome");
if (welcome) welcome.remove();
const msg = document.createElement("div");
msg.className = `wfm-ollama-msg ${role}`;
const roleLabel = document.createElement("div");
roleLabel.className = "wfm-ollama-msg-role";
roleLabel.textContent = role === "user" ? t("you") : "AI";
msg.appendChild(roleLabel);
const contentDiv = document.createElement("div");
contentDiv.className = "wfm-ollama-msg-content";
contentDiv.textContent = content;
msg.appendChild(contentDiv);
// Apply button for assistant messages
if (role === "assistant") {
const applyRow = document.createElement("div");
applyRow.className = "wfm-ollama-apply-row";
const applyBtn = document.createElement("button");
applyBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
applyBtn.textContent = t("applyToGenerateUI");
applyBtn.addEventListener("click", () => applyToGenerateUI(content));
applyRow.appendChild(applyBtn);
const copyBtn = document.createElement("button");
copyBtn.className = "wfm-btn wfm-btn-sm";
copyBtn.textContent = t("copy");
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(content).then(() => {
copyBtn.textContent = t("copied");
setTimeout(() => { copyBtn.textContent = t("copy"); }, 1500);
});
});
applyRow.appendChild(copyBtn);
msg.appendChild(applyRow);
}
chat.appendChild(msg);
chat.scrollTop = chat.scrollHeight;
}
function clearChat() {
ollamaState.chatHistory = [];
ollamaState.attachedImage = null;
updateAttachmentDisplay();
const chat = document.getElementById("wfm-ollama-chat");
if (chat) {
chat.innerHTML = `
<div class="wfm-ollama-welcome">
<p>AI Assistant</p>
<p>${t("assistantWelcome")}</p>
</div>
`;
}
}
// ============================================
// Image Attachment
// ============================================
function attachFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
ollamaState.attachedImage = { name: file.name, dataUrl: e.target.result };
updateAttachmentDisplay();
};
reader.readAsDataURL(file);
}
function updateAttachmentDisplay() {
const container = document.getElementById("wfm-ollama-attachments");
if (!container) return;
container.innerHTML = "";
if (!ollamaState.attachedImage) return;
const item = document.createElement("div");
item.className = "wfm-ollama-attachment";
const img = document.createElement("img");
img.src = ollamaState.attachedImage.dataUrl;
item.appendChild(img);
const name = document.createElement("span");
name.className = "wfm-ollama-attachment-name";
name.textContent = ollamaState.attachedImage.name;
item.appendChild(name);
const removeBtn = document.createElement("button");
removeBtn.className = "wfm-ollama-attachment-remove";
removeBtn.textContent = "×";
removeBtn.addEventListener("click", () => {
ollamaState.attachedImage = null;
updateAttachmentDisplay();
});
item.appendChild(removeBtn);
container.appendChild(item);
}
// ============================================
// Send Message
// ============================================
async function sendMessage() {
const input = document.getElementById("wfm-ollama-input");
const modelSelect = document.getElementById("wfm-ollama-model");
if (!input || !modelSelect) return;
const message = input.value.trim();
if (!message) return;
// バックエンドが変更されていたらモデル一覧を自動更新
const { backend } = getPromptAiConfig();
if (backend !== _promptAiBackendForModels) {
const status = document.getElementById("wfm-ollama-status");
if (status) { status.textContent = `Switching to ${backend}...`; status.className = "wfm-ollama-status"; }
try {
const models = await fetchAiModels();
const savedModel = loadPromptAiSettings().model || "";
modelSelect.innerHTML = models.length
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
: `<option value="">${t("noModelsFound")}</option>`;
_promptAiBackendForModels = backend;
if (status) { status.textContent = `${models.length} models [${backend}]`; status.className = "wfm-ollama-status connected"; }
} catch {
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
}
}
const model = modelSelect.value;
if (!model) {
showToast(t("selectModelFirst"), "error");
return;
}
addChatMessage("user", message);
input.value = "";
const userMsg = { role: "user", content: message };
if (ollamaState.attachedImage) {
userMsg.images = [ollamaState.attachedImage.dataUrl.split(",")[1]];
}
ollamaState.chatHistory.push(userMsg);
const sendBtn = document.getElementById("wfm-ollama-send-btn");
if (sendBtn) sendBtn.disabled = true;
try {
const reply = await chatWithAi(model, ollamaState.chatHistory);
addChatMessage("assistant", reply);
ollamaState.chatHistory.push({ role: "assistant", content: reply });
ollamaState.attachedImage = null;
updateAttachmentDisplay();
} catch (err) {
showToast(t("error") + ": " + err.message, "error");
} finally {
if (sendBtn) sendBtn.disabled = false;
}
}
// ============================================
// Translation
// ============================================
async function sendTranslate(direction) {
const input = document.getElementById("wfm-ollama-input");
const modelSelect = document.getElementById("wfm-ollama-model");
if (!input || !modelSelect) return;
const text = input.value.trim();
if (!text) return;
const model = modelSelect.value;
if (!model) {
showToast(t("selectModelFirst"), "error");
return;
}
const labels = { ja2en: "JA→EN", en2ja: "EN→JA", zh2en: "ZH→EN", en2zh: "EN→ZH" };
addChatMessage("user", `[${labels[direction] || direction}]\n${text}`);
input.value = "";
const btns = ["wfm-ollama-send-btn", "wfm-ollama-ja2en-btn", "wfm-ollama-en2ja-btn", "wfm-ollama-zh2en-btn", "wfm-ollama-en2zh-btn"]
.map((id) => document.getElementById(id));
btns.forEach((b) => { if (b) b.disabled = true; });
try {
const prompts = {
ja2en: `Translate the following Japanese text into English. Output only the translated text, nothing else.\n\n${text}`,
en2ja: `以下の英語のテキストを日本語に翻訳してください。翻訳結果のみを出力し、他の説明は不要です。\n\n${text}`,
zh2en: `Translate the following Chinese text into English. Output only the translated text, nothing else.\n\n${text}`,
en2zh: `请将以下英文翻译成中文。只输出翻译结果,不需要其他说明。\n\n${text}`,
};
const prompt = prompts[direction];
const reply = await chatWithAi(model, [{ role: "user", content: prompt }]);
addChatMessage("assistant", reply);
} catch (err) {
showToast(t("error") + ": " + err.message, "error");
} finally {
btns.forEach((b) => { if (b) b.disabled = false; });
}
}
// ============================================
// Apply to GenerateUI
// ============================================
function applyToGenerateUI(text) {
const textareas = document.querySelectorAll("#wfm-gen-prompt-fields textarea");
if (textareas.length === 0) {
showToast(t("noPromptFields"), "error");
return;
}
textareas[0].value = text;
textareas[0].dispatchEvent(new Event("input", { bubbles: true }));
textareas[0].dispatchEvent(new Event("change", { bubbles: true }));
showToast(t("appliedToGenerateUI"), "success");
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initAiChatUI() {
// AI model refresh
async function refreshModels() {
const select = document.getElementById("wfm-ollama-model");
const status = document.getElementById("wfm-ollama-status");
const { backend } = getPromptAiConfig();
try {
const models = await fetchAiModels();
const savedModel = loadPromptAiSettings().model || "";
if (select) {
select.innerHTML = models.length
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
: `<option value="">${t("noModelsFound")}</option>`;
}
_promptAiBackendForModels = backend;
if (status) {
status.textContent = `${models.length} models [${backend}]`;
status.className = "wfm-ollama-status connected";
}
} catch {
if (status) {
status.textContent = t("failedConnect");
status.className = "wfm-ollama-status error";
}
}
}
// Auto-load models on init
refreshModels();
document.getElementById("wfm-ollama-refresh-btn")?.addEventListener("click", refreshModels);
// Test connection
document.getElementById("wfm-ollama-test-btn")?.addEventListener("click", async () => {
const status = document.getElementById("wfm-ollama-status");
if (status) { status.textContent = "Testing..."; status.className = "wfm-ollama-status"; }
try {
await fetchAiModels();
if (status) { status.textContent = t("connectedCheck"); status.className = "wfm-ollama-status connected"; }
await refreshModels();
} catch {
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
}
});
// Send message
document.getElementById("wfm-ollama-send-btn")?.addEventListener("click", sendMessage);
// Enter to send
document.getElementById("wfm-ollama-input")?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Clear chat
document.getElementById("wfm-ollama-clear-btn")?.addEventListener("click", clearChat);
// File attachment
const fileInput = document.getElementById("wfm-ollama-file-input");
document.getElementById("wfm-ollama-attach-btn")?.addEventListener("click", () => fileInput?.click());
fileInput?.addEventListener("change", () => {
if (fileInput.files.length > 0) {
attachFile(fileInput.files[0]);
fileInput.value = "";
}
});
// Translation
document.getElementById("wfm-ollama-ja2en-btn")?.addEventListener("click", () => sendTranslate("ja2en"));
document.getElementById("wfm-ollama-en2ja-btn")?.addEventListener("click", () => sendTranslate("en2ja"));
document.getElementById("wfm-ollama-zh2en-btn")?.addEventListener("click", () => sendTranslate("zh2en"));
document.getElementById("wfm-ollama-en2zh-btn")?.addEventListener("click", () => sendTranslate("en2zh"));
}
+567
View File
@@ -0,0 +1,567 @@
/**
* Prompt Tab - Presets (API CRUD, groups, Preset Manager panel)
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage } from "./util.js";
// ============================================
// State
// ============================================
// Preset data (loaded from API, with localStorage migration)
let promptPresets = [];
let pmActiveTab = "all"; // "all" | "favorites" | "groups"
let pmSearchText = "";
let pmSelectedId = null; // currently selected preset id
export let pmGroups = {}; // { groupName: [presetId, ...] }
const PM_GROUPS_KEY = "wfm_prompt_preset_groups";
export const PROMPT_RESERVED_GROUPS = ["Batch"];
// ============================================
// Preset API helpers
// ============================================
export async function fetchPresets() {
try {
const res = await fetch("/api/wfm/prompts");
return res.ok ? await res.json() : [];
} catch { return []; }
}
export async function apiCreatePreset(data) {
try {
const res = await fetch("/api/wfm/prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await res.json();
return result.status === "ok" ? result.prompt : null;
} catch { return null; }
}
export async function apiUpdatePreset(id, updates) {
try {
const res = await fetch("/api/wfm/prompts/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...updates }),
});
const result = await res.json();
return result.status === "ok" ? result.prompt : null;
} catch { return null; }
}
export async function apiDeletePreset(id) {
try {
await fetch("/api/wfm/prompts/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
} catch { /* ignore */ }
}
// ============================================
// Migrate localStorage presets to API
// ============================================
const PRESETS_KEY = "wfm_prompt_presets";
async function migrateLocalStoragePresets() {
try {
const raw = localStorage.getItem(PRESETS_KEY);
if (!raw) return;
const local = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
// Only migrate if API has no presets yet
const existing = await fetchPresets();
if (existing.length > 0) {
localStorage.removeItem(PRESETS_KEY);
return;
}
for (const p of local) {
await apiCreatePreset({
name: p.name || "Untitled",
text: p.posText || p.text || "",
negText: p.negText || "",
category: "",
tags: [],
favorite: false,
});
}
localStorage.removeItem(PRESETS_KEY);
} catch { /* ignore migration errors */ }
}
// ============================================
// Preset data management
// ============================================
export async function loadAllPresets() {
await migrateLocalStoragePresets();
promptPresets = await fetchPresets();
// Load groups from localStorage
pmGroups = readJsonStorage(PM_GROUPS_KEY);
// Clean stale entries from groups (preserve reserved groups even if empty)
const validIds = new Set(promptPresets.map(p => p.id));
for (const g of Object.keys(pmGroups)) {
pmGroups[g] = (pmGroups[g] || []).filter(id => validIds.has(id));
if (pmGroups[g].length === 0 && !PROMPT_RESERVED_GROUPS.includes(g)) delete pmGroups[g];
}
// Ensure reserved groups always exist
for (const g of PROMPT_RESERVED_GROUPS) {
if (!pmGroups[g]) pmGroups[g] = [];
}
saveGroups();
renderPresetSelect();
renderGroupSelect();
renderPresetManager();
}
export function saveGroups() {
localStorage.setItem(PM_GROUPS_KEY, JSON.stringify(pmGroups));
renderGroupSelect();
}
function renderGroupSelect() {
const select = document.getElementById("wfm-preset-group-select");
if (!select) return;
const prevVal = select.value;
select.innerHTML = `<option value="">Select group...</option>`;
for (const g of Object.keys(pmGroups).sort()) {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
select.appendChild(opt);
}
if (prevVal && pmGroups[prevVal]) select.value = prevVal;
}
function renderPresetSelect() {
const select = document.getElementById("wfm-preset-select");
if (!select) return;
const prevVal = select.value;
select.innerHTML = `<option value="">${t("newPreset")}</option>`;
promptPresets.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.name;
select.appendChild(opt);
});
// Restore selection if still valid
if (prevVal && promptPresets.find(p => p.id === prevVal)) {
select.value = prevVal;
}
}
function selectPresetInEditor(preset) {
const presetSelect = document.getElementById("wfm-preset-select");
const presetName = document.getElementById("wfm-preset-name");
const presetCategory = document.getElementById("wfm-preset-category");
const presetPos = document.getElementById("wfm-preset-pos");
const presetNeg = document.getElementById("wfm-preset-neg");
if (presetSelect) presetSelect.value = preset ? preset.id : "";
if (presetName) presetName.value = preset ? preset.name : "";
if (presetCategory) presetCategory.value = preset ? (preset.category || "") : "";
if (presetPos) presetPos.value = preset ? (preset.text || preset.posText || "") : "";
if (presetNeg) presetNeg.value = preset ? (preset.negText || "") : "";
pmSelectedId = preset ? preset.id : null;
renderPresetManager();
}
// ============================================
// Preset Manager rendering
// ============================================
export function esc(s) {
return s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") : "";
}
export function renderPresetManager() {
const container = document.getElementById("wfm-pm-list");
if (!container) return;
container.innerHTML = "";
switch (pmActiveTab) {
case "all": renderPmAll(container); break;
case "favorites": renderPmFavorites(container); break;
case "groups": renderPmGroups(container); break;
}
}
function matchesSearch(p) {
if (!pmSearchText) return true;
const s = pmSearchText.toLowerCase();
return (p.name || "").toLowerCase().includes(s) ||
(p.text || "").toLowerCase().includes(s) ||
(p.category || "").toLowerCase().includes(s) ||
(p.tags || []).some(t => t.toLowerCase().includes(s));
}
// Ordered list of actual preset objects for a group (stale ids already filtered out by
// loadAllPresets, but this is defensive for callers that read pmGroups directly too).
export function getPresetsInGroup(groupName) {
const ids = pmGroups[groupName] || [];
return ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
}
export function isInBatchPreset(id) {
return (pmGroups["Batch"] || []).includes(id);
}
export function toggleBatchPreset(id) {
const batch = pmGroups["Batch"] || [];
const idx = batch.indexOf(id);
if (idx >= 0) { batch.splice(idx, 1); } else { batch.push(id); }
pmGroups["Batch"] = batch;
saveGroups();
}
export function clearBatchPresets() {
pmGroups["Batch"] = [];
saveGroups();
renderPresetManager();
showToast(t("promptBatchClear"), "success");
}
function createPmItem(preset) {
const el = document.createElement("div");
el.className = "wfm-pm-item" + (pmSelectedId === preset.id ? " active" : "");
const previewText = (preset.text || "").length > 50
? preset.text.substring(0, 50) + "..."
: (preset.text || "");
const catBadge = preset.category
? `<span style="font-size:9px;color:var(--wfm-primary);margin-left:4px;">[${esc(preset.category)}]</span>`
: "";
const inBatch = isInBatchPreset(preset.id);
el.innerHTML = `
<div class="wfm-pm-item-body">
<div class="wfm-pm-item-name">${preset.favorite ? '<span style="color:#ffd700;">&#9733;</span> ' : ""}${esc(preset.name)}${catBadge}</div>
<div class="wfm-pm-item-sub">${esc(previewText)}</div>
</div>
<div class="wfm-pm-item-actions">
<button class="wfm-pm-action-btn pm-batch-btn${inBatch ? " batch-active" : ""}" title="Batch">B</button>
<button class="wfm-pm-action-btn pm-fav-btn${preset.favorite ? " fav-active" : ""}" title="Favorite">&#9733;</button>
<button class="wfm-pm-action-btn pm-del-btn" title="Delete" style="color:var(--wfm-danger);">&#10005;</button>
</div>
`;
// Click to select in editor
el.addEventListener("click", (e) => {
if (e.target.closest(".wfm-pm-item-actions")) return;
selectPresetInEditor(preset);
});
// Batch toggle
el.querySelector(".pm-batch-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleBatchPreset(preset.id);
renderPresetManager();
});
// Favorite toggle
el.querySelector(".pm-fav-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const updated = await apiUpdatePreset(preset.id, { favorite: !preset.favorite });
if (updated) {
const idx = promptPresets.findIndex(p => p.id === preset.id);
if (idx >= 0) promptPresets[idx] = updated;
renderPresetManager();
}
});
// Delete
el.querySelector(".pm-del-btn").addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Delete "${preset.name}"?`)) return;
await apiDeletePreset(preset.id);
promptPresets = promptPresets.filter(p => p.id !== preset.id);
if (pmSelectedId === preset.id) {
pmSelectedId = null;
selectPresetInEditor(null);
}
renderPresetSelect();
renderPresetManager();
showToast(t("deleted"), "success");
});
return el;
}
function renderPmAll(container) {
const items = promptPresets.filter(matchesSearch);
if (items.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">${promptPresets.length === 0 ? "No presets yet.<br><small>Create one in the Presets panel</small>" : "No matches"}</div>`;
return;
}
for (const p of items) {
container.appendChild(createPmItem(p));
}
}
function renderPmFavorites(container) {
const favs = promptPresets.filter(p => p.favorite);
const items = favs.filter(matchesSearch);
if (items.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">${favs.length === 0 ? "No favorites yet.<br><small>Star presets to add them here</small>" : "No matches"}</div>`;
return;
}
for (const p of items) {
container.appendChild(createPmItem(p));
}
}
function renderPmGroups(container) {
const groupNames = Object.keys(pmGroups).sort();
if (groupNames.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">No groups yet.<br><small>Create groups from the Presets panel below</small></div>`;
return;
}
for (const groupName of groupNames) {
const ids = pmGroups[groupName] || [];
const presets = ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
const section = document.createElement("div");
const header = document.createElement("div");
header.className = "wfm-pm-group-header collapsed";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-pm-badge">${presets.length}</span>`;
const list = document.createElement("div");
list.style.display = "none";
header.addEventListener("click", () => {
list.style.display = list.style.display === "none" ? "block" : "none";
header.classList.toggle("collapsed");
});
for (const p of presets) {
const item = createPmItem(p);
// Add remove-from-group button
const removeBtn = document.createElement("button");
removeBtn.className = "wfm-pm-action-btn";
removeBtn.title = "Remove from group";
removeBtn.textContent = "➖";
removeBtn.style.fontSize = "10px";
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
// 予約グループ (Batch) は空になってもキーを保持する
if (pmGroups[groupName].length === 0 && !PROMPT_RESERVED_GROUPS.includes(groupName)) delete pmGroups[groupName];
saveGroups();
renderPresetManager();
});
item.querySelector(".wfm-pm-item-actions").prepend(removeBtn);
list.appendChild(item);
}
section.appendChild(header);
section.appendChild(list);
container.appendChild(section);
}
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initPresetsUI() {
loadAllPresets();
const presetSelect = document.getElementById("wfm-preset-select");
const presetName = document.getElementById("wfm-preset-name");
const presetCategory = document.getElementById("wfm-preset-category");
const presetPos = document.getElementById("wfm-preset-pos");
const presetNeg = document.getElementById("wfm-preset-neg");
// Load preset on selection
presetSelect?.addEventListener("change", () => {
const id = presetSelect.value;
const p = promptPresets.find(pp => pp.id === id);
selectPresetInEditor(p || null);
});
// Copy positive prompt to clipboard
document.getElementById("wfm-preset-copy-pos-btn")?.addEventListener("click", () => {
const text = presetPos?.value || "";
if (!text.trim()) {
showToast(t("noTextToCopy"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("copiedToClipboard"), "success");
});
});
// Copy negative prompt to clipboard
document.getElementById("wfm-preset-copy-neg-btn")?.addEventListener("click", () => {
const text = presetNeg?.value || "";
if (!text.trim()) {
showToast(t("noTextToCopy"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("copiedToClipboard"), "success");
});
});
// Save preset (create or update via API)
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", async () => {
const name = presetName?.value.trim();
const category = presetCategory?.value.trim() || "";
const pos = presetPos?.value || "";
const neg = presetNeg?.value || "";
if (!name) {
showToast(t("enterPresetName"), "error");
return;
}
if (!pos.trim() && !neg.trim()) {
showToast(t("noPromptToSave"), "error");
return;
}
const selectedId = presetSelect?.value;
const existing = selectedId ? promptPresets.find(p => p.id === selectedId) : null;
if (existing) {
// Update
const updated = await apiUpdatePreset(existing.id, {
name, text: pos, negText: neg, category,
});
if (updated) {
const idx = promptPresets.findIndex(p => p.id === existing.id);
if (idx >= 0) promptPresets[idx] = updated;
showToast(t("presetSaved"), "success");
}
} else {
// Create
const created = await apiCreatePreset({
name, text: pos, negText: neg, category,
tags: [], favorite: false,
});
if (created) {
promptPresets.push(created);
showToast(t("presetSaved"), "success");
}
}
pmSelectedId = null;
renderPresetSelect();
renderPresetManager();
if (presetSelect) presetSelect.value = "";
});
// Apply preset to GenerateUI
document.getElementById("wfm-preset-apply-btn")?.addEventListener("click", () => {
const pos = presetPos?.value || "";
const neg = presetNeg?.value || "";
const textareas = document.querySelectorAll("#wfm-gen-prompt-fields textarea");
if (textareas.length === 0) {
showToast(t("noPromptFields"), "error");
return;
}
let applied = false;
if (textareas.length > 0 && pos) {
textareas[0].value = pos;
textareas[0].dispatchEvent(new Event("input", { bubbles: true }));
applied = true;
}
if (textareas.length > 1 && neg) {
textareas[1].value = neg;
textareas[1].dispatchEvent(new Event("input", { bubbles: true }));
applied = true;
}
if (applied) showToast(t("appliedToGenerateUI"), "success");
});
// --- Group management (Presets side) ---
document.getElementById("wfm-preset-new-group-btn")?.addEventListener("click", () => {
const name = prompt("Group name:");
if (!name || !name.trim()) return;
const key = name.trim();
if (pmGroups[key]) {
showToast(t("groupExists"), "error");
return;
}
pmGroups[key] = [];
saveGroups();
renderPresetManager();
showToast(t("groupCreated", key), "success");
});
document.getElementById("wfm-preset-add-to-group-btn")?.addEventListener("click", () => {
const groupSelect = document.getElementById("wfm-preset-group-select");
const groupName = groupSelect?.value;
if (!groupName) {
showToast(t("selectGroupFirst"), "error");
return;
}
const id = presetSelect?.value;
if (!id) {
showToast(t("selectPresetFirst"), "error");
return;
}
if (!pmGroups[groupName]) pmGroups[groupName] = [];
if (pmGroups[groupName].includes(id)) {
showToast(t("alreadyInGroup"), "info");
return;
}
pmGroups[groupName].push(id);
saveGroups();
renderPresetManager();
showToast(t("addedToGroup"), "success");
});
document.getElementById("wfm-preset-del-group-btn")?.addEventListener("click", () => {
const groupSelect = document.getElementById("wfm-preset-group-select");
const groupName = groupSelect?.value;
if (!groupName) {
showToast(t("selectGroupFirst"), "error");
return;
}
if (PROMPT_RESERVED_GROUPS.includes(groupName)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
if (!confirm(`Delete group "${groupName}"?`)) return;
delete pmGroups[groupName];
saveGroups();
renderPresetManager();
showToast(t("groupDeleted", groupName), "success");
});
// --- Preset Manager ---
document.querySelectorAll(".wfm-pm-tab").forEach(btn => {
btn.addEventListener("click", () => {
pmActiveTab = btn.dataset.pmtab;
document.querySelectorAll(".wfm-pm-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderPresetManager();
});
});
document.getElementById("wfm-pm-search-input")?.addEventListener("input", (e) => {
pmSearchText = e.target.value.trim();
renderPresetManager();
});
document.getElementById("wfm-pm-batch-clear-btn")?.addEventListener("click", () => {
clearBatchPresets();
});
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Prompt Tab - Style manager (Col3 "Style" タブ)
*/
// Wildcardファイルマネージャーと同じCRUDパターン(一覧→エディタ→保存/削除/キャンセル)。
// 保存・削除後は generate-tab.js の refreshStylesList() を呼び、GenerateUIのStyleドロップダウンや
// Batchタブのチェックリストを再読み込み無しで最新状態へ同期する。
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { setupSearchClearBtn } from "./util.js";
import { refreshStylesList } from "./generate-tab.js";
import { esc } from "./prompt-presets.js";
let styleListData = [];
let styleSearchText = "";
let styleEditingOriginalName = null; // null = 新規作成
let styleEditingFile = null; // 編集中/追加先スタイルの定義元ファイル名(style/配下、null = デフォルトのcustom.json)
export async function styleFetchList() {
try {
const res = await fetch("/api/wfm/styles");
return res.ok ? await res.json() : [];
} catch { return []; }
}
export 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 };
}
}
export 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 };
}
}
export 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";
}
export async function styleRefreshList() {
styleListData = await styleFetchList();
styleRenderList();
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initStylesUI() {
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();
}
+30 -1509
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+475
View File
@@ -0,0 +1,475 @@
/**
* Prompt Tab - Wildcard file manager (list, editor, insert-at-cursor helpers)
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { esc } from "./prompt-presets.js";
// ============================================
// Wildcard API helpers
// ============================================
export async function wcFetchFiles() {
try {
const res = await fetch("/api/wfm/wildcards");
return res.ok ? await res.json() : [];
} catch { return []; }
}
export async function wcFetchContent(filename) {
try {
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(filename)}`);
const data = await res.json();
return data.content ?? null;
} catch { return null; }
}
export async function wcSaveFile(filename, content) {
try {
const res = await fetch("/api/wfm/wildcards/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, content }),
});
const data = await res.json();
return data.status === "ok" ? data.file : null;
} catch { return null; }
}
export async function wcDeleteFile(filename) {
try {
await fetch("/api/wfm/wildcards/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
} catch { /* ignore */ }
}
// ============================================
// Wildcard state & helpers
// ============================================
let wcFiles = [];
let wcEditingFilename = null; // null = new file
// Insert text at cursor; if open+close provided wraps selection
function wcInsertAtCursor(textarea, open, close = "") {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
let inserted, cursorStart, cursorEnd;
if (selected && close) {
inserted = open + selected + close;
cursorStart = start + open.length;
cursorEnd = cursorStart + selected.length;
} else if (close) {
inserted = open + close;
cursorStart = start + open.length;
cursorEnd = cursorStart;
} else {
inserted = open;
cursorStart = start + open.length;
cursorEnd = cursorStart;
}
textarea.value =
textarea.value.substring(0, start) +
inserted +
textarea.value.substring(end);
textarea.setSelectionRange(cursorStart, cursorEnd);
textarea.focus();
textarea.dispatchEvent(new Event("input"));
}
// ============================================
// Wildcard file list rendering
// ============================================
function wcRenderFileList() {
const list = document.getElementById("wfm-wc-file-list");
if (!list) return;
list.innerHTML = "";
if (wcFiles.length === 0) {
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">No wildcard files yet.<br><small>Click "+ New" to create one.</small></div>`;
return;
}
// Group by directory; root ("") first, then alphabetical
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
function addFileItem(f, indented) {
const item = document.createElement("div");
item.className = "wfm-wc-file-item" + (indented ? " wfm-wc-file-item--sub" : "");
item.innerHTML = `
<div class="wfm-wc-file-item-info">
<span class="wfm-wc-file-name">${esc(f.name)}</span>
<span class="wfm-wc-file-ext">.${esc(f.ext)}</span>
</div>
<div class="wfm-wc-file-item-actions">
<button class="wfm-pm-action-btn wfm-wc-use-btn" title="Insert __${esc(f.wc_name)}__ at cursor">Use</button>
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">&#9998;</button>
</div>
`;
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
e.stopPropagation();
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const content = await wcFetchContent(f.filename);
wcOpenEditor(f.wc_name, f.ext, content ?? "", f.filename);
});
item.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
list.appendChild(item);
}
for (const dir of dirs) {
if (dir) {
const header = document.createElement("div");
header.className = "wfm-wc-dir-header";
header.textContent = dir + "/";
list.appendChild(header);
}
for (const f of grouped[dir]) {
addFileItem(f, dir !== "");
}
}
}
// ============================================
// Wildcard file editor
// ============================================
function wcOpenEditor(name, ext, content, existingFilename) {
wcEditingFilename = existingFilename || null;
const editor = document.getElementById("wfm-wc-editor");
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
const deleteBtn = document.getElementById("wfm-wc-editor-delete-btn");
if (!editor) return;
nameInput.value = name || "";
extSelect.value = ext || "txt";
contentTA.value = content || "";
deleteBtn.style.display = existingFilename ? "" : "none";
editor.style.display = "";
nameInput.focus();
}
function wcCloseEditor() {
wcEditingFilename = null;
const editor = document.getElementById("wfm-wc-editor");
if (editor) editor.style.display = "none";
}
export async function wcRefreshFiles() {
wcFiles = await wcFetchFiles();
wcRenderFileList();
wcUpdateFilePicker();
}
// ============================================
// Wildcard file picker popup
// ============================================
// Renders the wildcard-file-reference list into `picker`, inserting into `targetTa` on click.
// Shared by the Form tab's fixed file-picker popup and any dynamically built toolbar
// (see createWildcardToolbar) so both stay in sync with a single implementation.
function renderFilePicker(picker, targetTa) {
picker.innerHTML = "";
if (wcFiles.length === 0) {
picker.innerHTML = `<div style="padding:8px;font-size:11px;color:var(--wfm-text-secondary);">No wildcard files</div>`;
return;
}
// Group by directory same as file list
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
for (const dir of dirs) {
if (dir) {
const sep = document.createElement("div");
sep.className = "wfm-wc-picker-dir";
sep.textContent = dir + "/";
picker.appendChild(sep);
}
for (const f of grouped[dir]) {
const btn = document.createElement("button");
btn.className = "wfm-wc-picker-item" + (dir ? " wfm-wc-picker-item--sub" : "");
btn.textContent = `__${f.wc_name}__`;
btn.addEventListener("click", () => {
wcInsertAtCursor(targetTa, `__${f.wc_name}__`);
picker.style.display = "none";
});
picker.appendChild(btn);
}
}
}
function wcUpdateFilePicker() {
const picker = document.getElementById("wfm-wc-file-picker");
const ta = document.getElementById("wfm-wc-prompt");
if (!picker || !ta) return;
renderFilePicker(picker, ta);
}
// ============================================
// Reusable wildcard syntax toolbar (buttons + file-reference picker)
// ============================================
const WC_SYNTAX_BUTTONS = [
{ open: "{", close: "}", label: "{ }", title: "Random pick set e.g. {cat|dog}" },
{ open: "|", close: "", label: "|", title: "Separator" },
{ open: "__", close: "__", label: "__", title: "Wildcard file reference e.g. __animals__" },
{ open: ":", close: "", label: ":", title: "Colon" },
{ open: ";", close: "", label: ";", title: "Semicolon" },
{ open: "$$", close: "", label: "$$", title: "Multi-pick e.g. {2$$cat|dog|bird}" },
{ open: "[", close: "]", label: "[ ]", title: "Bracket set e.g. [cat|dog]" },
];
// Builds a standalone wildcard-syntax toolbar (syntax buttons, LoRA/n$${} helpers, and the
// file-reference picker) wired to insert into the given textarea. The Form tab's Col3 panel
// uses the static toolbar markup in index.html instead (wired in initWildcardsUI below); this
// is for callers that need the same toolbar attached to a textarea built at runtime, such as
// the Table tab's wildcard edit modal (see prompt-table.js).
export function createWildcardToolbar(textareaEl) {
const toolbar = document.createElement("div");
toolbar.className = "wfm-wc-toolbar";
for (const b of WC_SYNTAX_BUTTONS) {
const btn = document.createElement("button");
btn.className = "wfm-wc-btn";
btn.title = b.title;
btn.textContent = b.label;
btn.addEventListener("click", () => wcInsertAtCursor(textareaEl, b.open, b.close));
toolbar.appendChild(btn);
}
const loraBtn = document.createElement("button");
loraBtn.className = "wfm-wc-btn";
loraBtn.title = "LoRA with LBW e.g. <lora:name:1:LBW=...;...>";
loraBtn.textContent = "<lora>";
loraBtn.addEventListener("click", () => {
wcInsertAtCursor(textareaEl, "<lora::1:LBW=;>");
// Move cursor to after "lora:" for the name
const pos = textareaEl.selectionStart - "<lora::1:LBW=;>".length + 6;
textareaEl.setSelectionRange(pos, pos);
textareaEl.focus();
});
toolbar.appendChild(loraBtn);
const nsetBtn = document.createElement("button");
nsetBtn.className = "wfm-wc-btn";
nsetBtn.title = "Multi-pick set e.g. {2$$cat|dog|bird}";
nsetBtn.textContent = "n$${ }";
nsetBtn.addEventListener("click", () => {
const n = prompt("Number of items to pick (n):", "2");
if (n === null) return;
const num = parseInt(n, 10);
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
const template = "{" + num + "$$|}";
const start = textareaEl.selectionStart;
wcInsertAtCursor(textareaEl, template);
// Move cursor between $$ and |
const inner = start + ("{" + num + "$$").length;
textareaEl.setSelectionRange(inner, inner);
textareaEl.focus();
});
toolbar.appendChild(nsetBtn);
const pickerWrap = document.createElement("div");
pickerWrap.className = "wfm-wc-file-picker-wrap";
const pickBtn = document.createElement("button");
pickBtn.className = "wfm-wc-btn";
pickBtn.title = "Insert wildcard file reference";
pickBtn.innerHTML = "&#128194; File";
const picker = document.createElement("div");
picker.className = "wfm-wc-file-picker";
picker.style.display = "none";
pickBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = picker.style.display !== "none";
picker.style.display = isOpen ? "none" : "";
if (!isOpen) renderFilePicker(picker, textareaEl);
});
document.addEventListener("click", () => { picker.style.display = "none"; });
pickerWrap.appendChild(pickBtn);
pickerWrap.appendChild(picker);
toolbar.appendChild(pickerWrap);
return toolbar;
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initWildcardsUI() {
// ── Wildcard syntax buttons ──────────────────────────────
document.querySelectorAll(".wfm-wc-btn[data-wc-open]").forEach(btn => {
btn.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const open = btn.dataset.wcOpen || "";
const close = btn.dataset.wcClose || "";
wcInsertAtCursor(ta, open, close);
});
});
// LoRA LBW template button
document.getElementById("wfm-wc-lora-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
wcInsertAtCursor(ta, "<lora::1:LBW=;>");
// Move cursor to after "lora:" for the name
const pos = ta.selectionStart - "<lora::1:LBW=;>".length + 6;
ta.setSelectionRange(pos, pos);
ta.focus();
});
// n$${ } button — asks for n then inserts multi-pick template
document.getElementById("wfm-wc-nset-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const n = prompt("Number of items to pick (n):", "2");
if (n === null) return;
const num = parseInt(n, 10);
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
const template = "{" + num + "$$|}";
const start = ta.selectionStart;
wcInsertAtCursor(ta, template);
// Move cursor between $$ and |
const inner = start + ("{" + num + "$$").length;
ta.setSelectionRange(inner, inner);
ta.focus();
});
// File picker toggle
const filePickBtn = document.getElementById("wfm-wc-filepick-btn");
const filePicker = document.getElementById("wfm-wc-file-picker");
filePickBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (!filePicker) return;
const isOpen = filePicker.style.display !== "none";
filePicker.style.display = isOpen ? "none" : "";
if (!isOpen) wcUpdateFilePicker();
});
document.addEventListener("click", () => {
if (filePicker) filePicker.style.display = "none";
});
// ── Wildcard prompt actions ──────────────────────────────
document.getElementById("wfm-wc-copy-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
const text = ta?.value || "";
if (!text.trim()) { showToast(t("noTextToCopy"), "error"); return; }
navigator.clipboard.writeText(text).then(() => showToast(t("copiedToClipboard"), "success"));
});
document.getElementById("wfm-wc-to-pos-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-pos");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast(t("sentToPositive"), "success");
});
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-neg");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast(t("sentToNegative"), "success");
});
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) { ta.value = ""; ta.focus(); }
});
// ── Wildcard file manager ────────────────────────────────
document.getElementById("wfm-wc-new-file-btn")?.addEventListener("click", () => {
wcOpenEditor("", "txt", "", null);
});
document.getElementById("wfm-wc-files-refresh-btn")?.addEventListener("click", () => {
wcRefreshFiles();
});
// Editor: save
document.getElementById("wfm-wc-editor-save-btn")?.addEventListener("click", async () => {
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
// Normalize separators and strip leading/trailing slashes
const rawName = (nameInput?.value || "").trim().replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
const ext = extSelect?.value || "txt";
const content = contentTA?.value || "";
if (!rawName) { showToast(t("pleaseEnterFilename"), "error"); return; }
// Validate each path component (allow letters, numbers, -, _, ., space)
const nameParts = rawName.split("/");
if (nameParts.some(p => !p || !/^[\w\-. ]+$/.test(p))) {
showToast(t("invalidPathFormat"), "error");
return;
}
const name = rawName;
const filename = `${name}.${ext}`;
const saved = await wcSaveFile(filename, content);
if (saved) {
showToast(t("savedAs", filename), "success");
wcCloseEditor();
wcRefreshFiles();
} else {
showToast(t("saveFailed"), "error");
}
});
// Editor: delete
document.getElementById("wfm-wc-editor-delete-btn")?.addEventListener("click", async () => {
if (!wcEditingFilename) return;
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
await wcDeleteFile(wcEditingFilename);
showToast(t("deletedName", wcEditingFilename), "success");
wcCloseEditor();
wcRefreshFiles();
});
// Editor: cancel
document.getElementById("wfm-wc-editor-cancel-btn")?.addEventListener("click", () => {
wcCloseEditor();
});
// Initial wildcard file load
wcRefreshFiles();
}
+112 -2
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>
@@ -794,6 +824,25 @@ export async function initSettingsTab() {
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
</label>
</div>
<p style="font-size:12px;color:var(--wfm-text-secondary);margin:12px 0 8px 0;">${t("fullBackupHint")}</p>
<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:8px;">
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
<input type="checkbox" id="wfm-settings-backup-include-workflows">
${t("fullBackupIncludeWorkflows")}
</label>
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
<input type="checkbox" id="wfm-settings-backup-include-wildcard">
${t("fullBackupIncludeWildcard")}
</label>
<span style="font-size:11px;color:var(--wfm-text-secondary);">${t("fullBackupExternalHint")}</span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export-full">${t("exportFullBackup")}</button>
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
${t("importFullBackup")}
<input type="file" id="wfm-settings-import-full-file" accept=".zip" style="display:none;">
</label>
</div>
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
</details>
@@ -1333,6 +1382,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;
@@ -1486,8 +1551,53 @@ export async function initSettingsTab() {
statusEl.textContent = t("importError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
// Reset file input so same file can be re-selected
e.target.value = "";
});
// --- Full Backup Export (ZIP) ---
document.getElementById("wfm-settings-export-full")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-data-status");
try {
const includeWorkflows = document.getElementById("wfm-settings-backup-include-workflows")?.checked ? "1" : "0";
const includeWildcard = document.getElementById("wfm-settings-backup-include-wildcard")?.checked ? "1" : "0";
const res = await fetch(`/api/wfm/settings/export-full?include_workflows=${includeWorkflows}&include_wildcard=${includeWildcard}`);
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "wfm-full-backup.zip";
a.click();
URL.revokeObjectURL(url);
statusEl.textContent = t("exportSuccess");
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("exportError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
});
// --- Full Backup Import (ZIP) ---
document.getElementById("wfm-settings-import-full-file")?.addEventListener("change", async (e) => {
const statusEl = document.getElementById("wfm-settings-data-status");
const file = e.target.files?.[0];
if (!file) return;
try {
const fd = new FormData();
fd.append("file", file, file.name);
const res = await fetch("/api/wfm/settings/import-full", {
method: "POST",
body: fd,
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || res.statusText);
statusEl.textContent = `${t("importSuccess")}: ${result.extracted?.length ?? 0} ${t("fullBackupFilesUnit")}`;
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("importError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
} finally {
e.target.value = "";
}
});
// Save G'MIC path setting
+1
View File
@@ -102,6 +102,7 @@ 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) {
+725
View File
@@ -0,0 +1,725 @@
/**
* Video Tab - MiniMax H3 (Image to Video) 専用の動画生成UI
* GenerateUIタブとは完全に独立。UI形式ワークフローJSONを直接編集し、
* comfyWorkflow.convertUiToApi() でAPI形式に変換して実行する。
* 状態は「テンプレートJSON+フォーム値」のみ保持し、生成の都度
* structuredClone() したコピーに書き込む(複数回生成をまたいだID不整合を防ぐ)。
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { comfyUI } from "./comfyui-client.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
import { escapeHtml } from "./util.js";
const DEFAULT_WORKFLOW_FILENAME = "minimax_test.json";
const state = {
templateWorkflow: null,
templateFilename: null,
firstFrameFilename: null,
lastFrameFilename: null,
generating: false,
// Whatever's currently shown in the center preview frame — a just-generated
// result, or a video dropped/picked in the Video Source panel. Used by the
// Frame/GIF property tabs so they work on any video, not just generated ones.
// { kind: "output"|"input", filename, subfolder, type } for anything already on
// the server, or { kind: "local", file } for a picked file not yet uploaded.
previewSource: null,
};
// ============================================
// ワークフロー取得
// ============================================
async function getRawWorkflow(filename) {
const res = await fetch(`/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
}
// ============================================
// MiniMax H3ノードの動的特定(ノードIDをハードコードしない)
// ============================================
// ノードのinputs[]のうちwidgetキーを持つものだけを抽出した並び順で、名前からインデックスを引く。
// widgets_values配列はこの並び順と1:1対応する(link接続されていてもwidgetキーを持つ入力は
// widgets_valuesにスロットを持つ「legacy full」形式のため)。
function _widgetIndex(node, name) {
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
return widgetInputs.findIndex((inp) => inp.name === name);
}
function locateMiniMaxNodes(workflow) {
if (!Array.isArray(workflow?.nodes) || !Array.isArray(workflow?.links)) return null;
const sgDef = (workflow.definitions?.subgraphs || [])
.find((sg) => (sg.nodes || []).some((n) => n.type === "MiniMaxH3ImageToVideo"));
if (!sgDef) return null;
const instanceNode = workflow.nodes.find((n) => n.type === sgDef.id);
if (!instanceNode || !Array.isArray(instanceNode.widgets_values)) return null;
const firstFrameSlot = instanceNode.inputs.findIndex((inp) => inp.name === "first_frame");
const lastFrameSlot = instanceNode.inputs.findIndex((inp) => inp.name === "last_frame");
const widthSlot = instanceNode.inputs.findIndex((inp) => inp.name === "width");
if (firstFrameSlot === -1 || lastFrameSlot === -1 || widthSlot === -1) return null;
const findLinkedNode = (targetSlot) => {
const link = workflow.links.find((l) => l[3] === instanceNode.id && l[4] === targetSlot);
if (!link) return null;
return workflow.nodes.find((n) => n.id === link[1]) || null;
};
// first_frameはoptional入力のため、LoadImageノードが接続されていないワークフロー
// (テキストのみで動作するT2V構成など)もサポート対象とする。未接続の場合は
// loadImageNode: null のまま返し、必要ならrunGeneration側で動的にノードを注入する。
const rawLoadImageNode = findLinkedNode(firstFrameSlot);
const loadImageNode = (rawLoadImageNode && rawLoadImageNode.type === "LoadImage") ? rawLoadImageNode : null;
const resolutionNode = findLinkedNode(widthSlot);
if (!resolutionNode || resolutionNode.type !== "ResolutionSelector") return null;
const promptIdx = _widgetIndex(instanceNode, "prompt");
const durationIdx = _widgetIndex(instanceNode, "value_1");
if (promptIdx === -1 || durationIdx === -1) return null;
return {
sgDef, instanceNode, loadImageNode, resolutionNode,
firstFrameSlot, lastFrameSlot, promptIdx, durationIdx,
};
}
// ============================================
// ワークフロー読み込み
// ============================================
function _showFramePreview(which, filename) {
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
if (!previewImg || !wrap || !filename) return;
previewImg.src = `${comfyUI.baseUrl}/view?filename=${encodeURIComponent(filename)}&type=input`;
wrap.style.display = "";
}
export async function loadWorkflowIntoVideoEditor(workflow, filename) {
const nodes = locateMiniMaxNodes(workflow);
if (!nodes) {
showToast(t("videoUnsupportedWorkflow"), "error");
return false;
}
state.templateWorkflow = workflow;
state.templateFilename = filename || "";
state.firstFrameFilename = null;
state.lastFrameFilename = null;
const nameEl = document.getElementById("wfm-video-wf-name");
if (nameEl) nameEl.textContent = filename || "Loaded Workflow";
const { instanceNode, resolutionNode, loadImageNode, promptIdx, durationIdx } = nodes;
const promptEl = document.getElementById("wfm-video-prompt");
if (promptEl) promptEl.value = instanceNode.widgets_values[promptIdx] ?? "";
const durationEl = document.getElementById("wfm-video-duration");
if (durationEl) durationEl.value = instanceNode.widgets_values[durationIdx] ?? 2;
const arIdx = _widgetIndex(resolutionNode, "aspect_ratio");
const mpIdx = _widgetIndex(resolutionNode, "megapixels");
const multIdx = _widgetIndex(resolutionNode, "multiple");
const aspectEl = document.getElementById("wfm-video-aspect-ratio");
if (aspectEl && arIdx !== -1 && resolutionNode.widgets_values[arIdx] != null) {
aspectEl.value = resolutionNode.widgets_values[arIdx];
}
const mpEl = document.getElementById("wfm-video-megapixels");
if (mpEl && mpIdx !== -1) mpEl.value = resolutionNode.widgets_values[mpIdx] ?? mpEl.value;
const multEl = document.getElementById("wfm-video-multiple");
if (multEl && multIdx !== -1) multEl.value = resolutionNode.widgets_values[multIdx] ?? multEl.value;
const fnIdx = _widgetIndex(loadImageNode, "image");
if (fnIdx !== -1 && loadImageNode.widgets_values[fnIdx]) {
state.firstFrameFilename = loadImageNode.widgets_values[fnIdx];
_showFramePreview("first", state.firstFrameFilename);
}
showToast(t("videoWorkflowLoaded", filename || ""), "success");
return true;
}
async function loadDefaultWorkflow() {
try {
const wf = await getRawWorkflow(DEFAULT_WORKFLOW_FILENAME);
await loadWorkflowIntoVideoEditor(wf, DEFAULT_WORKFLOW_FILENAME);
} catch {
// 初回自動ロード失敗は静かに無視する(環境によってファイルが存在しないこともある)。
// Workflowタブの「Load in Video」ボタンから明示的に読み込める。
}
}
// ============================================
// Aspect Ratio 選択肢(object_infoから動的取得、ハードコードしない)
// ============================================
async function _populateAspectRatioOptions() {
const select = document.getElementById("wfm-video-aspect-ratio");
if (!select) return;
try {
const info = await comfyUI.fetchObjectInfo("ResolutionSelector");
const inputDef = info?.ResolutionSelector?.input?.required?.aspect_ratio;
if (!inputDef) return;
const first = inputDef[0];
let values = null;
if (Array.isArray(first)) values = first;
else if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) values = inputDef[1].values;
else if (typeof first === "string" && Array.isArray(inputDef[1]?.options)) values = inputDef[1].options;
if (!values?.length) return;
select.innerHTML = values.map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
} catch {
// ResolutionSelectorが未導入の環境などは選択肢が空のまま(手入力不可のselectなので実質何もできないが、
// Videoタブ自体の初期化は継続させる)。
}
}
// ============================================
// first_frame / last_frame アップロード
// ============================================
function _wireDropZone(dropZoneId, fileInputId, onFile) {
const dropZone = document.getElementById(dropZoneId);
const fileInput = document.getElementById(fileInputId);
if (!dropZone || !fileInput) return;
fileInput.addEventListener("change", () => {
if (fileInput.files.length > 0) onFile(fileInput.files[0]);
});
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) onFile(e.dataTransfer.files[0]);
});
}
async function _handleFrameUpload(which, file) {
if (!file || !file.type.startsWith("image/")) return;
const statusEl = document.getElementById(`wfm-video-${which}-frame-status`);
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
if (previewImg) previewImg.src = URL.createObjectURL(file);
if (wrap) wrap.style.display = "";
if (statusEl) statusEl.textContent = "Uploading...";
try {
const result = await comfyUI.uploadImage(file, file.name);
if (which === "first") state.firstFrameFilename = result.name;
else state.lastFrameFilename = result.name;
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)";
}
}
}
function _clearFrame(which) {
if (which === "first") state.firstFrameFilename = null;
else state.lastFrameFilename = null;
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
const statusEl = document.getElementById(`wfm-video-${which}-frame-status`);
const fileInput = document.getElementById(`wfm-video-${which}-frame-file`);
if (wrap) wrap.style.display = "none";
if (previewImg) previewImg.src = "";
if (statusEl) statusEl.textContent = "";
if (fileInput) fileInput.value = "";
}
// ============================================
// フォーム値の読み取り・ワークフローへの反映
// ============================================
function _readFormValues() {
return {
prompt: document.getElementById("wfm-video-prompt")?.value || "",
aspectRatio: document.getElementById("wfm-video-aspect-ratio")?.value || "",
megapixels: Number(document.getElementById("wfm-video-megapixels")?.value) || 0.2,
multiple: Number(document.getElementById("wfm-video-multiple")?.value) || 32,
duration: Number(document.getElementById("wfm-video-duration")?.value) || 2,
};
}
function _applyFormToWorkflow(nodes, form) {
const { instanceNode, resolutionNode, loadImageNode, promptIdx, durationIdx } = nodes;
instanceNode.widgets_values[promptIdx] = form.prompt;
instanceNode.widgets_values[durationIdx] = form.duration;
const arIdx = _widgetIndex(resolutionNode, "aspect_ratio");
const mpIdx = _widgetIndex(resolutionNode, "megapixels");
const multIdx = _widgetIndex(resolutionNode, "multiple");
if (arIdx !== -1) resolutionNode.widgets_values[arIdx] = form.aspectRatio;
if (mpIdx !== -1) resolutionNode.widgets_values[mpIdx] = form.megapixels;
if (multIdx !== -1) resolutionNode.widgets_values[multIdx] = form.multiple;
// first_frameは既存のLoadImageノードが接続されている場合のみここで書き換える。
// 接続が無い場合(loadImageNode: null)は、first_frameが新規指定された時だけ
// _injectFrameNode()でノードごと注入する(未指定ならバイパス=未接続のまま実行)。
if (loadImageNode) {
const fnIdx = _widgetIndex(loadImageNode, "image");
if (fnIdx !== -1) loadImageNode.widgets_values[fnIdx] = state.firstFrameFilename;
}
}
// first_frame/last_frameが未接続のワークフローに対して、画像が指定された時だけ
// LoadImageノードを新規注入する共通ヘルパー。ノードID/リンクIDはclone内の実際の
// 最大値+1で採番するため、重複ID/リンクID不整合は起きない
// (CLAUDE.md記載の既知の落とし穴への対策)。未指定時は何もしない
// (テンプレート由来のlink:nullのまま=クリーンな状態を毎回cloneするので残留の心配がない=バイパス扱い)。
function _injectFrameNode(clone, instanceNode, targetSlot, filename) {
const maxNodeId = Math.max(clone.last_node_id || 0, ...clone.nodes.map((n) => n.id));
const maxLinkId = Math.max(clone.last_link_id || 0, ...clone.links.map((l) => l[0]));
const newNodeId = maxNodeId + 1;
const newLinkId = maxLinkId + 1;
clone.nodes.push({
id: newNodeId,
type: "LoadImage",
pos: [(instanceNode.pos?.[0] || 0) - 260, (instanceNode.pos?.[1] || 0) + 40],
size: [280, 314],
flags: {},
order: clone.nodes.length,
mode: 0,
inputs: [
{ localized_name: "image", name: "image", type: "COMBO", widget: { name: "image" }, link: null },
{ localized_name: "choose file to upload", name: "upload", type: "IMAGEUPLOAD", widget: { name: "upload" }, link: null },
],
outputs: [
{ localized_name: "IMAGE", name: "IMAGE", type: "IMAGE", links: [newLinkId] },
{ localized_name: "MASK", name: "MASK", type: "MASK", links: null },
],
properties: { "Node name for S&R": "LoadImage" },
widgets_values: [filename, "image"],
});
clone.links.push([newLinkId, newNodeId, 0, instanceNode.id, targetSlot, "IMAGE"]);
const targetInput = instanceNode.inputs[targetSlot];
if (targetInput) targetInput.link = newLinkId;
clone.last_node_id = newNodeId;
clone.last_link_id = newLinkId;
}
// ============================================
// 実行
// ============================================
async function runGeneration() {
if (state.generating) return;
if (!state.templateWorkflow) {
showToast(t("videoNoWorkflowLoaded"), "error");
return;
}
const clone = structuredClone(state.templateWorkflow);
const nodes = locateMiniMaxNodes(clone);
if (!nodes) {
showToast(t("videoUnsupportedWorkflow"), "error");
return;
}
_applyFormToWorkflow(nodes, _readFormValues());
// first_frame: 既存のLoadImageノードが無いワークフロー(T2V構成等)で、
// ユーザーが新たに画像を指定した場合のみノードを注入する。未指定ならバイパス(未接続のまま実行)。
if (!nodes.loadImageNode && state.firstFrameFilename) {
_injectFrameNode(clone, nodes.instanceNode, nodes.firstFrameSlot, state.firstFrameFilename);
}
if (state.lastFrameFilename) {
_injectFrameNode(clone, nodes.instanceNode, nodes.lastFrameSlot, state.lastFrameFilename);
}
const genBtn = document.getElementById("wfm-video-generate-btn");
const progressBar = document.getElementById("wfm-video-progress-bar");
const progressText = document.getElementById("wfm-video-progress-text");
state.generating = true;
if (genBtn) genBtn.disabled = true;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Converting workflow...";
try {
const apiWorkflow = await comfyWorkflow.convertUiToApi(clone);
const seedValue = Number(document.getElementById("wfm-video-noise-seed")?.value) || 0;
const seedMode = document.getElementById("wfm-video-seed-randomize")?.checked ? "random" : "fixed";
const { images, seed } = await comfyUI.generate(apiWorkflow, {
seedMode,
seedValue,
timeoutMs: 30 * 60 * 1000, // 動画生成は静止画より長時間になりうる
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
});
const seedEl = document.getElementById("wfm-video-noise-seed");
if (seedEl) seedEl.value = seed;
const outputMedia = images.filter((img) => img.type !== "temp");
if (progressText) progressText.textContent = t("videoDoneCount", outputMedia.length);
if (progressBar) progressBar.style.width = "100%";
_renderResult(outputMedia);
await _saveGeneratedVideoMeta(outputMedia, apiWorkflow);
showToast(t("videoGenerateSuccess"), "success");
} catch (err) {
showToast(t("errorWithMsg", err.message), "error");
if (progressText) progressText.textContent = "Error";
} finally {
state.generating = false;
if (genBtn) genBtn.disabled = false;
}
}
// ============================================
// 結果表示
// ============================================
// Sets (or clears) the center preview frame's video and records where it came from —
// `source` is what the Frame/GIF property tabs act on (see state.previewSource above).
// Revokes the previous blob: URL (if any) so picking a series of local files doesn't
// leak memory.
let _previewObjectUrl = null;
function _setPreviewMedia(url, source) {
state.previewSource = source;
const video = document.getElementById("wfm-video-preview-video");
const placeholder = document.getElementById("wfm-video-preview-placeholder");
if (!video || !placeholder) return;
if (_previewObjectUrl) {
URL.revokeObjectURL(_previewObjectUrl);
_previewObjectUrl = null;
}
if (url && url.startsWith("blob:")) _previewObjectUrl = url;
if (url) {
video.src = url;
video.style.display = "block";
placeholder.style.display = "none";
} else {
video.pause();
video.removeAttribute("src");
video.load();
video.style.display = "none";
placeholder.style.display = "";
}
}
function _renderResult(images) {
// MiniMax H3 always emits a single VIDEO output — but guard generically (prefer
// an mp4 if present) in case a future video workflow's SaveVideo/PreviewVideo
// ordering differs.
const media = (images || []).find((img) => img.filename.toLowerCase().endsWith(".mp4")) || images?.[0];
if (!media) {
_setPreviewMedia(null, null);
return;
}
const params = new URLSearchParams({ filename: media.filename, subfolder: media.subfolder || "", type: media.type || "output" });
const src = `${comfyUI.baseUrl}/view?${params}`;
_setPreviewMedia(src, {
kind: media.type === "input" ? "input" : "output",
filename: media.filename,
subfolder: media.subfolder || "",
type: media.type || "output",
});
}
// ============================================
// Gallery連携(生成結果のワークフローをサイドカーメタとして保存)
// ============================================
let _outputDir = "";
async function _fetchOutputDir() {
try {
const res = await fetch("/api/wfm/settings/output-dir");
if (res.ok) {
const data = await res.json();
_outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
}
} catch { /* non-critical */ }
}
async function _saveGeneratedVideoMeta(images, workflow) {
if (!_outputDir) await _fetchOutputDir();
if (!_outputDir) return;
for (const img of images) {
if (img.type !== "output") continue;
const parts = [_outputDir];
if (img.subfolder) parts.push(img.subfolder);
parts.push(img.filename);
const path = parts.join("/");
try {
await fetch("/wfm/gallery/image/meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, workflow }),
});
} catch { /* non-critical */ }
}
}
// ============================================
// i18nの適用(app.jsのapplyI18nToHtml()と同じ「個別要素にt()を適用」方式)
// ============================================
function _applyVideoI18n() {
const setText = (id, key) => {
const el = document.getElementById(id);
if (el) el.textContent = t(key);
};
setText("wfm-video-title-label", "videoTitle");
setText("wfm-video-first-frame-label", "videoFirstFrame");
setText("wfm-video-last-frame-label", "videoLastFrame");
setText("wfm-video-first-frame-drop-label", "videoDropOrClick");
setText("wfm-video-last-frame-drop-label", "videoDropOrClick");
setText("wfm-video-first-frame-clear", "clear");
setText("wfm-video-last-frame-clear", "clear");
setText("wfm-video-prompt-label", "videoPrompt");
setText("wfm-video-aspect-ratio-label", "videoAspectRatio");
setText("wfm-video-megapixels-label", "videoMegapixels");
setText("wfm-video-multiple-label", "videoMultiple");
setText("wfm-video-duration-label", "videoDuration");
setText("wfm-video-noise-seed-label", "videoNoiseSeed");
setText("wfm-video-randomize-label", "videoRandomizeSeed");
setText("wfm-video-generate-btn", "videoGenerate");
setText("wfm-video-edit-placeholder-label", "videoEditPlaceholder");
setText("wfm-video-source-label", "videoSourceLabel");
setText("wfm-video-source-hint", "videoSourceHint");
setText("wfm-video-source-drop-label", "videoSourceDropLabel");
setText("wfm-video-source-clear", "clear");
setText("wfm-video-preview-placeholder", "videoNoVideo");
setText("wfm-video-prop-tab-frame", "videoPropFrame");
setText("wfm-video-prop-tab-gif", "videoPropGif");
setText("wfm-video-frame-hint", "videoFrameHint");
setText("wfm-video-frame-capture-btn", "videoCaptureFrame");
setText("wfm-video-frame-save-btn", "videoSaveToOutput");
setText("wfm-video-gif-start-label", "videoGifStart");
setText("wfm-video-gif-end-label", "videoGifEnd");
setText("wfm-video-gif-fps-label", "videoGifFps");
setText("wfm-video-gif-width-label", "videoGifWidth");
setText("wfm-video-gif-convert-btn", "videoConvertToGif");
}
// ============================================
// Video Source panel — loads any local video into the preview frame, independent of
// generation, so Frame/GIF tools work without running MiniMax H3 first.
// ============================================
function _wireVideoSourcePanel() {
_wireDropZone("wfm-video-source-drop", "wfm-video-source-file", (file) => {
if (!file.type.startsWith("video/")) return;
const statusEl = document.getElementById("wfm-video-source-status");
_setPreviewMedia(URL.createObjectURL(file), { kind: "local", file });
if (statusEl) { statusEl.textContent = file.name; statusEl.style.color = ""; }
});
document.getElementById("wfm-video-source-clear")?.addEventListener("click", () => {
_setPreviewMedia(null, null);
const statusEl = document.getElementById("wfm-video-source-status");
if (statusEl) statusEl.textContent = "";
const fileInput = document.getElementById("wfm-video-source-file");
if (fileInput) fileInput.value = "";
});
}
// ============================================
// Properties panel tabs (Frame / GIF)
// ============================================
function _initPropTabs() {
const tabs = document.querySelectorAll(".wfm-video-prop-tab");
tabs.forEach((btn) => {
btn.addEventListener("click", () => {
tabs.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const target = btn.dataset.propTab;
document.querySelectorAll(".wfm-video-prop-content").forEach((c) => {
c.style.display = c.id === `wfm-video-prop-${target}` ? "" : "none";
});
});
});
}
// ============================================
// Frame tab — captures the preview video's current playback position as a PNG,
// client-side (canvas), and saves it into ComfyUI's own output folder.
// ============================================
let _capturedFrameBlob = null;
function _blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error("Failed to read blob"));
reader.readAsDataURL(blob);
});
}
function _initFrameTab() {
const captureBtn = document.getElementById("wfm-video-frame-capture-btn");
const saveBtn = document.getElementById("wfm-video-frame-save-btn");
const previewWrap = document.getElementById("wfm-video-frame-preview-wrap");
const previewImg = document.getElementById("wfm-video-frame-preview-img");
const statusEl = document.getElementById("wfm-video-frame-status");
captureBtn?.addEventListener("click", () => {
const video = document.getElementById("wfm-video-preview-video");
if (!video || video.style.display === "none" || !video.videoWidth) {
showToast(t("videoNoSourceLoaded"), "error");
return;
}
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
canvas.toBlob((blob) => {
if (!blob) return;
_capturedFrameBlob = blob;
if (previewImg) previewImg.src = URL.createObjectURL(blob);
if (previewWrap) previewWrap.style.display = "";
if (saveBtn) saveBtn.disabled = false;
if (statusEl) statusEl.textContent = "";
}, "image/png");
} catch (err) {
showToast(t("videoFrameCaptureFailed", err.message), "error");
}
});
saveBtn?.addEventListener("click", async () => {
if (!_capturedFrameBlob) return;
saveBtn.disabled = true;
if (statusEl) statusEl.textContent = "Saving...";
try {
const dataUrl = await _blobToDataUrl(_capturedFrameBlob);
const res = await fetch("/api/wfm/video/frame/save-to-output", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: dataUrl }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
if (statusEl) { statusEl.textContent = `✓ ${json.filename}`; statusEl.style.color = "var(--wfm-success)"; }
showToast(t("videoFrameSaved", json.filename), "success");
} catch (err) {
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
showToast(t("errorWithMsg", err.message), "error");
} finally {
saveBtn.disabled = false;
}
});
}
// ============================================
// GIF tab — sends the preview video (uploading it first if it's a local file not yet
// on the server) to the server, which decodes it with PyAV and encodes an animated
// GIF with Pillow (both already required by ComfyUI itself — no ffmpeg dependency).
// ============================================
function _initGifTab() {
const convertBtn = document.getElementById("wfm-video-gif-convert-btn");
const progressBar = document.getElementById("wfm-video-gif-progress-bar");
const progressText = document.getElementById("wfm-video-gif-progress-text");
const previewWrap = document.getElementById("wfm-video-gif-preview-wrap");
const previewImg = document.getElementById("wfm-video-gif-preview-img");
const statusEl = document.getElementById("wfm-video-gif-status");
convertBtn?.addEventListener("click", async () => {
if (!state.previewSource) {
showToast(t("videoNoSourceLoaded"), "error");
return;
}
convertBtn.disabled = true;
if (statusEl) statusEl.textContent = "";
if (progressBar) progressBar.style.width = "20%";
if (progressText) progressText.textContent = "Uploading...";
try {
let ref = state.previewSource;
if (ref.kind === "local") {
const result = await comfyUI.uploadImage(ref.file, ref.file.name);
ref = { kind: "input", filename: result.name, subfolder: result.subfolder || "", type: "input" };
// Cache the uploaded reference so a repeat conversion doesn't re-upload.
state.previewSource = ref;
}
const start = Number(document.getElementById("wfm-video-gif-start")?.value) || 0;
const endRaw = document.getElementById("wfm-video-gif-end")?.value;
const fps = Number(document.getElementById("wfm-video-gif-fps")?.value) || 10;
const widthRaw = document.getElementById("wfm-video-gif-width")?.value;
if (progressBar) progressBar.style.width = "60%";
if (progressText) progressText.textContent = t("videoGifConverting");
const res = await fetch("/api/wfm/video/to-gif", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: ref.filename,
subfolder: ref.subfolder || "",
type: ref.type,
start_time: start,
end_time: endRaw ? Number(endRaw) : null,
fps,
max_width: widthRaw ? Number(widthRaw) : null,
}),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
if (progressBar) progressBar.style.width = "100%";
if (progressText) progressText.textContent = t("videoGifDoneCount", json.frame_count ?? 0);
const params = new URLSearchParams({ filename: json.filename, subfolder: json.subfolder || "", type: "output" });
if (previewImg) previewImg.src = `${comfyUI.baseUrl}/view?${params}`;
if (previewWrap) previewWrap.style.display = "";
if (statusEl) { statusEl.textContent = `✓ ${json.filename}`; statusEl.style.color = "var(--wfm-success)"; }
showToast(t("videoGifSaved", json.filename), "success");
} catch (err) {
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
if (progressText) progressText.textContent = "Error";
showToast(t("errorWithMsg", err.message), "error");
} finally {
convertBtn.disabled = false;
}
});
}
// ============================================
// 初期化
// ============================================
export function initVideoTab() {
_applyVideoI18n();
_populateAspectRatioOptions();
_wireDropZone("wfm-video-first-frame-drop", "wfm-video-first-frame-file", (file) => _handleFrameUpload("first", file));
_wireDropZone("wfm-video-last-frame-drop", "wfm-video-last-frame-file", (file) => _handleFrameUpload("last", file));
document.getElementById("wfm-video-first-frame-clear")?.addEventListener("click", () => _clearFrame("first"));
document.getElementById("wfm-video-last-frame-clear")?.addEventListener("click", () => _clearFrame("last"));
document.getElementById("wfm-video-generate-btn")?.addEventListener("click", runGeneration);
_wireVideoSourcePanel();
_initPropTabs();
_initFrameTab();
_initGifTab();
loadDefaultWorkflow();
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Video helper utilities shared across tabs that deal with generated mp4 output
* (Lab tab's "use generated image for next" chaining, and the Video tab's future
* batch features). Kept dependency-free (no comfyUI/comfyEditor imports) so it can
* be reused from either tab without pulling in unrelated state.
*/
export function isVideoFilename(filename) {
return /\.(mp4|webm|mov)$/i.test(filename || "");
}
// Extracts the last frame of a video at `videoUrl` as a PNG Blob, by seeking a
// detached <video> element to just before its end and drawing that frame onto a
// canvas. Runs entirely client-side (no server round-trip / PyAV dependency).
//
// Seeks to (duration - 0.05s) rather than duration itself: seeking exactly to the
// end is unreliable across browsers (some clamp it back to the last keyframe's
// timestamp, which can fire a "seeked" event before the frame is actually decoded).
// The "seeked" listener also ignores events that don't land near that target time,
// since loading a video can fire an initial seeked-to-0 before the real seek lands.
export function extractLastFrameBlob(videoUrl) {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "auto";
let target = null;
let settled = false;
const cleanup = () => {
video.removeAttribute("src");
video.load();
};
const finish = (fn, arg) => {
if (settled) return;
settled = true;
cleanup();
fn(arg);
};
video.addEventListener("error", () => {
finish(reject, new Error("Failed to load video for last-frame extraction"));
});
video.addEventListener("loadedmetadata", () => {
if (!Number.isFinite(video.duration) || video.duration <= 0) {
finish(reject, new Error("Video has no readable duration"));
return;
}
target = Math.max(0, video.duration - 0.05);
video.currentTime = target;
});
video.addEventListener("seeked", () => {
if (target == null || Math.abs(video.currentTime - target) > 0.5) return;
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
canvas.toBlob((blob) => {
if (blob) finish(resolve, blob);
else finish(reject, new Error("Failed to encode last frame"));
}, "image/png");
} catch (err) {
finish(reject, err);
}
});
video.src = videoUrl;
});
}
+27 -35
View File
@@ -7,6 +7,7 @@
import { showToast, openModal, closeModal } from "./app.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
import { loadWorkflowIntoEditor } from "./generate-tab.js";
import { loadWorkflowIntoVideoEditor } from "./video-tab.js";
import { t, getSummaryPrompt } from "./i18n.js";
import { highlightJSON } from "./json-highlight.js";
import { openBadgeEditModal } from "./models-tab.js";
@@ -46,7 +47,7 @@ function saveBadgePalette(palette) {
// Open in ComfyUI / Send to Canvas
// ============================================
function sendToCanvas(workflowData) {
function sendToCanvas(workflowData, filename) {
try {
// window.opener経由でComfyUIキャンバスに直接ロード(推奨)
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
@@ -54,14 +55,13 @@ function sendToCanvas(workflowData) {
showToast(t("workflowSentToCanvasDirect"), "success");
return;
}
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
const fmt = comfyWorkflow.detectFormat(workflowData);
if (fmt === "api") {
showToast(t("apiFormatCanvasNoOpener"), "error");
return;
}
localStorage.setItem("wfm_pending_workflow", JSON.stringify(workflowData));
showToast(t("workflowSentToCanvas"), "success");
// フォールバック: Workflow Studio LibraryのWorkflows(W)タブを当該ワークフローで
// 絞り込み表示する。以前はUI形式限定で「タイトルをドラッグ」させていたが、
// サムネイルが見えず分かりにくい上、API形式のワークフローはそもそも非対応だった。
// 絞り込み表示なら実物のワークフローカード(サムネイル付き)が出るので、
// フォーマットを問わずユーザー自身がそこからドラッグできる。
localStorage.setItem("wfm_library_filter_request", filename || "");
showToast(t("workflowFilteredInLibrary"), "success");
} catch (err) {
showToast(t("errorWithMsg", err.message), "error");
}
@@ -616,6 +616,8 @@ async function showSidePanel(wf, cardEl) {
// Enable toolbar load buttons
const listLoadBtn = document.getElementById("wfm-list-load-btn");
if (listLoadBtn) { listLoadBtn.disabled = false; listLoadBtn.title = ""; }
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
if (listLoadVideoBtn) { listLoadVideoBtn.disabled = false; listLoadVideoBtn.title = ""; }
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = false; listOpenComfyBtn.title = ""; }
@@ -661,6 +663,8 @@ function closeSidePanel() {
if (titleEl) titleEl.textContent = "";
const listLoadBtn = document.getElementById("wfm-list-load-btn");
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
if (listLoadVideoBtn) { listLoadVideoBtn.disabled = true; listLoadVideoBtn.title = t("selectCardFirst"); }
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
renderSideGroup(null);
@@ -957,8 +961,6 @@ function openDetailModal(wf) {
<textarea class="wfm-textarea" id="wfm-detail-memo" rows="4" placeholder="${t("memoPlaceholder")}">${meta.memo || ""}</textarea>
</section>
<div class="wfm-modal-actions">
<button class="wfm-btn wfm-btn-primary" id="wfm-detail-load">${t("loadInGenerate")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-open-comfyui">${t("sendToCanvas")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-set-default">${t("setAsDefault")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-change-thumb">${t("changeThumbnail")}</button>
<input type="file" id="wfm-detail-thumb-file" accept="image/png,image/webp,image/jpeg" style="display:none">
@@ -1095,29 +1097,6 @@ function openDetailModal(wf) {
}
});
// Load in GenerateUI
document.getElementById("wfm-detail-load").addEventListener("click", async () => {
try {
const wfData = await getRawWorkflow(wf.filename);
const loaded = await loadWorkflowIntoEditor(wfData, wf.filename);
if (loaded === false) return;
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
closeModal();
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
});
// Send to Canvas
document.getElementById("wfm-detail-open-comfyui")?.addEventListener("click", async () => {
try {
const wfData = await getRawWorkflow(wf.filename);
sendToCanvas(wfData);
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
});
// Set as default workflow
document.getElementById("wfm-detail-set-default")?.addEventListener("click", async () => {
try {
@@ -1399,12 +1378,25 @@ export function initWorkflowTab() {
}
});
// Toolbar: Load in Video button (MiniMax H3専用。非対応ワークフローはloadWorkflowIntoVideoEditor内でエラートースト表示済み)
document.getElementById("wfm-list-load-video-btn")?.addEventListener("click", async () => {
if (!state.selectedWf) return;
try {
const wfData = await getRawWorkflow(state.selectedWf.filename);
const loaded = await loadWorkflowIntoVideoEditor(wfData, state.selectedWf.filename);
if (loaded === false) return;
document.querySelector('.wfm-tab[data-tab="video"]')?.click();
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
});
// Toolbar: Send to Canvas button
document.getElementById("wfm-list-open-comfyui-btn")?.addEventListener("click", async () => {
if (!state.selectedWf) return;
try {
const wfData = await getRawWorkflow(state.selectedWf.filename);
sendToCanvas(wfData);
sendToCanvas(wfData, state.selectedWf.filename);
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
+504 -96
View File
@@ -10,6 +10,7 @@
<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">
<link rel="stylesheet" href="/wfm_static/css/video-tab.css">
</head>
<body>
<!-- Header -->
@@ -23,9 +24,9 @@
<button class="wfm-tab" data-tab="models">Models</button>
<button class="wfm-tab" data-tab="generate">GenerateUI</button>
<button class="wfm-tab" data-tab="prompt">Prompt</button>
<button class="wfm-tab" data-tab="metadata">Metadata</button>
<button class="wfm-tab" data-tab="gallery">Gallery</button>
<button class="wfm-tab" data-tab="image-edit">Image Edit</button>
<button class="wfm-tab" data-tab="video">Video</button>
<button class="wfm-tab" data-tab="tagger">Tagger</button>
<button class="wfm-tab" data-tab="settings">Settings</button>
<button class="wfm-tab" data-tab="help">Help</button>
@@ -55,6 +56,7 @@
<option value="">All Groups</option>
</select>
<button id="wfm-list-load-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled title="Select a card first">Load in GenerateUI</button>
<button id="wfm-list-load-video-btn" class="wfm-btn wfm-btn-sm" disabled title="Select a card first">Load in Video</button>
<button id="wfm-list-open-comfyui-btn" class="wfm-btn wfm-btn-sm" disabled title="Select a card first">Send to Canvas</button>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-view="thumb" title="Thumbnail">&#8862;</button>
@@ -350,7 +352,8 @@
</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-bypass-btn" id="wfm-lora-bypass-toggle" title="Bypass LoRA node (force strength to 0)">⛔</button>
<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">
@@ -556,30 +559,32 @@
<!-- Center: 4 keyframe columns -->
<div class="wfm-lab-pane wfm-lab-center">
<div class="wfm-lab-columns">
<div class="wfm-lab-column" data-col="checkpoint">
<div class="wfm-lab-column" data-col="model">
<div class="wfm-lab-col-header">
<span>checkpoint</span>
<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="checkpoint" title="Add keyframe">+</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="checkpoint" title="Remove last keyframe">-</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-up" data-col="model" title="Move selected keyframe up">▲</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-down" data-col="model" title="Move selected keyframe down">▼</button>
<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-checkpoint"></div>
</div>
<div class="wfm-lab-column" data-col="vae">
<div class="wfm-lab-col-header">
<span>vae</span>
<div class="wfm-lab-col-btns">
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="vae" title="Add keyframe">+</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="vae" title="Remove last keyframe">-</button>
</div>
</div>
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-vae"></div>
<div 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-up" data-col="prompt" title="Move selected keyframe up">▲</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-down" data-col="prompt" title="Move selected keyframe down">▼</button>
<button type="button" id="wfm-lab-prompt-group-btn" class="wfm-btn wfm-btn-xs" title="Apply Prompt Group">PG</button>
<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>
@@ -590,6 +595,8 @@
<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-up" data-col="ksampler" title="Move selected keyframe up">▲</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-down" data-col="ksampler" title="Move selected keyframe down">▼</button>
<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>
@@ -901,6 +908,7 @@
<!-- Result Display -->
<div class="wfm-gen-result">
<img id="wfm-gen-result-img" style="display:none;max-width:100%;border-radius:6px;">
<video id="wfm-gen-result-video" controls style="display:none;max-width:100%;border-radius:6px;"></video>
<div id="wfm-gen-result-thumbs" class="wfm-gen-thumbs"></div>
</div>
</div>
@@ -930,6 +938,13 @@
<!-- Prompt Tab -->
<section id="wfm-tab-prompt" class="wfm-tab-content">
<div class="wfm-prompt-toplevel-nav">
<button id="wfm-prompt-toplevel-form" class="wfm-prompt-toplevel-tab active" data-prompt-toplevel="form">Form</button>
<button id="wfm-prompt-toplevel-table" class="wfm-prompt-toplevel-tab" data-prompt-toplevel="table">Table</button>
</div>
<!-- Form パネル -->
<div id="wfm-prompt-panel-form" class="wfm-prompt-panel active">
<div class="wfm-prompt-split wfm-prompt-split-3col">
<!-- Col 1: AI Assistant -->
<div class="wfm-prompt-split-col">
@@ -1155,76 +1170,144 @@
<!-- /Style タブ -->
</div>
</div>
</section>
</div>
<!-- /Form パネル -->
<!-- Metadata Tab -->
<section id="wfm-tab-metadata" class="wfm-tab-content">
<div class="wfm-metadata-layout">
<!-- Col1: Drop zone + preview -->
<div class="wfm-metadata-col1">
<div class="wfm-metadata-drop-zone" id="wfm-meta-drop">
<div class="wfm-meta-drop-label" id="wfm-meta-drop-label">
<div class="wfm-meta-drop-main">Drop image / JSON</div>
<div class="wfm-meta-drop-sub">PNG · WebP · JSON</div>
</div>
<img id="wfm-meta-preview-img" class="wfm-meta-preview-img" style="display:none;" alt="preview">
</div>
<div class="wfm-meta-file-info" id="wfm-meta-file-info"></div>
<div class="wfm-meta-format-note" id="wfm-meta-format-note">
<div class="wfm-meta-format-note-title" id="wfm-meta-format-note-title">Supported formats</div>
<ul class="wfm-meta-format-list">
<li id="wfm-meta-fmt-comfyui">ComfyUI — PNG / WebP / JSON</li>
<li id="wfm-meta-fmt-sdwebui">SD WebUI / SD Forge — PNG (parameters)</li>
<li id="wfm-meta-fmt-fooocus">Fooocus — PNG (fooocus_scheme)</li>
</ul>
<div class="wfm-meta-format-todo" id="wfm-meta-format-todo">Flux.2 / Qwen-Image / Z-Image subgraph workflows supported</div>
</div>
<input type="file" id="wfm-meta-file-input" accept="image/png,image/webp,.json,application/json" style="display:none;">
<!-- Table パネル -->
<div id="wfm-prompt-panel-table" class="wfm-prompt-panel">
<div class="wfm-prompt-table-subnav">
<button class="wfm-prompt-table-subtab active" data-ptable-subtab="presets">Presets</button>
<button class="wfm-prompt-table-subtab" data-ptable-subtab="presetsGroup">Presets Group</button>
<button class="wfm-prompt-table-subtab" data-ptable-subtab="wildcards">Wildcards</button>
<button class="wfm-prompt-table-subtab" data-ptable-subtab="style">Style</button>
</div>
<!-- Col2: Model info -->
<div class="wfm-metadata-col2">
<div class="wfm-meta-section" id="wfm-meta-ckpt-section">
<div class="wfm-meta-section-title">Checkpoint</div>
<div class="wfm-meta-list" id="wfm-meta-ckpt-list"></div>
<!-- Presets テーブル -->
<div id="wfm-ptable-presets" class="wfm-prompt-table-pane active">
<div class="wfm-prompt-table-toolbar">
<div class="wfm-search-wrap" style="flex:1;max-width:280px;">
<input type="text" id="wfm-ptable-presets-search" class="wfm-search-input" placeholder="Search presets...">
<button class="wfm-search-clear-btn" id="wfm-ptable-presets-search-clear-btn" title="Clear search">✕</button>
</div>
<button id="wfm-ptable-presets-sort-fav-btn" class="wfm-btn wfm-btn-sm" title="Sort favorites to top">★</button>
<button id="wfm-ptable-presets-sort-batch-btn" class="wfm-btn wfm-btn-sm" title="Sort batch-selected to top">B</button>
<span id="wfm-ptable-presets-count" class="wfm-nodes-count"></span>
<span style="flex:1;"></span>
<button id="wfm-ptable-presets-copy-pos-btn" class="wfm-btn wfm-btn-sm" title="Copy selected preset's positive prompt">PP Copy</button>
<button id="wfm-ptable-presets-copy-neg-btn" class="wfm-btn wfm-btn-sm" title="Copy selected preset's negative prompt">NP Copy</button>
<select id="wfm-ptable-presets-group-select" class="wfm-select" style="font-size:12px;padding:3px 6px;width:160px;flex:0 0 auto;">
<option value="">Select group...</option>
</select>
<button id="wfm-ptable-presets-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
<button id="wfm-ptable-presets-batch-clear-btn" class="wfm-btn wfm-btn-sm" title="Clear all batch presets">BC</button>
<button id="wfm-ptable-presets-deselect-btn" class="wfm-btn wfm-btn-sm" disabled title="Clear row selection">Deselect</button>
<button id="wfm-ptable-presets-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" disabled>Delete</button>
<button id="wfm-ptable-presets-add-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New Preset</button>
</div>
<div class="wfm-meta-section" id="wfm-meta-vae-section">
<div class="wfm-meta-section-title">VAE</div>
<div class="wfm-meta-list" id="wfm-meta-vae-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-diff-section">
<div class="wfm-meta-section-title">Diffusion Model</div>
<div class="wfm-meta-list" id="wfm-meta-diff-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-te-section">
<div class="wfm-meta-section-title">Text Encoder</div>
<div class="wfm-meta-list" id="wfm-meta-te-list"></div>
<div class="wfm-prompt-table-scroll">
<table class="wfm-table wfm-prompt-edit-table">
<thead>
<tr>
<th style="width:56px;">#</th>
<th style="width:28px;"></th>
<th style="width:160px;">Name</th>
<th style="width:110px;">Category</th>
<th>Positive</th>
<th>Negative</th>
<th style="width:28px;">B</th>
</tr>
</thead>
<tbody id="wfm-ptable-presets-tbody"></tbody>
</table>
</div>
</div>
<!-- Col3: LoRA + Prompt -->
<div class="wfm-metadata-col3">
<div class="wfm-meta-section" id="wfm-meta-lora-section">
<div class="wfm-meta-section-title">LoRA</div>
<div class="wfm-meta-list" id="wfm-meta-lora-list"></div>
</div>
<div class="wfm-meta-section wfm-meta-prompt-section" id="wfm-meta-prompt-section">
<div class="wfm-meta-section-title">Prompt</div>
<div class="wfm-meta-list" id="wfm-meta-prompt-list"></div>
<div class="wfm-meta-prompt-full-wrap">
<div class="wfm-meta-prompt-full-label" id="wfm-meta-prompt-full-label"></div>
<textarea class="wfm-meta-prompt-full" id="wfm-meta-prompt-full" readonly placeholder="Select a prompt above to view full text..."></textarea>
<div class="wfm-meta-prompt-actions">
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-copy-btn">Copy</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-genui-pos-btn">GenUI:P</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-genui-neg-btn">GenUI:N</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-preset-pos-btn">Prompt:P</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-preset-neg-btn">Prompt:N</button>
</div>
<!-- Presets Group テーブル -->
<div id="wfm-ptable-presetsGroup" class="wfm-prompt-table-pane">
<div class="wfm-prompt-table-toolbar">
<div class="wfm-search-wrap" style="flex:1;max-width:280px;">
<input type="text" id="wfm-ptable-presetsgroup-search" class="wfm-search-input" placeholder="Search groups...">
<button class="wfm-search-clear-btn" id="wfm-ptable-presetsgroup-search-clear-btn" title="Clear search">✕</button>
</div>
<span id="wfm-ptable-presetsgroup-count" class="wfm-nodes-count"></span>
<span style="flex:1;"></span>
<button id="wfm-ptable-presetsgroup-deselect-btn" class="wfm-btn wfm-btn-sm" disabled title="Clear row selection">Deselect</button>
<button id="wfm-ptable-presetsgroup-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" disabled>Delete</button>
<button id="wfm-ptable-presetsgroup-add-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New Group</button>
</div>
<div class="wfm-prompt-table-scroll">
<table class="wfm-table wfm-prompt-edit-table">
<thead>
<tr>
<th style="width:56px;">#</th>
<th style="width:160px;">Group Name</th>
<th>Presets</th>
</tr>
</thead>
<tbody id="wfm-ptable-presetsgroup-tbody"></tbody>
</table>
</div>
</div>
<!-- Wildcards テーブル -->
<div id="wfm-ptable-wildcards" class="wfm-prompt-table-pane">
<div class="wfm-prompt-table-toolbar">
<div class="wfm-search-wrap" style="flex:1;max-width:280px;">
<input type="text" id="wfm-ptable-wildcards-search" class="wfm-search-input" placeholder="Search wildcard files...">
<button class="wfm-search-clear-btn" id="wfm-ptable-wildcards-search-clear-btn" title="Clear search">✕</button>
</div>
<span id="wfm-ptable-wildcards-count" class="wfm-nodes-count"></span>
<span style="flex:1;"></span>
<button id="wfm-ptable-wildcards-deselect-btn" class="wfm-btn wfm-btn-sm" disabled title="Clear row selection">Deselect</button>
<button id="wfm-ptable-wildcards-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" disabled>Delete</button>
<button id="wfm-ptable-wildcards-add-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New File</button>
</div>
<div class="wfm-prompt-table-scroll">
<table class="wfm-table wfm-prompt-edit-table">
<thead>
<tr>
<th style="width:56px;">#</th>
<th style="width:220px;">File (folder/name)</th>
<th style="width:70px;">Ext</th>
<th>Content (one item per line, or YAML)</th>
</tr>
</thead>
<tbody id="wfm-ptable-wildcards-tbody"></tbody>
</table>
</div>
</div>
<!-- Style テーブル -->
<div id="wfm-ptable-style" class="wfm-prompt-table-pane">
<div class="wfm-prompt-table-toolbar">
<div class="wfm-search-wrap" style="flex:1;max-width:280px;">
<input type="text" id="wfm-ptable-style-search" class="wfm-search-input" placeholder="Search styles...">
<button class="wfm-search-clear-btn" id="wfm-ptable-style-search-clear-btn" title="Clear search">✕</button>
</div>
<span id="wfm-ptable-style-count" class="wfm-nodes-count"></span>
<span style="flex:1;"></span>
<button id="wfm-ptable-style-deselect-btn" class="wfm-btn wfm-btn-sm" disabled title="Clear row selection">Deselect</button>
<button id="wfm-ptable-style-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" disabled>Delete</button>
<button id="wfm-ptable-style-addfile-btn" class="wfm-btn wfm-btn-sm" disabled title="Select a style whose file is not custom.json to add a new style to that same file">+ Add to this file</button>
<button id="wfm-ptable-style-add-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New Style</button>
</div>
<div class="wfm-prompt-table-scroll">
<table class="wfm-table wfm-prompt-edit-table">
<thead>
<tr>
<th style="width:56px;">#</th>
<th style="width:160px;">Name</th>
<th style="width:110px;">File</th>
<th>Positive</th>
<th>Negative</th>
</tr>
</thead>
<tbody id="wfm-ptable-style-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- /Table パネル -->
</section>
<!-- Settings Tab -->
@@ -1257,6 +1340,7 @@
<button class="wfm-help-nav-item" data-help-page="aitool">AI TOOL Tab</button>
<button class="wfm-help-nav-item" data-help-page="tagger">Tagger Tab</button>
<button class="wfm-help-nav-item" data-help-page="imageedit">Image Edit Tab</button>
<button class="wfm-help-nav-item" data-help-page="video">Video Tab</button>
<button class="wfm-help-nav-item" data-help-page="shortcuts">Keyboard Shortcuts</button>
<button class="wfm-help-nav-item" data-help-page="trouble">Troubleshooting</button>
</div>
@@ -1330,9 +1414,11 @@
<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>
<li id="wfm-help-gen-30">The <b>Save</b> button (right end of the subtab bar) is hidden while the <b>Batch</b> or <b>Lab</b> subtab is active, since those work on workflow copies / their own Plan files rather than the currently loaded workflow; it reappears on Input/Model/Settings/Feeder</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1370,6 +1456,7 @@
<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>
<li id="wfm-help-gen-29">Bypass button (⛔, in the LoRA column header, next to Refresh Stack group): forces the target LoRA node's strength to 0 — strength_model/strength_clip for a standard LoraLoader, or every entry's strength/clipStrength for a Lora Manager node (its LoRA list is kept intact, not replaced) — without touching the actual ComfyUI node's mode; click again to restore the exact values that were there before toggling; targets whichever node Single/Stack Apply default to (the workflow's Lora Manager node if it has one, else the first LoRA node); clicking a Single or Stack Apply button clears the bypass state so it can't silently reassert itself on a later toggle</li>
</ul>
</div>
</div>
@@ -1426,14 +1513,20 @@
<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</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 Checkpoint/VAE/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-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 — Checkpoint, VAE, Prompt, and KSampler — instead of a blank placeholder, 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-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 independent keyframe list — Checkpoint, LoRA (Single-only, no Stack support here), or VAE — the shared cell list, its +/-, and its edit modal all currently operate on. Each of the three is its own timeline with its own keyframes, exactly like Prompt/KSampler; they only ever look merged because they share one column's worth of screen space. Adding, removing, or editing a keyframe while viewing one never touches the other two. 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">Checkpoint / LoRA / VAE each carry forward independently, the same effective-value rule as every other column (see above): a keyframe in one never resets the other two to the workflow default. A field left blank in the edit modal shows "(inherits: ...)" as its placeholder instead of "(workflow default)" when an earlier keyframe in that same list already set something, so it's clear what actually applies. "Revert to #1's setting" and Bypass — like +/- — only ever affect whichever ONE of the three is currently selected by the C/L/V toggle, never the other two</li>
<li id="wfm-help-lab-16">Bypass LoRA node checkbox (LoRA keyframe's own edit modal only): forces that keyframe's strength_model/strength_clip to 0 at apply-time, standing in for ComfyUI's own node Bypass (mode:4) — the workflow used here is already converted to API format, which has no mode concept, so zeroing strength is the behavioral equivalent. Disables the strength fields while checked; independent of the row-level Bypass checkbox above, which skips the whole row instead of touching the node</li>
<li id="wfm-help-lab-17">Apply LoRA syntax + trigger words to Positive prompt checkbox (LoRA column only): when checked, appends &lt;lora:name:strengthModel:strengthClip&gt; plus that LoRA's cached CivitAI trigger words to the Positive prompt, comma-separated, at apply-time — the same format GenerateUI's own Model tab Apply button writes. Switching keyframes (a different LoRA, this checkbox turned off, or the row Bypassed) always strips exactly what the previous iteration injected before adding anything new, so a batch never accumulates every LoRA it has used. Disabled automatically while Bypass LoRA node is checked</li>
<li id="wfm-help-lab-18">A single click on a keyframe cell selects it (blue outline) instead of opening it — double-click to open the edit modal as before. Each column's header (Model / Prompt / Ksampler) also has <b>&#9650; / &#9660;</b> buttons that swap the selected keyframe with its immediate neighbor to reorder them without retyping anything; keyframe #1 (the baseline) can never move, and nothing can be swapped into its slot</li>
<li id="wfm-help-lab-19">Prompt column header's <b>PG</b> button: pick one of the Prompt tab's preset groups (Table view's Presets Group, the same groups used by the Batch tab's checklist) and replace the entire Prompt column with one keyframe per preset in that group's order, starting at #1 (#1 is overwritten, not appended after). If the group has more presets than the current Batch count, Batch count is raised automatically to fit (capped at 50, the Batch input's own limit); anything still beyond that shows the usual "keyframes beyond Batch count" warning instead of being silently dropped</li>
</ul>
</div>
@@ -1469,6 +1562,12 @@
<h2 id="wfm-help-prompt-title">Prompt Tab</h2>
<button type="button" class="wfm-btn wfm-btn-sm wfm-help-jump-btn" data-jump-tab="prompt">Open tab &rarr;</button>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-prompt-card-toplevel-title">Form / Table</h3>
<ul>
<li id="wfm-help-prompt-15">Two top-level tabs: <b>Form</b> is the original one-item-at-a-time editor described below (AI Assistant / Presets / Wildcard / Style panes); <b>Table</b> shows the same underlying data all at once as editable spreadsheets — useful once there are more than a handful of presets, wildcard files, or styles to manage</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-prompt-card-ai-title">AI Assistant &amp; Translation</h3>
<ul>
@@ -1503,18 +1602,35 @@
<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 class="wfm-help-card">
<h3 id="wfm-help-prompt-card-table-title">Table View</h3>
<ul>
<li id="wfm-help-prompt-16">The Table tab has four sub-tabs of its own: <b>Presets</b>, <b>Presets Group</b>, <b>Wildcards</b>, and <b>Style</b> — each an editable table built from the exact same data as the Form tab, so changes on either side stay in sync</li>
<li id="wfm-help-prompt-17">Every table shares a left-most auto-numbered <b>#</b> column: click a number to select that row (highlighted red) — multiple rows can be selected at once; the toolbar's <b>Delete</b> button removes every selected row in one confirm, and <b>Deselect</b> clears the selection. This replaced per-row delete buttons, which were too easy to misclick</li>
<li id="wfm-help-prompt-18"><b>Double-click</b> a row's number to open a larger prompt-editing modal instead of the small in-table textarea. Presets and Style show <b>Positive</b>/<b>Negative</b> toggle buttons above one shared textarea (switching preserves whatever you typed on the other side); Wildcards shows just the file's content. <b>Save</b> writes the change back and closes the modal; <b>Close</b> discards it</li>
<li id="wfm-help-prompt-19">The toolbar's <b>+ New ...</b> button adds a blank row at the top with small &#10003; (Add) / &#10005; (Cancel) icons in place of its number; fill in the fields and click &#10003; to create it, or &#10005; to discard the row without saving</li>
<li id="wfm-help-prompt-20">Presets table — the <b>&#9733;</b> (favorite) and <b>B</b> (Batch group) columns work like toggle switches: click to turn them gold/yellow, click again to clear; the toolbar's <b>BC</b> button clears every preset out of the Batch group at once, same as the Form tab's Preset Manager</li>
<li id="wfm-help-prompt-21">Presets table toolbar — <b>PP Copy</b> / <b>NP Copy</b> copy the single selected row's positive/negative prompt to the clipboard; the <b>group select + Add to Group</b> adds every currently selected preset to the chosen group in one click. Doing the group assignment from here (search/select presets, then pick from a short group list) replaced the old per-group preset picker, which became unusable once there were many presets</li>
<li id="wfm-help-prompt-22">Presets Group table — create, rename, and delete groups; each group's members are shown as removable badges (&#10005;). The reserved <b>Batch</b> group is not listed here — it's managed entirely through the Presets table's B column and BC button instead</li>
<li id="wfm-help-prompt-23">Wildcards table — same files as the Form tab's Wildcard file manager; editing the <b>File</b> path or the <b>Ext</b> dropdown renames the file (saves the content under the new name, then removes the old file)</li>
<li id="wfm-help-prompt-24">Style table — same styles as the Form tab's Style manager, editable inline the same way as Presets</li>
<li id="wfm-help-prompt-25">Presets table toolbar — the ★ and B sort buttons (next to the search box) bring favorited / batch-selected presets to the top of the list; both can be enabled together (★ takes priority, B breaks ties), and everything else keeps its original relative order</li>
<li id="wfm-help-prompt-26">Style table toolbar — select exactly one row whose <b>File</b> badge isn't <code>custom.json</code> to enable <b>+ Add to this file</b>: it adds a blank row targeting that same file (shown as a badge in the File column even before saving) — the same capability as the Form tab's Style manager (see above); custom.json entries and multi-row selections leave the button disabled, use the regular <b>+ New Style</b> button for those instead</li>
</ul>
</div>
</div>
<div class="wfm-help-page" id="wfm-help-page-metadata">
<div class="wfm-help-article-header">
<h2 id="wfm-help-metadata-title">Metadata Tab</h2>
<button type="button" class="wfm-btn wfm-btn-sm wfm-help-jump-btn" data-jump-tab="metadata">Open tab &rarr;</button>
<button type="button" class="wfm-btn wfm-btn-sm wfm-help-jump-btn" data-jump-tab="gallery" data-jump-subtab="metadata">Open tab &rarr;</button>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-metadata-card-overview-title">Overview</h3>
<p id="wfm-help-metadata-desc">Drop a ComfyUI-generated PNG/WebP or workflow JSON to instantly extract and view the embedded model and prompt information.</p>
<ul>
<li id="wfm-help-metadata-1">Left column: drop zone (image preview for PNG/WebP) and file info; click or drop to load</li>
<li id="wfm-help-metadata-6">This is now a sub-tab inside the <b>Gallery</b> tab (to the right of Style_Catalog), rather than its own top-level tab — everything else about it works exactly the same</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1528,7 +1644,7 @@
<div class="wfm-help-card">
<h3 id="wfm-help-metadata-card-formats-title">Supported Formats</h3>
<ul>
<li id="wfm-help-metadata-5">Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows), SD WebUI, SD Forge, Fooocus. Model extraction covers UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, and QuadrupleCLIPLoader (e.g. HiDream 4-CLIP).</li>
<li id="wfm-help-metadata-5">Supported: ComfyUI PNG/WebP/MP4/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows; MP4 reads the workflow/prompt container tags written by SaveVideo, including all-in-one video nodes like MiniMax H3 ImageToVideo), SD WebUI, SD Forge, Fooocus. Model extraction covers UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, and QuadrupleCLIPLoader (e.g. HiDream 4-CLIP).</li>
</ul>
</div>
</div>
@@ -1567,12 +1683,15 @@
<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">
<h3 id="wfm-help-settings-card-misc-title">Data &amp; Misc</h3>
<ul>
<li id="wfm-help-settings-8">Data Management: export all plugin data to JSON / import to restore</li>
<li id="wfm-help-settings-8">Data Management: export core settings/metadata/prompts to JSON / import to restore</li>
<li id="wfm-help-settings-15">Full Backup (ZIP): unlike the JSON export above, also includes folder-based data (AI Skills, Lab Plans, Style presets) and the Tagger database; Restore overwrites existing files — restart ComfyUI afterward</li>
<li id="wfm-help-settings-16">Two opt-in checkboxes above the Full Backup buttons additionally include ComfyUI's default workflows folder and the Impact Pack's wildcard folder in the export — both export-only, never restored automatically; copy the ZIP's _external/ folder back by hand if you want them</li>
<li id="wfm-help-settings-13">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</li>
</ul>
</div>
@@ -1619,6 +1738,7 @@
<li id="wfm-help-gallery-2">3-column layout: folder tree (left) | image grid (center) | detail panel (right)</li>
<li id="wfm-help-gallery-3">Thumbnail and Table view modes; double-click any image to open fullscreen lightbox</li>
<li id="wfm-help-gallery-4">Toolbar filters: favorites (★), text search (filename / tag / prompt), tag filter, group filter, sort by date/name</li>
<li id="wfm-help-gallery-26">Four sub-tabs at the top of the Gallery tab: <b>Output</b> (this browser), <b>ImagePrompt</b>, <b>Style_Catalog</b>, and <b>Metadata</b> — the last one embeds the full Metadata Tab (see its own help page) so you can drop an image there to inspect its embedded model/prompt data without leaving Gallery</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1650,7 +1770,7 @@
<div class="wfm-help-card">
<h3 id="wfm-help-gallery-card-detail-title">Detail Panel</h3>
<ul>
<li id="wfm-help-gallery-7">Detail panel — Info tab: filename, size, date, tags (add/remove), memo; <b>download icon</b> (on image hover) to download the single image; "Move To..." moves the image to another folder, "Delete File" removes it permanently</li>
<li id="wfm-help-gallery-7">Detail panel — Info tab: filename, size, dimensions (image width×height) or video duration, date, tags (add/remove), memo; <b>download icon</b> (on image hover) to download the single image; "Move To..." moves the image to another folder, "Delete File" removes it permanently</li>
<li id="wfm-help-gallery-8">Detail panel — JSON tab: displays workflow JSON from PNG embedded data (prompt / workflow keys) or from metadata saved by the Generate UI tab; 'Copy &amp; Send Canvas' button copies to clipboard and stores the workflow for title-drag to canvas</li>
<li id="wfm-help-gallery-9">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image; the <b>__Feeder__</b> group is reserved (shown with 🔒) and cannot be renamed or deleted — rename and delete buttons are disabled when it is selected</li>
</ul>
@@ -1663,13 +1783,14 @@
<li id="wfm-help-gallery-14"><b>Tagger</b> button (purple, in the detail panel action row): opens the selected image directly in the Tagger tab's Single mode for instant tagging</li>
<li id="wfm-help-gallery-16"><b>Image Edit</b> 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</li>
<li id="wfm-help-gallery-17"><b>Send GenUI Image</b> 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</li>
<li id="wfm-help-gallery-27"><b>Send to LI node</b> button (toolbar, next to Send GenUI Image): uploads the selected image and writes its filename into the image widget of the currently selected node on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination as Image Edit tab's Send to LI node; requires Workflow Studio to have been opened from ComfyUI's top menu button</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-gallery-card-formats-title">Formats &amp; Auto-save</h3>
<ul>
<li id="wfm-help-gallery-10">Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the JSON tab</li>
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF; workflow extraction reads ComfyUI prompt and workflow keys embedded in PNG metadata</li>
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF, MP4; workflow extraction reads ComfyUI prompt/workflow keys embedded in PNG metadata or in MP4 container tags (as written by SaveVideo, e.g. MiniMax H3 output)</li>
</ul>
</div>
</div>
@@ -1746,7 +1867,7 @@
<div class="wfm-help-card">
<h3 id="wfm-help-sidepanel-card-info-title">Information (I) tab</h3>
<ul>
<li id="wfm-help-sidepanel-13">I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF and QuadrupleCLIPLoader node types</li>
<li id="wfm-help-sidepanel-13">I tab (Information): drop a ComfyUI PNG/WebP/MP4/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF and QuadrupleCLIPLoader node types; MP4 reads the workflow/prompt tags embedded by SaveVideo, including all-in-one video nodes like MiniMax H3 ImageToVideo</li>
<li id="wfm-help-sidepanel-14">Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center</li>
<li id="wfm-help-sidepanel-15">Information tab — LoRA sub-tab: drag individual LoRA to place LoraLoader; "Multiple LORA" section at bottom places all LoRAs in a single Lora Loader (LoraManager) node; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy</li>
</ul>
@@ -1754,7 +1875,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">
@@ -1777,11 +1898,11 @@
</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>
<p id="wfm-help-ai-3">Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image ("generate a cat picture", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the "Allow image generation" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)</p>
<p id="wfm-help-ai-3">Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image ("generate a cat picture", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the "Allow image generation" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; generate_image also accepts optional generation parameters the model can fill in from the request &mdash; steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size (number of images produced in that one call), and aspect_ratio (a ratio preset like "3:2", applied only if the workflow has a Resolution Selector node feeding width/height &mdash; ignored otherwise); anything the model doesn't supply keeps whatever the loaded workflow already has set; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)</p>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-ai-card-svg-title">Chat pane — SVG generation</h3>
@@ -1801,7 +1922,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>
@@ -1916,6 +2037,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">
@@ -1948,7 +2070,7 @@
<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-19b">Send to Workflow: uploads the composite PNG to ComfyUI's input folder via /upload/image and 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>
@@ -1969,6 +2091,23 @@
</div>
</div>
<div class="wfm-help-page" id="wfm-help-page-video">
<h2 id="wfm-help-video-title">Video Tab</h2>
<p id="wfm-help-video-desc">A dedicated video generation tab for the MiniMax H3 (Image to Video) workflow, independent of the GenerateUI tab.</p>
<div class="wfm-help-card">
<ul>
<li id="wfm-help-video-1">Set first_frame (optional) / last_frame (optional) images, prompt, aspect_ratio / megapixels / multiple (resolution), duration (seconds), and noise_seed, then click Generate. Leaving first_frame empty runs the workflow text-only (bypassed), and workflows with no LoadImage node at all are also supported.</li>
<li id="wfm-help-video-2">Model selection (UNet / CLIP / VAE / Audio VAE) is fixed to the loaded workflow's defaults and cannot be changed from this tab.</li>
<li id="wfm-help-video-3">Use the "Load in Video" button in the Workflow tab's detail modal to load a workflow containing a MiniMax H3 Image-to-Video node. Unsupported workflows show an error instead.</li>
<li id="wfm-help-video-4">Generated videos (mp4) can also be browsed and played back in the Gallery tab.</li>
<li id="wfm-help-video-5">The Video Source panel next to the preview lets you drop or pick any local video (not just a generated one) to use the Frame/GIF tools below without running MiniMax H3 first; Clear resets it back to empty.</li>
<li id="wfm-help-video-6">Frame tab (right panel): play/seek the preview video to the position you want, click Capture Frame to grab that frame client-side, then Save to Output to write it as a PNG into ComfyUI's output folder.</li>
<li id="wfm-help-video-7">GIF tab (right panel): set Start/End (seconds), FPS, and an optional Max Width, then Convert to GIF — the server decodes the video and encodes an animated GIF into ComfyUI's output folder.</li>
</ul>
</div>
<button class="wfm-help-jump-btn wfm-btn wfm-btn-sm" data-jump-tab="video">Go to Video Tab</button>
</div>
<div class="wfm-help-page" id="wfm-help-page-shortcuts">
<div class="wfm-help-article-header">
<h2 id="wfm-help-shortcuts-title">Keyboard Shortcuts</h2>
@@ -2075,6 +2214,7 @@
<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>
<button id="wfm-gallery-subtab-metadata" class="wfm-gallery-subtab-btn" data-gallery-subtab="metadata">Metadata</button>
</div>
<!-- Output ギャラリーパネル -->
@@ -2100,6 +2240,7 @@
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
</select>
<button id="wfm-gallery-clear-filters-btn" class="wfm-btn wfm-btn-sm" title="Clear all filters"></button>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-gallery-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-gallery-view="table" title="Table">&#8801;</button>
@@ -2108,8 +2249,8 @@
<button id="wfm-gallery-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<button id="wfm-gallery-send-image-edit-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to Image Edit tab">Image Edit</button>
<button id="wfm-gallery-send-genui-image-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to GenerateUI Image input">Send GenUI Image</button>
<button id="wfm-gallery-send-li-node-btn" class="wfm-btn wfm-btn-sm" title="Upload and write selected image's filename into the selected node's image widget (e.g. LoadImage)">Send to LI node</button>
<button id="wfm-gallery-send-cc-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to ComfyUI Comic Creator's selected panel" style="display:none;">Send CC</button>
<button id="wfm-gallery-clear-filters-btn" class="wfm-btn wfm-btn-sm" title="Clear all filters"></button>
</div>
<!-- 複数選択時の一括操作バー -->
<div id="wfm-gallery-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
@@ -2164,20 +2305,23 @@
</div>
<!-- タブナビ -->
<div class="wfm-side-tab-nav">
<button class="wfm-gallery-detail-tab-btn active" data-detail-tab="info">Info</button>
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="info">Info</button>
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="meta">JSON</button>
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="group">Groups</button>
<button class="wfm-gallery-detail-tab-btn active" data-detail-tab="prompt">Prompt</button>
</div>
<div class="wfm-gallery-detail-action-row">
<button id="wfm-gallery-open-metadata-btn" class="wfm-gallery-action-btn wfm-gallery-action-btn-green" title="Open in Metadata tab (or Alt+click image)">Metadata</button>
<button id="wfm-gallery-load-genui-btn" class="wfm-gallery-action-btn wfm-gallery-action-btn-primary" title="Load workflow into GenerateUI">Load GenUI</button>
<button id="wfm-gallery-load-genui-btn" class="wfm-gallery-action-btn wfm-gallery-action-btn-primary" title="Load workflow into GenerateUI">GenUI</button>
<button id="wfm-gallery-open-tagger-btn" class="wfm-gallery-action-btn wfm-gallery-action-btn-tagger" title="Open in Tagger"></button>
<button id="wfm-gallery-download-btn" class="wfm-gallery-action-btn wfm-gallery-action-btn-orange" title="Download">Download</button>
</div>
<!-- Info タブ -->
<div id="wfm-gallery-detail-info" class="wfm-gallery-detail-tab-content active">
<div id="wfm-gallery-detail-info" class="wfm-gallery-detail-tab-content" style="display:none;">
<div class="wfm-gallery-info-section">
<div class="wfm-gallery-info-row" id="wfm-gallery-info-name"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-size"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-dim"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-date"></div>
</div>
<!-- ImagePromptサイドカー ( .txt / ponyxlWildcardsVault 由来のプロンプトがある画像のみ表示) -->
@@ -2223,6 +2367,21 @@
<div id="wfm-gallery-detail-group" class="wfm-gallery-detail-tab-content" style="display:none;overflow-y:auto;">
<!-- JS動的生成: renderDetailGroup() -->
</div>
<!-- Prompt タブ(埋め込みワークフローから抽出したpositive/negative。Metadataタブより
手数少なく確認できるよう、頻度の高いプロンプト確認用に既定選択タブとしている。
複数プロンプト候補がある場合に備え、Metadataタブと同じPOS/NEGリスト
→ クリックで全文表示のUIをそのまま再利用している(見た目・挙動を統一)。 -->
<div id="wfm-gallery-detail-prompt" class="wfm-gallery-detail-tab-content">
<div class="wfm-meta-section wfm-meta-prompt-section" id="wfm-gallery-prompt-section">
<div class="wfm-meta-section-title">Prompt</div>
<div class="wfm-meta-list" id="wfm-gallery-prompt-list"></div>
<div class="wfm-meta-prompt-full-wrap">
<div class="wfm-meta-prompt-full-label" id="wfm-gallery-prompt-full-label"></div>
<textarea class="wfm-meta-prompt-full" id="wfm-gallery-prompt-full" readonly placeholder="Select a prompt above to view full text..."></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -2368,6 +2527,77 @@
</div>
</div>
<!-- /Style_Catalog ギャラリーパネル -->
<!-- Metadata ギャラリーパネル -->
<div id="wfm-gallery-panel-metadata" class="wfm-gallery-panel">
<div class="wfm-metadata-layout">
<!-- Col1: Drop zone + preview -->
<div class="wfm-metadata-col1">
<div class="wfm-metadata-drop-zone" id="wfm-meta-drop">
<div class="wfm-meta-drop-label" id="wfm-meta-drop-label">
<div class="wfm-meta-drop-main">Drop image / video / JSON</div>
<div class="wfm-meta-drop-sub">PNG · WebP · MP4 · JSON</div>
</div>
<img id="wfm-meta-preview-img" class="wfm-meta-preview-img" style="display:none;" alt="preview">
</div>
<div class="wfm-meta-file-info" id="wfm-meta-file-info"></div>
<div class="wfm-meta-format-note" id="wfm-meta-format-note">
<div class="wfm-meta-format-note-title" id="wfm-meta-format-note-title">Supported formats</div>
<ul class="wfm-meta-format-list">
<li id="wfm-meta-fmt-comfyui">ComfyUI — PNG / WebP / MP4 / JSON</li>
<li id="wfm-meta-fmt-sdwebui">SD WebUI / SD Forge — PNG (parameters)</li>
<li id="wfm-meta-fmt-fooocus">Fooocus — PNG (fooocus_scheme)</li>
</ul>
<div class="wfm-meta-format-todo" id="wfm-meta-format-todo">Flux.2 / Qwen-Image / Z-Image subgraph workflows supported</div>
</div>
<input type="file" id="wfm-meta-file-input" accept="image/png,image/webp,video/mp4,.mp4,.json,application/json" style="display:none;">
</div>
<!-- Col2: Model info -->
<div class="wfm-metadata-col2">
<div class="wfm-meta-section" id="wfm-meta-ckpt-section">
<div class="wfm-meta-section-title">Checkpoint</div>
<div class="wfm-meta-list" id="wfm-meta-ckpt-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-vae-section">
<div class="wfm-meta-section-title">VAE</div>
<div class="wfm-meta-list" id="wfm-meta-vae-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-diff-section">
<div class="wfm-meta-section-title">Diffusion Model</div>
<div class="wfm-meta-list" id="wfm-meta-diff-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-te-section">
<div class="wfm-meta-section-title">Text Encoder</div>
<div class="wfm-meta-list" id="wfm-meta-te-list"></div>
</div>
</div>
<!-- Col3: LoRA + Prompt -->
<div class="wfm-metadata-col3">
<div class="wfm-meta-section" id="wfm-meta-lora-section">
<div class="wfm-meta-section-title">LoRA</div>
<div class="wfm-meta-list" id="wfm-meta-lora-list"></div>
</div>
<div class="wfm-meta-section wfm-meta-prompt-section" id="wfm-meta-prompt-section">
<div class="wfm-meta-section-title">Prompt</div>
<div class="wfm-meta-list" id="wfm-meta-prompt-list"></div>
<div class="wfm-meta-prompt-full-wrap">
<div class="wfm-meta-prompt-full-label" id="wfm-meta-prompt-full-label"></div>
<textarea class="wfm-meta-prompt-full" id="wfm-meta-prompt-full" readonly placeholder="Select a prompt above to view full text..."></textarea>
<div class="wfm-meta-prompt-actions">
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-copy-btn">Copy</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-genui-pos-btn">GenUI:P</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-genui-neg-btn">GenUI:N</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-preset-pos-btn">Prompt:P</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-preset-neg-btn">Prompt:N</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /Metadata ギャラリーパネル -->
</section>
<!-- Image Edit Tab -->
@@ -2400,7 +2630,7 @@
<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-send-workflow-btn" title="Upload and write this image's filename into the selected node's image widget (e.g. LoadImage)">Send to LI node</button>
</div>
<!-- Tool options -->
@@ -2457,6 +2687,168 @@
</div>
</section>
<!-- Video Tab (MiniMax H3専用・GenerateUIタブとは独立) -->
<section id="wfm-tab-video" class="wfm-tab-content">
<div class="wfm-video-layout">
<div class="wfm-video-form-panel">
<div class="wfm-video-header">
<h3 id="wfm-video-title-label">Video (MiniMax H3)</h3>
<span id="wfm-video-wf-name" class="wfm-video-wf-name">No workflow loaded</span>
</div>
<div class="wfm-video-frame-slots">
<div class="wfm-video-frame-slot">
<label id="wfm-video-first-frame-label">First Frame (optional)</label>
<div class="wfm-i2i-preview-wrap" id="wfm-video-first-frame-wrap" style="display:none;">
<img class="wfm-i2i-preview-img" id="wfm-video-first-frame-preview">
</div>
<div class="wfm-i2i-drop-zone" id="wfm-video-first-frame-drop">
<label class="wfm-i2i-drop-label">
<span id="wfm-video-first-frame-drop-label">Drop image or click to select</span>
<input type="file" accept="image/*" id="wfm-video-first-frame-file" style="display:none;">
</label>
</div>
<div style="display:flex;gap:6px;align-items:center;">
<span class="wfm-i2i-status" id="wfm-video-first-frame-status" style="flex:1;"></span>
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-first-frame-clear">Clear</button>
</div>
</div>
<div class="wfm-video-frame-slot">
<label id="wfm-video-last-frame-label">Last Frame (optional)</label>
<div class="wfm-i2i-preview-wrap" id="wfm-video-last-frame-wrap" style="display:none;">
<img class="wfm-i2i-preview-img" id="wfm-video-last-frame-preview">
</div>
<div class="wfm-i2i-drop-zone" id="wfm-video-last-frame-drop">
<label class="wfm-i2i-drop-label">
<span id="wfm-video-last-frame-drop-label">Drop image or click to select</span>
<input type="file" accept="image/*" id="wfm-video-last-frame-file" style="display:none;">
</label>
</div>
<div style="display:flex;gap:6px;align-items:center;">
<span class="wfm-i2i-status" id="wfm-video-last-frame-status" style="flex:1;"></span>
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-last-frame-clear">Clear</button>
</div>
</div>
</div>
<label for="wfm-video-prompt" id="wfm-video-prompt-label">Prompt</label>
<textarea id="wfm-video-prompt" class="wfm-textarea" rows="6"></textarea>
<div class="wfm-video-resolution-row">
<div>
<label for="wfm-video-aspect-ratio" id="wfm-video-aspect-ratio-label">Aspect Ratio</label>
<select id="wfm-video-aspect-ratio" class="wfm-input"></select>
</div>
<div>
<label for="wfm-video-megapixels" id="wfm-video-megapixels-label">Megapixels</label>
<input type="number" id="wfm-video-megapixels" class="wfm-input" step="0.01" min="0.01" value="0.2">
</div>
<div>
<label for="wfm-video-multiple" id="wfm-video-multiple-label">Multiple</label>
<input type="number" id="wfm-video-multiple" class="wfm-input" step="8" min="8" value="32">
</div>
</div>
<div class="wfm-video-resolution-row">
<div>
<label for="wfm-video-duration" id="wfm-video-duration-label">Duration (seconds)</label>
<input type="number" id="wfm-video-duration" class="wfm-input" step="0.5" min="0.5" value="2">
</div>
<div>
<label for="wfm-video-noise-seed" id="wfm-video-noise-seed-label">Noise Seed</label>
<input type="number" id="wfm-video-noise-seed" class="wfm-input" value="0">
</div>
<div class="wfm-video-seed-randomize-wrap">
<label><input type="checkbox" id="wfm-video-seed-randomize" checked> <span id="wfm-video-randomize-label">Randomize</span></label>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;">
<button id="wfm-video-generate-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Generate</button>
</div>
<div class="wfm-gen-progress-container">
<div class="wfm-gen-progress-track">
<div id="wfm-video-progress-bar" class="wfm-gen-progress-bar"></div>
</div>
<span id="wfm-video-progress-text" class="wfm-gen-progress-text">Ready</span>
</div>
</div>
<!-- 中央ペイン: プレビュー(生成結果 or ドロップされた任意の動画) + 編集(将来機能用プレースホルダー) -->
<div class="wfm-video-center-panel">
<div class="wfm-video-preview-row">
<div class="wfm-video-preview-frame" id="wfm-video-preview-frame">
<span class="wfm-placeholder" id="wfm-video-preview-placeholder">No video</span>
<video id="wfm-video-preview-video" class="wfm-video-preview-el" controls style="display:none;"></video>
</div>
<div class="wfm-video-source-panel">
<div class="wfm-video-pane-header" id="wfm-video-source-label">Video Source</div>
<p class="wfm-video-prop-hint" id="wfm-video-source-hint">Load any video here to use Frame/GIF tools without generating one.</p>
<div class="wfm-i2i-drop-zone" id="wfm-video-source-drop">
<label class="wfm-i2i-drop-label">
<span id="wfm-video-source-drop-label">Drop video or click to select</span>
<input type="file" accept="video/*" id="wfm-video-source-file" style="display:none;">
</label>
</div>
<div style="display:flex;gap:6px;align-items:center;margin-top:6px;">
<span class="wfm-i2i-status" id="wfm-video-source-status" style="flex:1;"></span>
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-source-clear">Clear</button>
</div>
</div>
</div>
<div class="wfm-video-edit-panel">
<span id="wfm-video-edit-placeholder-label" class="wfm-placeholder">Editing tools coming soon</span>
</div>
</div>
<!-- 右ペイン: プロパティ(Frame抽出 / GIF変換の2タブ) -->
<div class="wfm-video-properties-panel">
<div class="wfm-video-prop-tabnav">
<button type="button" class="wfm-video-prop-tab active" data-prop-tab="frame" id="wfm-video-prop-tab-frame">Frame</button>
<button type="button" class="wfm-video-prop-tab" data-prop-tab="gif" id="wfm-video-prop-tab-gif">GIF</button>
</div>
<div id="wfm-video-prop-frame" class="wfm-video-prop-content active">
<p class="wfm-video-prop-hint" id="wfm-video-frame-hint">Play/seek the preview video to the position you want, then capture.</p>
<button type="button" id="wfm-video-frame-capture-btn" class="wfm-btn wfm-btn-sm" style="width:100%;">Capture Frame</button>
<div class="wfm-i2i-preview-wrap" id="wfm-video-frame-preview-wrap" style="display:none;">
<img class="wfm-i2i-preview-img" id="wfm-video-frame-preview-img">
</div>
<button type="button" id="wfm-video-frame-save-btn" class="wfm-btn wfm-btn-primary wfm-btn-sm" style="width:100%;margin-top:6px;" disabled>Save to Output</button>
<span class="wfm-i2i-status" id="wfm-video-frame-status"></span>
</div>
<div id="wfm-video-prop-gif" class="wfm-video-prop-content" style="display:none;">
<div class="wfm-lab-field-row">
<label id="wfm-video-gif-start-label">Start (s)</label>
<input type="number" id="wfm-video-gif-start" class="wfm-input" value="0" min="0" step="0.1">
</div>
<div class="wfm-lab-field-row">
<label id="wfm-video-gif-end-label">End (s)</label>
<input type="number" id="wfm-video-gif-end" class="wfm-input" placeholder="end" min="0" step="0.1">
</div>
<div class="wfm-lab-field-row">
<label id="wfm-video-gif-fps-label">FPS</label>
<input type="number" id="wfm-video-gif-fps" class="wfm-input" value="10" min="1" max="30">
</div>
<div class="wfm-lab-field-row">
<label id="wfm-video-gif-width-label">Max Width</label>
<input type="number" id="wfm-video-gif-width" class="wfm-input" placeholder="original" min="16">
</div>
<button type="button" id="wfm-video-gif-convert-btn" class="wfm-btn wfm-btn-primary wfm-btn-sm" style="width:100%;margin-top:8px;">Convert to GIF</button>
<div class="wfm-gen-progress-container" style="margin-top:8px;">
<div class="wfm-gen-progress-track">
<div id="wfm-video-gif-progress-bar" class="wfm-gen-progress-bar"></div>
</div>
<span id="wfm-video-gif-progress-text" class="wfm-gen-progress-text"></span>
</div>
<div class="wfm-i2i-preview-wrap" id="wfm-video-gif-preview-wrap" style="display:none;">
<img class="wfm-i2i-preview-img" id="wfm-video-gif-preview-img">
</div>
<span class="wfm-i2i-status" id="wfm-video-gif-status"></span>
</div>
</div>
</div>
</section>
<!-- Tagger Tab -->
<section id="wfm-tab-tagger" class="wfm-tab-content">
<div class="wfm-tagger-subtab-nav">
@@ -2809,6 +3201,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">
@@ -2831,6 +3226,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">
+121
View File
@@ -0,0 +1,121 @@
"""Regenerate README.index.md / DEVLOG.index.md from README.md / DEVLOG.md.
README.md and DEVLOG.md are excluded from graphify's semantic extraction
(.graphifyignore) because they are too large for reliable local-Ollama
extraction: big chunks make the model summarize in prose instead of emitting
JSON, and time out on slower models/hardware. These condensed index files are
extracted instead. Run this whenever README.md or DEVLOG.md changes, then
`graphify --update` to refresh the graph.
Usage:
python tools/generate_doc_index.py [readme|devlog|all]
(defaults to "all")
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def generate_readme_index() -> Path:
text = (ROOT / "README.md").read_text(encoding="utf-8")
details_re = re.compile(
r"<details>\s*<summary><h3>(.*?)</h3></summary>\s*(.*?)</details>",
re.S,
)
bullet_re = re.compile(r"^- \*\*(.+?)\*\*", re.M)
feature_lines: list[str] = []
for title, body in details_re.findall(text):
names = bullet_re.findall(body)
feature_lines.append(f"### {title.strip()}")
if names:
feature_lines.append(", ".join(names))
feature_lines.append("")
tail_start = text.find("## Installation")
tail = text[tail_start:] if tail_start != -1 else ""
intro_end = text.find("## Screenshots")
intro = text[:intro_end].strip() if intro_end != -1 else ""
out = [
"# README Index — ComfyUI-Workflow-Studio",
"",
"Condensed index of README.md: intro summary, feature names per tab (full "
"descriptions live in the excluded README.md), and the installation/usage/"
"requirements/project-structure sections verbatim. Exists so graphify can "
"extract this without feeding the full README (with its screenshot table) to "
"the local Ollama model. Regenerate with "
"`python tools/generate_doc_index.py readme` whenever README.md changes.",
"",
intro,
"",
"## Features (tab -> feature names)",
"",
*feature_lines,
tail,
]
content = "\n".join(out)
out_path = ROOT / "README.index.md"
out_path.write_text(content, encoding="utf-8")
return out_path
def generate_devlog_index() -> Path:
text = (ROOT / "DEVLOG.md").read_text(encoding="utf-8")
entries = re.split(r"^## ", text, flags=re.M)[1:]
lines: list[str] = []
for entry in entries:
parts = entry.split("\n", 1)
version_line = parts[0].strip()
rest = parts[1] if len(parts) > 1 else ""
m = re.search(r"^### (.+)$", rest, re.M)
subtitle = m.group(1).strip() if m else ""
lines.append(
f"- **{version_line}** — {subtitle}" if subtitle else f"- **{version_line}**"
)
out = [
"# DEVLOG Index — ComfyUI-Workflow-Studio",
"",
"Condensed version-by-version index of DEVLOG.md (each line: version + "
'one-line summary of what changed and why). Full rationale, code details, '
'and "How to apply" lessons live in the excluded DEVLOG.md itself; this '
"index exists so graphify can extract a queryable semantic node per release "
"without feeding the full log to the local Ollama model. Regenerate with "
"`python tools/generate_doc_index.py devlog` whenever DEVLOG.md changes.",
"",
*lines,
"",
]
content = "\n".join(out)
out_path = ROOT / "DEVLOG.index.md"
out_path.write_text(content, encoding="utf-8")
return out_path
def main() -> None:
target = sys.argv[1] if len(sys.argv) > 1 else "all"
written: list[Path] = []
if target in ("readme", "all"):
written.append(generate_readme_index())
if target in ("devlog", "all"):
written.append(generate_devlog_index())
if not written:
print(f"unknown target {target!r}; use readme|devlog|all", file=sys.stderr)
raise SystemExit(2)
for p in written:
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size:,} bytes)")
if __name__ == "__main__":
main()
+412 -31
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 LI node"
// ============================================
// WFS Image Edit / Galleryタブから呼ばれる。アップロード済みの画像ファイル名を、現在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");
@@ -2246,6 +2376,35 @@ const createDraggableItem = (label, type, data, subtitle) => {
// Toggle & exports
// ============================================
// Opens the Library panel on the Workflows (W) tab, filtered down to a single
// workflow by filename. Used as the "Send to Canvas" fallback when window.opener
// isn't available (or the workflow is API-format, which the title-drag mechanism
// can't handle at all) — the user gets the actual workflow card, thumbnail
// included, to drag onto the canvas themselves instead of a bare title element.
export const showWorkflowInLibrary = async (filename) => {
const panel = createPanel();
state.visible = true;
panel.style.display = "flex";
state.topTab = "workflows";
panel.querySelectorAll(".wfm-nlp-top-tab").forEach(b => b.classList.toggle("active", b.dataset.toptab === "workflows"));
state.wfSubTab = "wf-all";
state.wfSubTab2 = null;
if (!state.wfLoaded) await loadWfData();
state.searchText = (filename || "").toLowerCase().trim();
const searchInput = panel.querySelector(".wfm-nlp-search-input");
if (searchInput) {
searchInput.value = filename || "";
searchInput.placeholder = "Search workflows...";
}
rebuildSubTabs();
renderContent();
installCanvasDropHandler();
};
export const togglePanel = async () => {
const panel = createPanel();
state.visible = !state.visible;
@@ -2394,6 +2553,81 @@ async function _readAllPNGTextChunks(file) {
return chunks;
}
// ── MP4 container metadata (moov/udta/meta の mdta keys/ilst) ──
// ComfyUIのSaveVideoはPNGのtEXtチャンクと同じキー名(workflow=UI形式, prompt=API形式)で
// JSON文字列をisobmff(mp4)のudta/meta(mdta)にコンテナレベルタグとして書き込む(FFmpegの
// movflags=+use_metadata_tags)。static/js/metadata-tab.js の readMP4MetaTags() と同一ロジック。
function _findMP4Box(view, bytes, start, end, targetType) {
let offset = start;
while (offset + 8 <= end) {
const size = view.getUint32(offset);
const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
let boxSize = size;
let bodyStart = offset + 8;
if (size === 1) {
if (offset + 16 > end) break;
const hi = view.getUint32(offset + 8);
const lo = view.getUint32(offset + 12);
boxSize = hi * 4294967296 + lo;
bodyStart = offset + 16;
} else if (size === 0) {
boxSize = end - offset;
}
if (boxSize < 8) break;
if (type === targetType) return { bodyStart, end: offset + boxSize };
offset += boxSize;
}
return null;
}
async function _readMP4MetaTags(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
if (bytes.length < 12) return null;
const ftyp = String.fromCharCode(bytes[4], bytes[5], bytes[6], bytes[7]);
if (ftyp !== "ftyp") return null;
const moov = _findMP4Box(view, bytes, 0, bytes.length, "moov");
if (!moov) return null;
const udta = _findMP4Box(view, bytes, moov.bodyStart, moov.end, "udta");
let meta = udta ? _findMP4Box(view, bytes, udta.bodyStart, udta.end, "meta") : null;
if (!meta) meta = _findMP4Box(view, bytes, moov.bodyStart, moov.end, "meta");
if (!meta) return null;
const metaBody = meta.bodyStart + 4; // meta は FullBox (version+flags 4バイト)
const keysBox = _findMP4Box(view, bytes, metaBody, meta.end, "keys");
const ilstBox = _findMP4Box(view, bytes, metaBody, meta.end, "ilst");
if (!keysBox || !ilstBox) return null;
const utf8 = new TextDecoder("utf-8", { fatal: false });
let off = keysBox.bodyStart + 4; // version+flags をスキップ
const count = view.getUint32(off); off += 4;
const keyNames = [];
for (let i = 0; i < count && off + 8 <= keysBox.end; i++) {
const ksize = view.getUint32(off);
if (ksize < 8) break;
keyNames.push(utf8.decode(bytes.slice(off + 8, off + ksize)));
off += ksize;
}
const result = {};
let ioff = ilstBox.bodyStart;
while (ioff + 8 <= ilstBox.end) {
const itemSize = view.getUint32(ioff);
const keyIndex = view.getUint32(ioff + 4);
if (itemSize < 8) break;
const itemEnd = Math.min(ioff + itemSize, ilstBox.end);
const data = _findMP4Box(view, bytes, ioff + 8, itemEnd, "data");
if (data) {
const payload = bytes.slice(data.bodyStart + 8, data.end); // type(4)+locale(4)をスキップ
const name = keyNames[keyIndex - 1];
if (name) { try { result[name] = utf8.decode(payload); } catch { /* skip */ } }
}
ioff = itemEnd;
}
return result;
}
// ============================================
// UI→API変換(static/js/comfyui-workflow.js の convertUiToApi() 相当ロジックの複製)
// web/comfyui/ は static/js/ と配信URLが異なりESモジュールをimportできないため、
@@ -3171,6 +3405,13 @@ function _extractPromptsAPI(wf) {
if (combined) textMap.set(id, combined);
continue;
}
// MiniMaxH3ImageToVideo等の動画生成オールインワンノード。CLIPTextEncode等を経由せず
// 自ノードの"prompt"入力に直接テキストを持つ(positive/negativeの区別も無い)ため、
// _isTextEncoderNode判定を通さずtextMapへ直接投入する(fallbackのtextsに載る)。
if (typeof n.inputs?.prompt === "string" && /ImageToVideo|TextToVideo/i.test(ct)) {
textMap.set(id, n.inputs.prompt);
continue;
}
if (!_isTextEncoderNode(ct)) continue;
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
// model text encoders use this key instead of "text"/"text_g".
@@ -3298,6 +3539,7 @@ 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");
const isMP4 = file.type === "video/mp4" || name.endsWith(".mp4");
async function fromWorkflow(originalWf, source) {
let wf = originalWf;
@@ -3333,6 +3575,17 @@ async function _extractAllMetadata(file) {
const wf = _extractWorkflowFromEXIF(exif);
return wf ? await fromWorkflow(wf, "comfyui") : null;
}
if (isMP4) {
let tags; try { tags = await _readMP4MetaTags(file); } catch { return null; }
if (!tags) return null;
for (const key of ["prompt", "workflow"]) {
const raw = tags[key];
if (!raw) continue;
let wf; try { wf = JSON.parse(_sanitizeJSON(raw)); } catch { continue; }
if (wf) return await fromWorkflow(wf, "comfyui");
}
return null;
}
// PNG
const chunks = await _readAllPNGTextChunks(file);
if (!chunks) return null;
@@ -3442,7 +3695,7 @@ const setupInfoDropHandlers = (container) => {
const renderInfoModels = (container, meta) => {
container.innerHTML = "";
if (!meta) {
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/JSON file to view model info</div>`;
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/MP4/JSON file to view model info</div>`;
return;
}
const sections = [
@@ -3481,7 +3734,7 @@ const renderInfoModels = (container, meta) => {
const renderInfoLoras = (container, meta) => {
container.innerHTML = "";
if (!meta || !meta.loras || meta.loras.length === 0) {
container.innerHTML = `<div class="wfm-nlp-info-empty">${meta ? "No LoRA found" : "Drop a PNG/WebP/JSON file to view LoRA info"}</div>`;
container.innerHTML = `<div class="wfm-nlp-info-empty">${meta ? "No LoRA found" : "Drop a PNG/WebP/MP4/JSON file to view LoRA info"}</div>`;
return;
}
for (const lora of meta.loras) {
@@ -3530,7 +3783,7 @@ const renderInfoPrompts = (container, meta) => {
const promptFull = panelEl?.querySelector("#wfm-nlp-info-prompt-full");
const promptFullLabel = panelEl?.querySelector("#wfm-nlp-info-prompt-full-label");
if (!meta) {
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/JSON file to view prompts</div>`;
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/MP4/JSON file to view prompts</div>`;
return;
}
const allPrompts = [
@@ -3595,8 +3848,8 @@ const renderInfoTab = (container) => {
<div class="wfm-nlp-info-layout">
<div class="wfm-nlp-info-drop" id="wfm-nlp-info-drop">
<img id="wfm-nlp-info-preview-img" style="display:none;max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;">
<span id="wfm-nlp-info-drop-label" style="font-size:11px;color:var(--descrip-text,#999);pointer-events:none;">Drop PNG / WebP / JSON</span>
<input type="file" id="wfm-nlp-info-file-input" accept=".png,.webp,.json,image/png,image/webp,application/json" style="display:none;">
<span id="wfm-nlp-info-drop-label" style="font-size:11px;color:var(--descrip-text,#999);pointer-events:none;">Drop PNG / WebP / MP4 / JSON</span>
<input type="file" id="wfm-nlp-info-file-input" accept=".png,.webp,.mp4,.json,image/png,image/webp,video/mp4,application/json" style="display:none;">
</div>
<div id="wfm-nlp-info-fileinfo" class="wfm-nlp-info-fileinfo">—</div>
<div id="wfm-nlp-info-subcontent" class="wfm-nlp-info-subcontent"></div>
@@ -3622,11 +3875,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);
@@ -3645,22 +3925,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);
}
}
@@ -3676,54 +3985,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}`);
@@ -3875,6 +4229,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">
@@ -3894,6 +4249,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">
@@ -3947,6 +4314,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;
@@ -3978,10 +4350,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");
@@ -3992,10 +4367,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";
@@ -4069,11 +4448,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");
});
@@ -4119,7 +4500,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 = "";
@@ -4351,7 +4732,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())
@@ -4376,7 +4757,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";
+18 -1
View File
@@ -1,5 +1,5 @@
import { app } from "../../scripts/app.js";
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet, showWorkflowInLibrary } from "./node_sets_menu.js";
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
@@ -572,6 +572,23 @@ app.registerExtension({
};
},
async setup() {
// Send to Canvas fallback: when the Workflow Studio SPA can't reach this page
// directly via window.opener (e.g. it isn't the opener that spawned it, or
// the workflow is API-format and window.opener.wfmReceiveWorkflow can't
// handle it either), the SPA writes the workflow's filename here instead of
// the old title-drag mechanism. localStorage "storage" events fire in every
// same-origin tab except the one that wrote the value, so this listener
// picks it up here and opens the Library panel pre-filtered to that
// workflow's card for the user to drag onto the canvas themselves.
window.addEventListener("storage", (e) => {
if (e.key === "wfm_library_filter_request" && e.newValue != null) {
showWorkflowInLibrary(e.newValue);
// Consume it so a future page load (which reads the stale value but
// never sees a "storage" event for it) doesn't re-trigger the filter.
localStorage.removeItem("wfm_library_filter_request");
}
});
const injectStyles = () => {
const styleId = "wfm-top-menu-button-styles";
if (document.getElementById(styleId)) return;
+627
View File
@@ -0,0 +1,627 @@
{
"id": "8e2c1a4f-6b3d-4a9e-9c7f-1d5e8f3a2b6c",
"revision": 0,
"last_node_id": 10,
"last_link_id": 11,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": [
315,
474
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
378124453179007,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": [
210,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 10
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": [
210,
270
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 9
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": [
315,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
51.3,
182.49999999999997
],
"size": [
315,
98
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
1,
11
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3,
5
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": []
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
]
},
{
"id": 10,
"type": "VAELoader",
"pos": [
61.897062649999256,
347.8818042399999
],
"size": [
270,
58
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
10
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "VAELoader"
},
"widgets_values": [
"vae-ft-mse-840000-ema-pruned.safetensors"
]
},
{
"id": 6,
"type": "ImpactWildcardEncode",
"pos": [
415,
150
],
"size": [
420,
322
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 11
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"slot_index": 2,
"links": [
4
]
},
{
"localized_name": "populated_text",
"name": "populated_text",
"type": "STRING",
"slot_index": 3,
"links": []
}
],
"properties": {
"Node name for S&R": "ImpactWildcardEncode"
},
"widgets_values": [
"a photo of __sample_season/spring__, with __sample_lighting/natural__, highly detailed",
"",
"populate",
"Select the LoRA to add to the text",
"Select the Wildcard to add to the text",
123456789,
"randomize"
]
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
1,
"CLIP"
],
[
4,
6,
2,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
10,
10,
0,
8,
1,
"VAE"
],
[
11,
4,
0,
6,
0,
"MODEL"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [
0,
0
],
"scale": 1
},
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}