Compare commits

...
101 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 4.6 cbec20633e release: v0.3.65 — Mask Editor One 連携修正・トップバーアイコン修正・Mask ABRブラシ対応
- Mask Editor One store_image API フィールド名不一致修正 (image_b64 → bg_image_b64、後方互換対応)
- トップバーアイコン消滅バグ修正: MutationObserver でVue再レンダリング後もSVGを再適用
- Mask Tool ABRブラシライブラリ対応 (Mask Editor One v0.1.9+ 必須)
  - プロパティパネルに Select ボタン・Spacing・Angle・Sz Jitter・Rot. Jitter コントロール追加
  - ABRボタンをオプションバーからプロパティパネルに移動
  - ブラシサイズJitter時の0px防止 (IndexSizeError修正)
- Mask ブラシカーソルをキャンバス外では非表示に
- ヘルプ更新: Mask Tool (SAM3・ABRブラシ) / BG Remove (BiRefNet) / i18n 3言語
- README 更新: v0.3.65 / Mask Editor One 必須要件・機能説明追記
- DEVLOG 更新: v0.3.65 エントリ追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 12:04:20 +09:00
ketle-manandClaude Sonnet 4.6 8b82a3103d feat: Image Edit mask Add/Subtract, BiRefNet BG Remove, SAM3 segmentation
- LayerManager: add `operation` property (add/subtract) to mask layers with toJSON/fromJSON support and toggleOperation()
- _updateCompositeView: detect consecutive maskApply mask groups, composite with lighten(add)/destination-out(subtract) matching Mask Editor One logic
- Layer panel: A/S toggle button on mask layers (green=add, red=subtract); sync MaskTool.mode on layer select
- BG Remove: BiRefNet option via Mask Editor One API (store_image → birefnet/remove_bg → grayscale-to-RGBA)
- Mask tool: SAM3 button with text prompt, max masks selector, multi-select thumbnails, Add/Erase mode, Apply Selected
- Bugfix: maskApply mask at layers end (no target) now falls back to overlay-only instead of disappearing
- Bugfix: brush cursor dot appearing outside Image Edit tab (rect.width===0 guard)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 16:15:29 +09:00
ketle-manandClaude Sonnet 4.6 d70905d032 fix: Image Edit ツールメニューを画像読み込み前にも表示
Select/Draw/Text/Shape の _renderToolOptions 条件に
this._xxxTool の存在チェックが含まれており、画像読み込み前は
全ツールが null → else(coming soon)に落ちていた。

コンストラクタでデフォルト値で早期初期化することで修正。
_initCanvases() での再生成と衝突しない。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 14:11:00 +09:00
ketle-manandClaude Sonnet 4.6 390921a13d release: v0.3.63 — ノードタブ複数選択・全タブフィルタークリア・検索Xボタン
- Nodes tab: Ctrl+click / Shift+click multi-select with bulk action bar (add/remove/create group, bulk favorite/unfavorite)
- All tabs (Workflow/Nodes/Models/Gallery): ✕ Clear button resets all filters in one click
- All tabs: inline ✕ overlay button on search inputs clears text immediately
- Fix: node bulk bar layout collapse (moved outside flex container)
- Fix: search clear button not showing (display:'flex' instead of '')
- Fix: node card multi-selected style not applied (added .wfm-card.multi-selected to main.css)
- docs/12_Tagger.png, docs/13_ImageEdit.png added (missed in v0.3.62)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 13:23:15 +09:00
ketle-manandClaude Sonnet 4.6 78b34c99f7 feat: WFS_PromptText を GenerateUI Prompt タブで認識・ドロップ title 固定
- analyzeWorkflow に WFS_PromptText 検出を追加(positive/negative を個別の prompt_node として登録)
- _getWidgetMapping に WFS_PromptText フォールバックマッピングを追加(UI→API 変換対応)
- placePromptNode のドロップ時 title をプリセット名から「Wfs Prompt」固定に変更

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 21:58:27 +09:00
ketle-manandClaude Sonnet 4.6 71ed9788a6 release: v0.3.62 — Send GenUI Image ボタン追加・tagger_settings.json バグ修正
- Gallery タブツールバーに Send GenUI Image ボタンを追加(Image Edit ボタン右隣)
  選択画像を ComfyUI へアップロードし GenerateUI Input Image スロット 0 に自動適用
- tagger_settings.json がデータエクスポート対象から漏れていたバグを修正
- ヘルプ helpGallery17 を EN/JA/ZH で追加
- README: v0.3.62 Changelog・スクリーンショット(Tagger/Image Edit)追加
- DEVLOG 更新・pyproject.toml バージョン 0.3.62 に更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 18:10:47 +09:00
ketle-manandClaude Sonnet 4.6 4cfc793c76 docs: Send GenUI Image ボタンのヘルプを追加(helpGallery17)
- templates/index.html: wfm-help-gallery-17 エントリ追加
- static/js/i18n.js: helpGallery17 キー追加(EN/JA/ZH)
- static/js/app.js: helpIdMap に wfm-help-gallery-17 → helpGallery17 追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:54:06 +09:00
ketle-manandClaude Sonnet 4.6 a7b5667a50 feat: ギャラリータブに Send GenUI Image ボタンを追加
ギャラリーツールバーの Image Edit ボタン右隣に「Send GenUI Image」ボタンを
設置。選択中の画像をギャラリーから取得し、ComfyUI にアップロードした後、
GenerateUI タブの Input > Image スロット 0 へ自動的に適用する。

- templates/index.html: Send GenUI Image ボタン追加
- static/js/comfyui-editor.js: comfyEditor.applyImageToSlot() メソッド追加
- static/js/gallery-tab.js: comfyUI/comfyEditor をインポート、ボタンハンドラ追加
- static/js/i18n.js: galleryNoLoadImageNode / gallerySentGenUI キー追加(EN/JA/ZH)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:49:14 +09:00
ketle-manandClaude Sonnet 4.6 1eaf4b2158 fix: tagger_settings.json をデータエクスポート/インポート対象に追加
settings_routes.py の _DATA_FILES リストから tagger_settings.json が
漏れており、データバックアップ時にTagger設定が含まれなかった。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:36:29 +09:00
ketle-manandClaude Sonnet 4.6 cbf35e8ddf feat: v0.3.61 — G'MIC バグ修正・セキュリティ・Draw/Mask ブラシ操作性改善
- G'MIC-Qt 引数順序修正 (-o output input が正しい順序)
- CREATE_NEW_CONSOLE フラグ追加(Windows GUI 表示に必須)
- G'MIC 設定パスのプレースホルダーを汎用パスに変更
- handle_result パストラバーサル対策(gmic_temp 外を 403 拒否)
- ヘルプ追加: G'MIC Filter 使い方・インストール手順(EN/JA/ZH)
- Draw/Mask ブラシカーソルを円形オーバーレイに変更(ブラシサイズ連動)
- キャンバス余白からのドラッグ開始対応(ie-canvas-wrap.mousedown)
- キャンバス外へのストローク継続対応(window mousemove/mouseup 追跡)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 16:43:07 +09:00
ketle-man b985f73d35 v0.3.60: Image Edit Filter Tool - G'MIC-Qt integration
Implement G'MIC-Qt filter tool in the Image Edit tab:
- Add gmic_routes.py (new): /api/wfm/gmic/open, /status/{id}, /result endpoints
- Register gmic_routes in wfm.py
- Enable Filter button in index.html (remove ie-tool-placeholder)
- Implement _gmicOpenGui, _gmicWaitForJob, _gmicApplyResult, _gmicAbort in image-edit-tab.js
- Add G'MIC-Qt path setting UI and save logic to settings-tab.js
2026-06-27 14:51:58 +09:00
ketle-manandClaude Sonnet 4.6 0d8ae03c7b feat: v0.3.59 — Image Edit Mask Tool 実装・Draw/Mask 描画バグ修正・クリッピング保存対応
- Mask Tool (🎭) 新規実装: マスクレイヤー追加(M ボタン)・プロパティペイン(Size/Hardness/Add-Erase)・ツールオプションバー(Paint/Invert/Overlay/Blur)・マスクオーバーレイ表示
- クリッピングマスク機能: ✂ ボタンで直下レイヤーをマスク範囲でクリッピング;PNG保存・Gallery・ComfyUI送信に反映
- Draw/Mask 描画バグ修正: layer.canvas 直接描画により描画中も全レイヤーが常時表示
- レイヤーパネル幅 190→220px (✂ ボタン追加による見切れ解消)
- ヘルプ更新 (EN/JA/ZH): Mask Tool カード追加・Add/Erase ボタン・✂ ボタン説明

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 12:39:34 +09:00
ketle-manandClaude Sonnet 4.6 9fa1490d4b feat: v0.3.58 — GenerateUI Model タブ GGUF対応拡張(Diffusion Model / Text Encoder)
- Diffusion Model: LoaderGGUF / LoaderGGUFAdvanced を認識、gguf_name キーを自動選択
- Text Encoder: CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF 対応
  - 単体CLIP: clip_name + type + device(GGUF only) のUI
  - DualCLIP: clip_name1 + clip_name2 + type + device(GGUF only) の2段UI
  - type選択肢を /object_info から動的取得
- Models タブ GenUI Model ボタン: unet=gguf_name / textencoder=clip_name にも対応
- ヘルプ3点セット更新(helpGen5、helpGen17・helpGallery15・16のi18n漏れ修正含む)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 10:49:52 +09:00
ketle-manandClaude Sonnet 4.6 3a5637a4cf feat: v0.3.57 — Image Edit Blur Tool / BG Remove Tool 追加
- Blur ツール (≈): Whole Blur / Whole Mosaic(全体適用)+ Rect Blur / Rect Mosaic(矩形ドラッグ選択)
- BG Remove ツール (⬚): Lightweight @imgly(ブラウザ内処理)+ BiRefNet stub(Python backend, coming soon)
- サイドバーの Blur / BG Remove アイコンから ie-tool-placeholder を除去(白色表示)
- ヘルプに Blur Tool / BG Remove Tool カードを追加(英語・日本語・中国語 i18n 対応)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:27:46 +09:00
ketle-manandClaude Sonnet 4.6 be66dc3d5a feat: v0.3.56 — Style機能追加(GenerateUI ツールバー & Batchタブ)
- GenerateUI ツールバーに Style チェックボックス+ドロップダウンを追加
  - user/default/Workflow-Studio/style/*.json を自動読み込み
  - {prompt} プレースホルダー置換、negative_prompt 末尾連結
  - ワークフローコピーにのみ適用(currentWorkflow は変更しない)
- Batch左ペインに Style タブを追加(全スタイルのフラットリスト、All/None)
- Batch Queue に Style 列を追加(7列目)
- GET /api/wfm/styles エンドポイントを settings_routes.py に追加
- ヘルプ・README・DEVLOG 更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 19:02:44 +09:00
ketle-manandClaude Sonnet 4.6 5a3d69fe14 fix(generate-tab): Batch/Stack グループをバッチ実行UIに復元
v0.3.55 で誤って追加した RESERVED フィルターを削除。
Batch/Stack はグループ削除保護のための予約グループであり、
バッチ実行選択UIに表示され選択できることが正しい動作。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:56:53 +09:00
ketle-manandClaude Sonnet 4.6 8c5fce77c5 feat: v0.3.55 — グループ機能バグ修正・ヘルプi18n対応
- Windows バックスラッシュ/スラッシュ不一致によるグループメンバー消失を修正
- ネットワークドライブ切断時の全消去防止(アクセス可能ディレクトリ0件ならクリーンアップスキップ)
- _scan_model_names に 30秒 TTL キャッシュ追加(起動時高負荷の解消)
- RESERVED_GROUPS (Batch/Stack) 保護バイパス修正(bulkRemoveFromGroup, renderPmGroups)
- groupFilter 陳腐化バグ修正(削除後に自動リセット)
- toggleGroupEnable 部分失敗時の状態乖離修正
- 生成UI全バッチグループから Batch/Stack 予約グループを除外
- GenerateUI LoRA グループのパス正規化追加
- batchComplete トーストを英語ハードコードから t() に変更
- Prompt tab マイグレーション時の negText 消失バグ修正
- ヘルプタブ Image Edit セクションを日本語・中国語対応(37キー×3言語追加)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:46:17 +09:00
ketle-manandClaude Sonnet 4.6 7360b17842 feat(image-edit): v0.3.54 — Shape ツール追加・Models overlay ボタン修正
- Image Edit タブに Shape ツール(□ / S キー)を追加
  - ShapeTool.js 新規作成: Rect / Ellipse / Line / FreeLine 対応
  - ドラッグ中は overlayCanvas に青い破線プレビュー表示
  - 確定ごとに独立した draw レイヤーとして追加(Undo 1操作で除去可)
  - Tool Options: Shape / Rounded / Fill / Stroke / Opacity / Undo
  - Line / FreeLine 時は Stroke None を非表示(常にストローク有効)
  - S キーショートカット追加
- Models タブ: Batch(B) / Stack(S) overlay ボタンが async await 後に
  e.currentTarget が null になり色が変わらないバグを修正
  → await 前に const btn = e.currentTarget で参照を保存
- ヘルプ更新: Shape ツール説明・S キーショートカットを追記
- DEVLOG / README / pyproject.toml を v0.3.54 に更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:24:58 +09:00
ketle-man 2ce9fb2ab9 Merge feature/image-edit-tab: v0.3.53 Image Edit Tab 強化 2026-06-25 15:16:33 +09:00
ketle-manandClaude Sonnet 4.6 baa3c32abd feat(image-edit): v0.3.53 — Image Edit Tab 強化(オブジェクト操作・レイヤーロック・ギャラリー保存)
- SelectTool: 移動・リサイズ・回転・Flip H/V によるオブジェクト操作
- TextTool: バウンディングボックスサイズで配置、リサイズ時に再描画(拡大ぼやけ防止)
- テキスト再編集: ダブルクリックで既存テキストレイヤーを再編集
- 回転ハンドル位置バグ修正: _getRotateHandle() の x 座標符号を修正
- レイヤーロック: 🔒/🔓 トグル、ロック中は SelectTool 操作無効(オレンジ表示)
- Layer 1 自動ロック: 初回画像読み込み時に誤移動を防止
- Save to Gallery ボタン: wfs-image-YYYYMMDDHHmmss デフォルト名でギャラリールートに保存
- Gallery タブ「Image Edit」ボタン追加
- Help に Image Edit ページ追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:16:01 +09:00
ketle-manandClaude Sonnet 4.6 5fb843c6e2 feat(image-edit): Phase 1 — Image Edit タブ追加(Draw・Text・レイヤー・保存)
- static/js/image-edit/LayerManager.js: Layer + LayerManager (追加/削除/順序/可視/不透明度/合成)
- static/js/image-edit/DrawTool.js: ソフトブラシ描画・消しゴム (PaintToolベース)
- static/js/image-edit/TextTool.js: Canvas直描きテキスト・フォント/サイズ/太字/斜体対応
- static/js/image-edit-tab.js: メインUI — ツールバー・アクションバー・ズーム/パン・Undo/Redo・PNG保存・ComfyUIアップロード・From Gallery連携
- static/css/image-edit-tab.css: タブ専用スタイル (ツールバー/キャンバス/レイヤーパネル/テキストオーバーレイ)
- templates/index.html: Image Editタブ追加 + CSS読み込み追加
- static/js/app.js: import・初期化・i18nマップ追加
- static/js/i18n.js: tabImageEdit キー追加 (3言語)

Mask/Blur/Filter/BG Removeはプレースホルダー(Phase 2以降)
キーボードショートカット: B=Draw, T=Text, Ctrl+Z=Undo, Ctrl+Y=Redo, Space+Drag=Pan, Wheel=Zoom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:16:04 +09:00
ketle-manandClaude Sonnet 4.6 76dc2cbbf6 feat(genui): LATENT IMAGEにプリセット機能を追加 — 正方形ボタン・解像度ドロップダウン・WHSet/HWSet・カスタムプリセット管理(+/-)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:51:14 +09:00
ketle-manandClaude Sonnet 4.6 8c98eabf59 fix: GenUI ModelでLoRA適用後にModelサブタブ再表示で値がリセットされるバグ修正 (v0.3.52)
renderLoraPaneがModelサブタブ切替のたびにel.innerHTMLを再構築する際、
currentValをcurrentAnalysis(古いanalysis)から取得していたため、
applyToGenUIでcurrentWorkflowを更新してもサブタブ再表示時に元の値に戻っていた。
currentWorkflowから直接読み取るよう修正し、syntax/trigger wordsも保存・復元する。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:45:39 +09:00
ketle-manandClaude Sonnet 4.6 8b3be02600 chore: bump version to v0.3.51; update README and DEVLOG
- pyproject.toml: 0.3.50 → 0.3.51
- README: version badge updated, Input/Model tab and GenUI Model button
  descriptions updated; v0.3.51 Changelog entry added
- DEVLOG: v0.3.50 → v0.3.51 for all new-feature entries added this session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:57:16 +09:00
ketle-manandClaude Sonnet 4.6 e82808e16a docs(help): update help for Embedding GenUI PP/NP and Hypernetwork in Gen UI
- gen-4: describe Embeddings selector + Paste button in Prompt tab
- gen-5: add Hypernetwork (with Strength field) to Model tab list
- models-8: add Hypernetwork to GenUI Model list; add Embedding GenUI PP/NP button description
- All three languages updated (EN/JP/ZH)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:53:40 +09:00
ketle-manandClaude Sonnet 4.6 751f507f91 fix(genui): reduce Positive Prompt textarea height by 2 rows (~45px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:49:50 +09:00
ketle-manandClaude Sonnet 4.6 9ece4b3549 feat(embedding): add GenUI PP/NP buttons for Embeddings + Paste in Prompt tab
- Models tab: show GenUI PP / GenUI NP buttons for Embedding type in side panel nav, info panel, and detail modal
- Generate UI > Prompt tab: add Embeddings section with filter/select/weight + Paste button (inserts at cursor position of last focused textarea)
- comfyEditor: appendEmbeddingToPrompt() updates both workflow JSON and textarea
- closeSidePanel(): hides nav buttons to prevent stale state on model type switch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:34:25 +09:00
ketle-manandClaude Sonnet 4.6 8af4a05a48 feat(genui): add Hypernetwork support to GenerateUI Model tab
GenerateUIのModelタブ最下部にHypernetworkセクションを追加。
ワークフロー解析でHypernetworkLoaderを検出し、
strength入力フィールドも含めてApply対応。
ModelsタブGenUI ModelボタンもHypernetworkタイプで有効化。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:59:33 +09:00
ketle-manandClaude Sonnet 4.6 87c805c51d fix(models): fix Embedding not showing via WFS backend API
ComfyUIの/embeddingsエンドポイントへの直接fetchがHTMLを返す問題を修正。
WFSバックエンドの/api/wfm/models/files?type=embeddingを新設し、
folder_pathsからモデルファイルを確実に取得するよう変更。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:29:59 +09:00
ketle-manandClaude Sonnet 4.6 22ed57efb3 fix(models): support V3 ComfyNode Combo format in _fetchModelList
HypernetworkLoaderがV3形式(options)でComboを返すため
Hypernetworkモデルが空になっていたバグを修正。
fetchEmbeddingsにエラーログを追加しデバッグを容易にした。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:15:11 +09:00
ketle-manandClaude Sonnet 4.6 070015ea3d feat(models): add GenUI Model button to side panel tab nav
CivitAIタブ横のタブナビゲーション右端にGenUI Modelボタンを追加。
GenUI非対応タイプでは自動的に非表示になる。モーダルの既存ボタンは維持。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:01:03 +09:00
ketle-manandClaude Sonnet 4.6 474dea9e7f fix: gallery prompt search hang — background indexer + asyncio.to_thread (v0.3.50)
- Remove on-demand metadata read from list_images() (root cause of hang)
- Wrap list_images() route in asyncio.to_thread() to free the event loop
- Add background indexer (start_background_index / _bg_index_folder):
  processes uncached images 10 at a time with 50ms sleep between batches;
  auto-starts on folder load; cancellable on folder switch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 12:36:33 +09:00
ketle-manandClaude Sonnet 4.6 465871b9f8 feat: GenerateUI A1111-style wildcard expansion + Gallery bug fixes (v0.3.49)
- GenerateUI: SPA-side __name__ wildcard expansion for non-ImpactWildcard nodes
  (CLIPTextEncode etc.); nested expansion up to 5 passes; ImpactWildcard* skipped
  (server-side); works in single and batch generation
- Gallery: fix Export button missing text label in bulk action bar
- Gallery: fix prompt search missing images with no cached prompt index —
  on-demand metadata read + cache build when search is active
- Help text updated (EN/JA/ZH): helpGen16 added
- Version bumped to 0.3.49

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:43:17 +09:00
ketle-manandClaude Sonnet 4.6 c3bee0ee09 docs: ギャラリータブの画像ダウンロード・エクスポート機能のドキュメント更新
- README.md: Gallery Tabセクションに機能説明を追加
- DEVLOG.md: v0.3.48エントリを追加(新機能の実装詳細)
- templates/index.html: ヘルプテキストを更新(wfm-help-gallery-6, -7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:57:11 +09:00
ketle-manandClaude Sonnet 4.6 3dc593da4e feat: ギャラリータブに画像ダウンロードと一括エクスポート機能を追加
- 詳細パネルのプレビュー画像にマウスホバーでダウンロードアイコンをオーバーレイ表示
- 詳細パネルから単一画像をダウンロード可能に
- 複数選択メニューに「エクスポート」ボタンを追加
- 複数選択した画像をZIPファイルにしてエクスポート
- Backend: POST /wfm/gallery/images/export-zip エンドポイント実装
- Frontend: ダウンロード・ZIPエクスポート機能実装
- i18n対応: 英語/日本語/中国語

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:36:09 +09:00
ketle-manandClaude Sonnet 4.6 aa8c8f73fe fix: update JS group state after model file move (v0.3.47)
When models were moved to a subdirectory via bulkMoveModels, state.modelGroups
was not updated to reflect the new model paths, causing moved models to
disappear from group filter views immediately after the operation.

Added in-place rename of group members (from → to) inside the moved.forEach
loop, mirroring the server-side rename already performed by move_models in
models_service.py. Generate tab Batch groups are unaffected as they fetch
fresh from the server on each tab open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 08:34:59 +09:00
ketle-manandClaude Sonnet 4.6 281fba373c feat: Prompt tab AI Assistant LM Studio support + Settings AI Assistant section (v0.3.46)
- Settings tab: rename "Ollama Settings" to "AI Assistant Settings (Prompt Tab)";
  add backend selector (Ollama / LM Studio), URL input, model select, Test button;
  save to localStorage wfm_prompt_ai_settings (independent of AI TOOL tab)
- Prompt tab: replace Python proxy (/api/wfm/ollama/*) with direct browser-to-backend
  calls; supports Ollama (/api/chat) and LM Studio (/v1/chat/completions);
  image attachments auto-converted to correct format per backend
- Auto-refresh model list when backend changes: sendMessage() detects mismatch
  between current backend setting and what was used to populate the dropdown,
  then re-fetches before sending to avoid stale model name errors
- i18n (EN/JA/ZH): ollamaSettings, ollamaUrl, saveOllama, ollamaSaved,
  helpPrompt1, helpSettings5, helpTrouble2 updated for dual-backend support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 18:17:12 +09:00
ketle-manandClaude Sonnet 4.6 2b84c69ef9 feat: group stale-entry auto-cleanup + Gallery Shift+click range select (v0.3.45)
- Models tab: scan valid model files on group load, remove stale entries;
  move_models() also updates group membership for moved models
- Gallery tab (folder view): cleanup stale metadata entries after each
  folder scan, synced with 60s TTL + folder-mtime cache
- Gallery tab (group list): list_images_in_group() filters non-existent
  paths and removes them from the group in a single write
- Gallery tab: Shift+click range-selects from the last anchor item;
  works in both thumbnail and table views; anchor resets on folder
  change, reload, and Deselect All; help updated (EN/JA/ZH)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:16:45 +09:00
ketle-manandClaude Sonnet 4.6 7523892bc2 feat: Gallery tab performance + compare mode + bulk API + help improvements (v0.3.44)
Phase 2 — Performance:
- Server-side 256px JPEG thumbnail generation (Pillow, disk cache data/thumb_cache/)
- Infinite-scroll paging: 50 images/page with IntersectionObserver sentinel
- Cache TTL extended 30s → 60s

Phase 3 — Features:
- Bulk API endpoints: POST /wfm/gallery/bulk/favorite and /wfm/gallery/bulk/group
  replace N-parallel individual requests with single-request batch operations
- Image compare mode: Ctrl+click 2–4 images → Compare button → side-by-side lightbox
  (position:fixed close button avoids overflow:auto clipping)
- Prompt search: A1111 parameters text cached as prompt_cache on detail-panel open,
  included in list_images() text search alongside filename/tag/memo

Bulk bar UI cleanup:
- Dropdown placeholder: "Add to Group..." → "Select Group"
- Button: "Add" → "Add to Group"
- New "Remove from Group" button (uses POST /wfm/gallery/bulk/group action:remove)
- Button: "Delete" → "Delete File"

Help tab (Gallery page):
- helpGallery4/6/7 updated to reflect prompt search, compare, Remove from Group,
  Delete File; EN/JA/ZH all updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 14:07:49 +09:00
ketle-manandClaude Sonnet 4.6 d8fc1b62a3 feat: Send to Canvas direct load via window.opener + API-format DnD enabled (v0.3.43)
- node_sets_menu.js: register window.wfmReceiveWorkflow() so SPA can call
  app.handleFile() directly via window.opener; remove API-format grayout
  from W-tab items (all formats now draggable and double-clickable)
- workflow-tab.js: sendToCanvas() prefers window.opener.wfmReceiveWorkflow
  over localStorage fallback; remove API-format disabled state from side
  panel and detail modal Send to Canvas buttons
- gallery-tab.js: Copy & Send Canvas prefers window.opener; remove API-
  format disabled state from _updateCopyCanvasBtn
- i18n.js: add apiFormatCanvasNoOpener key (EN/JA/ZH); update helpGallery8
  and helpSidepanel17 to reflect new direct-send behaviour
- comfyui-client.js: queuePrompt() accepts extraData; generate() passes
  extra_pnginfo so SaveImage embeds workflow metadata in output PNGs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:09:34 +09:00
ketle-manandClaude Sonnet 4.6 f2162844fb feat: Gallery Feeder mode + WFS_GalleryFeeder node + security fixes (v0.3.42)
- Add WFS_GalleryFeeder custom node (group_name/index/sort_mode/seed → IMAGE)
- Add Feeder tab Gallery mode ([Image Loop]/[Gallery] toggle, node selector,
  group picker, image grid with click-to-set-index, Run/Stop loop)
- Add gallery_feeder_extension.js: After Gen combo + Run/Stop widgets on
  WFS_GalleryFeeder node in ComfyUI canvas (serialize:false, waitForExecution)
- Add Gallery thumbnail F button to toggle __Feeder__ group membership (cyan overlay)
- Add __Feeder__ reserved group: rename/delete blocked (403 server + disabled UI);
  FC toolbar button clears members; ensure_group auto-creates on Feeder init
- Security: escapeHtml() applied to tag names, filenames, error messages in gallery-tab.js;
  single-quote escaping added to util.js escapeHtml
- Security: _check_path_allowed() added to save_image_meta, toggle_favorite,
  add_to_group, remove_from_group in gallery_service.py
- Security/bugfix: _init_allowed_root() in gallery_routes.py initializes _allowed_root
  at module load (settings.json → ComfyUI default); fixes serve_image 404 in
  Feeder Gallery mode and prevents list_folder_tree from overriding _allowed_root
- Bugfix: WFS_GalleryFeeder seed max 0x7FFFFFFF → 0xffffffffffffffff (KSampler parity)
- Update README, DEVLOG, pyproject.toml to v0.3.42

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:16:09 +09:00
ketle-manandClaude Sonnet 4.6 272de3d168 fix: ImpactWildcardEncode/Processor prompt not shown in Generate UI (v0.3.41)
- Add CLIPTextEncodeEditPlus BFS role propagation (text1/text2 inputs)
- Skip empty text_edit in CLIPTextEncodeEditPlus prompt_nodes to prevent
  blank textarea when upstream ImpactWildcard nodes supply the prompt
- Always overwrite wildcard_text from widgets[0] in convertUiToApi to
  guard against object_info widget-order differences between impact-pack versions
- Add static fallback mapping for ImpactWildcardProcessor/Encode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 22:04:42 +09:00
ketle-manandClaude Sonnet 4.6 7181f4e20a feat: add Chat pane and Wildcard generation to AI TOOL tab (v0.3.40)
- AI TOOL tab: 3-pane layout expanded to 4-pane (Translation | Chat | TOOLS | Settings)
- Chat pane: multi-turn LLM conversation with history; Enter to send, Shift+Enter for newline; Clear resets history; Ollama /api/chat, LM Studio /v1/chat/completions
- TOOLS pane: new "Create wildcards" task — enter category name and count, generates plain-text entries one per line (no markdown, no numbering)
- Both features added to the Library side panel A tab (Chat sub-tab + wildcard dropdown option)
- i18n: aiStatusChatting / aiToastChatFailed / aiToastWcNoName added in EN/JA/ZH
- Help: AI TOOL tab card updated to 6 items; helpSidepanel16 updated in EN/JA/ZH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:43:03 +09:00
ketle-manandClaude Sonnet 4.6 735e8ba876 feat: Send to Canvas button + Library title drag-to-canvas (v0.3.39)
- Workflow tab: replaced "Open in ComfyUI" with "Send to Canvas" (toolbar
  and detail modal); stores workflow JSON in localStorage for title-drag
- Gallery tab: renamed "Copy Workflow" to "Copy & Send Canvas"; copies to
  clipboard and stores for title-drag simultaneously
- Workflow Studio Library: panel title turns blue with green indicator when
  a pending workflow is stored; drag title onto canvas to load via
  app.loadGraphData(); title resets automatically after loading
- i18n: sendToCanvas / copyAndSendCanvas / workflowSentToCanvas (EN/JA/ZH)
- Help: helpWf6, helpGallery8 updated; helpSidepanel17 added (EN/JA/ZH)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:33:13 +09:00
ketle-manandClaude Sonnet 4.6 c5a2662bef feat: add Tagger tab + GenUI:P button + ZIT workflow fix (v0.3.38)
### Tagger tab (new)
- WD Tagger (ONNX/NCHW/NHWC), SwinV2, DeepDanbooru (.h5/TF optional), Ollama VLM
- Single subtab: drag & drop / upload, 5 output targets (GenUI:P / Prompt / Gallery / File / DB)
- Batch subtab: folder processing, Save DB / Write to File / Write .txt output options
- DB subtab: searchable SQLite, row edit, CSV export
- Backend: tagger_service.py, tagger_db_service.py, tagger_routes.py (17 endpoints)
- Security: model name validation, extension allowlist, path traversal fix in gallery_service

### Tagger single — GenUI:P button
- Appends generated tags to GenerateUI positive prompt and applies to loaded workflow immediately

### GenerateUI — ZIT/Lumina2 workflow prompt fix
- Remove ConditioningZeroOut from COND_PASSTHROUGH; shared CLIPTextEncode now correctly
  gets role "positive" instead of "unknown" in ZIT workflows
- workflow_analyzer: add lumina2 → Z-IMAGE to _CLIP_TYPE_TO_MODEL

### Gallery detail panel
- Split tab row (Info/JSON/Groups) and action row (Metadata/Load GenUI/Tagger) to separate rows
- Add Tagger button (purple) to open selected image in Tagger tab
- Fix Save to Gallery: tags now stored as array (was string, broke forEach on refresh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:04:42 +09:00
ketle-manandClaude Sonnet 4.6 127d85d913 feat: Select All button for Models & Gallery bulk bars, Gallery bulk bar i18n (v0.3.37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:22:04 +09:00
ketle-manandClaude Fable 5 85ce2e05df fix: review fixes (Save safety, help i18n), util.js refactor, toast i18n, README refresh (v0.3.36)
- GenerateUI Save: overwrite confirmation with UI-format warning, Raw JSON
  sync, HTML-injection-safe filename input, double-submit guard
- Help i18n: v0.3.35 help updates were overwritten by stale i18n strings;
  synced helpGen2/helpModels2/helpGallery8/10 in 3 languages, registered
  missing helpIdMap entries, added helpGallery12/13
- Toast i18n: ~115 hard-coded English toasts replaced with t() (+~60 shared
  keys in EN/JA/ZH); fixed undefined Settings keys (textSize*/jsonColor*)
- Refactor: new shared util.js (escapeHtml unified across 5 files,
  readJsonStorage/getSettings replace 15 localStorage parses); sortModels
  precomputes sort keys; Gallery action buttons split from tab-button class
  (all !important CSS removed)
- README: intro rewritten around 3 pillars (Management / GenerateUI /
  Library side panel); new screenshots (LoRA Stack, Batch, Multi-select);
  WS Library image updated; pyproject description aligned

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:37:06 +09:00
ketle-manandClaude Sonnet 4.6 3190cc1443 feat: Models table sort, Gallery Load GenUI, GenerateUI Save button, help update (v0.3.35)
- Models tab: click column header to sort table (asc▲/desc▼/clear); active column highlighted in accent color; E/D label for Enable/Disable column
- Gallery tab: Load GenUI button loads embedded workflow into GenerateUI tab; Metadata button restyled green; warning toast if no workflow or unsupported format
- GenerateUI tab: Save button at right end of subtab row; filename dialog defaults to current workflow name; saves to Workflow tab via import API
- Help: Models tab note and Troubleshooting entry added for model loading error after move/delete (requires Hard Reset or ComfyUI restart)
- README and DEVLOG updated for v0.3.35

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:27:46 +09:00
ketle-manandClaude Sonnet 4.6 070e378314 feat: Models table Type/BaseModel columns, modal delete, bulk move, B/S fix, Gallery-Metadata link (v0.3.34)
- Table view: add CivitAI Type and Base Model columns between Subdirectory and Extension
- Detail modal: Delete button removes model file + all sidecar files
- Bulk action bar: file move operation (model + sidecars) with subfolder create; 3-row layout (Group/Badge/File); Select button green; dropdown width 330px; Delete Files moved to File row right end; X button removed
- B/S buttons (Checkpoint/LoRA table+card): targeted active-class toggle instead of full grid re-render; renderModelGrid only called when showBatchOnly filter is active
- Backend: GET /api/wfm/models/subdirs and POST /api/wfm/models/move with path traversal protection and _VALID_MODEL_TYPES whitelist
- Gallery: Alt+click or Metadata button opens selected image in Metadata tab via loadFileIntoMetadataTab()
- Help and DEVLOG updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:11:22 +09:00
ketle-manandClaude Sonnet 4.6 dbc9ff0820 fix: LoRA pane load overwrite, tab reset, Stack group visibility, CLIPTextEncodeEditPlus mapping, Stack Apply trigger words (v0.3.33)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 00:49:21 +09:00
ketle-manandClaude Sonnet 4.6 3957ec7d21 feat: Lora Loader (LoraManager) detection, M-tab Groups drop, P-tab Groups subtab (v0.3.32)
- I/Metadata tabs: detect LoRAs from Lora Loader (LoraManager) nodes in both workflow and API formats (inputs.loras.__value__)
- Fix strength parsing: use parseFloat to handle string values like "0.20" from LoraManager
- placeLoraMgrNode: sync text widget with LoRA syntax (<lora:name:s:c>) after drop
- M tab Groups: add "All N LoRAs" drag/drop item to place Lora Loader (LoraManager) with full group
- P tab: add Groups subtab (row2) showing wfm_prompt_preset_groups from localStorage
- Help (i18n.js), README, DEVLOG updated for v0.3.32

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:11:02 +09:00
ketle-manandClaude Sonnet 4.6 0c7ca31c99 feat: split LoRA pane into Single/Stack tabs, fix GenUI Model LoRA handling (v0.3.31)
- renderLoraPane rewritten: Single tab (filter, dropdown, M/C strength, Apply, P,
  LORA SYNTAX, TRIGGER WORDS) and Stack tab (target, Apply, P, toggle-all,
  global adj, LORA SYNTAX, TRIGGER WORDS, model list) are now independent
- _refreshLoraSingleDynamic: auto-updates LORA SYNTAX/TRIGGER WORDS on model or
  strength change in Single tab
- comfyEditor.switchLoraSingleTab() / disableAllStack() added for external access
- applyToGenUI (models-tab): now handles Lora Loader (LoraManager) via lora_nodes;
  switches to Single tab, disables Stack, updates LORA SYNTAX/TRIGGER WORDS display
- i18n helpGen5 / helpModels8 updated (EN/JA/ZH)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:47:13 +09:00
ketle-manandClaude Sonnet 4.6 ff9df1ad92 feat: LoRA stack trigger words fix, stack refresh button, help troubleshooting (v0.3.30)
- Fix trigger words display: inactive stack models no longer contribute trigger
  words; initial render and per-model toggle/toggle-all now filter to active only
- Add LoRA column header refresh button (🔄) for manual Stack group reload
  without switching away from the Model tab
- Add help troubleshooting entry for "No such file or directory" error when
  running LoRA Stack (comfyui-lora-manager compatibility issue)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:00:29 +09:00
ketle-manandClaude Sonnet 4.6 c0fde1c42e feat: LoRA unified pane, Help sidebar, Feeder UI, Batch Sampler/Scheduler, LoraManager support (v0.3.29)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:10:00 +09:00
ketle-manandClaude Sonnet 4.6 3886ad2b42 fix: workflow batch 404 and rename double-submit bug (v0.3.23)
- fix: workflow batch used non-existent route /api/wfm/workflows/{name};
  corrected to /api/wfm/workflows/raw?filename={name}
- fix: rename modal accumulated blur listeners on #wfm-modal-title-input
  across modal opens; subsequent renames sent two requests back-to-back
  (first succeeded, second hit 409/404); store listener ref on
  titleInput._commitRename and removeEventListener before re-registering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:49:59 +09:00
ketle-manandClaude Sonnet 4.6 080d75b8c4 feat: remove card view, add 4-type batch selector UI (v0.3.22)
- Remove Card view from Workflow and Models tabs (Thumbnail/Table only);
  localStorage fallback: stored "card" value defaults to "thumb"
- Replace Checkpoint Batch toggle with per-column-header checkboxes in
  Batch Queue pane (Checkpoint/Lora/Prompt/Workflow, radio behavior);
  Batch panel below Generate repurposed as status display + Pause/Stop
- Add _runBatchLoop() generic loop + _runBatchGenerate() 4-type dispatcher:
  Checkpoint (file-tree + group), Lora (group), Prompt (preset objects →
  positive/negative nodes), Workflow (group, restores original after batch)
- Remove ~86 lines of card-view CSS; keep nodes-tab card view intact
- Update help strings (EN/JA/ZH): helpWf1, helpModels2, helpGen11/13/14
- Update README features and Changelog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:22:29 +09:00
ketle-manandClaude Sonnet 4.6 eada3923c7 feat: CivitAI host setting, Info/Sample sub-tabs, URL fix, help i18n (v0.3.21)
- Settings tab: add civitai.com / civitai.red host selector; saved to
  settings.json and synced to localStorage for use across tabs
- CivitAI panel: split into Info / Sample sub-tabs; images moved to
  Sample tab with count in label; images open full-size in new tab
- URL fix: modelId=null entries now use /model-versions/{versionId}
  fallback (fixes all 324 existing cache entries client-side)
- Help: add helpGen12/13/14 (Batch tab panes) and helpSettings11/12/13
  (Text Size / RAW JSON Colors / CivitAI Host) with EN/JA/ZH; update
  helpGen3 (4-tab→5-tab) and helpGen11; fix JA/ZH English-mixed terms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:47:21 +09:00
ketle-manandClaude Sonnet 4.6 ef0fccbd36 feat: CivitAI panel — Type/Hash display, image new-tab, URL fix, 3-state UI (v0.3.20)
- Add Type badge, Base Model row, Hash row (BLAKE3/SHA256) to CivitAI side panel
- Extract fileHashes (BLAKE3, SHA256, AutoV2 etc.) in _extract_info
- Sample images wrapped in <a target="_blank"> to open full-size in new tab
- Fix broken model page URL caused by batch POST API omitting model.id;
  fall back to top-level modelId field in _extract_info; also reconstruct
  URL client-side from modelId+versionId for existing cached data
- Add 3-state CivitAI tab UI: unchecked / checked-not-found / found
- CivitAI tab click now calls renderSideCivitai to always show latest state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:35:45 +09:00
ketle-manandClaude Sonnet 4.6 9564aa8c6e feat: CivitAI batch speedup, retry backoff, API key, richer metadata (v0.3.19)
- civitai_service.py: replace per-model GET with POST /model-versions/by-hash
  batch endpoint (up to 100 hashes/request); batch_fetch now runs in 2 phases:
  Phase 1 hashes all files, Phase 2 POSTs uncached hashes in one shot —
  100-model fetch drops from ~50 s to a few seconds
- Add exponential backoff retry (1s/2s/4s, max 3 attempts) for HTTP
  429/500/502/503/504 in both fetch_by_hash and _batch_fetch_post
- Add CivitAI API key support: CIVITAI_API_KEY env var takes priority over
  settings.json civitai_api_key; Bearer token added to all requests
- settings_routes.py: exclude civitai_api_key from data export bundle
- _extract_info: add nsfwLevel, air (AIR URN), stats (downloadCount/thumbsUp),
  updatedAt, publishedAt, imageDetails (url/width/height/nsfwLevel per image),
  fileMeta (fp/size/format); images URL list kept for backward compatibility
- Normalize hash casing: lowercase for cache keys, uppercase for API calls
- models-tab.js: apply data.hashes from done SSE event directly to
  state.modelMetadata to fix CivitAI tab blank after batch fetch
- settings-tab.js + i18n.js (EN/JA/ZH): add CivitAI API Key settings section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:44:05 +09:00
ketle-manandClaude Sonnet 4.6 7395fe2432 feat: add Batch tab to GenerateUI, expand Models bulk actions (v0.3.18)
- GenerateUI: new Batch subtab (5th tab) with 3-pane layout —
  left pane: file-tree checkpoint selector (initially all unchecked);
  center pane: group-based selection with inner tabs (Checkpoint/Lora/
  Prompt/Workflow); right pane: live Batch Queue preview combining both
  selections with duplicates removed
- Checkpoint selector removed from right panel dropdown; Batch tab
  is now the single entry point for checkpoint selection
- Group selection state refactored from flat selectedModels Set to
  selectedGroups + partialSelections so group membership changes in the
  Models tab are always reflected without stale entries
- Models: bulk action bar adds Deselect All, bulk Favorite/Unfavorite,
  bulk +Badge/-Badge; i18n keys added for all three (EN/JA/ZH)
- civitai_service.py: remove SSL CERT_NONE fallback (v0.3.17 patch,
  SSL verification is now never disabled)
- Help, README, DEVLOG updated to v0.3.18

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:55:48 +09:00
ketle-manandClaude Sonnet 4.6 49e2519f89 chore: bump pyproject.toml version to 0.3.17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:20:09 +09:00
ketle-manandClaude Sonnet 4.6 5390f0c94d fix: SSL cert error on CivitAI + Lora Manager UI-to-API conversion
- civitai_service: add _make_ssl_context() that tries certifi first,
  falls back to system store, then CERT_NONE with warning; fixes
  CERTIFICATE_VERIFY_FAILED on Windows Portable Python environments
- comfyui-workflow: convertUiToApi() now detects nodes with
  properties.__lm_widget_ids (Lora Loader (LoraManager)) and maps
  widgets_values via that index, wrapping the loras array as
  {"__value__": [...]} for the API format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:18:10 +09:00
ketle-manandClaude Sonnet 4.6 b20f46dc93 feat: show CivitAI image as preview fallback when no local file exists
When the backend preview download fails (server-side network restriction etc.),
loadPreviewImage() now falls back to civitaiCache[sha256].images[0] so the
card and side panel always show the CivitAI image after fetching.
fetchCivitaiForModel() also refreshes the grid and side panel regardless of
preview_saved, using the CivitAI URL directly when no local file was saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:32:20 +09:00
ketle-manandClaude Sonnet 4.6 14c538eb55 docs: v0.3.15 README and DEVLOG update
- README: version badge 0.3.14 → 0.3.15, Sample Workflows section,
  CivitAI auto-preview note, Create Tags in TOOLS pane, v0.3.15 changelog
- DEVLOG: add 2026-05-20 v0.3.15 entry covering all three changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:35:51 +09:00
ketle-manandClaude Sonnet 4.6 ef0017a0fc docs: update help text for CivitAI auto-preview and Create Tags
- helpModels5 (EN/JA/ZH): note that preview is auto-downloaded when missing
- helpAi3 (EN/JA/ZH): add Create Tags / タグ作成 / 标签生成 to TOOLS task list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:33:37 +09:00
ketle-manandClaude Sonnet 4.6 19e77a00db feat: add Create tags option to TOOLS pane VLM task selector
Add "Create tags" task to the VLM dropdown in both the SPA AI TOOL tab
and the Library panel A tab. Sends a prompt that asks the VLM to output
comma-separated English tags describing the image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:31:38 +09:00
ketle-manandClaude Sonnet 4.6 826988cc0b feat: auto-save CivitAI preview image for models without preview
When fetching CivitAI info (individual or batch), if the model has no
preview image, automatically download the first CivitAI image and save
it as {model_stem}.preview.png next to the model file.

- civitai_service.py: add download_image() static method
- models_routes.py: auto-download preview in handle_civitai_fetch and
  handle_civitai_batch; batch summary includes preview_saved count
- models-tab.js: refresh side panel and grid on preview_saved=true;
  batch done toast shows "+N preview" when previews were saved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:16:16 +09:00
ketle-manandClaude Sonnet 4.6 28bfbcda6b docs: add Sample Workflows section to README
Explains that workflows/ contains sample workflows and that some may
require additional custom nodes installable via ComfyUI Manager or git clone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:54:18 +09:00
ketle-manandClaude Sonnet 4.6 6eef19e656 feat: add sample workflow files for distribution
Add workflows/ directory with 13 sample workflows (JSON + PNG thumbnails)
covering SD1.5, SDXL, DWPose, face detailer, and image editing presets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:46:51 +09:00
ketle-manandClaude Sonnet 4.6 646721a5f4 docs: update DEVLOG for AI TOOL tab English label fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:54:47 +09:00
ketle-manandClaude Sonnet 4.6 4c44215f70 fix: English-only static labels for AI TOOL tab (SPA + Library panel)
- Replace all Japanese-hardcoded labels in templates/index.html AI TOOL section with English
- Replace all Japanese-hardcoded labels in node_sets_menu.js AI tab (HTML + JS toast/status strings)
- Rename VLM subtab to TOOLS in Library panel subtab list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:52:20 +09:00
ketle-manandClaude Sonnet 4.6 66f46af8b6 v0.3.14: AI TOOL tab 3-pane layout + RAW JSON color customization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:34:17 +09:00
ketle-manandClaude Sonnet 4.6 95266a20b8 v0.3.13: AI tab (Translation, VLM, Settings) for SPA and Library panel
- Add A tab to SPA (templates/index.html) with Translation, VLM, Settings sub-tabs
- Add new static/js/ai-tab.js module (Ollama / LM Studio backends, full i18n via t())
- Add A tab to Workflow Studio Library side panel (node_sets_menu.js)
- Settings shared between SPA and Library panel via localStorage (wfm_ai_settings)
- URL security: validate backend URL with new URL() before any network call
- Rename ollamaSettings to 'Ollama Settings (Prompt Tab)' in EN/JA/ZH to distinguish from AI tab
- Add AI tab help card and helpSidepanel16 to Help tab (index.html + i18n.js + app.js)
- Update docs/6_ws_library.png, README.md (v0.3.13), DEVLOG.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:42:40 +09:00
ketle-manandClaude Sonnet 4.6 d54234b18d v0.3.12: Raw JSON search, workflow analyzer extended model detection, README screenshots refresh
- GenerateUI: always-visible search bar in Raw JSON panel (match count, ↑/↓ nav, Escape/✕ clear, orange/yellow highlights, scroll sync)
- workflow_analyzer: CLIPLoader type-field detection (_CLIP_TYPE_TO_MODEL), subgraph node scanning, UnetLoaderGGUF support, NewBie/Ovis/HiDream/Wan/Cosmos/Lumina added
- README: screenshots replaced with 8 feature-focused images; v0.3.12 changelog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:46:59 +09:00
ketle-manandClaude Sonnet 4.6 4298fd9ea2 v0.3.11: Metadata tab extended loader support + Settings text size slider
- metadata-tab.js: add UnetLoaderGGUF/UNETLoaderGGUF as Diffusion Model
  and QuadrupleCLIPLoader as Text Encoder (was already in node_sets_menu.js
  for the I tab; now consistent across both Metadata tab and side panel)
- settings-tab.js: add applyTextareaFontSize() + Text Size section (10-28px
  slider that applies to all 8 prompt/chat textareas at once)
- app.js: apply saved textareaFontSize on page load alongside theme
- templates/index.html: update help text for Metadata and Settings tabs
- README/DEVLOG: v0.3.11 documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:03:10 +09:00
ketle-manandClaude Sonnet 4.6 b8b4a65881 v0.3.10: Information tab drag-to-canvas, extended node type support, bug fixes
- Add drag & drop from I tab (model/lora/Prompts) to ComfyUI canvas
  - model sub-tab: places CheckpointLoaderSimple / VAELoader / UNETLoader / CLIPLoader
  - lora sub-tab: drag individual LoRA → LoraLoader; Multiple LORA section → Lora Loader (LoraManager)
  - Prompts sub-tab: drag prompt → CLIPTextEncode with text pre-filled
  - Double-click also places nodes at canvas center
- Add UnetLoaderGGUF / UNETLoaderGGUF detection as Diffusion Model
- Add QuadrupleCLIPLoader (HiDream, 4-CLIP workflows) detection as Text Encoder
- Fix CLIPLoader widget name in MODEL_NODE_MAP (clip_name1 → clip_name)
- Fix Information tab preview area height (fixed 110px, prevents list compression)
- Update help (index.html, app.js, i18n.js EN/JA/ZH) with new drag features
- Update README and DEVLOG for v0.3.10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:25:37 +09:00
ketle-manandClaude Sonnet 4.6 0015638874 v0.3.9: side panel I tab + top bar icon fix
- Add Information (I) tab to ComfyUI side panel: drop PNG/WebP/JSON to
  inspect model, LoRA, and prompt metadata without leaving the canvas;
  sub-tabs: model / lora / Prompts with full-text preview and Copy button
- Shorten side panel tab labels to W / N / P / M / I with full name on hover
- Fix top bar icons invisible in newer ComfyUI: remove early-return guard
  that skipped SVG injection when Iconify had already converted the MDI
  class to an inline SVG; replace MutationObserver with simple rAF retry
- Update README, DEVLOG, help text (EN/JA/ZH), and index.html for v0.3.9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:48:37 +09:00
ketle-manandClaude Sonnet 4.6 a48d9c1437 v0.3.8: Metadata tab - data9 workflow support and unclassified prompt text type
- Extended extractPromptsFromNodeSet to resolve link sources from any STRING-output
  node (ComfySwitchNode, PrimitiveStringMultiline, etc.) without type restriction
- Added fallback for SamplerCustomAdvanced (no positive/negative inputs): return
  textMap contents as texts[] instead of null
- Extended step4 PrimitiveStringMultiline and step7 final fallback to search
  subgraph nodes via collectAllNodes
- Added "text" prompt type (no POS/NEG badge) for cases where polarity cannot be
  determined; buildPromptItem and handleFile updated accordingly
- Added metaPromptText i18n key (EN/JA/ZH)
- Updated README (v0.3.8, feature description) and DEVLOG

Supported in data9: flux2_fp8, flux2_klein_4b_distilled, ernie_image,
qwen_image_edit_2511, wan2_2_14B_animate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:32:49 +09:00
ketle-manandClaude Sonnet 4.6 48303e1452 docs: update README and DEVLOG for v0.3.7
README:
- Bump version badge to 0.3.7
- Update Metadata Tab feature section: subgraph support, expanded
  format list, updated prompt extraction description
- Add v0.3.7 changelog entry

DEVLOG:
- Append i18n/HTML text update notes to v0.3.7 entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:05:56 +09:00
ketle-manandClaude Sonnet 4.6 b0171add24 docs: update Metadata tab format note and help text (EN/JA/ZH)
- metaFormatTodo: replace "more formats planned" with note that
  Flux.2 / Qwen-Image / Z-Image subgraph workflows are now supported
- helpMetadata5: list Flux.2 / Qwen-Image / Z-Image explicitly
- Update static fallback text in index.html accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:03:17 +09:00
ketle-manandClaude Sonnet 4.6 7958ad1276 v0.3.7: Metadata tab support for Flux2/Qwen/Z-Image subgraph workflows
- Add extractPromptsFromNodeSet() to handle CLIPTextEncode+KSampler in
  subgraphs (definitions.subgraphs) used by official ComfyUI templates
- Add extractMarkdownNoteModels() to parse **section** → - [model](url)
  from MarkdownNote nodes as fallback model extraction
- Add PrimitiveStringMultiline prompt support (flux2-klein workflows)
- Fix extractCheckpoints/extractVAEs to use collectAllNodes() so
  subgraph loaders are included
- Restructure extractPromptsLiteGraph into 7-stage fallback chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:09:06 +09:00
ketle-manandClaude Sonnet 4.6 fa62684b2e v0.3.6: Metadata tab for PNG/WebP/JSON model and prompt extraction
- Add Metadata tab (between Prompt and Gallery) with drag-and-drop support
- Extract checkpoint, VAE, diffusion model, text encoder, LoRA from PNG/WebP/JSON
- Extract positive/negative prompts with SDXL Prompt Styler support
- One-click transfer to GenerateUI and Prompt tab presets
- Support ComfyUI LiteGraph, API format, SD WebUI, Fooocus metadata
- Add i18n (EN/JA/ZH) for all new UI elements and help card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:49:38 +09:00
ketle-manandClaude Sonnet 4.6 8fcc4af262 fix: correct PublisherId to statsu for Registry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:49:52 +09:00
ketle-manandClaude Sonnet 4.6 33f1d22e30 ci: use official Comfy-Org/publish-node-action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:46:57 +09:00
ketle-manandClaude Sonnet 4.6 184da84718 ci: pass registry token via env variable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:40:22 +09:00
ketle-manandClaude Sonnet 4.6 e3aeec8022 ci: pipe N to skip tracking prompt in publish workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:39:04 +09:00
ketle-manandClaude Sonnet 4.6 c444db45e6 ci: add GitHub Actions workflow for Registry publish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:37:33 +09:00
ketle-manandClaude Sonnet 4.6 a1613bd1e8 chore: bump version to 0.3.5 for Registry publish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:28:05 +09:00
ketle-manandClaude Sonnet 4.6 30162789b8 docs: add thumbnail and update Registry icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:21:44 +09:00
ketle-manandClaude Sonnet 4.6 1cd3fb3fff v0.3.5: Feeder subtab for comfyui-image-feeder + workflow analysis improvements
- Add Feeder subtab to GenerateUI: ImageFeeder node selector, all node parameters,
  folder tree, image grid with checkbox selection, preview panel, presets,
  Run/Stop loop with After gen modes (Loop / Increment / Fixed)
- Run loop captures image_loop_node_sync WebSocket messages to auto-update Index;
  uses right-pane seed settings for KSampler (fixes same-image-repeated bug)
- Improve analyzeWorkflow: BFS CONDITIONING chain expansion (5 iterations),
  CLIPTextEncodeSDXL/Refiner, SDXLPromptStyler/Advanced, KSamplerAdvanced noise_seed,
  TextEncodeQwenImageEditPlus, PrimitiveStringMultiline, Power Lora Loader (rgthree),
  Checkpoint Loader (WAS) with space in class name
- Fix KSamplerAdvanced seed key via data-seed-key attribute in editor
- Add Help card for Feeder subtab with EN/JA/ZH i18n
- Update README to v0.3.5, add Feeder subtab section and changelog entry
- Update DEVLOG with full implementation notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:11:25 +09:00
ketle-manandClaude Sonnet 4.6 5b087713b9 v0.3.4: Checkpoint Batch folder-tree dropdown, Pause/Resume, Settings side-by-side layout
- Checkpoint Batch: replace Include/Exclude text inputs with folder-tree dropdown
  - Folder-level checkboxes with indeterminate state for partial selection
  - Expand/collapse folders with ▶; supports any subfolder depth
  - Filter search, All / None quick-select buttons
- Checkpoint Batch: add Pause / Resume button (suspends between models)
- Checkpoint Batch: Stop while paused now immediately exits the loop
- GenerateUI Settings tab: KSampler and Latent Image side by side (50% each)
- Help tab: update gen-6, add gen-11 (batch), fix gallery-5~11 i18n gap (5-item shift)
- i18n: add helpGen11, helpGallery11; update helpGallery5-10 across EN/JA/ZH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:41:57 +09:00
ketle-manandClaude Sonnet 4.6 6a2da8e4e9 docs: update DEVLOG v0.3.3 with subdirectory support and ImpactWildcardEncode fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:43:52 +09:00
ketle-manandClaude Sonnet 4.6 54fc57344a feat: wildcard subdirectory support
- wildcard_service.py: rglob recursive scan; _safe_path allows
  forward-slash separated relative paths (path traversal still blocked);
  save_file auto-creates parent directories; all responses include
  dir and wc_name fields (e.g. wc_name="color/animals").
- prompt-tab.js: file list and file picker group files by directory with
  a collapsible directory header; insert uses __wc_name__ (e.g.
  __color/animals__); editor name input accepts "folder/name" paths;
  save validation checks each path component individually.
- index.html: update editor name input placeholder to "name or folder/name".
- main.css: add .wfm-wc-dir-header, .wfm-wc-file-item--sub,
  .wfm-wc-picker-dir, .wfm-wc-picker-item--sub; add -webkit-user-select
  for Safari compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:41:46 +09:00
ketle-manandClaude Sonnet 4.6 1cc0e94724 Fix: ImpactWildcardEncode validation error in GenerateUI
- comfyui-workflow.js: validate COMBO widget values against object_info
  choices; fall back to first valid choice when stored value (e.g.
  "Select Wildcard 🟢 Full Cache") is not in the current options list.
  Prevents "Prompt outputs failed validation" with Impact Pack nodes.
- comfyui-workflow.js: recognize ImpactWildcardEncode/ImpactWildcardProcessor
  as prompt nodes in analyzeWorkflow (role detection via sampler refs);
  use wildcard_text as the editable text field (textKey).
- comfyui-editor.js: use node's textKey (defaulting to "text") when
  applying and syncing prompt fields, so ImpactWildcardEncode edits
  update wildcard_text instead of the non-existent text field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:27:53 +09:00
ketle-manandClaude Sonnet 4.6 823f1ebd54 v0.3.3: Wildcard support panel & Impact Pack integration
- Add wildcard input toolbar to Prompt tab (right column) with one-click
  syntax buttons ({|}, {n$|}, __|__, <lora:>, etc.) and selection wrapping
- Add wildcard file manager: create/edit/delete .txt/.yaml files stored in
  user/default/Workflow-Studio/wildcard/
- Tab-ify Prompt tab center pane (Presets / Preset Manager toggle)
- New wildcard CRUD API (GET/POST /api/wfm/wildcards/*)
- New Wildcard Integration section in Settings tab: auto-detects
  ComfyUI-Impact-Pack and creates a directory junction (Windows) or symlink
  (other OS) between WFS wildcard dir and Impact Pack wildcards dir; existing
  files are migrated automatically; link can be removed via UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:02:10 +09:00
ketle-manandClaude Sonnet 4.6 0886ea6e2b v0.3.2: Checkpoint Batch for GenerateUI & Seed layout fix
- Add Checkpoint Batch panel to GenerateUI right column: checkbox ON/OFF,
  include/exclude subfolder filters (comma-separated), real-time model count
  preview, amber progress bar with model name and index display
- Refactor handleGenerate into _coreGenerate (throws) + _runBatchGenerate loop;
  Stop button now aborts both single generation and batch loop
- Fix Seed row layout: seed input and mode selector now stacked vertically
  so neither is clipped in narrow right panels
- Update Help tab, DEVLOG, README for v0.3.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:14:55 +09:00
ketle-manandClaude Sonnet 4.6 226fc885d7 v0.3.1: Gallery folder/file operations, tree state persistence, workflow auto-save
- Gallery: add folder create/delete and file delete/move (single & multi-select)
- Gallery: preserve folder tree expanded state across operations
- Gallery: improve workflow metadata extraction (workflow > prompt > gallery_metadata.json)
- Generate tab: auto-save workflow metadata to gallery on image generation
- Help tab: update Gallery section to reflect new v0.3.1 features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:38:33 +09:00
ketle-manandClaude Sonnet 4.6 8d48c2276f v0.3.0: Cross-type group filter & sidebar group display fix
- models-tab.js: group filter dropdown now shows groups from all model
  types with [TypeLabel] prefix; selecting a group auto-switches type
- node_sets_menu.js: fix TypeError in renderModelGroups caused by v0.2.9
  per-type group format; add type label badge to each group header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:23:50 +09:00
ketle-manandClaude Sonnet 4.6 918ab54004 v0.2.9: Model Enable/Disable, per-type groups, multi-select & bulk delete
- Toggle model visibility by renaming file extension (.disabled suffix)
  per card (⏸ button), per group (Enable All / Disable All), or via
  status filter (All / Enabled / Disabled)
- Groups are now scoped per model type; old flat groups auto-migrated
- Multi-select mode with bulk action bar: add/remove from groups,
  permanently delete model files + preview + sidecar (.json/.info)
- Fix: subdirectory model disable path-doubling error
- Fix: Hypernetwork tab showing C/O/M/B from new ComfyUI COMBO format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 10:11:29 +09:00
ketle-manandClaude Sonnet 4.6 f25c042c39 v0.2.8: Move data dir to user/default/Workflow-Studio, add export/import
- py/config.py: DATA_DIR now resolves to ComfyUI/user/default/Workflow-Studio/
  with fallback to data/ when user/default/ does not exist
- py/routes/settings_routes.py: add GET /api/wfm/settings/export and
  POST /api/wfm/settings/import endpoints for full data bundle backup
- static/js/settings-tab.js: add Data Management section (export/import UI)
- static/js/i18n.js: add 8 new keys in EN/JA/ZH
- templates/index.html, static/js/app.js: add helpSettings8/9 to Help tab
- README.md, DEVLOG.md: updated for v0.2.8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:11:40 +09:00
ketle-manandClaude Sonnet 4.6 1c9ec7ac19 Fix: Gallery tab auto-selects root folder on load
Previously the gallery showed "Select a folder to browse images."
until the user manually clicked a folder. Now the root folder is
automatically selected when the folder tree loads.

Also update docs/screenshot_gallery.png and screenshot_settings.png.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:09:12 +09:00
105 changed files with 46582 additions and 1225 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Publish to Comfy Registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+15
View File
@@ -185,3 +185,18 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
5. `INPUT_TYPES` 変更時は `widgets_values` も同時更新
6. 削除した依存への参照が残っていないか grep で確認
7. ComfyUI完全再起動 → ワークフロー読み込み → エンドツーエンド実行確認
---
## リリース手順
コミット・プッシュ・GitHub リリース(`gh release create`)を行う際は、**必ず以下の順序**で実施する。
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
2. `README.md` のバージョンバッジを更新(`![Version](https://img.shields.io/badge/version-X.Y.Z-green)`)
3. `README.md` の Changelog に新バージョンのエントリを追加
4. `DEVLOG.md` の先頭に変更詳細エントリを追加
5. 上記すべてを同一コミットにまとめてプッシュ
6. `gh release create vX.Y.Z` でリリースを作成
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
+4531
View File
File diff suppressed because it is too large Load Diff
+599 -49
View File
@@ -1,48 +1,71 @@
# ComfyUI-Workflow-Studio
A comprehensive workflow management and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
A comprehensive workflow, asset management, and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
**📁 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
**📚 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.2.7-green)
![Version](https://img.shields.io/badge/version-0.3.65-green)
## Screenshots
| Workflow Tab | GenerateUI Tab |
| Workflow Tab | Models Tab |
|:---:|:---:|
| ![Workflow](docs/screenshot_workflow.png) | ![GenerateUI](docs/screenshot_generate.png) |
| ![Workflow Tab](docs/1_workflowtab.png) | ![Models Tab](docs/2_modelstab.png) |
| Prompt Tab | Settings Tab |
| Prompt Input Assistance | Gen UI Feeder |
|:---:|:---:|
| ![Prompt](docs/screenshot_prompt.png) | ![Settings](docs/screenshot_settings.png) |
| ![Prompt Input Assistance](docs/3_PromptInputAssistance.png) | ![Gen UI Feeder](docs/4_GenUI_feeder.png) |
| Nodes Tab | Models Tab |
| GenUI LoRA Stack | GenUI Batch |
|:---:|:---:|
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| ![GenUI LoRA Stack](docs/9_GenUI_LoraStack.png) | ![GenUI Batch](docs/10_GenUI_Batch.png) |
| Gallery Tab | Help & Support Tab |
| Models Multi-select Menu | Top Bar |
|:---:|:---:|
| ![Gallery](docs/screenshot_gallery.png) | ![Help](docs/screenshot_help.png) |
| ![Models Multi-select Menu](docs/11_multiple_select_menu.png) | ![Top Bar](docs/5_topbar.png) |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
| WS Library | Library Information |
|:---:|:---:|
| ![Workflow Studio Library](docs/screenshot_wf_node_library.png) | ![ComfyUI Top Bar](docs/screenshot_comfyui_topbar.png) |
| ![WS Library](docs/6_ws_library.png) | ![Library Information](docs/7_library_Infomation.png) |
| Tagger Tab | Image Edit Tab |
|:---:|:---:|
| ![Tagger Tab](docs/12_Tagger.png) | ![Image Edit Tab](docs/13_ImageEdit.png) |
| Customize | |
|:---:|:---:|
| ![Customize](docs/8_Customize.png) | |
---
## Features
### Workflow Tab
- **Thumbnail / Card / Table views** — switch between view modes to browse your workflow library
- **Thumbnail / Table views** — switch between view modes to browse your workflow library
- **Thumbnail side panel** — preview workflow canvas snapshots in the side panel
- **Badge filtering** — filter by user-defined badges (free labels you assign to each workflow)
- **Search** — full-text search across workflow names and metadata
- **Side panel tabs** — Thumbnail preview, JSON viewer with syntax highlighting, and Group management
- **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, open in ComfyUI directly
- **Default view setting** — persist your preferred view mode (Thumbnail / Card / Table)
- **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)
- **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
### Canvas Snapshot (v0.1.2)
- **One-click capture** — click the camera button in ComfyUI's top bar to snapshot the current workflow canvas
@@ -50,19 +73,48 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Embedded workflow metadata** — workflow JSON is embedded in the PNG (tEXt chunk), compatible with ComfyUI's drag-and-drop import
- **Auto-import** — the captured workflow is automatically imported and appears in the Workflow tab
### GenerateUI Tab (v0.2.4)
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
- **Settings tab** — KSampler (top) and LatentImage (bottom) in the left column; 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
### GenerateUI Tab (v0.3.5)
- **5-tab layout** — Input / Model / Settings / Feeder / Batch tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
- **Save button** — located at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API
- **Input tab** — Prompt and Image inner tabs (drag-and-drop upload); Prompt tab shows Positive Prompt and Negative Prompt textareas, plus an **Embeddings selector** at the bottom (Filter + Select + Weight input + Paste button); Paste inserts `(embedding:Name:weight)` at the cursor position of the last focused textarea (defaults to Positive when neither is focused); Raw JSON (540px) in the right column
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device), **Hypernetwork** (with Strength field) selectors with filter; Raw JSON on the right
- **Settings tab** — KSampler and Latent Image side by side at 50% width each; Raw JSON on the right
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow; built-in **search bar** (always shown) finds all matches as you type with count display (`3/12`); navigate with ↑/↓ buttons or Enter / Shift+Enter; Escape or ✕ clears; current match highlighted in orange, other matches in yellow
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
- **Seed control** — randomize, lock, or manually set seeds
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
- **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
- **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
- **Batch tab** (v0.3.18) — dedicated 3-pane layout for assembling the batch queue:
- **Left pane** — 4 tabs: **Checkpoints** (file-tree; Filter / All / None), **Sampler** (KSampler sampler list), **Scheduler** (KSampler scheduler list), **Style** (flat list of all styles from `Workflow-Studio/style/`; All / None buttons)
- **Center pane** — group-based selection with inner tabs (Checkpoint | Lora | Prompt | Workflow); Checkpoint/Lora groups come from the Models tab, Prompt groups from the Prompt tab, Workflow groups from the Workflow tab — check a group to add all its members, expand ▶ to select individually
- **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
- **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 more
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
### Feeder subtab (v0.3.5 / v0.3.42)
Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons at the top of the left pane (persisted in `localStorage`).
**Image Loop mode** — requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder)** custom node.
- **ImageFeeder node control** — select the target node from a dropdown auto-populated from the loaded workflow; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection) and Apply to the workflow
- **Image library** — 3-pane layout: folder tree (left) browsing `user/default/image-loop-data/`, image grid with checkbox selection (center), preview panel with resolution and file size (right)
- **Selection management** — check individual images; All / None buttons for the current folder; selected files are reflected in `selected_files` on Apply
- **Presets** — save the current directory + selection as a named preset; load or delete presets (server-side persistence via `image-feeder-presets.json`)
- **Continuous Run loop** — Run / Stop buttons below the presets; **After gen** mode controls index behavior after each generation:
- **Loop** — advance index and wrap back to 0 when all images are exhausted (runs indefinitely)
- **Increment** — advance index and auto-stop when all images are consumed
- **Fixed** — always use the same index
- **Index sync** — after each generation the node returns `next_index` via WebSocket (`image_loop_node_sync`); the Index field updates automatically
- **Seed** — Run loop uses the right-pane seed setting (Random / Fixed / Increment / Decrement); the node's own Seed field only affects random sort order
**Gallery mode** (v0.3.42) — no external plugin required; uses the built-in **WFS_GalleryFeeder** custom node.
- **WFS_GalleryFeeder node** — reads images directly from a Gallery group and outputs them one at a time; inputs: `group_name`, `index`, `sort_mode` (filename_asc / filename_desc / random), `seed`
- **Node & group selector** — pick the WFS_GalleryFeeder node from the loaded workflow and choose which Gallery group to feed images from; `__Feeder__` group is auto-created on first open
- **Image grid** — center pane shows all images in the selected group; click any image to update the Index to that position
- **After Gen modes** — Loop (wrap at end and continue indefinitely), Increment (stop automatically when last image is reached), Fixed (always use the same index)
- **Run / Stop controls** — left pane Run / Stop buttons manage the generation loop; After Gen combo (loop / increment / fixed), ▶ Run, and ■ Stop widgets are also available directly on the WFS_GalleryFeeder node in the ComfyUI canvas (`gallery_feeder_extension.js`)
### Prompt Tab
- **3-column layout** — AI Assistant, Presets editor, and Preset Manager displayed side-by-side
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
- **Image attachment** — attach reference images for vision-capable models
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
@@ -71,6 +123,18 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
- **Clipboard copy** — copy positive/negative prompts individually (PP Copy / NP Copy)
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
- **Wildcard input toolbar** — one-click buttons to insert `{|}`, `{n$|}`, `__|__`, `<lora::1:LBW=;>` and other wildcard syntax; wraps selected text when applicable
- **Wildcard file manager** — create, view, and edit `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; click a filename in the file picker to insert `__filename__` at cursor
### Metadata Tab (v0.3.8)
- **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); 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
- **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 subgraph workflows), SD WebUI, SD Forge, Fooocus
- **Format note** — supported formats and covered model types are always shown in the left column
### Settings Tab
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
@@ -81,19 +145,39 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
- **Eagle connection** — set Eagle API endpoint for auto-save
- **Ollama connection** — configure Ollama server URL
- **CivitAI Host** — choose which site opens when clicking a model link: `civitai.com` (SFW only) or `civitai.red` (unrestricted); saved to `settings.json` and synced to `localStorage` for the Models tab to use without extra fetches
- **CivitAI API Key** — optional Bearer token for authenticated CivitAI access; stored in `settings.json` (excluded from data exports); environment variable `CIVITAI_API_KEY` takes priority if set
- **Default workflow** — set a workflow to auto-load on startup
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
- **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
### Gallery Tab (v0.2.7)
### Gallery Tab (v0.3.44)
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch tag/group/favorite operations
- **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
- **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)
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
- **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
- **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
- **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
- **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)
- **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
- **Performance** — with folder-level mtime cache (30s TTL) for fast incremental refresh
- **Performance** — server-side 256px JPEG thumbnail generation with disk cache (`data/thumb_cache/`); infinite-scroll paging (50 images per page, IntersectionObserver); folder-level mtime cache (60s TTL); bulk operations use single-request API endpoints
### Nodes Tab (v0.1.7)
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
@@ -102,40 +186,103 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Node detail panel** — view I/O specifications, edit tags, manage groups
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
- **Multi-select** — Ctrl+click to toggle individual nodes, Shift+click for range selection; bulk action bar appears with: add to group / remove from group / create & add group / bulk favorite / unfavorite
- **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, category, package, tag, group, and favorites filter in one click
### Models Tab (v0.2.3)
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding) with sub-tab switching
- **Thumbnail / Card / Table views** — switch between view modes with pagination (24 items per page)
- **Thumbnail / Table views** — switch between view modes with pagination (24 items per page); Table view includes **Type** and **Base Model** columns sourced from cached CivitAI data, displayed between the Subdirectory and Extension columns; Enable/Disable column labeled **E/D**
- **Table column sort** — click any column header to sort ascending (▲), click again for descending (▼), click a third time to clear; active column highlighted in accent color
- **Search & Filter** — full-text search, filter by tags, groups, and favorites
- **User-defined badges** — assign free-label badges to models; badge colors shared with the Workflow tab palette
- **Side panel tabs** — Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
- **CivitAI integration** — fetch model metadata by SHA256 hash, view base model, trained words, tags, and model page link
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
- **Group management** — create, rename, delete groups and assign/remove models
- **Side panel tabs** — opens to CivitAI tab by default when a model is selected; Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
- **CivitAI integration** — fetch model metadata by SHA256 hash; side panel shows **Type** (badge), **Base Model**, **Hash** (BLAKE3/SHA256, click to copy), trained words, tags, and model page link; model link opens in the site selected in Settings (civitai.com or civitai.red)
- **CivitAI panel — Info / Sample sub-tabs** — Info tab shows model details (name, type, base model, hash, trigger words, tags, description); Sample tab shows all sample images (count shown in tab label); clicking any image opens the full-size version in a new tab
- **CivitAI panel states** — three distinct states: not yet checked (fetch button), checked but not found on CivitAI (re-check button with notice), and found (full info display); clicking the CivitAI tab always refreshes to the latest state
- **Batch CivitAI fetch** — one-click batch fetch using `POST /model-versions/by-hash` (up to 100 models per request) with SSE progress streaming; previews are auto-saved for models without one
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload; **Delete** button permanently removes the model file and all associated sidecar files (preview images, `.json`, `.civitai.info`, `.metadata.json`, `.cm-info.json`)
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork); **Embedding** type shows **GenUI PP** / **GenUI NP** buttons instead — appends `(embedding:Name:1.0)` to the Positive or Negative prompt of the loaded workflow; also accessible from the side panel nav bar and the detail modal
- **Group management** — create, rename, delete groups and assign/remove models; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab); Checkpoint and LoRA tabs include **B (Batch)** and **S (Stack)** quick-assign buttons per card/row for one-click group membership toggle without grid re-render
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
- **Enable / Disable models** — hide models from ComfyUI by renaming the file extension (`.disabled` suffix); toggle per card (⏸ button), per group (Enable All / Disable All), or filter by status (All / Enabled / Disabled)
- **Multi-select & bulk operations** — click the green **Select** button to enter selection mode and check multiple models; the bulk action bar is organized in three rows:
- **Group row** — select an existing group and **Add** / **Remove**, or type a new name and **Create & Add**
- **Badge row** — select a badge and **+Badge** / **−Badge** to apply or remove from all selected
- **File row** — select a destination subfolder (or type a new folder name and create it) then **Move** to relocate the model file with all sidecar files; **Delete Files** (right end) permanently removes selected models and all associated files
- **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, folder filter, group filter, favorites filter, and status filter in one click
### Workflow Studio Library (ComfyUI Side Panel) (v0.2.5)
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
- **Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
### Tagger Tab (v0.3.38)
- **3 sub-tabs** — Single / Batch / DB for single-image tagging, folder batch processing, and tag database management
- **Model support** — WD Tagger (ONNX, NCHW/NHWC auto-detect), SwinV2 (ONNX), DeepDanbooru (.h5, requires TensorFlow); place each model in its own subfolder under `ComfyUI/models/tagger/<model-name>/` containing the `.onnx` + `selected_tags.csv` (or `.h5` + label file)
- **Threshold sliders** — General threshold (default 0.35) and Character threshold for `character:` prefix tags (default 0.85); sliders update the display value in real time
- **Ollama VLM** — optional vision model alongside WD Tagger; set API URL and select model (↻ to refresh list); results from both models are merged into one comma-separated tag string
- **Single tab** — drag & drop an image onto the preview area or click Upload; Gallery detail panel **Tagger** button opens the selected image directly here
- **Single output options** — (1) **GenUI:P** — appends tags to the GenerateUI tab's positive prompt and immediately applies to the loaded workflow; (2) **Send to Prompt** — appends tags to the Prompt tab's positive textarea; (3) **Save to Gallery** — saves tags to Gallery image metadata (requires image opened from Gallery); (4) **Write to File** — embeds tags into JPEG EXIF (`ImageDescription`) or PNG `tEXt` chunk (`Tags` key), other formats get a `.tags.json` sidecar; (5) **Save to DB** — stores in internal SQLite database
- **Batch tab** — enter a folder path, configure WD Tagger and Ollama settings; output options: **Save to DB** (default on), **Write to File** (EXIF/PNG metadata), **Write .txt** (creates `<filename>.txt` alongside each image with all tags); real-time progress bar and log; Stop cancels after the current image
- **DB tab** — searchable SQLite database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records
- **Dependencies** — install into ComfyUI's embedded Python: `python_embedded\python.exe -m pip install -r requirements.txt`; for GPU inference use `onnxruntime-gpu`; TensorFlow (DeepDanbooru) is optional and commented out in `requirements.txt`
### Image Edit Tab (v0.3.65)
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG
- **Loading images** — drag & drop onto the canvas, Upload button, or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★); tool options bar updates per active tool
- **Select** — click to select; drag to move; drag corner handles to resize; drag circle handle to rotate; Flip H / Flip V / Rotate angle in the options bar; double-click a text layer to re-edit its content
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
- **Mask** — paint a white mask onto a dedicated mask layer, or use SAM3 text-prompt segmentation (requires Mask Editor One); 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**: Paint / SAM3 sub-tool buttons, Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); SAM3 button disabled when Mask Editor One is not installed; **Properties pane — Paint mode**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (visible when Mask Editor One is installed): Select opens the ABR stamp-brush library — choosing a brush replaces the default circle; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (random size per stamp, 0–100%), Rot. Jitter (random rotation per stamp on/off) controls appear; **Properties pane — SAM3 mode**: enter a text prompt (e.g. "cat"), set Max candidates (3/6/9/12), click Segment — candidate mask thumbnails appear; click to select (blue border + ✓, multi-select supported), click Apply Selected (N) to apply with current Mode (Add / Erase); a new mask layer is created automatically if needed; **Mask layer A/S operation**: A/S toggle button on each mask layer row — Add (green) composites normally, Subtract (red) cuts the mask from the layer below; **✂ clipping mask button** on the layer row clips the layer directly 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
- **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)
- **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)
### AI TOOL Tab (v0.3.14)
- **4-pane layout** — Translation | Chat | TOOLS | Settings; all panes always visible simultaneously; no sub-tab switching required
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama or LM Studio; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio uses `/v1/chat/completions`
- **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 (Wildcards)** (v0.3.40) — select "Create wildcards" from the task dropdown; enter a category name and count; click Run to generate plain-text wildcard entries one per line (no markdown, no numbering); result can be copied directly into wildcard `.txt` files
- **Settings pane** — choose backend (Ollama / LM Studio), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio)
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
- **Tab layout (W / N / P / M / I / A)** — compact single-letter tabs with full name shown on hover
- **W — Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
- **N — Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
- **Category sub-tab** — dropdown to filter nodes by top-level category
- **Package sub-tab** — dropdown to filter nodes by custom node package name
- **Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs)
- **Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs); LoRA groups show an **All N LoRAs** item — drag to canvas to place a `Lora Loader (LoraManager)` node with all LoRAs pre-loaded
- **P — Prompts tab** — browse prompt presets with All / ★ Favorites / Categories sub-tabs; **Groups sub-tab** (row 2) — view presets by group (shared with the Batch tab's `wfm_prompt_preset_groups`)
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama or LM Studio; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
- **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)
- **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
- **⚙ Theme settings** — customize panel background, sub-header background, text, border, and secondary text colors; saved to localStorage and applied on every open
### Help & Support Tab (v0.1.3)
- **Sidebar navigation** — 2-column layout: left sidebar (15 topics) + right content pane; click any topic to switch the displayed content
- **Support** — fixed at the bottom of the sidebar; shows GitHub and Ko-fi links in the right pane
- **Feature list** — overview of all features organized by tab
- **Tips** — quick tips for drag & drop import, favorites, and default workflow
- **Support links** — GitHub repository and Ko-fi donation page
- **Keyboard Shortcuts** and **Troubleshooting** sections included
---
@@ -156,6 +303,22 @@ 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
@@ -190,8 +353,10 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
### Optional
- **[Ollama](https://ollama.com/)** — for AI chat assistant and translation features
- **[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)
- **[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
---
@@ -207,6 +372,379 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### v0.3.65
- **Image Edit — Mask Editor One integration fixes** — `store_image` API field mismatch (`image_b64` → `bg_image_b64`) corrected so BiRefNet and SAM3 no longer report "no image available"; `server.py` updated to accept both field names for backward compatibility
- **Image Edit — top bar icon persistence** — WFS / Snapshot / Node Sets SVG icons in the ComfyUI action bar disappeared when the Properties Panel was opened (Vue re-render reset custom `innerHTML` to `<i>`); fixed with a `MutationObserver` that re-applies SVG icons whenever a reset is detected
- **Image Edit — Mask Tool: ABR brush library** (requires Mask Editor One) — Select button in the Properties pane opens a folder-tree + thumbnail-grid brush picker; stamp brushes are loaded from Mask Editor One's `/mask_editor/brushes/` folder; ✕ clears back to the default circle brush
- **Image Edit — Mask Tool: ABR brush controls** — when an image brush is active, Hardness is disabled and new controls appear in the Properties pane: Spacing (stamp interval 5–100%), Angle (fixed rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random angle per stamp on/off); size jitter clamped to minimum 1 px to prevent zero-dimension canvas errors
- **Image Edit — Mask brush cursor** — brush size circle is now hidden outside the canvas; only shown when the pointer is within the canvas bounds (window-level mousemove no longer shows the cursor overlay outside)
### v0.3.64
- **Image Edit — Mask layer Add/Subtract** — A/S toggle button on each mask layer row; Add (green, default) composites the mask normally; Subtract (red) cuts the mask area from the layer below; multiple mask layers are composited back-to-front matching Mask Editor One's CanvasCompositor logic
- **Image Edit — BiRefNet BG Remove** (requires Mask Editor One + `birefnet.safetensors` in `ComfyUI/models/background_removal/`) — BiRefNet model now fully functional via Mask Editor One's Python backend; grayscale mask returned as RGBA PNG and applied as a new layer or in-place per the New Layer toggle
- **Image Edit — SAM3 segmentation** (requires Mask Editor One) — new SAM3 sub-tool in the Mask tool options bar; enter a text prompt, set Max candidates (3/6/9/12), click Segment; candidate mask thumbnails shown in the Properties pane; click to select (multi-select), Apply Selected (N) applies all chosen masks with Add or Erase mode; new mask layer auto-created if active layer is not a mask
### v0.3.63
- **Nodes tab — multi-select** — Ctrl+click to toggle individual nodes, Shift+click for range selection; bulk action bar appears with add to group / remove from group / create & add group / bulk favorite / unfavorite; works in both Card and Table view modes
- **All tabs — search clear (✕)** — inline ✕ overlay button inside each search box (Workflow / Nodes / Models / Gallery); shown only when text is entered, click to clear and re-filter immediately
- **All tabs — clear all filters button** — new ✕ Clear button at the rightmost end of each tab's toolbar; resets all active filters (search, group, tag, folder, favorites, badge) in one click; Gallery sort order is preserved
### v0.3.62
- **Gallery tab — Send GenUI Image button** — new 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; `comfyEditor.applyImageToSlot(file, slotIndex)` added to `comfyui-editor.js` for reusable image-slot injection
- **Bug fix: tagger_settings.json missing from data export** — `tagger_settings.json` was absent from `_DATA_FILES` in `settings_routes.py`; Tagger model directory and threshold settings were silently excluded from Settings → Data Management export/import; fixed by adding the file to the export list
- **Help — Send GenUI Image** — new help card (`helpGallery17`) added in EN/JA/ZH; registered in `helpIdMap`
- **Screenshots — Tagger and Image Edit tabs** — `docs/12_Tagger.png` and `docs/13_ImageEdit.png` added to the README screenshots section
### v0.3.61
- **Image Edit — Draw / Mask brush cursor** — cursor replaced with a size-accurate circle overlay matching the current brush size; the circle scales correctly with canvas zoom so the visible area matches exactly what will be painted
- **Image Edit — Draw / Mask out-of-canvas strokes** — strokes can now start from the canvas margin area (outside the image boundary) and continue past the edge without interruption; `window`-level `mousemove` / `mouseup` tracking keeps the stroke alive when the pointer leaves the canvas element
- **G'MIC — argument order fix** — corrected `gmic_qt.exe` launch arguments to `-o <output> <input>` (the only order G'MIC-Qt Standalone accepts); the previous order caused the GUI to exit immediately without opening
- **G'MIC — settings path placeholder** — replaced the hard-coded user-specific default path with a generic `C:\path\to\gmic_qt.exe` placeholder; settings load without a pre-filled path for new users
- **G'MIC — security: path traversal fix** — `POST /api/wfm/gmic/result` now validates that `result_path` is inside the `gmic_temp` directory before reading the file; requests for paths outside that directory are rejected with 403
- **Help — G'MIC Filter section** — added two new help cards (G'MIC Filter usage and G'MIC-Qt Installation) with full i18n support (English / Japanese / Chinese)
### v0.3.60
- **Image Edit — Filter tool (G'MIC-Qt integration)** — the previously inactive ★ Filter tool now launches G'MIC-Qt GUI with the active layer image; select a filter and click OK, then close the "G'MIC-Qt filter output" window to apply the result back as the active layer; polling-based job system with progress display, Cancel button, and Undo support; requires G'MIC-Qt Standalone (`gmic_qt.exe`) configured in Settings
- **Settings — G'MIC-Qt Executable Path** — new collapsible section in Settings for entering and saving the path to `gmic_qt.exe`
### v0.3.59
- **Image Edit — Mask Tool** — new Mask tool (🎭) in the Image Edit sidebar; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the layer list also switches to the Mask tool; **Tool Options bar**: Paint button, Invert checkbox (inverts overlay preview), Overlay color picker (default red), Blur slider (0–50 px) for overlay edge softening; **Properties pane** (left of the Layers panel, shown when Mask tool is active): Size (1–200 px), Hardness (0–100%), Mode — **Add** / **Erase** toggle buttons (active mode highlighted in blue)
- **Image Edit — Clipping mask (✂ button)** — each mask layer row shows a ✂ toggle; when enabled (blue), the mask clips the layer directly below it so only the painted area is visible; clipping is applied to PNG save / Save to Gallery / Send to ComfyUI exports
- **Image Edit — Draw & Mask paint bugfix** — Draw and Mask tools now paint directly onto `layer.canvas`; all other layers remain fully visible during brush strokes (previously the composite view was replaced by the active layer only while painting)
- **Image Edit — Layer panel width** — increased from 190 px to 220 px to accommodate the additional ✂ button on mask layer rows
- **Help — Mask Tool section** — added Mask Tool card with full i18n support (English / Japanese / Chinese); updated Tools list, layer type icon list, and Layer panel "M button" description
### v0.3.58
- **GenerateUI Model tab — Diffusion Model GGUF support** — `LoaderGGUF` and `LoaderGGUFAdvanced` nodes are now recognized alongside `UNETLoader` / `UnetLoaderGGUF`; the correct input key (`gguf_name` vs `unet_name`) is selected automatically on Apply; the Models tab "GenUI Model" button also applies correctly to `LoaderGGUF` workflows
- **GenerateUI Model tab — Text Encoder expanded (CLIPLoader / DualCLIPLoader / GGUF variants)** — the Text Encoder section now renders a node-type-aware UI: **single CLIP** (`CLIPLoader` / `ClipLoaderGGUF`) shows one clip selector + **type** dropdown + **device** (GGUF only); **Dual CLIP** (`DualCLIPLoader` / `DualClipLoaderGGUF`) shows two clip selectors (clip_name1 / clip_name2) + **type** dropdown + **device** (GGUF only); type options are fetched dynamically from `/object_info`; filter applies to the first selector only
### v0.3.57
- **Image Edit — Blur tool** — new Blur tool (≈) in the Image Edit sidebar; **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**: toggle to enter rect-draw mode then drag to select a region — blur or mosaic is applied on mouse release; Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo (Ctrl+Z)
- **Image Edit — BG Remove tool** — new BG Remove tool (⬚); **Lightweight (@imgly)**: background removal running entirely in the browser via CDN (~40 MB model cached after first load); **BiRefNet**: stub wired to a future Python backend endpoint (coming soon); **New Layer** checkbox (default on) adds the result as a new layer above the original — uncheck to replace the active layer in-place; supports Undo
- **Image Edit — sidebar icons** — removed `ie-tool-placeholder` dimming from Blur and BG Remove buttons; both now display at full brightness matching other active tools
- **Help — Blur / BG Remove sections** — added two new help cards (Blur Tool and BG Remove Tool) with full i18n support (English / Japanese / Chinese); Tools list updated with Blur and BG Remove overview items
### v0.3.56
- **GenerateUI — Style selector** — new checkbox and dropdown next to the Reset Workflow button; when enabled, a Fooocus-style JSON style is applied to positive and negative prompts at generation time; style files (`*.json`) are read from `user/default/Workflow-Studio/style/`; multiple files are merged into a single list sorted by filename; the style's `{prompt}` placeholder is replaced with the original prompt text, otherwise the style text is appended; `negative_prompt` is appended to the existing negative prompt; styles are applied to a per-generation workflow copy and never modify the loaded workflow
- **Batch tab — Style batch** — new **Style** tab in the Batch left pane lists all styles loaded from the style directory (flat list with All / None buttons); select any combination, enable the **Style** column in Batch Queue, then Generate to run each selected style sequentially; Batch Queue expanded from 6 to 7 columns (Style added as the rightmost column)
### v0.3.55
- **Group feature — fix data loss on Windows (backslash/slash mismatch)** — on Windows, ComfyUI returns model paths with backslashes (`sub\model.safetensors`) while the backend normalized to forward slashes; this caused all subdir group members to be flagged as missing and deleted on save; fixed by normalizing both stored and scanned paths to forward slashes in `get_model_groups` and `save_model_groups`
- **Group feature — network drive protection** — if no model directories are accessible (e.g. external drive disconnected), the group cleanup step is now skipped entirely to prevent wiping all group memberships
- **Group feature — scan cache** — `_scan_model_names` now caches results for 30 seconds to eliminate redundant full directory scans on startup (caused high CPU load / crash on large model sets)
- **Group feature — RESERVED_GROUPS protection** — `bulkRemoveFromGroup` and `renderPmGroups` now prevent Batch/Stack reserved group keys from being deleted even when all members are removed
- **Group feature — stale groupFilter reset** — after deleting a group, `state.groupFilter` is automatically cleared if it pointed to the now-deleted group (previously showed 0 models)
- **Group feature — toggleGroupEnable partial failure** — only models listed in `data.ok` are updated client-side, preventing state divergence when some enable/disable calls fail
- **GenerateUI batch groups — restore Batch/Stack visibility** — reverts an incorrect filter that hid Batch and Stack groups from the batch group selection UI; these groups are now selectable for batch execution as intended
- **GenerateUI LoRA groups — path normalization** — backslash-to-slash normalization added after fetching LoRA groups from the API, matching the Checkpoint fix
- **Batch complete toast — i18n** — replaced hardcoded English toast with `t("batchComplete", ...)` supporting all 3 languages
- **Prompt tab migration — fix negText loss** — `migrateLocalStoragePresets` now correctly passes `negText` when calling `apiCreatePreset`, preventing negative prompts from being silently dropped during localStorage→API migration
- **Help tab — Image Edit section i18n** — the Image Edit help page was hardcoded in English with no language switching support; all 6 sections (30 items + 7 headings) now have i18n IDs wired to English / Japanese / Chinese translations
### v0.3.54
- **Image Edit — Shape tool (S)** — new Shape tool added to the Image Edit toolbar; draw Rect, Ellipse, Line, and FreeLine shapes by dragging; each committed shape becomes an independent draw layer that can be undone in a single step; Tool Options: shape type selector, Rounded toggle (Rect/Ellipse only), Fill color + None toggle (hidden for Line/FreeLine), Stroke color + width (Stroke None hidden for Line/FreeLine where stroke is always active), Opacity slider, Undo button; blue dashed outline previews the shape during drag; keyboard shortcut **S** activates the tool
- **Models tab — fix B/S overlay buttons not highlighting** — the Batch (B) and Stack (S) card overlay buttons failed to turn yellow after clicking because `e.currentTarget` is nulled by the browser after an `async` event handler yields at `await`; fixed by capturing `const btn = e.currentTarget` before the await in both handlers
### v0.3.53
- **Image Edit Tab** — new layer-based image editing tab with Select / Draw / Text tools, multi-layer compositing, canvas zoom & pan, and Undo/Redo; text layers are sized to exact bounding-box dimensions and regenerated at display resolution on resize to prevent blurring; double-click a text layer to re-edit its content; Gallery tab gains an "Image Edit" toolbar button to send the selected image directly to the editor
- **Image Edit — Layer lock** — lock icon (🔒/🔓) on each layer row; locked layers display an orange bounding box and ignore all Select-tool transforms; Layer 1 is auto-locked when the first image is loaded to prevent accidental moves
- **Image Edit — Save to Gallery** — "Save to Gallery" button (next to Save PNG) saves the composite image to the Gallery root folder; filename defaults to `wfs-image-YYYYMMDDHHmmss`; backed by new `POST /wfm/gallery/image/save` API endpoint
- **Help tab — Image Edit page** — new dedicated help page covering tools, layer panel, text quality, export options, and keyboard shortcuts
### v0.3.52
- **GenerateUI Model tab — fix LoRA selection reset when switching subtabs** — applying a LoRA via "GenUI Model" button and then switching to another subTab (Prompt/Image/Settings) and back to Model caused the LoRA selector, syntax display, and trigger words to revert to the original workflow values; fixed by reading `currentVal` directly from `currentWorkflow` instead of the stale `currentAnalysis`, and by saving/restoring the Single tab's syntax and trigger words across re-renders
### v0.3.51
- **Models tab — Embedding type: GenUI PP / GenUI NP buttons** — Embedding models now show two buttons ("GenUI PP" and "GenUI NP") in the side panel nav, detail side panel, and detail modal (replacing the single "GenUI Model" button); clicking either appends `(embedding:Name:1.0)` to the Positive or Negative prompt of the loaded workflow
- **GenerateUI Prompt tab — Embeddings selector** — new section at the bottom of the Prompt tab: Filter input, model select dropdown, Weight input (default 1.0), and Paste button; Paste inserts `(embedding:Stem:weight)` at the cursor position of the last focused textarea (Positive or Negative); focus state is tracked on click/keyup/blur so the button works even after clicking away
- **GenerateUI Model tab — Hypernetwork support** — Hypernetwork section added with model selector (filter) and Strength field; applies to `HypernetworkLoader` nodes in the workflow; GenUI Model button in Models tab also triggers Hypernetwork apply
- **Models tab — fix Embedding and Hypernetwork models not shown** — `_fetchModelList` now handles the ComfyUI V3 node format (`options` key) in addition to V1/V2 formats; `fetchEmbeddings` switched from the `/embeddings` ComfyUI endpoint to the WFS backend API (`/api/wfm/models/files?type=embedding`) to avoid SPA URL routing conflicts
- **Models tab — GenUI Model button in side panel nav** — "GenUI Model" button added to the tab navigation bar (Info / Group / CivitAI) of the side panel; appears for supported model types (Checkpoint, LoRA, VAE, etc.) and hides for unsupported types; Embedding type shows PP/NP nav buttons instead
### v0.3.50
- **Gallery tab — fix prompt search causing ComfyUI hang under large folders** — the v0.3.49 on-demand metadata read ran synchronously on the aiohttp event loop, blocking ComfyUI when folders contained many uncached images; fixed by: (1) removing on-demand reads from `list_images()`; (2) wrapping `list_images()` in `asyncio.to_thread()` to keep the event loop free; (3) adding a background indexer (`start_background_index`) that automatically indexes uncached images after a folder is loaded — processes 10 images per batch with a 50 ms sleep between batches; cancellable on folder switch
### v0.3.49
- **GenerateUI tab — A1111-style SPA-side wildcard expansion** — when prompt text in non-ImpactWildcard nodes (e.g. CLIPTextEncode) contains `__name__` syntax, the SPA expands it at generation time by randomly picking a line from the matching file in the WFS wildcard folder; nested wildcards are re-expanded up to 5 passes; unresolved wildcards are passed through unchanged; ImpactWildcardEncode/Processor nodes are skipped (handled server-side by Impact Pack); works in both single and batch generation
- **Gallery tab — fix Export button missing label** — the bulk action bar's Export button had no text label because the `textContent` assignment was missing from `updateUITexts()`; fixed by adding the `galleryBulkExport` i18n key assignment alongside the existing Move/Delete entries
- **Gallery tab — fix prompt search not matching images with no cached index** — `prompt_cache` was only built when an image was opened in the detail panel; A1111 images (or any images never opened) were invisible to prompt search; now when search is active and `prompt_cache` is empty, the image file is read on-demand and the cache is populated and persisted for future searches
### v0.3.48
- **Gallery tab — single image download** — hover over a detail-panel preview image to reveal a download overlay (⬇ icon); click to download the original file with its original filename preserved
- **Gallery tab — bulk export to ZIP** — multiple selected images can be exported as a ZIP archive via the "Export" button in the bulk action bar; filename: `gallery_export_<timestamp>.zip`; backend: `POST /wfm/gallery/images/export-zip` with path-traversal protection
### v0.3.47
- **Models tab — fix stale group state after file move** — when models were moved to a subdirectory via "Move To...", `state.modelGroups` in the JS client was not updated to reflect the new model paths; the group filter view would lose the moved models immediately after the operation. Now the client-side group membership is updated in sync with the server-side rename (mirrors the existing Python `move_models` logic). Generate tab Batch groups are unaffected as they always fetch fresh from the server.
### v0.3.46
- **Prompt tab AI Assistant — LM Studio support** — switched from Python proxy (`/api/wfm/ollama/*`) to direct browser-to-backend API calls (same pattern as the AI TOOL tab); supports Ollama (`/api/chat`) and LM Studio (`/v1/chat/completions`); image attachments are automatically converted to the correct format per backend
- **Settings tab — AI Assistant Settings (Prompt Tab)** — renamed from "Ollama Settings"; added backend selector (Ollama / LM Studio), URL input with per-backend placeholder, model select with Refresh, and Test button; settings saved to localStorage `wfm_prompt_ai_settings` (independent of AI TOOL tab settings)
- **Auto-refresh on backend change** — if the backend is switched in Settings tab while Prompt tab is already open, `sendMessage()` now detects the mismatch and automatically re-fetches the model list before sending; model status shows the active backend name (`[ollama]` / `[lmstudio]`)
- **Help text updated** (EN/JA/ZH) — Prompt tab, Settings tab, and Troubleshooting sections updated to reflect Ollama / LM Studio dual-backend support
### v0.3.45
- **Group auto-cleanup — Models tab** — stale group entries (models that no longer exist on disk) are automatically removed when the Models tab is loaded or the model type is switched; models moved within the app via "Move To..." also update their group membership immediately
- **Group auto-cleanup — Gallery tab (folder view)** — metadata entries for missing files are cleaned up each time a folder is scanned (`list_images`), synchronized with the 60s TTL + folder-mtime cache
- **Group auto-cleanup — Gallery tab (group image list)** — `list_images_in_group` (used by the Feeder tab Gallery mode) now filters out non-existent paths and removes them from the group in a single write operation
- **Gallery tab — Shift+click range selection** — Shift+click selects all images between the last clicked item (anchor) and the current item; works in both thumbnail and table views; anchor is updated on Ctrl+click and regular click; resets on folder change, image reload, and Deselect All; help text updated (EN/JA/ZH)
### v0.3.44
- **Gallery tab — server-side thumbnail generation** — `GET /wfm/gallery/image/thumb?path=...&w=256` endpoint generates 256px JPEG thumbnails via Pillow and caches them to `data/thumb_cache/` keyed by `md5(path:mtime:width)`; GIF served as-is to preserve animation; falls back to the original file if Pillow is unavailable; thumbnail view and table view both switched from `/image/serve` to `/image/thumb`
- **Gallery tab — infinite-scroll paging** — thumbnail view renders 50 images initially; an IntersectionObserver sentinel (rootMargin 300px) appends the next page on scroll; sentinel is disconnected and removed when all images are rendered; paging resets on every `renderImages()` call (folder change, filter, sort)
- **Gallery tab — bulk API endpoints** — `POST /wfm/gallery/bulk/favorite` and `POST /wfm/gallery/bulk/group` handle favorite set and group add/remove for any number of images in a single request; replaced N-parallel individual fetches in `bulkSetFavorite()` and `bulkAddToGroup()`
- **Gallery tab — image compare mode** — Ctrl+click 2–4 images, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; CSS grid adapts to the image count via `--compare-cols` custom property; close button is `position:fixed` at the top-right corner to avoid `overflow:auto` clipping
- **Gallery tab — "Remove from Group" bulk button** — new button in the Bulk Bar (right of "Add to Group"); uses the same group selector dropdown; calls `POST /wfm/gallery/bulk/group` with `action:"remove"`
- **Gallery tab — bulk bar UI labels** — dropdown placeholder "Add to Group..." → "Select Group"; "Add" button → "Add to Group"; "Delete" button → "Delete File" (distinguishes file deletion from group removal)
- **Gallery tab — prompt search** — text search now covers A1111 `parameters` text embedded in PNG/JPEG metadata; prompt text is extracted on first detail-panel open and stored as `prompt_cache` in `metadata.json`; subsequent `list_images` searches include `prompt_cache` without re-reading image files
- **Help tab — Gallery mode i18n** — Feeder subtab Gallery mode section (h4, description, 6 bullet points) now translated to Japanese and Chinese via `i18n.js`
- **Help tab — font size ×1.5** — nav items, headings, body text, link cards, and support card text all scaled up in `main.css`
- **Help tab — search box** — live search input above the nav sidebar filters matching pages and auto-clicks the first result; placeholder text localized (EN/JA/ZH)
### v0.3.43
- **Send to Canvas — direct canvas load via `window.opener`** — clicking "Send to Canvas" (Workflow tab) or "Copy & Send Canvas" (Gallery tab) now sends the workflow directly to the ComfyUI canvas without requiring a title drag; uses `window.opener.wfmReceiveWorkflow()` registered by `node_sets_menu.js` via `app.handleFile()`; both UI and API formats are supported; fallback to localStorage + title drag when `window.opener` is unavailable (e.g. opened from a bookmark), UI-format only
- **Workflow Studio Library — API-format DnD enabled** — API-format workflows in the W (Workflows) sub-tab are no longer grayed out; drag & drop and double-click now work for all formats via `app.handleFile()`
### v0.3.42
- **Feeder tab — Gallery mode** — new [Image Loop] / [Gallery] toggle in the Feeder tab; Gallery mode drives a WFS_GalleryFeeder node without any external plugin: node selector, group select, sort mode / index / seed, After Gen (Loop / Increment / Fixed), Run / Stop; center pane shows the selected group's images — click any image to jump to that index; `__Feeder__` group is auto-created on first open
- **WFS_GalleryFeeder custom node** — new ComfyUI node (Workflow Studio category) that reads images from a Gallery group and outputs one `IMAGE` at a time; inputs: `group_name`, `index`, `sort_mode` (filename_asc / filename_desc / random), `seed`; skips missing files; `IS_CHANGED` ensures re-execution on every queue
- **WFS_GalleryFeeder — ComfyUI canvas controls** — `gallery_feeder_extension.js` adds After Gen combo (loop / increment / fixed), ▶ Run button, and ■ Stop button as non-serialized widgets directly on the node in the ComfyUI canvas; manages per-node loop state with `waitForExecution()` Promise pattern; `onRemoved` hook auto-stops the loop on node deletion
- **Gallery tab — thumbnail F button** — cyan overlay button (top-left of each image card) toggles `__Feeder__` group membership; active state is always visible in cyan, inactive shows only on hover; initial state derived from `img.groups` returned by `list_images()`
- **Gallery tab — `__Feeder__` reserved group** — shown with 🔒 prefix in group dropdowns; rename and delete blocked server-side (403) and client-side (buttons disabled); **FC** toolbar button clears all group members without deleting the group
- **Security: XSS fixes in `gallery-tab.js`** — tag names, filenames, and server error messages now escaped via `escapeHtml()` before insertion into `innerHTML` (tag badge list, detail panel `alt` attributes, lightbox footer, folder tree errors, grid errors); `util.js` `escapeHtml` updated to also escape single quotes (`'` → `&#x27;`)
- **Security: path validation in `gallery_service.py`** — `save_image_meta`, `toggle_favorite`, `add_to_group`, `remove_from_group` now call `_check_path_allowed()` before any file operation; previously these four methods were missing the guard that other write methods already had
- **Security: `_init_allowed_root()` in `gallery_routes.py`** — `_allowed_root` is now initialized at module load from saved `gallery_output_dir` (falls back to ComfyUI default output dir); prevents `list_folder_tree` from overriding `_allowed_root` via a crafted `?root=` request, and also fixes serve_image returning 404 in Feeder Gallery mode before the Gallery tab is opened
- **Bug fix: WFS_GalleryFeeder seed validation error** — seed `max` changed from `0x7FFFFFFF` to `0xffffffffffffffff` (matching KSampler); `applySeedToWorkflow()` was writing values exceeding the old max, causing `value_bigger_than_max` validation failures
### v0.3.41
- **Generate UI — ImpactWildcardEncode / ImpactWildcardProcessor prompt detection** — fixed: wildcard text inputs from comfy-impact-pack nodes were not shown in the Prompt tab of Generate UI
- `CLIPTextEncodeEditPlus` node (used as the text encoder in these workflows) now correctly propagates positive/negative roles upstream to ImpactWildcard nodes via BFS
- `CLIPTextEncodeEditPlus` with an empty `text_edit` field is no longer added to `prompt_nodes` ahead of the upstream ImpactWildcard node, preventing a blank text area
- `widgets[0]` is now always written to `wildcard_text` in the API conversion step, regardless of `object_info` widget-order differences between impact-pack versions
- Static fallback mapping added for `ImpactWildcardProcessor` and `ImpactWildcardEncode` so detection works even when the ComfyUI server is offline
### v0.3.40
- **AI TOOL tab — Chat pane** — new pane (between Translation and TOOLS) for multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio uses `/v1/chat/completions`
- **AI TOOL tab — Wildcard generation** — new "Create wildcards" task in the TOOLS pane dropdown; enter a category name and count, click Run to generate plain-text entries one per line (no markdown, no numbering); output can be pasted directly into wildcard `.txt` files
- **Library A tab — Chat and Wildcards** — both features added to the side panel's A tab: Chat as a new sub-tab (Translation → Chat → TOOLS → Settings), wildcard generation as a new option in the TOOLS sub-tab dropdown
- **Help updated** — AI TOOL tab help card updated to 6 items (Chat and Wildcards added); Library panel sidepanel-16 updated; EN/JA/ZH
### v0.3.39
- **Workflow tab — Send to Canvas button** — replaced "Open in ComfyUI" (toolbar and detail modal) with "Send to Canvas"; stores the selected workflow in `localStorage` for title-drag to the canvas without opening a new browser tab
- **Gallery tab — Copy & Send Canvas button** — renamed "Copy Workflow" to "Copy & Send Canvas"; copies the embedded workflow JSON to the clipboard and simultaneously stores it for title-drag
- **Workflow Studio Library — title drag** — after "Send to Canvas" or "Copy & Send Canvas" is clicked, the "Workflow Studio Library" panel title turns blue with a green ● indicator; drag the title onto the ComfyUI canvas to load the workflow; title resets automatically after loading
### v0.3.38
- **Tagger tab** — new tab for automatic image tagging; supports WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and Ollama VLM; 3 sub-tabs: Single (drag & drop / upload, 5 output targets), Batch (folder processing with real-time progress), DB (searchable SQLite database with CSV export)
- **Tagger single — GenUI:P button** — new button (left of "Send to Prompt") that appends generated tags to the GenerateUI positive prompt and immediately applies them to the loaded workflow; shows a warning if no workflow is loaded in GenerateUI
- **Tagger batch — Write .txt output** — new output option that creates `<filename>.txt` alongside each processed image containing all generated tags (comma-separated)
- **Gallery detail panel — Tagger button** — new action button in the Gallery detail panel opens the selected image directly in the Tagger tab for instant single-image tagging
- **Gallery detail panel layout** — tabs (Info / JSON / Groups) and action buttons (Metadata / Load GenUI / Tagger) are now on separate rows, preventing button overflow on narrow panels
- **Gallery — Save to Gallery tags fix** — tags saved from the Tagger tab were stored as a string instead of an array, causing `forEach is not a function` errors on the next Gallery refresh; now correctly stored as a string array
- **GenerateUI — ZIT/Lumina2 workflow prompt fix** — `ConditioningZeroOut` removed from the CONDITIONING passthrough set; previously it propagated the "negative" role upstream through the shared CLIPTextEncode node, marking it as "unknown" and hiding the positive prompt in the GenerateUI Input tab; ZIT (Z-Image Turbo) workflows now correctly show the positive prompt
- **Workflow analyzer — lumina2 model type** — CLIPLoader `type: "lumina2"` is now correctly classified as Z-IMAGE in the workflow type detector
### v0.3.37
- **Models tab — Select All button** — new button added to the right of "Deselect All" in the multi-select action bar; selects all models currently visible after applying the active search / filter / group filters
- **Gallery tab — Select All button** — new button added to the right of "Deselect All" in the bulk action bar; selects all images in the current folder view
- **Gallery bulk bar — i18n** — all bulk action bar buttons and the selection count were hard-coded English strings; they now use the `t()` translation system (EN / JA / ZH) so the bar respects the selected UI language
### v0.3.36
- **GenerateUI Save — overwrite protection** — saving now checks for an existing file and asks for confirmation before overwriting; if the target file is in UI format, a dedicated warning explains that saving in API format will lose the node layout
- **GenerateUI Save — Raw JSON sync** — unapplied Raw JSON edits are now included in the save (parsed and synced back to the editor); invalid JSON aborts the save with an error instead of silently saving stale content
- **GenerateUI Save — hardening** — filename is set via DOM (no HTML-attribute injection), image extensions (`.png` etc.) are stripped from the default name, and a double-submit guard prevents duplicate POSTs
- **Help i18n fixed** — v0.3.35 help text updates (Save button, table sort, JSON tab rename) were being overwritten at startup by stale i18n strings and never displayed; all help keys synced in EN / JA / ZH and new entries registered in the help ID map
- **Toast i18n** — ~115 hard-coded English toasts replaced with translated messages (~60 new shared keys in EN / JA / ZH); Settings labels for Text Size / RAW JSON Colors that displayed raw key names are now properly defined
- **Refactoring** — new shared `util.js` (`escapeHtml` unified across 5 files — the Metadata tab copy was missing `"` escaping; `readJsonStorage` / `getSettings` replace 15 direct `localStorage` parses); Models table sort now precomputes sort keys (faster on large libraries); Gallery action buttons moved off the tab-button class, removing all `!important` CSS overrides
- **README refreshed** — intro rewritten around the three pillars (Management / GenerateUI / Library side panel); new screenshots: GenUI LoRA Stack, GenUI Batch, Models Multi-select Menu; WS Library image updated
### v0.3.35
- **Models tab — Table column sort** — click any column header to sort ascending (▲), click again for descending (▼), click a third time to clear; active column name highlighted in accent color; Enable/Disable column header now labeled **E/D**
- **Gallery tab — Load GenUI button** — new button to the right of the Metadata button in the detail panel; loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; warning toast shown if no workflow is embedded or the format is unsupported; Metadata button restyled green, Load GenUI uses primary accent color
- **GenerateUI tab — Save button** — new button 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; workflow name label updates to the saved filename on success
### v0.3.33
- **LoRA pane — workflow load no longer overwrites LoraManager** — removed the auto-apply block that replaced `inputs.loras` / `inputs.text` with Stack group contents on every workflow load; Stack is now applied only when the Apply button is pressed
- **LoRA pane — 🔄 button no longer resets to Single tab** — active tab state is saved before `innerHTML` rebuild and restored afterwards; staying on Stack tab after refresh
- **Models tab — Stack group hidden for non-LoRA types** — when switching to Checkpoint or other non-LoRA types, the Stack group is now excluded from the group filter dropdown and side-panel group management (LoRA-only concept)
- **CLIPTextEncodeEditPlus widget mapping fix** — `convertUiToApi` no longer advances `wIdx` for UI slot inputs that have an actual link, preventing index misalignment when `object_info` lists optional STRING inputs (e.g. `text1`) before widget inputs (e.g. `text_edit`); fixes `+af` appearing as the positive prompt instead of the edit text
- **Stack Apply — trigger words now applied correctly on first press** — trigger words (`currentAllTriggers` / `currentActiveTriggers`) are now recomputed at click time from the latest `_stackActive` + `metadata` + `civitaiCache`; previously used a stale snapshot captured at render time, causing trigger words to be missing on the first Apply and to linger after disabling a model
### v0.3.32
- **Lora Loader (LoraManager) LoRA detection** — I tab and Metadata tab now correctly detect LoRAs from `Lora Loader (LoraManager)` nodes in API format (`inputs.loras.__value__`); `strength`/`clipStrength` stored as strings (e.g. `"0.20"`) are now parsed correctly
- **LoraManager node placement — LoRA syntax** — placing a `Lora Loader (LoraManager)` node via drag now also updates the `text` widget with `<lora:name:strength>` syntax (previously only the `loras` array widget was set)
- **M tab Groups — LoRA → LoraManager drop** — LoRA groups in the Library M tab show an **All N LoRAs** item; drag to canvas places a `Lora Loader (LoraManager)` node with all group LoRAs; double-click also places at canvas center
- **P tab — Groups sub-tab** — new Groups sub-tab (row 2) in the Library P tab; groups are read from `localStorage["wfm_prompt_preset_groups"]` (shared with Batch); expanding a group shows draggable preset items
### v0.3.31
- **LoRA pane — Single/Stack tabs** — LoRA pane split into two tabs: Single (filter, model dropdown, M/C strength, target node, Apply, P, LORA SYNTAX, TRIGGER WORDS) and Stack (target node, Apply, P, Toggle-all, global strength adjuster, LORA SYNTAX, TRIGGER WORDS, model list); each tab has independent strength settings
- **GenUI Model — LoRA improvements** — now correctly handles `Lora Loader (LoraManager)` nodes (previously failed to find the node); on apply: switches to Single tab, disables all Stack models, and updates LORA SYNTAX / TRIGGER WORDS in the Single tab panel
### v0.3.30
- **LoRA Stack trigger words fix** — inactive stack models no longer show their trigger words in the Trigger words display; both initial render and per-model toggle/toggle-all now filter to active models only
- **Help troubleshooting** — added entry for "No such file or directory" error when running LoRA Stack (comfyui-lora-manager compatibility issue)
### v0.3.29
- **LoRA section unified** — single LoRA and Stack sections merged into one pane; Apply button handles both cases (stack models present → apply all active + prompt sync; no stack → apply selected single LoRA + prompt sync); Ctrl+Apply removed, now plain Apply by default
- **LoRA prompt sync** — Apply inserts `<lora:stem:M:C>` syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by diff (removes stale tokens, re-adds active ones); works for both single LoRA and Stack
- **P button** — placed to the right of Apply; immediately writes the current Positive prompt textarea to the target workflow node without switching tabs
- **LoRA UI layout fixes** — "Stack" label moved from ID row to Strength row left of the toggle-all checkbox; ID dropdown expanded to `flex:1` (no max-width cap); M/C input width 54 px → 60 px to prevent value truncation
- **Models tab — CivitAI default** — side panel opens to the CivitAI tab by default when a model is selected
- **Help tab — sidebar navigation** — redesigned as 2-column layout: left sidebar (14 topics + Support fixed at bottom) + right content pane; content switches on click
- **Input tab — Prompt/Image tabs + taller textboxes** — Prompt and Image split into inner tabs; Positive prompt rows 4 → 8, Negative prompt rows 3 → 6
- **Reset Workflow button** — added to the GenerateUI toolbar; re-reads the loaded workflow file from disk and reloads the editor
- **LoRA Stack trigger words** — CivitAI trigger words for active stack models appended to Positive prompt on Apply; diff-updated on re-apply
- **Feeder subtab UI** — (root) folder selected automatically on tab open; PREVIEW pane moved to the far right of the layout; preview auto-updates to the current processing image during a RUN
- **Batch tab — Sampler / Scheduler** — left pane gains Sampler and Scheduler tabs (lists fetched from ComfyUI); Batch Queue gains two new columns; batch runs each value through KSampler sequentially
- **Lora Loader (LoraManager) support** — workflow analysis, single Apply, Stack Apply, and Batch all write `inputs.loras.__value__` array + `inputs.text` syntax for LoraManager nodes
- **Stack enable/disable** — per-model checkbox and Toggle All checkbox; disabled models excluded from syntax/prompt; LoraManager apply preserves them as `active: false`
- **Global strength adjuster** — `[−][step][+]` controls for Model and CLIP strength applied to all stack models at once
- **model-and-prompt-from-metadata node support** — `CLIPTextEncodeEditPlus`, `ImageMetadataCheckpointLoader`, `ImageMetadataPromptLoader`, `ImageMetadataLoRALoader` added to workflow analysis
- **Models thumbnail bug fixes** — preview endpoint now sends `Cache-Control: no-cache`; files under 100 bytes skipped; `.disabled` model files included in preview change lookup
### v0.3.23
- **Bug fix: workflow batch 404** — `_runBatchGenerate()` workflow case was fetching `/api/wfm/workflows/{filename}` (non-existent route); corrected to `/api/wfm/workflows/raw?filename={filename}`
- **Bug fix: rename double-submit** — `addEventListener("blur", commitRename)` was accumulating on the persistent `#wfm-modal-title-input` element each time the modal opened; subsequent renames fired the handler twice, sending two rename requests back-to-back (first succeeds, second returns 409 or 404); fixed by storing the listener reference on `titleInput._commitRename` and calling `removeEventListener` before re-registering
### v0.3.22
- **Card view removed** — Workflow tab and Models tab no longer offer Card view; Thumbnail and Table remain; if `"card"` was previously stored in localStorage it falls back to Thumbnail automatically
- **Batch type selector** — replaced the Checkpoint Batch toggle with per-type checkboxes on the Batch Queue column headers (Checkpoint / Lora / Prompt / Workflow); radio behavior ensures only one type is active at a time; the Batch panel below Generate repurposed as a status display showing the active type, amber progress bar, and Pause/Resume / Stop controls
- **4-type batch generation** — `_runBatchGenerate()` dispatches to Checkpoint (file-tree + group selection), Lora (group selection), Prompt (preset group items applied to positive/negative nodes), and Workflow (group selection, restores original workflow after batch); all types use the same generic `_runBatchLoop()` with pause/abort support
- **Help updated** — EN / JA / ZH strings updated for `helpWf1`, `helpModels2`, `helpGen11`, `helpGen13`, `helpGen14`
### v0.3.21
- **CivitAI Host setting** — new dropdown in Settings tab (CivitAI section): choose `civitai.com` (SFW only) or `civitai.red` (unrestricted); selection is saved to `settings.json` and synced to `localStorage` so the Models tab uses it immediately without extra server calls
- **CivitAI panel — Info / Sample sub-tabs** — the CivitAI side panel is split into two sub-tabs; Info tab contains model details and actions; Sample tab shows all sample images (count displayed in label); images open full-size in a new browser tab on click
- **Bug fix: model page URL for existing cache** — `modelId: null` entries (the majority of the cache) previously generated broken links (`/models?modelVersionId=…`); both the Python service and the JS renderer now fall back to `/{host}/model-versions/{versionId}` which redirects correctly to the model page; all 324 existing cache entries are fixed immediately without re-fetching
- **Help updated** — Batch tab entries (gen-12 / gen-13 / gen-14) and Settings entries (settings-11 Text Size / settings-12 RAW JSON Colors / settings-13 CivitAI Host) are now fully localized in EN / JA / ZH; `helpGen3` updated to reflect 5-tab layout; `helpGen11` rewritten to describe the Batch toggle; JA/ZH translations no longer contain untranslated English terms (Filter, Pause/Resume, Stop)
### v0.3.20
- **CivitAI panel — Type / Base Model / Hash display** — side panel CivitAI tab now shows a Type badge (e.g. CHECKPOINT, LORA), Base Model row, and Hash row (BLAKE3 preferred, SHA256 fallback from CivitAI API or local metadata); click the hash to copy the full value to clipboard
- **CivitAI panel — images open in new tab** — sample images in the CivitAI tab are now wrapped in links; clicking an image opens the full-size version in a new browser tab
- **CivitAI panel — 3-state UI** — the panel now distinguishes between "not yet checked" (blue fetch button), "checked but not found on CivitAI" (re-check button with a notice), and "found" (full info); no more ambiguity after running batch fetch
- **CivitAI tab click refresh** — clicking the CivitAI tab in the side panel now re-renders its content, ensuring the latest fetch state is always shown without needing to reselect the model
- **Bug fix: model page URL broken after batch fetch** — the batch `POST /model-versions/by-hash` endpoint can omit `model.id` inside the version object; `_extract_info` now falls back to the top-level `modelId` field so the model page link is always correct; existing cached entries with a broken URL are also fixed client-side from `modelId` + `versionId`
- **CivitAI cache — `fileHashes` field added** — `_extract_info` now stores the primary file's full hash map (BLAKE3, SHA256, AutoV2, etc.) so the Hash row is populated for newly fetched or refreshed models
### v0.3.19
- **CivitAI batch fetch — major speedup** — switched from one-by-one `GET` requests (0.5 s delay each) to `POST /model-versions/by-hash` batch endpoint; up to 100 hashes per request, reducing 100-model fetch from ~50 s to a few seconds
- **Retry with exponential backoff** — `fetch_by_hash` and the new batch POST both retry on HTTP 429 / 500 / 502 / 503 / 504 with 1 s → 2 s → 4 s delays (max 3 attempts), as recommended in the CivitAI API docs
- **CivitAI API key support** — optional Bearer token can be set in Settings tab (CivitAI API Key section); environment variable `CIVITAI_API_KEY` takes priority over the stored key; API keys are excluded from data exports
- **Richer metadata** — `_extract_info` now captures `nsfwLevel` (numeric), `air` (AIR URN identifier), `stats` (download count, thumbs-up/down), `updatedAt`, `publishedAt`, image dimensions / per-image NSFW level (`imageDetails`), and primary file precision / format (`fileMeta`)
- **Hash consistency** — cache keys use lowercase hex; API requests send uppercase hex as required by the CivitAI spec
- **Bug fix: CivitAI tab blank after batch fetch** — the `done` SSE event now applies `data.hashes` directly to `state.modelMetadata` so the side-panel CivitAI tab renders correctly even when the subsequent `fetchModelMetadata()` call races ahead of the server flush
### v0.3.18
- **Batch tab** — new 5th subtab in GenerateUI with a dedicated 3-pane layout for assembling checkpoint batch queues; left pane: file-tree checkpoint selection (filter, All/None, initially unchecked); center pane: group-based selection with inner tabs (Checkpoint / Lora / Prompt / Workflow — Lora/Prompt/Workflow coming soon); right pane: live Batch Queue preview combining both pane selections with duplicates removed
- **Checkpoint selector moved** — the checkpoint dropdown is removed from the right panel; selection is now done exclusively in the Batch tab left and center panes; the right panel retains only the enable/disable toggle and progress display
- **Group selection fix** — batch group selection now stores selected group names (not raw model names), so changes to group membership in the Models tab are always reflected in the queue without stale entries
- **Models tab bulk operations expanded** — bulk action bar now includes: Deselect All (clear all selections without exiting select mode), ★ Favorite / ☆ Unfavorite, +Badge / −Badge (apply or remove a badge from all selected models); existing group and delete operations unchanged
### v0.3.17
- **CivitAI SSL fix** — resolved `CERTIFICATE_VERIFY_FAILED` errors when fetching model info on Windows Portable Python environments; uses `certifi` CA bundle when available, falls back to system store, and disables verification only as a last resort with a warning log
- **Lora Manager support** — `Lora Loader (LoraManager)` nodes (ComfyUI-Lora-Manager) are now correctly converted from UI format to API format in the GenerateUI tab; the `loras` widget array is wrapped to `{"__value__": [...]}` as required by the node, enabling generation with LoRA Manager workflows
### v0.3.16
- **CivitAI preview fallback** — model cards and the detail panel's Info tab now display the first CivitAI image directly when no local preview file exists; if the backend download fails (e.g. network restriction server-side), the browser fetches the image URL from the CivitAI cache as a fallback so the preview is always shown after fetching
### v0.3.15
- **Sample workflows** — added `workflows/` directory containing 13 sample workflows (SD1.5 / SDXL / DWPose / Face Detailer / Image Editing) with PNG thumbnails, bundled in the package
- **CivitAI auto-preview** — when fetching CivitAI info (individual or batch), the first CivitAI image is automatically downloaded and saved as `{model_stem}.preview.png` if no preview exists; batch summary reports the count of auto-saved previews
- **TOOLS pane — Create Tags** — new task option in the VLM dropdown (both SPA AI TOOL tab and Library A tab): generates a comma-separated list of descriptive English tags from the image
### v0.3.14
- **AI TOOL tab redesign** — tab renamed from "A" to "AI TOOL"; sub-tab navigation removed in favor of a permanent 3-pane layout (Translation 40% | TOOLS 40% | Settings 20%) — all panes always visible simultaneously; "VLM" pane renamed to "TOOLS"
- **Settings — RAW JSON Colors** — new accordion section with 6 color pickers to customize the 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 scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
- **Help tab updated** — AI TOOL tab card rewritten; Settings card updated with RAW JSON Colors entry; Library panel sidepanel-16 entry updated
### v0.3.13
- **AI Tab (A)** — new tab added to both the SPA and the Workflow Studio Library side panel (rightmost tab)
- **Translation sub-tab** — translate text via Ollama or LM Studio between Japanese, English, Chinese, or a custom Free language; ⇄ swap button exchanges both language selectors and text content simultaneously; language selections persisted automatically
- **VLM sub-tab** — drop zone (110px, matching the I tab) accepts images via drag & drop or click; task selector (Describe Image / Create Prompt); Run button streams result into output area with Copy button; Ollama uses `images:[base64]` payload, LM Studio uses `image_url` content block
- **Settings sub-tab** — backend radio (Ollama / LM Studio), API URL input, connection test with live status, model selector with refresh, Free language name fields (source and destination); saved to `localStorage` key `wfm_ai_settings` and shared between SPA and Library panel
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme before any network call
- **Settings Tab — Ollama section renamed** — "Ollama 設定" → "Ollama 設定(プロンプトタブ)" to distinguish it from the new AI tab's settings
- **Help Tab updated** — added AI Tab (A) feature card and Library panel A tab entry; i18n support in EN/JA/ZH
### v0.3.12
- **GenerateUI — Raw JSON search** — always-visible search bar above the Raw JSON editor; finds all matches as you type with match count (`3/12`); navigate forward/back with ↑/↓ buttons, Enter / Shift+Enter, or Escape / ✕ to clear; current match highlighted in orange, other matches in yellow; search overlay syncs with editor scroll and stays updated while editing
- **Workflow analyzer — extended model type detection** — added CLIPLoader `type` field mapping to model family (`flux`, `hidream_i`, `wan`, `cosmos`, `lumina`, `ovis`, etc.) via `_CLIP_TYPE_TO_MODEL`; subgraph nodes (`definitions.subgraphs`) now scanned for model detection; `UnetLoaderGGUF` and similar loaders matched via `"UnetLoader" in ntype`; new families added: NewBie, Ovis, HiDream, Wan, Cosmos, Lumina; model-name detection refactored into reusable `_detect_model_type_from_name()`
- **Screenshots refreshed** — replaced tab-by-tab screenshots with 8 feature-focused images
### v0.3.11
- **Metadata Tab — extended loader support** — `metadata-tab.js` now recognizes `UnetLoaderGGUF` and `UNETLoaderGGUF` as Diffusion Model (e.g. HiDream GGUF workflows), and `QuadrupleCLIPLoader` as Text Encoder (e.g. HiDream 4-CLIP); previously these were detected only in the side panel I tab, not in the Metadata tab itself
- **Settings Tab — Text Size** — new slider (10–28 px) in the Settings tab adjusts font size for all prompt and chat text areas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard text area and file editor, and Metadata prompt full preview; applied immediately on drag and persisted with Save Settings
### v0.3.10
- **Information tab — drag to canvas** — model, lora, and Prompts sub-tabs in the side panel I tab now support drag & drop onto the ComfyUI canvas; double-click also places nodes at canvas center
- model sub-tab: each item places the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`)
- lora sub-tab: drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (shown for 1+ LoRAs) places all LoRAs in a single `Lora Loader (LoraManager)` node
- Prompts sub-tab: drag a prompt to place `CLIPTextEncode` with the full text pre-filled
- **Extended node type support** — `UnetLoaderGGUF` / `UNETLoaderGGUF` now detected as Diffusion Model; `QuadrupleCLIPLoader` (HiDream and similar 4-CLIP workflows) now detected as Text Encoder
- **Bug fix: CLIPLoader widget name** — `textencoder` type in `MODEL_NODE_MAP` was incorrectly using `clip_name1` (DualCLIPLoader's widget) instead of `clip_name`; Text Encoder models now drop with the correct filename
- **Preview area fixed height** — Information tab drop zone fixed at 110px so model/prompt lists are no longer compressed when a large image is loaded
### v0.3.9
- **Side panel I tab** — new Information tab in the Workflow Studio Library side panel; drop a ComfyUI PNG/WebP/JSON directly in the panel to inspect models, LoRAs, and prompts without opening the Metadata tab; sub-tabs: model / lora / Prompts with full-text preview and Copy button
- **Side panel tab redesign** — tabs shortened to single letters (W / N / P / M / I) with full name shown on hover
- **Bug fix: top bar icons** — icons (Workflow Studio / camera / Node Sets) were invisible in newer ComfyUI versions because Iconify converted the MDI CSS class to an inline SVG before the custom SVG could be injected; fixed by removing the early-return guard and adopting the same simple `requestAnimationFrame` retry pattern used by lora-manager
### v0.3.8
- **Metadata Tab: extended subgraph support** — added extraction for Flux.2 Dev fp8 (`SamplerCustomAdvanced`), Flux.2 Klein 4B Distilled (image edit), Ernie Image, Qwen-Image-Edit 2511 (`TextEncodeQwenImageEditPlus`), and WAN2.2 14B Animate (top-level CLIPTextEncode + subgraph KSampler)
- **Prompt type: Text (no badge)** — when positive/negative polarity cannot be determined (indirect sampler connections, cross-level links, `PrimitiveStringMultiline` etc.), prompts are listed without a POS/NEG badge; action buttons still work normally
- **Prompt extraction improvements** — `extractPromptsFromNodeSet` now resolves link sources from any STRING-output node (e.g. `ComfySwitchNode`, `PrimitiveStringMultiline`) without type restriction; `PrimitiveStringMultiline` search and final text-encoder fallback both extended to include subgraph nodes via `collectAllNodes`
- **i18n** — added `metaPromptText` key (EN: "Text" / JA: "テキスト" / ZH: "文本")
### v0.3.7
- **Metadata Tab: subgraph workflow support** — Flux.2 (Dev fp8 / Klein), Qwen-Image-Edit (/ 2511 / Layered), and Z-Image (Base / Turbo) official templates now correctly extract models and prompts; these use ComfyUI's `definitions.subgraphs` format with `UNETLoader`, `CLIPLoader`, and `VAELoader` inside the subgraph
- **Prompt extraction improvements** — 7-stage fallback chain: ImageMetadataPromptLoader → WFS_PromptText → top-level CLIPTextEncode+KSampler → `PrimitiveStringMultiline` (flux2-klein) → subgraph CLIPTextEncode+KSampler → PromptStyler → all CLIPTextEncode text
- **MarkdownNote model fallback** — `extractMarkdownNoteModels()` parses `**section** → - [model](url)` patterns from MarkdownNote nodes as a supplemental model source
- **Format note updated** — "Flux.2 / Qwen-Image / Z-Image subgraph workflows supported" (EN/JA/ZH)
### v0.3.6
- **Metadata Tab** — new tab (between Prompt and Gallery) that extracts model and prompt information from ComfyUI-generated PNG/WebP images and workflow JSON files; 3-column layout: drop zone with image preview (left), Checkpoint/VAE/Diffusion Model/Text Encoder lists (center), LoRA list with strength values + Prompt list with full-text viewer (right)
- **Prompt action buttons** — Copy, GenUI:P/N (set GenerateUI prompts), Prompt:P/N (set Prompt tab preset prompts) directly from the extracted prompt text
- **SDXL Prompt Styler support** — improved prompt extraction resolves `PromptStyler → CLIPTextEncode` link chains in both LiteGraph and API workflow formats
- **Format support** — ComfyUI (PNG/WebP/JSON), SD WebUI, SD Forge, Fooocus
### v0.3.5
- **Feeder subtab** — new subtab in GenerateUI for controlling [comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder) nodes; left pane: node selector + all ImageFeeder parameters + presets + Run/Stop controls; center pane: folder tree, image grid with checkbox selection, and preview panel
- **Run loop with After gen modes** — Loop (wrap and continue), Increment (advance and auto-stop), Fixed (constant index); index updates automatically via WebSocket `image_loop_node_sync` after each generation; seed comes from the right-pane seed setting
- **Workflow analysis improvements** — `analyzeWorkflow` now handles multi-hop CONDITIONING chains via BFS (up to 5 iterations), CLIPTextEncodeSDXL / CLIPTextEncodeSDXLRefiner, SDXLPromptStyler / SDXLPromptStylerAdvanced, KSamplerAdvanced (`noise_seed`), TextEncodeQwenImageEditPlus, PrimitiveStringMultiline, Power Lora Loader (rgthree), and `Checkpoint Loader` (WAS) with space in the class name
### v0.3.4
- **Wildcard support panel** — new right column in the Prompt tab with a one-click toolbar for inserting wildcard syntax (`{|}`, `{n$|}`, `__|__`, `:`, `;`, `$`, `<lora::1:LBW=;>`, `[]`, single/multi pick) and wrapping selected text; dedicated prompt textarea and wildcard file picker (click to insert `__filename__`)
- **Wildcard file manager** — create, view, edit, and delete `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; editor opens inline with filename input and save/cancel controls
- **Preset/Preset Manager tab switch** — the center pane of the Prompt tab is now a two-tab panel (Presets / Preset Manager) to reclaim horizontal space for the new wildcard panel
- **Wildcard Integration setting** — new accordion section in Settings tab; auto-detects ComfyUI-Impact-Pack; when installed, provides a button to create a directory junction (Windows) or symlink (other OS) from the WFS wildcard directory to Impact Pack's `wildcards/` directory; existing WFS files are migrated automatically; a Remove Link button reverses the operation
### v0.3.2
- **Checkpoint Batch** — new panel in the GenerateUI right column (below Seed, above results); enable via checkbox to auto-generate once per checkpoint model; filter included/excluded subfolders by comma-separated names (empty = all checkpoints); real-time preview of matched model count; amber progress bar shows current model name and index; Stop button aborts the loop after the current generation completes; failed models are counted and reported in the summary toast
- **Seed layout fix** — seed value input and mode selector (Random / Fixed / Increment / Decrement) now stacked vertically so neither is clipped in narrow panels
### v0.3.1
- **Folder operations** — create subfolders and delete folders (with all contents) directly from the folder tree header buttons
- **File operations** — move or delete individual images from the detail panel; bulk move and bulk delete from the multi-select bar
- **Tree state preserved** — folder expansion state is maintained after create/delete/move/refresh operations
- **Workflow viewer improved** — Metadata tab now reads both `prompt` (API format, the default for most ComfyUI-generated images) and `workflow` (UI format) keys from PNG metadata, covering virtually all ComfyUI output
- **Workflow auto-save** — after each generation in the Generate UI tab, the current workflow is automatically saved to gallery metadata for the generated images; shown in the Metadata tab even if PNG embedding is absent
### v0.3.0
- **Cross-type group filter** — the group filter dropdown in the Models tab now shows groups from all model types simultaneously, each prefixed with a type label (`[Checkpoint]`, `[LoRA]`, etc.); selecting a group automatically switches to the corresponding model type
- **Sidebar group display fixed** — the ComfyUI sidebar "Model Groups" view now correctly renders per-type groups (introduced in v0.2.9) with a type label on each group header; fixes a crash where `.filter is not a function` was thrown on the new nested group structure
### v0.2.9
- **Model Enable/Disable** — hide models from ComfyUI by renaming the file extension to `.disabled`; toggle per model (⏸ button on each card/row), per group (Enable All / Disable All buttons in the Group panel), or filter the grid by status (All / Enabled only / Disabled only). Changes take effect after ComfyUI refresh.
- **Per-type model groups** — groups are now scoped to each model type; checkpoint groups only appear in the Checkpoint tab, LoRA groups only in the LoRA tab, etc. Existing flat groups are automatically migrated.
- **Multi-select mode** — click the Select button in the toolbar to enter selection mode; check individual models across Thumbnail / Card / Table views
- **Bulk group operations** — with models selected, add or remove them from any group in one click, or create a new group and add them simultaneously
- **Bulk file delete** — permanently delete selected model files along with all associated preview images and sidecar files (`.json`, `.civitai.info`, `.info`); requires confirmation before deleting
- **Bug fix** — models in subdirectories could not be disabled due to a path-doubling error; fixed by computing the rename target from `path.name` instead of `model_name`
- **Bug fix** — Hypernetwork tab showed `C`, `O`, `M`, `B` as fake model items; caused by the new ComfyUI API format returning `"COMBO"` as the first element; fixed in `comfyui-client.js`
### v0.2.8
- **Data storage moved** — plugin data (settings, metadata, prompts, etc.) is now stored in `ComfyUI/user/default/Workflow-Studio/` instead of `custom_nodes/ComfyUI-Workflow-Studio/data/`; falls back to the old `data/` directory if `user/default/` does not exist
- **Data Management section** — new Settings tab section to export all plugin data to a single JSON file and import it back; useful for migration, backup, and reinstallation
### v0.2.7
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
@@ -311,7 +849,8 @@ ComfyUI-Workflow-Studio/
│ ├── wfm.py # Main class & route registration
│ ├── config.py # Path configuration
│ ├── nodes/
│ │ └── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
│ │ ├── 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
@@ -330,7 +869,7 @@ ComfyUI-Workflow-Studio/
│ ├── settings_service.py # Settings persistence
│ └── png_extractor.py # PNG metadata extraction
├── templates/
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
│ └── 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
@@ -338,19 +877,26 @@ ComfyUI-Workflow-Studio/
│ ├── 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 (image browser, groups, metadata)
│ ├── 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
└── data/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
│ ├── node_sets_menu.js # Workflow Studio Library side panel
│ └── gallery_feeder_extension.js # WFS_GalleryFeeder canvas widgets (After Gen / Run / Stop)
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
```
---
@@ -365,6 +911,10 @@ MIT License
- [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/willchil/ComfyUI-Lora-Manager) — Plugin architecture and UI pattern 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
+4 -3
View File
@@ -9,15 +9,16 @@ WorkflowStudio.add_routes()
# Register custom nodes (isolated loading)
_NODE_MODULES = {
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText"),
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText", "Prompt Text (WFS)"),
"WFS_GalleryFeeder": (".py.nodes.gallery_feeder_node", "WFS_GalleryFeeder", "Gallery Feeder (WFS)"),
}
for _name, (_mod_path, _cls_name) in _NODE_MODULES.items():
for _name, (_mod_path, _cls_name, _display_name) in _NODE_MODULES.items():
try:
import importlib
_mod = importlib.import_module(_mod_path, package=__name__)
NODE_CLASS_MAPPINGS[_name] = getattr(_mod, _cls_name)
NODE_DISPLAY_NAME_MAPPINGS[_name] = "Prompt Text (WFS)"
NODE_DISPLAY_NAME_MAPPINGS[_name] = _display_name
except Exception as _e:
print(f"[WARNING] Workflow Studio: Failed to load '{_name}': {_e}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+19 -4
View File
@@ -10,13 +10,22 @@ PLUGIN_DIR = Path(__file__).resolve().parent.parent
# Directory paths
TEMPLATES_DIR = PLUGIN_DIR / "templates"
STATIC_DIR = PLUGIN_DIR / "static"
DATA_DIR = PLUGIN_DIR / "data"
# Default workflows directory: ComfyUI's user/default/workflows
# PLUGIN_DIR is custom_nodes/ComfyUI-Workflow-Studio
# ComfyUI root is custom_nodes/../../ = two levels up
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
_COMFYUI_WORKFLOWS = _COMFYUI_ROOT / "user" / "default" / "workflows"
COMFYUI_ROOT = _COMFYUI_ROOT # exposed for external use
# Data directory: prefer ComfyUI's user/default/Workflow-Studio/, fallback to plugin data/
_COMFYUI_USER_DEFAULT = _COMFYUI_ROOT / "user" / "default"
if _COMFYUI_USER_DEFAULT.is_dir():
DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"
logger.info("Workflow Studio: Using user data dir: %s", DATA_DIR)
else:
DATA_DIR = PLUGIN_DIR / "data"
logger.info("Workflow Studio: user/default not found, using plugin data dir: %s", DATA_DIR)
# Default workflows directory: ComfyUI's user/default/workflows
_COMFYUI_WORKFLOWS = _COMFYUI_USER_DEFAULT / "workflows"
# Use ComfyUI workflows dir if it exists, otherwise fallback to plugin data dir
if _COMFYUI_WORKFLOWS.is_dir():
@@ -59,3 +68,9 @@ NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
PROMPTS_FILE = DATA_DIR / "prompts.json"
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
WILDCARD_DIR = DATA_DIR / "wildcard"
# Tagger
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
TAGGER_SETTINGS_FILE = DATA_DIR / "tagger_settings.json"
TAGGER_MODELS_DIR = _COMFYUI_ROOT / "models" / "tagger"
+95
View File
@@ -0,0 +1,95 @@
"""WFS_GalleryFeeder – Feeds images from a gallery group into a workflow."""
import json
import random
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
# Feeder専用グループ名(gallery_metadata.json 内で予約される)
FEEDER_GROUP = "__Feeder__"
class WFS_GalleryFeeder:
"""Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"group_name": ("STRING", {"default": FEEDER_GROUP}),
"index": ("INT", {"default": 0, "min": 0, "max": 99999}),
"sort_mode": (["filename_asc", "filename_desc", "random"],),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "execute"
CATEGORY = "Workflow Studio"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, **kwargs):
# 毎回実行させる(インデックスが変わるため)
return float("NaN")
def execute(self, group_name: str, index: int, sort_mode: str, seed: int):
import torch
import numpy as np
from PIL import Image as PILImage
paths = self._get_sorted_paths(group_name, sort_mode, seed)
if not paths:
logger.warning("WFS_GalleryFeeder: group '%s' is empty or not found", group_name)
return (torch.zeros(1, 64, 64, 3),)
img_path = paths[index % len(paths)]
try:
img = PILImage.open(img_path).convert("RGB")
except Exception as e:
logger.error("WFS_GalleryFeeder: failed to load '%s': %s", img_path, e)
return (torch.zeros(1, 64, 64, 3),)
arr = np.array(img, dtype=np.float32) / 255.0
tensor = torch.from_numpy(arr).unsqueeze(0)
return (tensor,)
# ── 内部ヘルパー ──────────────────────────────────────────────
def _get_sorted_paths(self, group_name: str, sort_mode: str, seed: int) -> list[str]:
metadata_file = self._find_metadata_file()
if not metadata_file.exists():
return []
try:
with open(metadata_file, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.error("WFS_GalleryFeeder: metadata read error: %s", e)
return []
paths = [
key for key, meta in data.get("images", {}).items()
if group_name in meta.get("groups", []) and Path(key).exists()
]
if sort_mode == "filename_asc":
paths.sort(key=lambda p: Path(p).name.lower())
elif sort_mode == "filename_desc":
paths.sort(key=lambda p: Path(p).name.lower(), reverse=True)
elif sort_mode == "random":
rng = random.Random(seed)
rng.shuffle(paths)
return paths
@staticmethod
def _find_metadata_file() -> Path:
# py/nodes/ -> py/ -> plugin root -> ComfyUI/custom_nodes/ -> ComfyUI root
plugin_root = Path(__file__).resolve().parent.parent.parent
comfyui_root = plugin_root.parent.parent
user_default = comfyui_root / "user" / "default"
if user_default.is_dir():
return user_default / "Workflow-Studio" / "gallery_metadata.json"
return plugin_root / "data" / "gallery_metadata.json"
+269 -1
View File
@@ -1,9 +1,12 @@
"""
Gallery Routes - ギャラリータブ用APIエンドポイント
"""
import asyncio
import base64
import json
import mimetypes
import logging
import re
from pathlib import Path
from aiohttp import web
@@ -15,21 +18,63 @@ logger = logging.getLogger(__name__)
_service = GalleryService(DATA_DIR)
def _init_allowed_root() -> None:
"""起動時に保存済みの出力ディレクトリで _allowed_root を初期化する。
Gallery タブを開く前に Feeder タブの Gallery モードが serve_image を呼んでも
404 にならないようにするために必要。"""
try:
settings_file = DATA_DIR / "settings.json"
if settings_file.exists():
with open(settings_file, "r", encoding="utf-8") as f:
settings = json.load(f)
saved_root = settings.get("gallery_output_dir", "").strip()
if saved_root and Path(saved_root).is_dir():
_service.update_output_root(saved_root)
return
except Exception as e:
logger.warning("GalleryRoutes: failed to load saved gallery root: %s", e)
# フォールバック: ComfyUI のデフォルト output フォルダ
try:
import folder_paths # type: ignore
output_dir = folder_paths.get_output_directory()
if output_dir and Path(output_dir).is_dir():
_service.update_output_root(output_dir)
except Exception as e:
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
_init_allowed_root()
def setup_routes(app: web.Application):
app.router.add_get("/wfm/gallery/folders", list_folders)
app.router.add_get("/wfm/gallery/images", list_images)
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
app.router.add_get("/wfm/gallery/image/serve", serve_image)
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
app.router.add_get("/wfm/gallery/groups", list_groups)
app.router.add_post("/wfm/gallery/groups", create_group)
app.router.add_put("/wfm/gallery/groups/{name}", rename_group)
app.router.add_delete("/wfm/gallery/groups/{name}", delete_group)
app.router.add_post("/wfm/gallery/groups/ensure", ensure_group)
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
app.router.add_post("/wfm/gallery/groups/{name}/clear", clear_group_images)
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
# バルク操作
app.router.add_post("/wfm/gallery/bulk/favorite", bulk_favorite)
app.router.add_post("/wfm/gallery/bulk/group", bulk_group)
# フォルダ・ファイル操作
app.router.add_post("/wfm/gallery/folder", create_folder_route)
app.router.add_delete("/wfm/gallery/folder", delete_folder_route)
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
app.router.add_post("/wfm/gallery/images/move", move_images_route)
app.router.add_post("/wfm/gallery/images/export-zip", export_images_zip)
# ──────────────────────────────────────────────────────────────
@@ -64,7 +109,8 @@ async def list_images(request: web.Request) -> web.Response:
return web.json_response({"error": "folder parameter required"}, status=400)
try:
images = _service.list_images(
images = await asyncio.to_thread(
_service.list_images,
folder,
search=search,
sort_by=sort_by,
@@ -72,6 +118,9 @@ async def list_images(request: web.Request) -> web.Response:
tag_filter=tag_filter,
group_filter=group_filter,
)
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
if not search:
_service.start_background_index(folder)
return web.json_response({"images": images, "total": len(images)})
except Exception as e:
logger.error("list_images error: %s", e)
@@ -122,6 +171,70 @@ async def serve_image(request: web.Request) -> web.Response:
})
async def serve_thumb(request: web.Request) -> web.Response:
"""サムネイル配信。Pillowで縮小したJPEGをディスクキャッシュして返す。"""
path = request.rel_url.query.get("path", "")
if not path:
return web.Response(status=400)
try:
width = int(request.rel_url.query.get("w", "256"))
width = max(32, min(512, width))
except ValueError:
width = 256
thumb_path = _service.serve_thumbnail(path, width)
if thumb_path is None:
return web.Response(status=404)
ext = thumb_path.suffix.lower()
if ext in (".jpg", ".jpeg"):
content_type = "image/jpeg"
elif ext == ".gif":
content_type = "image/gif"
else:
content_type = mimetypes.guess_type(str(thumb_path))[0] or "image/jpeg"
return web.FileResponse(thumb_path, headers={
"Content-Type": content_type,
"Cache-Control": "max-age=86400",
})
# ──────────────────────────────────────────────────────────────
# バルク操作
# ──────────────────────────────────────────────────────────────
async def bulk_favorite(request: web.Request) -> web.Response:
"""POST /wfm/gallery/bulk/favorite — 複数画像のお気に入りを一括設定"""
try:
body = await request.json()
paths = body.get("paths", [])
value = bool(body.get("value", True))
if not paths:
return web.json_response({"error": "paths required"}, status=400)
result = _service.bulk_set_favorite(paths, value)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def bulk_group(request: web.Request) -> web.Response:
"""POST /wfm/gallery/bulk/group — 複数画像のグループ追加/削除を一括処理"""
try:
body = await request.json()
paths = body.get("paths", [])
group = body.get("group", "").strip()
action = body.get("action", "add") # "add" or "remove"
if not paths or not group:
return web.json_response({"error": "paths and group required"}, status=400)
if action not in ("add", "remove"):
return web.json_response({"error": "action must be 'add' or 'remove'"}, status=400)
result = _service.bulk_group_op(paths, group, action)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def save_image_meta(request: web.Request) -> web.Response:
try:
body = await request.json()
@@ -147,6 +260,40 @@ async def toggle_favorite(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
async def save_image_to_gallery(request: web.Request) -> web.Response:
"""Image Edit Tab からの PNG をギャラリーのルートフォルダに保存する。"""
try:
body = await request.json()
filename = body.get("filename", "").strip()
image_data = body.get("imageData", "")
if not filename or not image_data:
return web.json_response({"error": "filename and imageData required"}, status=400)
if _service._allowed_root is None:
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
# ファイル名サニタイズ(OSで使えない文字を除去)
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
if not safe.lower().endswith(".png"):
safe += ".png"
# base64 data URL をデコード
raw = image_data
if "," in raw:
raw = raw.split(",", 1)[1]
image_bytes = base64.b64decode(raw)
save_path = _service._allowed_root / safe
save_path.write_bytes(image_bytes)
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
return web.json_response({"ok": True, "path": str(save_path)})
except Exception as e:
logger.error("save_image_to_gallery error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
@@ -169,10 +316,15 @@ async def create_group(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
_RESERVED_GROUPS = {"__Feeder__"}
async def rename_group(request: web.Request) -> web.Response:
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
try:
old_name = request.match_info.get("name", "")
if old_name in _RESERVED_GROUPS:
return web.json_response({"error": f"Group '{old_name}' is reserved and cannot be renamed"}, status=403)
body = await request.json()
new_name = body.get("new_name", "").strip()
if not old_name or not new_name:
@@ -187,6 +339,8 @@ async def rename_group(request: web.Request) -> web.Response:
async def delete_group(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
if name in _RESERVED_GROUPS:
return web.json_response({"error": f"Group '{name}' is reserved and cannot be deleted"}, status=403)
ok = _service.delete_group(name)
return web.json_response({"ok": ok})
@@ -217,3 +371,117 @@ async def list_group_images(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
images = _service.list_images_in_group(name)
return web.json_response({"images": images})
async def clear_group_images(request: web.Request) -> web.Response:
"""POST /wfm/gallery/groups/{name}/clear - グループ内の全画像を除外"""
name = request.match_info.get("name", "")
if not name:
return web.json_response({"error": "name required"}, status=400)
ok = _service.clear_group(name)
return web.json_response({"ok": ok})
async def ensure_group(request: web.Request) -> web.Response:
"""POST /wfm/gallery/groups/ensure - グループが存在しない場合のみ作成"""
try:
body = await request.json()
name = body.get("name", "").strip()
if not name:
return web.json_response({"error": "name required"}, status=400)
_service.ensure_group(name)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# フォルダ・ファイル操作
# ──────────────────────────────────────────────────────────────
async def create_folder_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/folder - 新規フォルダ作成"""
try:
body = await request.json()
parent = body.get("parent", "")
name = body.get("name", "").strip()
if not parent or not name:
return web.json_response({"ok": False, "error": "parent and name required"}, status=400)
result = _service.create_folder(parent, name)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_folder_route(request: web.Request) -> web.Response:
"""DELETE /wfm/gallery/folder - フォルダ削除"""
try:
body = await request.json()
folder = body.get("path", "")
if not folder:
return web.json_response({"ok": False, "error": "path required"}, status=400)
result = _service.delete_folder(folder)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/delete - 画像削除(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
if not paths:
return web.json_response({"deleted": [], "errors": ["paths required"]}, status=400)
result = _service.delete_images(paths)
return web.json_response(result)
except Exception as e:
return web.json_response({"deleted": [], "errors": [str(e)]}, status=500)
async def move_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/move - 画像移動(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
dest = body.get("dest", "")
if not paths or not dest:
return web.json_response({"moved": [], "errors": ["paths and dest required"]}, status=400)
result = _service.move_images(paths, dest)
return web.json_response(result)
except Exception as e:
return web.json_response({"moved": [], "errors": [str(e)]}, status=500)
async def export_images_zip(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/export-zip - 複数画像をZIPファイルにしてダウンロード"""
import zipfile
import io
try:
body = await request.json()
paths = body.get("paths", [])
if not paths:
return web.json_response({"error": "paths required"}, status=400)
# ZIPファイルをメモリ上に作成
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for path in paths:
file_path = Path(path)
# セキュリティ: 許可されたディレクトリ内のみ
if not _service._check_path_allowed(file_path):
continue
if file_path.exists() and file_path.is_file():
# ファイル名のみをアーカイブに入れる(パス情報は含めない)
zf.write(file_path, arcname=file_path.name)
zip_buffer.seek(0)
return web.Response(
body=zip_buffer.getvalue(),
content_type="application/zip",
headers={"Content-Disposition": "attachment; filename=gallery_export.zip"}
)
except Exception as e:
logger.error("Error exporting images to ZIP: %s", e)
return web.json_response({"error": str(e)}, status=500)
+183
View File
@@ -0,0 +1,183 @@
"""G'MIC-Qt integration API routes."""
import asyncio
import base64
import datetime
import json
import logging
import mimetypes
import os
import subprocess
import threading
import uuid
from pathlib import Path
from aiohttp import web
from ..services.settings_service import SettingsService
from ..config import DATA_DIR
logger = logging.getLogger(__name__)
_settings = SettingsService()
# Global job store
# key: job_id, value: dict {status, message, result_path, error}
_gmic_jobs = {}
_gmic_jobs_lock = threading.Lock()
def _gmic_run_gui(job_id: str, input_path: str, output_path: str, gmic_exe: str):
"""Run gmic_qt.exe in a background process/thread and wait for completion.
G'MIC-Qt Standalone argument order: -o <output> <input>
After OK the "filter output" window appears; closing it saves to output_path.
"""
args = [gmic_exe, "-o", output_path, input_path]
logger.info("[gmic] Launching: %s", " ".join(args))
try:
with _gmic_jobs_lock:
_gmic_jobs[job_id]["status"] = "processing"
_gmic_jobs[job_id]["message"] = "G'MIC GUIで編集中..."
import time
kwargs = {}
if os.name == "nt":
# Required on Windows to show the GUI window from a background process
kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE
proc = subprocess.Popen(args, **kwargs)
proc.wait()
time.sleep(0.5)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
with _gmic_jobs_lock:
_gmic_jobs[job_id]["status"] = "completed"
_gmic_jobs[job_id]["result_path"] = output_path
_gmic_jobs[job_id]["message"] = "完了"
logger.info("[gmic] Result saved: %s", output_path)
else:
raise ValueError("G'MIC GUIがキャンセルされました")
except Exception as e:
with _gmic_jobs_lock:
_gmic_jobs[job_id]["status"] = "failed"
_gmic_jobs[job_id]["error"] = str(e)
_gmic_jobs[job_id]["message"] = f"エラー: {str(e)}"
logger.error("[gmic] Error: %s", e)
def setup_routes(app: web.Application):
"""Register G'MIC API routes."""
app.router.add_post("/api/wfm/gmic/open", handle_open)
app.router.add_get("/api/wfm/gmic/status/{job_id}", handle_status)
app.router.add_post("/api/wfm/gmic/result", handle_result)
async def handle_open(request: web.Request) -> web.Response:
"""POST /api/wfm/gmic/open - Open image in G'MIC GUI."""
try:
body = await request.json()
image_b64 = body.get("image_b64", "")
if not image_b64:
return web.json_response({"error": "image_b64 field is required"}, status=400)
# Retrieve setting for gmic path
data = _settings.load()
gmic_exe = data.get("gmic_qt_path", "")
if not os.path.exists(gmic_exe):
return web.json_response({
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure the path in settings."
}, status=400)
ext = ".png"
if image_b64.startswith("data:"):
header, image_b64 = image_b64.split(",", 1)
mime = header.split(";")[0].split(":")[1]
guessed = mimetypes.guess_extension(mime)
if guessed:
ext = ".jpg" if guessed == ".jpe" else guessed
img_bytes = base64.b64decode(image_b64)
temp_dir = Path(DATA_DIR) / "gmic_temp"
temp_dir.mkdir(parents=True, exist_ok=True)
now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
input_path = str(temp_dir / f"gmic_input_{now_str}{ext}")
output_path = str(temp_dir / f"gmic_output_{now_str}_{str(uuid.uuid4())[:8]}{ext}")
with open(input_path, "wb") as f:
f.write(img_bytes)
job_id = str(uuid.uuid4())
with _gmic_jobs_lock:
_gmic_jobs[job_id] = {
"status": "pending",
"message": "G'MIC GUIを起動中...",
"result_path": None,
"error": None,
}
# Start G'MIC thread
t = threading.Thread(
target=_gmic_run_gui,
args=(job_id, input_path, output_path, gmic_exe),
daemon=True,
)
t.start()
return web.json_response({"job_id": job_id, "status": "pending", "message": "起動中"})
except Exception as e:
logger.error("[gmic] open error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_status(request: web.Request) -> web.Response:
"""GET /api/wfm/gmic/status/{job_id} - Check job status."""
try:
job_id = request.match_info.get("job_id", "")
with _gmic_jobs_lock:
job = _gmic_jobs.get(job_id)
if job is None:
return web.json_response({"error": "Job not found"}, status=404)
return web.json_response({
"job_id": job_id,
"status": job["status"],
"message": job.get("message", ""),
"result_path": job.get("result_path"),
"error": job.get("error"),
})
except Exception as e:
logger.error("[gmic] status error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_result(request: web.Request) -> web.Response:
"""POST /api/wfm/gmic/result - Get result image base64."""
try:
body = await request.json()
result_path = body.get("result_path", "")
if not result_path or not os.path.exists(result_path):
return web.json_response({"error": f"Result file not found: {result_path}"}, status=404)
# Path traversal guard: only allow files inside gmic_temp
allowed_dir = (Path(DATA_DIR) / "gmic_temp").resolve()
try:
Path(result_path).resolve().relative_to(allowed_dir)
except ValueError:
return web.json_response({"error": "Access denied: path outside gmic_temp"}, status=403)
with open(result_path, "rb") as f:
img_bytes = f.read()
ext = os.path.splitext(result_path)[1].lower()
mime_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff"
}
mime = mime_map.get(ext, "image/png")
b64 = base64.b64encode(img_bytes).decode("ascii")
return web.json_response({"image_b64": f"data:{mime};base64,{b64}"})
except Exception as e:
logger.error("[gmic] result error: %s", e)
return web.json_response({"error": str(e)}, status=500)
+258 -7
View File
@@ -27,6 +27,13 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
app.router.add_get("/api/wfm/models/disabled", handle_get_disabled)
app.router.add_post("/api/wfm/models/toggle", handle_toggle_model)
app.router.add_post("/api/wfm/models/group-toggle", handle_toggle_group)
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
app.router.add_get("/api/wfm/models/subdirs", handle_get_subdirs)
app.router.add_post("/api/wfm/models/move", handle_move_models)
app.router.add_get("/api/wfm/models/files", handle_list_model_files)
# ── Model Metadata ─────────────────────────────────────────
@@ -61,9 +68,10 @@ async def handle_save_metadata(request: web.Request) -> web.Response:
async def handle_get_groups(request: web.Request) -> web.Response:
"""GET /api/wfm/models/groups"""
"""GET /api/wfm/models/groups?type=checkpoint"""
model_type = request.query.get("type", "")
try:
result = await asyncio.to_thread(_service.get_model_groups)
result = await asyncio.to_thread(_service.get_model_groups, model_type or None)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model groups: %s", e)
@@ -71,10 +79,17 @@ async def handle_get_groups(request: web.Request) -> web.Response:
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/models/groups"""
"""POST /api/wfm/models/groups
Body: { "model_type": "checkpoint", "groups": { "groupName": [...] } }
"""
try:
groups = await request.json()
result = await asyncio.to_thread(_service.save_model_groups, groups)
body = await request.json()
model_type = body.get("model_type", "")
groups = body.get("groups", {})
if not model_type:
return web.json_response({"error": "model_type required"}, status=400)
result = await asyncio.to_thread(_service.save_model_groups, groups, model_type)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving model groups: %s", e)
@@ -138,10 +153,24 @@ async def handle_civitai_fetch(request: web.Request) -> web.Response:
"message": "Model not found on CivitAI"
})
# Auto-set preview if none exists and CivitAI has images
preview_saved = False
if info.get("images"):
has_preview = await asyncio.to_thread(_service.find_preview_image, model_type, model_name)
if not has_preview:
image_url = info["images"][0]
preview_path = file_path.parent / (file_path.stem + ".preview.png")
preview_saved = await asyncio.to_thread(
CivitaiService.download_image, image_url, preview_path
)
if preview_saved:
logger.info("Auto-saved preview for %s from CivitAI", model_name)
return web.json_response({
"status": "ok",
"sha256": sha256,
"civitai": info,
"preview_saved": preview_saved,
})
except Exception as e:
logger.error("CivitAI fetch error: %s", e)
@@ -257,6 +286,28 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
all_meta[mname]["sha256"] = sha
_service._save_metadata(all_meta)
# Auto-set previews for models without preview image
dirs = _get_model_dirs(model_type)
preview_saved_count = 0
for model_name, result in results.items():
civitai = result.get("civitai")
if not civitai or not civitai.get("images"):
continue
if _service.find_preview_image(model_type, model_name):
continue
model_file = None
for d in dirs:
p = d / model_name
if p.is_file():
model_file = p
break
if not model_file:
continue
preview_path = model_file.parent / (model_file.stem + ".preview.png")
if CivitaiService.download_image(civitai["images"][0], preview_path):
preview_saved_count += 1
logger.info("Auto-saved preview for %s from CivitAI", model_name)
# Send final result
summary = {
"total": len(model_files),
@@ -264,6 +315,7 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
"errors": sum(1 for r in results.values() if r.get("error")),
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
"preview_saved": preview_saved_count,
}
await response.write(send_sse("done", summary))
await response.write_eof()
@@ -295,7 +347,7 @@ async def handle_get_preview(request: web.Request) -> web.Response:
return web.FileResponse(
preview_path,
headers={"Content-Type": content_type or "image/png",
"Cache-Control": "public, max-age=3600"},
"Cache-Control": "no-cache"},
)
except Exception as e:
logger.error("Error serving model preview: %s", e)
@@ -334,7 +386,7 @@ async def handle_change_preview(request: web.Request) -> web.Response:
if image_data is None:
return web.json_response({"error": "file required"}, status=400)
# Resolve model file path
# Resolve model file path (also check .disabled variants)
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
model_path = None
@@ -343,6 +395,11 @@ async def handle_change_preview(request: web.Request) -> web.Response:
if candidate.is_file():
model_path = candidate
break
# disabled モデルも対象にする
disabled_candidate = d / (model_name + ".disabled")
if disabled_candidate.is_file():
model_path = disabled_candidate
break
if not model_path:
return web.json_response({"error": "Model file not found"}, status=404)
@@ -386,3 +443,197 @@ async def handle_get_filepath(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error resolving model filepath: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Model Enable / Disable ────────────────────────────────
async def handle_get_disabled(request: web.Request) -> web.Response:
"""GET /api/wfm/models/disabled?type=checkpoint
Returns list of disabled model names (normalized, without .disabled suffix).
"""
model_type = request.query.get("type", "")
if not model_type:
return web.json_response({"error": "type required"}, status=400)
try:
result = await asyncio.to_thread(_service.scan_disabled_models, model_type)
return web.json_response(result)
except Exception as e:
logger.error("Error scanning disabled models: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_model(request: web.Request) -> web.Response:
"""POST /api/wfm/models/toggle
Body: { "model_type": "checkpoint", "model_name": "v1-5.safetensors", "enabled": false }
Renames the model file to add/remove .disabled suffix.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_name = body.get("model_name", "")
enabled = body.get("enabled", True)
if not model_type or not model_name:
return web.json_response({"error": "model_type and model_name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
return web.json_response({"status": "ok", "enabled": enabled})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except OSError as e:
logger.error("OS error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
except Exception as e:
logger.error("Error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_group(request: web.Request) -> web.Response:
"""POST /api/wfm/models/group-toggle
Body: { "model_type": "checkpoint", "group_name": "MyGroup", "enabled": true }
Enables or disables all models belonging to the specified group.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
group_name = body.get("group_name", "")
enabled = body.get("enabled", True)
if not model_type or not group_name:
return web.json_response({"error": "model_type and group_name required"}, status=400)
groups = await asyncio.to_thread(_service.get_model_groups, model_type)
members = groups.get(group_name, [])
ok_list = []
error_list = []
for model_name in members:
if ".." in model_name:
continue
try:
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
ok_list.append(model_name)
except Exception as e:
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({
"status": "ok",
"enabled": enabled,
"ok": ok_list,
"errors": error_list,
})
except Exception as e:
logger.error("Error toggling group: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_models(request: web.Request) -> web.Response:
"""POST /api/wfm/models/delete
Body: { "model_type": "checkpoint", "model_names": ["a.safetensors", "b.safetensors"] }
Deletes each model file and associated preview/sidecar files, then removes metadata.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_names = body.get("model_names", [])
if not model_type or not isinstance(model_names, list) or not model_names:
return web.json_response({"error": "model_type and model_names[] required"}, status=400)
ok_list = []
error_list = []
for model_name in model_names:
if ".." in str(model_name):
error_list.append({"model": model_name, "error": "Invalid model name"})
continue
try:
result = await asyncio.to_thread(_service.delete_model, model_type, model_name)
ok_list.append({"model": model_name, "deleted": result["deleted"]})
logger.info("Deleted model: %s (%s files)", model_name, len(result["deleted"]))
except FileNotFoundError as e:
error_list.append({"model": model_name, "error": str(e)})
except Exception as e:
logger.error("Error deleting model %s: %s", model_name, e)
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({"status": "ok", "ok": ok_list, "errors": error_list})
except Exception as e:
logger.error("Error in delete_models: %s", e)
return web.json_response({"error": str(e)}, status=500)
_VALID_MODEL_TYPES = frozenset(
["checkpoint", "lora", "vae", "controlnet", "unet", "textencoder", "hypernetwork", "embedding"]
)
async def handle_get_subdirs(request: web.Request) -> web.Response:
"""GET /api/wfm/models/subdirs?type=checkpoint
Returns list of root-level subdirectory names for the given model type.
"""
try:
model_type = request.rel_url.query.get("type", "")
if model_type not in _VALID_MODEL_TYPES:
return web.json_response({"error": "Invalid model type"}, status=400)
result = await asyncio.to_thread(_service.get_subdirs, model_type)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_move_models(request: web.Request) -> web.Response:
"""POST /api/wfm/models/move
Body: { "model_type": "checkpoint", "model_names": [...], "dest": "sdxl" }
dest: "" = root, or a root-level subfolder name (no path separators, no "..").
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_names = body.get("model_names", [])
dest_subdir = body.get("dest", "")
if model_type not in _VALID_MODEL_TYPES:
return web.json_response({"error": "Invalid model type"}, status=400)
if not isinstance(model_names, list) or not model_names:
return web.json_response({"error": "model_names must be a non-empty list"}, status=400)
if not isinstance(dest_subdir, str):
return web.json_response({"error": "dest must be a string"}, status=400)
result = await asyncio.to_thread(_service.move_models, model_type, model_names, dest_subdir)
return web.json_response(result)
except Exception as e:
logger.error("Error in move_models: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Model File List ───────────────────────────────────────
async def handle_list_model_files(request: web.Request) -> web.Response:
"""GET /api/wfm/models/files?type=<model_type>
Returns sorted list of model file names (with extension) for the given type.
Used as a fallback when ComfyUI's /embeddings endpoint is not accessible.
"""
from ..services.models_service import MODEL_TYPE_FOLDER_KEYS
model_type = request.query.get("type", "")
if not model_type or model_type not in MODEL_TYPE_FOLDER_KEYS:
return web.json_response({"error": "invalid type"}, status=400)
try:
result = await asyncio.to_thread(_service.list_model_files, model_type)
return web.json_response(result)
except Exception as e:
logger.error("Error listing model files for %s: %s", model_type, e)
return web.json_response({"error": str(e)}, status=500)
+120 -1
View File
@@ -1,18 +1,31 @@
"""Settings API routes."""
import asyncio
import json
import logging
from pathlib import Path
from aiohttp import web
from ..services.settings_service import SettingsService
from ..config import DEFAULT_WORKFLOWS_DIR
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
logger = logging.getLogger(__name__)
_service = SettingsService()
# Data files included in export/import
_DATA_FILES = [
"settings.json",
"metadata.json",
"node_metadata.json",
"node_sets.json",
"prompts.json",
"model_metadata.json",
"gallery_metadata.json",
"tagger_settings.json",
]
def setup_routes(app: web.Application):
"""Register settings API routes."""
@@ -22,6 +35,9 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
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/styles", handle_get_styles)
async def handle_get(request: web.Request) -> web.Response:
@@ -151,3 +167,106 @@ async def handle_set_output_dir(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error setting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _load_data_file(filename: str):
"""Load a single data file, return empty dict/list if missing."""
path = DATA_DIR / filename
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
# エクスポートから除外するsettings.jsonのキー(APIキーなどの機密情報)
_SETTINGS_EXPORT_EXCLUDE = {"civitai_api_key"}
def _build_export_bundle() -> dict:
"""Collect all data files into a single export bundle."""
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
for filename in _DATA_FILES:
data = _load_data_file(filename)
if data is None:
continue
if filename == "settings.json" and isinstance(data, dict):
data = {k: v for k, v in data.items() if k not in _SETTINGS_EXPORT_EXCLUDE}
bundle[filename] = data
return bundle
async def handle_export(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
try:
bundle = await asyncio.to_thread(_build_export_bundle)
body = json.dumps(bundle, ensure_ascii=False, indent=2)
return web.Response(
body=body.encode("utf-8"),
content_type="application/json",
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
)
except Exception as e:
logger.error("Error exporting data: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _apply_import_bundle(bundle: dict) -> dict:
"""Write imported bundle back to individual data files. Returns summary."""
summary = {"imported": [], "skipped": []}
DATA_DIR.mkdir(parents=True, exist_ok=True)
for filename in _DATA_FILES:
if filename not in bundle:
summary["skipped"].append(filename)
continue
try:
path = DATA_DIR / filename
with open(path, "w", encoding="utf-8") as f:
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
summary["imported"].append(filename)
except Exception as e:
logger.error("Import failed for %s: %s", filename, e)
summary["skipped"].append(filename)
return summary
async def handle_import(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
try:
bundle = await request.json()
if not isinstance(bundle, dict):
return web.json_response({"error": "Invalid bundle format"}, status=400)
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
return web.json_response({"status": "ok", **summary})
except Exception as e:
logger.error("Error importing data: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _load_styles() -> list:
"""Load all style JSON files from DATA_DIR/style/ directory."""
style_dir = DATA_DIR / "style"
styles = []
if not style_dir.is_dir():
return styles
for json_file in sorted(style_dir.glob("*.json")):
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
styles.extend(data)
except Exception as e:
logger.warning("Failed to load style file %s: %s", json_file.name, e)
return styles
async def handle_get_styles(request: web.Request) -> web.Response:
"""GET /api/wfm/styles - Get all styles from style directory."""
try:
styles = await asyncio.to_thread(_load_styles)
return web.json_response(styles)
except Exception as e:
logger.error("Error loading styles: %s", e)
return web.json_response({"error": str(e)}, status=500)
+214
View File
@@ -0,0 +1,214 @@
"""Tagger API routes."""
import asyncio
import logging
from aiohttp import web
from ..config import TAGGER_DB_FILE, TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE
from ..services.tagger_db_service import TaggerDbService
from ..services.tagger_service import TaggerService
logger = logging.getLogger(__name__)
_svc = TaggerService(TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE)
_db = TaggerDbService(TAGGER_DB_FILE)
def setup_routes(app: web.Application):
app.router.add_get("/wfm/tagger/models", handle_models)
app.router.add_post("/wfm/tagger/predict", handle_predict)
app.router.add_get("/wfm/tagger/ollama/models", handle_ollama_models)
app.router.add_post("/wfm/tagger/ollama/predict", handle_ollama_predict)
app.router.add_post("/wfm/tagger/batch/start", handle_batch_start)
app.router.add_get("/wfm/tagger/batch/status", handle_batch_status)
app.router.add_post("/wfm/tagger/batch/stop", handle_batch_stop)
app.router.add_get("/wfm/tagger/db/list", handle_db_list)
app.router.add_get("/wfm/tagger/db/search", handle_db_search)
app.router.add_put("/wfm/tagger/db/{id}", handle_db_update)
app.router.add_delete("/wfm/tagger/db/{id}", handle_db_delete)
app.router.add_get("/wfm/tagger/db/export", handle_db_export)
app.router.add_post("/wfm/tagger/db/save", handle_db_save)
app.router.add_post("/wfm/tagger/write_meta", handle_write_meta)
app.router.add_get("/wfm/tagger/settings", handle_settings_get)
app.router.add_post("/wfm/tagger/settings", handle_settings_save)
async def handle_models(request: web.Request) -> web.Response:
try:
models = await asyncio.to_thread(_svc.list_models)
return web.json_response({"models": models})
except Exception as e:
logger.error("handle_models: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_predict(request: web.Request) -> web.Response:
try:
body = await request.json()
b64 = body.get("image_b64", "")
model = body.get("model", "")
if not b64 or not model:
return web.json_response({"error": "image_b64 and model required"}, status=400)
result = await asyncio.to_thread(
_svc.predict, b64, model,
float(body.get("threshold", 0.35)),
float(body.get("char_threshold", 0.85)),
)
return web.json_response(result)
except Exception as e:
logger.error("handle_predict: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_ollama_models(request: web.Request) -> web.Response:
api_url = request.rel_url.query.get("api_url", "http://127.0.0.1:11434")
try:
models = await asyncio.to_thread(_svc.ollama_models, api_url)
return web.json_response({"models": models})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_ollama_predict(request: web.Request) -> web.Response:
try:
body = await request.json()
result = await asyncio.to_thread(
_svc.ollama_predict,
body.get("image_b64", ""),
body.get("api_url", "http://127.0.0.1:11434"),
body.get("model", ""),
body.get("prompt", ""),
int(body.get("max_tags", 40)),
)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_batch_start(request: web.Request) -> web.Response:
try:
body = await request.json()
result = await asyncio.to_thread(
_svc.batch_start,
body.get("folder", ""),
body.get("model", ""),
float(body.get("threshold", 0.35)),
float(body.get("char_threshold", 0.85)),
bool(body.get("use_ollama", False)),
body.get("ollama_api", "http://127.0.0.1:11434"),
body.get("ollama_model", ""),
body.get("ollama_prompt", ""),
int(body.get("ollama_max_tags", 40)),
bool(body.get("save_db", True)),
bool(body.get("write_file", False)),
bool(body.get("write_txt", False)),
_db,
)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_batch_status(request: web.Request) -> web.Response:
return web.json_response(_svc.batch_status())
async def handle_batch_stop(request: web.Request) -> web.Response:
_svc.batch_stop()
return web.json_response({"ok": True})
async def handle_db_list(request: web.Request) -> web.Response:
try:
limit = int(request.rel_url.query.get("limit", 100))
offset = int(request.rel_url.query.get("offset", 0))
rows = await asyncio.to_thread(_db.list, limit, offset)
total = await asyncio.to_thread(_db.total)
return web.json_response({"rows": rows, "total": total})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_db_search(request: web.Request) -> web.Response:
try:
q = request.rel_url.query.get("q", "")
rows = await asyncio.to_thread(_db.search, q)
return web.json_response({"rows": rows})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_db_update(request: web.Request) -> web.Response:
try:
row_id = int(request.match_info["id"])
body = await request.json()
await asyncio.to_thread(
_db.update, row_id,
body.get("interrogator_tags", ""),
body.get("vlm_tags", ""),
)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_db_delete(request: web.Request) -> web.Response:
try:
row_id = int(request.match_info["id"])
await asyncio.to_thread(_db.delete, row_id)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_db_export(request: web.Request) -> web.Response:
try:
csv_data = await asyncio.to_thread(_db.export_csv)
return web.Response(
body=csv_data.encode("utf-8"),
content_type="text/csv",
headers={"Content-Disposition": "attachment; filename=tagger_tags.csv"},
)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_db_save(request: web.Request) -> web.Response:
try:
body = await request.json()
row_id = await asyncio.to_thread(
_db.save,
body.get("filename", ""),
body.get("interrogator_tags", ""),
body.get("vlm_tags", ""),
)
return web.json_response({"ok": True, "id": row_id})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_write_meta(request: web.Request) -> web.Response:
try:
body = await request.json()
result = await asyncio.to_thread(
_svc.write_meta_to_file,
body.get("path", ""),
body.get("tags", ""),
)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_settings_get(request: web.Request) -> web.Response:
return web.json_response(_svc.load_settings())
async def handle_settings_save(request: web.Request) -> web.Response:
try:
body = await request.json()
await asyncio.to_thread(_svc.save_settings, body)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+98
View File
@@ -0,0 +1,98 @@
"""Wildcard file management API routes."""
import asyncio
import logging
from aiohttp import web
from ..config import DATA_DIR, COMFYUI_ROOT
from ..services.wildcard_service import WildcardService
logger = logging.getLogger(__name__)
_service = WildcardService(DATA_DIR / "wildcard")
def setup_routes(app: web.Application):
app.router.add_get("/api/wfm/wildcards", handle_list)
app.router.add_get("/api/wfm/wildcards/content", handle_get_content)
app.router.add_post("/api/wfm/wildcards/save", handle_save)
app.router.add_post("/api/wfm/wildcards/delete", handle_delete)
app.router.add_get("/api/wfm/wildcards/link-status", handle_link_status)
app.router.add_post("/api/wfm/wildcards/create-link", handle_create_link)
app.router.add_post("/api/wfm/wildcards/remove-link", handle_remove_link)
async def handle_list(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.list_wildcards)
return web.json_response(result)
except Exception as e:
logger.error("Error listing wildcards: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_content(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
try:
content = await asyncio.to_thread(_service.get_content, filename)
return web.json_response({"content": content})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except Exception as e:
logger.error("Error getting wildcard content: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
content = body.get("content", "")
result = await asyncio.to_thread(_service.save_file, filename, content)
return web.json_response({"status": "ok", "file": result})
except Exception as e:
logger.error("Error saving wildcard: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
await asyncio.to_thread(_service.delete_file, filename)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error("Error deleting wildcard: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_link_status(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.get_link_status, COMFYUI_ROOT)
return web.json_response(result)
except Exception as e:
logger.error("Error getting wildcard link status: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_create_link(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.create_link, COMFYUI_ROOT)
return web.json_response({"status": "ok", **result})
except RuntimeError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error creating wildcard link: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_remove_link(request: web.Request) -> web.Response:
try:
await asyncio.to_thread(_service.remove_link)
return web.json_response({"status": "ok"})
except RuntimeError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error removing wildcard link: %s", e)
return web.json_response({"error": str(e)}, status=500)
+289 -65
View File
@@ -3,6 +3,8 @@
import hashlib
import json
import logging
import ssl
import time
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
@@ -11,9 +13,48 @@ from ..config import DATA_DIR
logger = logging.getLogger(__name__)
def _make_ssl_context():
"""Return an SSL context with CA verification.
1. certifi CA bundle (available in virtually all ComfyUI envs via torch→requests→certifi)
2. System default SSL context (OS certificate store)
Returns None if both fail — callers fall back to urlopen without context
(Python's own default, which also uses certifi when available).
SSL verification is never disabled to avoid MitM exposure.
"""
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except Exception:
pass
try:
return ssl.create_default_context()
except Exception:
pass
logger.warning("CivitAI: could not build SSL context; falling back to urllib default")
return None
_SSL_CONTEXT = _make_ssl_context # lazy sentinel
def _get_ssl_context():
global _SSL_CONTEXT
if callable(_SSL_CONTEXT):
_SSL_CONTEXT = _make_ssl_context()
return _SSL_CONTEXT
CIVITAI_API_BASE = "https://civitai.com/api/v1"
CIVITAI_CACHE_FILE = DATA_DIR / "civitai_cache.json"
# HTTPステータスコード: 指数バックオフでリトライする対象
_RETRY_CODES = {429, 500, 502, 503, 504}
_MAX_RETRIES = 3
# POST /model-versions/by-hash は最大100件まで一括送信可能
_BATCH_CHUNK_SIZE = 100
class CivitaiService:
"""Fetch and cache CivitAI model metadata."""
@@ -21,6 +62,8 @@ class CivitaiService:
def __init__(self):
self._cache = None
# ── キャッシュ管理 ────────────────────────────────────────
def _load_cache(self):
if self._cache is not None:
return self._cache
@@ -42,12 +85,37 @@ class CivitaiService:
def get_cached(self, sha256_hash):
"""Return cached CivitAI data for a given hash, or None."""
cache = self._load_cache()
return cache.get(sha256_hash)
return cache.get(sha256_hash.lower())
def get_all_cached(self):
"""Return the full cache dict."""
return self._load_cache()
# ── APIキー / ヘッダー ────────────────────────────────────
@staticmethod
def _get_api_key():
"""Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over settings.json."""
import os
env_key = os.environ.get("CIVITAI_API_KEY", "").strip()
if env_key:
return env_key
try:
from ..services.settings_service import SettingsService
return SettingsService().load().get("civitai_api_key", "").strip() or None
except Exception:
return None
def _build_headers(self):
"""Build request headers, optionally including Bearer token."""
headers = {"User-Agent": "ComfyUI-Workflow-Studio/1.0"}
api_key = self._get_api_key()
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
# ── ハッシュ計算 ──────────────────────────────────────────
@staticmethod
def calculate_sha256(file_path, chunk_size=65536):
"""Calculate SHA256 hash of a file."""
@@ -61,43 +129,130 @@ class CivitaiService:
if not chunk:
break
h.update(chunk)
return h.hexdigest()
return h.hexdigest() # 小文字16進数
# ── 単体フェッチ (GET) ────────────────────────────────────
def fetch_by_hash(self, sha256_hash):
"""Fetch model version info from CivitAI by SHA256 hash.
"""Fetch model version info from CivitAI by SHA256 hash (GET).
429/5xx は指数バックオフでリトライ。
Returns dict with model info, or None if not found.
Caches successful results.
"""
# Check cache first
sha256_lower = sha256_hash.lower()
cache = self._load_cache()
if sha256_hash in cache:
return cache[sha256_hash]
if sha256_lower in cache:
return cache[sha256_lower]
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash}"
try:
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash.upper()}"
if not data or "id" not in data:
for attempt in range(_MAX_RETRIES):
try:
req = Request(url, headers=self._build_headers())
with urlopen(req, timeout=15, context=_get_ssl_context()) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not data or "id" not in data:
return None
info = self._extract_info(data)
cache[sha256_lower] = info
self._save_cache()
return info
except HTTPError as e:
if e.code == 404:
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
return None
if e.code in _RETRY_CODES and attempt < _MAX_RETRIES - 1:
wait = 2 ** attempt
logger.warning("CivitAI %s: retrying in %ss (attempt %d/%d)",
e.code, wait, attempt + 1, _MAX_RETRIES)
time.sleep(wait)
continue
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
return None
except (URLError, Exception) as e:
if attempt < _MAX_RETRIES - 1:
wait = 2 ** attempt
logger.warning("CivitAI request failed: %s — retrying in %ss", e, wait)
time.sleep(wait)
continue
logger.warning("CivitAI request failed: %s", e)
return None
# Extract useful fields
info = self._extract_info(data)
cache[sha256_hash] = info
self._save_cache()
return info
return None
except HTTPError as e:
if e.code == 404:
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
return None
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
return None
except (URLError, Exception) as e:
logger.warning("CivitAI request failed: %s", e)
return None
# ── 一括フェッチ (POST) ───────────────────────────────────
def _batch_fetch_post(self, sha256_hashes):
"""POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。
レスポンスの files[].hashes.SHA256 でリクエストのハッシュと照合する。
Returns: { sha256_lower: info_or_none }
"""
if not sha256_hashes:
return {}
url = f"{CIVITAI_API_BASE}/model-versions/by-hash"
results = {h.lower(): None for h in sha256_hashes}
cache = self._load_cache()
for chunk_start in range(0, len(sha256_hashes), _BATCH_CHUNK_SIZE):
chunk = sha256_hashes[chunk_start:chunk_start + _BATCH_CHUNK_SIZE]
chunk_lower = {h.lower() for h in chunk}
for attempt in range(_MAX_RETRIES):
try:
body = json.dumps([h.upper() for h in chunk]).encode("utf-8")
req = Request(
url,
data=body,
headers={**self._build_headers(), "Content-Type": "application/json"},
method="POST",
)
with urlopen(req, timeout=30, context=_get_ssl_context()) as resp:
versions = json.loads(resp.read().decode("utf-8"))
# 各バージョンを files[].hashes.SHA256 でリクエストのハッシュと照合
for version_data in versions:
info = self._extract_info(version_data)
matched = False
for f in version_data.get("files", []):
file_sha256 = f.get("hashes", {}).get("SHA256", "").lower()
if file_sha256 in chunk_lower:
cache[file_sha256] = info
results[file_sha256] = info
matched = True
break
if not matched:
# files にハッシュがない場合は versionId で照合を試みる
logger.debug("CivitAI: could not match version %s to a requested hash",
version_data.get("id"))
break # チャンク成功
except HTTPError as e:
if e.code in _RETRY_CODES and attempt < _MAX_RETRIES - 1:
wait = 2 ** attempt
logger.warning("CivitAI batch POST %s: retrying in %ss", e.code, wait)
time.sleep(wait)
continue
logger.warning("CivitAI batch POST error: %s %s", e.code, e.reason)
break
except (URLError, Exception) as e:
if attempt < _MAX_RETRIES - 1:
wait = 2 ** attempt
logger.warning("CivitAI batch POST failed: %s — retrying in %ss", e, wait)
time.sleep(wait)
continue
logger.warning("CivitAI batch POST failed: %s", e)
break
self._save_cache()
return results
# ── 情報抽出 ──────────────────────────────────────────────
@staticmethod
def _extract_info(data):
@@ -106,101 +261,170 @@ class CivitaiService:
images = data.get("images", [])
files = data.get("files", [])
# Get primary file info
primary_file = None
for f in files:
if f.get("primary"):
primary_file = f
break
# プライマリファイルを特定
primary_file = next((f for f in files if f.get("primary")), None)
if not primary_file and files:
primary_file = files[0]
# Get image URLs (first 5)
image_urls = []
# 画像情報(URL・寸法・NSFWレベル)を最大5件取得
image_list = []
for img in images[:5]:
url = img.get("url", "")
if url:
# Optimize for thumbnail
optimized = url.replace("/original=true", "/width=450,optimized=true")
image_urls.append(optimized)
img_url = img.get("url", "")
if not img_url:
continue
image_list.append({
"url": img_url,
"width": img.get("width"),
"height": img.get("height"),
"nsfwLevel": img.get("nsfwLevel", 0),
})
# プライマリファイルのメタ情報(精度・フォーマット)
file_meta = {}
file_hashes = {}
if primary_file:
pm = primary_file.get("metadata", {})
file_meta = {
"fp": pm.get("fp"),
"size": pm.get("size"),
"format": pm.get("format"),
}
# BLAKE3, SHA256, AutoV2 等のハッシュ
file_hashes = primary_file.get("hashes", {})
stats = data.get("stats", {})
# バッチ POST API は model オブジェクト内に id を含まない場合がある
# トップレベルの modelId をフォールバックとして使用する
model_id = model.get("id") or data.get("modelId")
version_id = data.get("id", "")
return {
"versionId": data.get("id"),
"modelId": model.get("id"),
"versionId": version_id,
"modelId": model_id,
"modelName": model.get("name", ""),
"versionName": data.get("name", ""),
"type": model.get("type", ""),
"description": data.get("description") or model.get("description", ""),
"tags": model.get("tags", []),
"nsfw": model.get("nsfw", False),
"nsfwLevel": data.get("nsfwLevel", 0),
"air": data.get("air", ""),
"creator": data.get("creator", {}).get("username", ""),
"images": image_urls,
# 後方互換のため URLリストを維持しつつ詳細情報も保存
"images": [img["url"] for img in image_list],
"imageDetails": image_list,
"trainedWords": data.get("trainedWords", []),
"baseModel": data.get("baseModel", ""),
"fileSize": primary_file.get("sizeKB", 0) if primary_file else 0,
"fileMeta": file_meta,
"fileHashes": file_hashes,
"downloadUrl": data.get("downloadUrl", ""),
"modelUrl": f"https://civitai.com/models/{model.get('id', '')}?modelVersionId={data.get('id', '')}",
"modelUrl": (
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
if model_id else (
f"https://civitai.com/model-versions/{version_id}"
if version_id else ""
)
),
"stats": {
"downloadCount": stats.get("downloadCount", 0),
"thumbsUpCount": stats.get("thumbsUpCount", 0),
"thumbsDownCount": stats.get("thumbsDownCount", 0),
},
"updatedAt": data.get("updatedAt", ""),
"publishedAt": data.get("publishedAt", ""),
}
# ── バッチフェッチ ────────────────────────────────────────
def batch_fetch(self, model_files, progress_callback=None):
"""Batch fetch CivitAI info for multiple model files.
Phase 1: SHA256 計算("hashing")
Phase 2: POST で一括取得("fetching")— キャッシュ済みはスキップ
Args:
model_files: list of (model_name, file_path) tuples
progress_callback: fn(current, total, model_name, status) called per model
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
"""
import time
results = {}
total = len(model_files)
cache = self._load_cache()
# Phase 1: ハッシュ計算
hashes_needed = [] # [(model_name, sha256_lower, original_index)]
for i, (model_name, file_path) in enumerate(model_files):
if progress_callback:
progress_callback(i, total, model_name, "hashing")
# Calculate hash
sha256 = self.calculate_sha256(file_path)
if not sha256:
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
continue
results[model_name] = {"sha256": sha256, "civitai": None}
# Check cache
if sha256 in cache:
results[model_name]["civitai"] = cache[sha256]
if progress_callback:
progress_callback(i, total, model_name, "cached")
progress_callback(i + 1, total, model_name, "not_found")
continue
# Fetch from API
sha256_lower = sha256.lower()
results[model_name] = {"sha256": sha256_lower, "civitai": None}
if sha256_lower in cache:
results[model_name]["civitai"] = cache[sha256_lower]
if progress_callback:
progress_callback(i + 1, total, model_name, "cached")
else:
hashes_needed.append((model_name, sha256_lower, i))
# Phase 2: POST で一括取得(未キャッシュ分)
if hashes_needed:
if progress_callback:
progress_callback(i, total, model_name, "fetching")
progress_callback(len(results), total, "", "fetching")
info = self.fetch_by_hash(sha256)
if info:
results[model_name]["civitai"] = info
# 同一ハッシュが複数モデルに対応する場合を考慮
sha256_to_entries: dict[str, list] = {}
for name, sha256_lower, idx in hashes_needed:
sha256_to_entries.setdefault(sha256_lower, []).append((name, idx))
if progress_callback:
status = "found" if info else "not_found"
progress_callback(i, total, model_name, status)
batch_results = self._batch_fetch_post(list(sha256_to_entries.keys()))
# Rate limit: small delay between API calls
time.sleep(0.5)
for sha256_lower, info in batch_results.items():
for name, idx in sha256_to_entries.get(sha256_lower, []):
results[name]["civitai"] = info
if progress_callback:
status = "found" if info else "not_found"
progress_callback(idx + 1, total, name, status)
if progress_callback:
progress_callback(total, total, "", "done")
return results
# ── 画像ダウンロード ──────────────────────────────────────
@staticmethod
def download_image(url, save_path, timeout=15):
"""Download an image from URL and save to save_path. Returns True on success."""
try:
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
with urlopen(req, timeout=timeout, context=_get_ssl_context()) as resp:
data = resp.read()
with open(save_path, "wb") as f:
f.write(data)
return True
except Exception as e:
logger.warning("Failed to download preview from %s: %s", url, e)
return False
# ── キャッシュ操作 ────────────────────────────────────────
def clear_cache(self, sha256_hash=None):
"""Clear cache for a specific hash or all."""
cache = self._load_cache()
if sha256_hash:
cache.pop(sha256_hash, None)
cache.pop(sha256_hash.lower(), None)
else:
cache.clear()
self._save_cache()
+62 -1
View File
@@ -64,7 +64,7 @@ class GalleryMetadataStore:
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups"}
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
@@ -117,9 +117,70 @@ class GalleryMetadataStore:
result.append(key)
return result
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
stale_set = {self._normalize_path(p) for p in stale_paths}
count = 0
for key, meta in self._data["images"].items():
if key in stale_set and group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
count += 1
if count:
self._save_to_disk()
return count
def get_group_member_set(self, group_name: str) -> set:
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
return {
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
def clear_group(self, group_name: str) -> bool:
"""グループ内の全画像を除外する(グループ自体は残す)"""
for meta in self._data["images"].values():
if group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
return self._save_to_disk()
def ensure_group(self, name: str) -> bool:
"""グループが存在しない場合のみ作成する"""
if any(g["name"] == name for g in self._data.get("groups", [])):
return True
return self.create_group(name)
def delete(self, image_path: str) -> bool:
"""画像のメタデータエントリを削除する"""
key = self._normalize_path(image_path)
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
def rename_path(self, old_path: str, new_path: str) -> bool:
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
old_key = self._normalize_path(old_path)
new_key = self._normalize_path(new_path)
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
"""folder_path 配下の存在しないファイルのメタデータを削除する。
existing_paths: そのフォルダの現在のファイルパス集合(_normalize_path 済み)
"""
folder_key = self._normalize_path(folder_path)
if not folder_key.endswith("/"):
folder_key += "/"
stale = [
key for key in self._data["images"]
if key.startswith(folder_key) and key not in existing_paths
]
if not stale:
return 0
for key in stale:
del self._data["images"][key]
self._save_to_disk()
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
return len(stale)
+298 -16
View File
@@ -1,12 +1,14 @@
"""
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
"""
import hashlib
import json
import logging
import mimetypes
import os
import re
import struct
import threading
import time
from pathlib import Path
from typing import Any
@@ -26,7 +28,7 @@ _PNG_CHUNK_MAX = 32 * 1024 * 1024
_JPEG_READ_MAX = 64 * 1024 * 1024
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
_CACHE_TTL = 30.0
_CACHE_TTL = 60.0
class _FolderCache:
@@ -72,17 +74,82 @@ class GalleryService:
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
self._allowed_root: Path | None = None
self._folder_cache = _FolderCache()
self._bg_cancel = threading.Event()
self._bg_cancel.set() # 初期状態: キャンセル済み
def update_output_root(self, root_path: str) -> None:
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
p = Path(root_path).resolve() if root_path else None
self._allowed_root = p
# ──────────────────────────────────────────────────────────────
# バックグラウンドインデックス
# ──────────────────────────────────────────────────────────────
def start_background_index(self, folder_path: str) -> None:
"""フォルダ内の未キャッシュ画像をバックグラウンドでインデックスする。
フォルダロード時に呼び出す。前回のインデックス処理は自動キャンセル。"""
self._bg_cancel.set() # 前のスレッドをキャンセル
cancel = threading.Event()
self._bg_cancel = cancel
t = threading.Thread(
target=self._bg_index_folder,
args=(folder_path, cancel),
daemon=True,
)
t.start()
def _bg_index_folder(self, folder_path: str, cancel: threading.Event) -> None:
"""バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。
10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。"""
try:
folder = Path(folder_path).resolve()
if not folder.is_dir() or not self._check_path_allowed(folder):
return
entries = self._scan_folder(folder)
batch: dict[str, dict] = {}
processed = 0
for name, abs_path, _, _ in entries:
if cancel.is_set():
return
ext = Path(name).suffix.lower()
if ext not in (".png", ".jpg", ".jpeg"):
continue
meta = self.metadata_store.get(abs_path)
if meta.get("prompt_cache"):
continue
embedded: dict = {}
if ext == ".png":
embedded = self._read_png_metadata(Path(abs_path))
else:
embedded = self._read_jpeg_metadata(Path(abs_path))
if not embedded:
continue
prompt_cache = " ".join(
str(v) for k, v in embedded.items()
if k != "workflow" and isinstance(v, str) and len(v) < 50_000
).strip()
if not prompt_cache:
continue
batch[abs_path] = {"prompt_cache": prompt_cache}
processed += 1
if len(batch) >= 10:
for p, d in batch.items():
self.metadata_store.save(p, d)
batch = {}
time.sleep(0.05) # 50ms 他の処理に譲る
if batch and not cancel.is_set():
for p, d in batch.items():
self.metadata_store.save(p, d)
if processed:
logger.debug("BG index done: %s (%d indexed)", folder_path, processed)
except Exception as e:
logger.debug("BG index error: %s", e)
def _check_path_allowed(self, path: Path) -> bool:
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
if self._allowed_root is None:
resolved = path.resolve()
return resolved == path.resolve()
return False
try:
path.resolve().relative_to(self._allowed_root)
return True
@@ -188,6 +255,10 @@ class GalleryService:
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
raw_entries = self._scan_folder(folder)
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
self.metadata_store.cleanup_stale_images(str(folder), existing_paths)
results = []
for name, abs_path, size, mtime in raw_entries:
# グループフィルタ(サーバーサイド)
@@ -218,13 +289,14 @@ class GalleryService:
"groups": meta.get("groups", []),
}
# 検索フィルタ
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト)
if search:
s = search.lower()
if not (
s in name.lower()
or s in item["memo"].lower()
or any(s in t.lower() for t in tags)
or s in meta.get("prompt_cache", "").lower()
):
continue
@@ -263,6 +335,14 @@ class GalleryService:
saved = self.metadata_store.get(str(path))
# workflow キーを除く文字列フィールドをプロンプトキャッシュとして保存(検索用)
prompt_text = " ".join(
str(v) for k, v in embedded.items()
if k != "workflow" and isinstance(v, str) and len(v) < 50_000
).strip()
if prompt_text and prompt_text != saved.get("prompt_cache", ""):
self.metadata_store.save(str(path), {"prompt_cache": prompt_text})
stat = path.stat()
return {
"filename": path.name,
@@ -350,34 +430,77 @@ class GalleryService:
return result
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""PNG埋め込みメタデータからComfyUIワークフローを抽出"""
"""ワークフローを抽出する。
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
"""
path = Path(image_path).resolve()
if path.suffix.lower() != ".png":
return None
if not self._check_path_allowed(path):
return None
embedded = self._read_png_metadata(path)
workflow_str = embedded.get("workflow") or embedded.get("Workflow")
if workflow_str:
try:
return json.loads(workflow_str)
except json.JSONDecodeError:
pass
return None
if path.suffix.lower() == ".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
# 3. gallery_metadata.json に保存されたworkflow
saved = self.metadata_store.get(str(path))
return saved.get("workflow") or None
# ──────────────────────────────────────────────────────────────
# メタデータ保存
# ──────────────────────────────────────────────────────────────
def save_image_meta(self, image_path: str, data: dict) -> bool:
if not self._check_path_allowed(Path(image_path).resolve()):
return False
return self.metadata_store.save(image_path, data)
def toggle_favorite(self, image_path: str) -> bool:
if not self._check_path_allowed(Path(image_path).resolve()):
return False
meta = self.metadata_store.get(image_path)
new_val = not meta.get("favorite", False)
self.metadata_store.save(image_path, {"favorite": new_val})
return new_val
def bulk_set_favorite(self, paths: list[str], value: bool) -> dict:
"""複数画像のお気に入りを一括設定する"""
ok = fail = 0
for p in paths:
if not self._check_path_allowed(Path(p).resolve()):
fail += 1
continue
if self.metadata_store.save(p, {"favorite": value}):
ok += 1
else:
fail += 1
return {"ok": ok, "fail": fail}
def bulk_group_op(self, paths: list[str], group_name: str, action: str = "add") -> dict:
"""複数画像のグループ追加 / 削除を一括処理する。action は "add" または "remove"。"""
ok = fail = 0
for p in paths:
success = self.add_to_group(p, group_name) if action == "add" else self.remove_from_group(p, group_name)
if success:
ok += 1
else:
fail += 1
return {"ok": ok, "fail": fail}
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
@@ -395,6 +518,8 @@ class GalleryService:
return self.metadata_store.delete_group(name)
def add_to_group(self, image_path: str, group_name: str) -> bool:
if not self._check_path_allowed(Path(image_path).resolve()):
return False
meta = self.metadata_store.get(image_path)
groups = meta.get("groups", [])
if group_name not in groups:
@@ -403,17 +528,131 @@ class GalleryService:
return True
def remove_from_group(self, image_path: str, group_name: str) -> bool:
if not self._check_path_allowed(Path(image_path).resolve()):
return False
meta = self.metadata_store.get(image_path)
groups = [g for g in meta.get("groups", []) if g != group_name]
return self.metadata_store.save(image_path, {"groups": groups})
def list_images_in_group(self, group_name: str) -> list[str]:
return self.metadata_store.list_images_in_group(group_name)
all_paths = self.metadata_store.list_images_in_group(group_name)
existing = [p for p in all_paths if Path(p).is_file()]
stale = [p for p in all_paths if not Path(p).is_file()]
if stale:
self.metadata_store.remove_stale_paths_from_group(group_name, stale)
logger.info("Cleaned up %d stale paths from group '%s'", len(stale), group_name)
return existing
def clear_group(self, group_name: str) -> bool:
return self.metadata_store.clear_group(group_name)
def ensure_group(self, name: str) -> bool:
return self.metadata_store.ensure_group(name)
# ──────────────────────────────────────────────────────────────
# 画像配信
# ──────────────────────────────────────────────────────────────
def create_folder(self, parent_path: str, name: str) -> dict:
"""選択フォルダ内に新しいサブフォルダを作成する"""
parent = Path(parent_path).resolve()
if not parent.is_dir():
return {"ok": False, "error": "Parent directory not found"}
if not self._check_path_allowed(parent):
return {"ok": False, "error": "Access denied"}
name = name.strip()
invalid_chars = set(r'\/:*?"<>|')
if not name or any(c in invalid_chars for c in name):
return {"ok": False, "error": "Invalid folder name"}
new_folder = parent / name
if new_folder.exists():
return {"ok": False, "error": "Folder already exists"}
try:
new_folder.mkdir()
self._folder_cache.invalidate(parent)
return {"ok": True, "path": str(new_folder).replace("\\", "/")}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_folder(self, folder_path: str) -> dict:
"""フォルダを再帰的に削除する"""
import shutil
folder = Path(folder_path).resolve()
if not folder.is_dir():
return {"ok": False, "error": "Directory not found"}
if not self._check_path_allowed(folder):
return {"ok": False, "error": "Access denied"}
if self._allowed_root and folder == self._allowed_root:
return {"ok": False, "error": "Cannot delete root folder"}
try:
parent = folder.parent
shutil.rmtree(folder)
self._folder_cache.invalidate(parent)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_images(self, paths: list) -> dict:
"""画像ファイルを削除する(複数対応)"""
deleted = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
try:
p.unlink()
self.metadata_store.delete(str(p))
self._folder_cache.invalidate(p.parent)
deleted.append(str(p).replace("\\", "/"))
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"deleted": deleted, "errors": errors}
def move_images(self, paths: list, dest_folder: str) -> dict:
"""画像ファイルを別フォルダへ移動する(複数対応)"""
dest = Path(dest_folder).resolve()
if not dest.is_dir():
return {"ok": False, "error": "Destination folder not found", "moved": [], "errors": []}
if not self._check_path_allowed(dest):
return {"ok": False, "error": "Access denied", "moved": [], "errors": []}
moved = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
dest_path = dest / p.name
counter = 1
while dest_path.exists():
dest_path = dest / f"{p.stem}_{counter}{p.suffix}"
counter += 1
try:
p.rename(dest_path)
old_str = str(p).replace("\\", "/")
new_str = str(dest_path).replace("\\", "/")
self.metadata_store.rename_path(old_str, new_str)
self._folder_cache.invalidate(p.parent)
self._folder_cache.invalidate(dest)
moved.append({"from": old_str, "to": new_str, "filename": dest_path.name})
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"moved": moved, "errors": errors}
def serve_image(self, image_path: str):
"""画像のPathオブジェクトを返す(ルートで使用)"""
p = Path(image_path).resolve()
@@ -425,3 +664,46 @@ class GalleryService:
logger.warning("serve_image: path not allowed: %s", p)
return None
return p
def serve_thumbnail(self, image_path: str, width: int = 256) -> Path | None:
"""縮小サムネイルのPathを返す。
ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。
GIFはアニメーション保持のため元ファイルをそのまま返す。
Pillowが使えない場合は元ファイルにフォールバック。
"""
p = Path(image_path).resolve()
if not p.is_file():
return None
if p.suffix.lower() not in IMAGE_EXTENSIONS:
return None
if not self._check_path_allowed(p):
logger.warning("serve_thumbnail: path not allowed: %s", p)
return None
# GIF はアニメーション保持のためそのまま返す
if p.suffix.lower() == ".gif":
return p
try:
mtime = int(p.stat().st_mtime * 1000)
except OSError:
return None
cache_key = hashlib.md5(f"{p}:{mtime}:{width}".encode()).hexdigest()
cache_dir = self.data_dir / "thumb_cache"
cache_dir.mkdir(exist_ok=True)
thumb_path = cache_dir / f"{cache_key}.jpg"
if thumb_path.exists():
return thumb_path
try:
from PIL import Image
with Image.open(p) as img:
img = img.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: failed for %s: %s", p, e)
return p # フォールバック: 元ファイル
+374 -7
View File
@@ -2,6 +2,8 @@
import json
import logging
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -9,10 +11,23 @@ from ..config import MODEL_METADATA_FILE
logger = logging.getLogger(__name__)
_SCAN_CACHE_TTL = 30 # seconds — reuse scan result within this window
# Preview image extensions to search for (in priority order)
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp"]
# All sidecar extensions to delete when a model is deleted
_SIDECAR_EXTENSIONS = [
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp",
".metadata.json", ".cm-info.json", ".json", ".civitai.info", ".info",
]
_DISABLED_SUFFIX = ".disabled"
_MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".pt2"}
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
@@ -59,6 +74,7 @@ class ModelsService:
def __init__(self):
self.metadata_file = MODEL_METADATA_FILE
self._scan_cache: dict = {} # model_type -> (timestamp, frozenset)
def _now_iso(self):
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -100,15 +116,180 @@ class ModelsService:
self._save_metadata(data)
return entry
def get_model_groups(self):
data = self._load_metadata()
return data.get("_groups", {})
def _scan_model_names(self, model_type: str) -> set:
"""モデルタイプの全ファイル名をスキャンして返す(相対パス、/区切り、.disabled除く)。
結果は30秒キャッシュされ、起動時の多重スキャンによる高負荷を防ぐ。"""
now = time.monotonic()
cached = self._scan_cache.get(model_type)
if cached is not None:
ts, names = cached
if now - ts < _SCAN_CACHE_TTL:
return names
def save_model_groups(self, groups):
dirs = _get_model_dirs(model_type)
names = set()
for d in dirs:
if not d.is_dir():
continue
for f in d.rglob("*"):
if not f.is_file():
continue
try:
rel = str(f.relative_to(d)).replace("\\", "/")
if rel.endswith(_DISABLED_SUFFIX):
rel = rel[: -len(_DISABLED_SUFFIX)]
names.add(rel)
except ValueError:
pass
self._scan_cache[model_type] = (now, names)
return names
def list_model_files(self, model_type: str) -> list:
"""モデルタイプのモデルファイル名をソートして返す(拡張子付き、/区切り)。
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。"""
dirs = _get_model_dirs(model_type)
seen = set()
for d in dirs:
if not d.is_dir():
continue
for f in d.rglob("*"):
if not f.is_file():
continue
name = f.name
if name.endswith(_DISABLED_SUFFIX):
name = name[: -len(_DISABLED_SUFFIX)]
if Path(name).suffix.lower() not in _MODEL_EXTENSIONS:
continue
try:
rel = str(f.relative_to(d)).replace("\\", "/")
if rel.endswith(_DISABLED_SUFFIX):
rel = rel[: -len(_DISABLED_SUFFIX)]
seen.add(rel)
except ValueError:
pass
return sorted(seen)
def get_model_groups(self, model_type=None):
data = self._load_metadata()
data["_groups"] = groups
raw = data.get("_groups", {})
# Migrate old flat format { "groupName": [...] } → per-type { "checkpoint": {...} }
if raw and not any(k in MODEL_TYPE_FOLDER_KEYS for k in raw):
logger.info("Migrating groups to per-type format (old data moved to _groups_legacy)")
data["_groups_legacy"] = raw
data["_groups"] = {}
self._save_metadata(data)
raw = {}
if model_type:
groups = raw.get(model_type, {})
# Guard: if no model directories are accessible (e.g. network drive disconnected),
# skip cleanup entirely to prevent mass-deletion of group members.
accessible_dirs = [d for d in _get_model_dirs(model_type) if d.is_dir()]
if not accessible_dirs:
configured_dirs = _get_model_dirs(model_type)
if configured_dirs:
logger.warning(
"No accessible directories for model_type=%s — skipping group cleanup "
"to avoid data loss (drives may be disconnected)", model_type
)
return {g: [m.replace("\\", "/") for m in members]
for g, members in groups.items()}
# No directories configured at all — return as-is (nothing to validate against)
return {g: [m.replace("\\", "/") for m in members] for g, members in groups.items()}
valid_names = self._scan_model_names(model_type)
cleaned = {}
dirty = False
for g_name, members in groups.items():
# Normalize backslashes (Windows ComfyUI paths) to forward slashes
normalized = [m.replace("\\", "/") for m in members]
filtered = [m for m in normalized if m in valid_names]
cleaned[g_name] = filtered
if len(filtered) != len(members) or normalized != list(members):
dirty = True
if dirty:
removed = sum(len(groups[g]) - len(cleaned[g]) for g in groups)
if removed > 0:
logger.info("Cleaned up %d stale group entries for model_type=%s", removed, model_type)
if not isinstance(data.get("_groups"), dict):
data["_groups"] = {}
data["_groups"][model_type] = cleaned
self._save_metadata(data)
return cleaned
return raw
def save_model_groups(self, groups, model_type):
# Normalize all member names to forward slashes (ComfyUI on Windows uses backslashes)
normalized = {g: [m.replace("\\", "/") for m in members] for g, members in groups.items()}
data = self._load_metadata()
if not isinstance(data.get("_groups"), dict):
data["_groups"] = {}
data["_groups"][model_type] = normalized
self._save_metadata(data)
return groups
# Also invalidate scan cache so next get_model_groups sees the fresh data
self._scan_cache.pop(model_type, None)
return normalized
def find_model_file(self, model_type, model_name):
"""Find a model file, checking both enabled and disabled states.
Returns (Path, is_enabled) or (None, None) if not found.
"""
dirs = _get_model_dirs(model_type)
for d in dirs:
enabled = d / model_name
if enabled.is_file():
return enabled, True
disabled = d / (model_name + _DISABLED_SUFFIX)
if disabled.is_file():
return disabled, False
return None, None
def enable_model(self, model_type, model_name):
"""Rename model.safetensors.disabled → model.safetensors."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if is_enabled:
return
target = path.parent / path.name[: -len(_DISABLED_SUFFIX)]
path.rename(target)
logger.info("Enabled model: %s", model_name)
def disable_model(self, model_type, model_name):
"""Rename model.safetensors → model.safetensors.disabled."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if not is_enabled:
return
target = path.parent / (path.name + _DISABLED_SUFFIX)
path.rename(target)
logger.info("Disabled model: %s", model_name)
def scan_disabled_models(self, model_type):
"""Scan directories for .disabled files.
Returns list of normalized model names (without .disabled suffix).
"""
dirs = _get_model_dirs(model_type)
disabled = []
for d in dirs:
if not d.is_dir():
continue
for f in d.rglob("*"):
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
try:
rel = str(f.relative_to(d))
if rel.endswith(_DISABLED_SUFFIX):
rel = rel[: -len(_DISABLED_SUFFIX)]
disabled.append(rel.replace("\\", "/"))
except ValueError:
pass
return disabled
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
@@ -138,7 +319,7 @@ class ModelsService:
for ext in _PREVIEW_EXTENSIONS:
preview = parent / (stem + ext)
if preview.is_file():
if preview.is_file() and preview.stat().st_size >= 100:
logger.debug("Preview found: %s", preview)
return preview
@@ -148,3 +329,189 @@ class ModelsService:
logger.debug("Preview: model file not found in any dir: %s", model_name)
return None
def delete_model(self, model_type, model_name):
"""Delete a model file and all associated preview/sidecar files.
Also removes the model's metadata entry.
Returns dict with deleted file paths.
"""
if ".." in model_name:
raise ValueError(f"Invalid model name: {model_name}")
path, _ = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
deleted = []
parent = path.parent
# Compute stem from the original (non-disabled) filename.
# path.name for a disabled file is e.g. "model.safetensors.disabled"
# so we strip the .disabled suffix first, then take Path.stem.
orig_name = path.name
if orig_name.endswith(_DISABLED_SUFFIX):
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
stem = Path(orig_name).stem # "model.safetensors" -> "model"
# Delete the model file itself
path.unlink()
deleted.append(str(path))
logger.info("Deleted model file: %s", path)
# Delete all sidecar files (previews, metadata, info)
for ext in _SIDECAR_EXTENSIONS:
sidecar = parent / (stem + ext)
if sidecar.is_file():
sidecar.unlink()
deleted.append(str(sidecar))
logger.info("Deleted sidecar: %s", sidecar)
# Remove metadata entry
data = self._load_metadata()
if model_name in data:
del data[model_name]
self._save_metadata(data)
return {"deleted": deleted}
def get_subdirs(self, model_type):
"""Return sorted list of root-level subdirectory names for a model type."""
dirs = _get_model_dirs(model_type)
subdirs = set()
for d in dirs:
if not d.is_dir():
continue
for item in d.iterdir():
if item.is_dir():
subdirs.add(item.name)
return sorted(subdirs)
def move_models(self, model_type, model_names, dest_subdir):
"""Move model files + sidecar files to dest_subdir (root-level subfolder name).
dest_subdir: "" = model root, "sdxl" = <root>/sdxl/
Creates the destination directory if it does not exist.
Updates metadata keys to reflect new paths.
Returns {"moved": [{"from": ..., "to": ...}], "errors": [...]}
"""
# Validate dest_subdir: must be a plain folder name (no separators, no "..", not absolute)
if dest_subdir:
if ".." in dest_subdir:
return {"moved": [], "errors": [{"model": "*", "error": "Invalid destination: '..' not allowed"}]}
if "/" in dest_subdir or "\\" in dest_subdir:
return {"moved": [], "errors": [{"model": "*", "error": "Invalid destination: must be a single folder name"}]}
if Path(dest_subdir).is_absolute():
return {"moved": [], "errors": [{"model": "*", "error": "Invalid destination: absolute paths not allowed"}]}
moved = []
errors = []
model_dirs = _get_model_dirs(model_type)
meta_data = self._load_metadata()
meta_changed = False
renames = [] # [(old_name, new_name)]
for model_name in model_names:
if ".." in model_name:
errors.append({"model": model_name, "error": "Invalid model name"})
continue
path, _is_enabled = self.find_model_file(model_type, model_name)
if path is None:
errors.append({"model": model_name, "error": f"Model not found: {model_name}"})
continue
try:
# Determine which root dir this model lives in
root_dir = None
for d in model_dirs:
try:
path.relative_to(d)
root_dir = d
break
except ValueError:
pass
if root_dir is None:
errors.append({"model": model_name, "error": "Cannot determine root directory"})
continue
# Destination directory — verify it stays within root_dir after resolution
dest_dir = root_dir / dest_subdir if dest_subdir else root_dir
try:
dest_dir.resolve().relative_to(root_dir.resolve())
except ValueError:
errors.append({"model": model_name, "error": "Destination is outside model root"})
continue
dest_dir.mkdir(parents=True, exist_ok=True)
# Original filename without .disabled suffix
orig_name = path.name
if orig_name.endswith(_DISABLED_SUFFIX):
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
stem = Path(orig_name).stem
new_path = dest_dir / path.name
if new_path.resolve() == path.resolve():
errors.append({"model": model_name, "error": "Already in destination"})
continue
# Refuse to overwrite an existing file
if new_path.exists():
errors.append({"model": model_name, "error": f"Destination already exists: {path.name}"})
continue
# Move the model file
shutil.move(str(path), str(new_path))
logger.info("Moved model: %s → %s", path, new_path)
# Move all sidecar files (skip if destination already exists)
for ext in _SIDECAR_EXTENSIONS:
sidecar = path.parent / (stem + ext)
if sidecar.is_file():
sidecar_dest = dest_dir / (stem + ext)
if sidecar_dest.exists():
logger.warning("Sidecar destination exists, skipping: %s", sidecar_dest)
continue
try:
shutil.move(str(sidecar), str(sidecar_dest))
except Exception as se:
logger.warning("Could not move sidecar %s: %s", sidecar, se)
# Compute new logical model name (relative to root, forward slashes)
new_rel = str(new_path.relative_to(root_dir)).replace("\\", "/")
if new_rel.endswith(_DISABLED_SUFFIX):
new_rel = new_rel[: -len(_DISABLED_SUFFIX)]
# Update metadata key
if model_name in meta_data:
meta_data[new_rel] = meta_data.pop(model_name)
meta_changed = True
moved.append({"from": model_name, "to": new_rel})
renames.append((model_name, new_rel))
except Exception as e:
logger.error("Error moving model %s: %s", model_name, e)
errors.append({"model": model_name, "error": str(e)})
# Update group entries for renamed/moved models
if renames:
groups_for_type = meta_data.get("_groups", {}).get(model_type, {})
groups_dirty = False
for old_name, new_name in renames:
for members in groups_for_type.values():
for i, m in enumerate(members):
if m == old_name:
members[i] = new_name
groups_dirty = True
if groups_dirty:
if not isinstance(meta_data.get("_groups"), dict):
meta_data["_groups"] = {}
meta_data["_groups"][model_type] = groups_for_type
meta_changed = True
if meta_changed:
self._save_metadata(meta_data)
return {"moved": moved, "errors": errors}
+108
View File
@@ -0,0 +1,108 @@
"""Tagger タグ DB サービス(SQLite)。"""
import csv
import io
import logging
import sqlite3
from pathlib import Path
logger = logging.getLogger(__name__)
class TaggerDbService:
def __init__(self, db_file: Path):
self.db_file = db_file
self._init_db()
def _conn(self) -> sqlite3.Connection:
return sqlite3.connect(str(self.db_file))
def _init_db(self):
self.db_file.parent.mkdir(parents=True, exist_ok=True)
with self._conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT,
interrogator_tags TEXT,
vlm_tags TEXT,
all_tags TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
for sql in [
"CREATE INDEX IF NOT EXISTS idx_all_tags ON images(all_tags)",
"CREATE INDEX IF NOT EXISTS idx_filename ON images(filename)",
"CREATE INDEX IF NOT EXISTS idx_created_at ON images(created_at DESC)",
]:
conn.execute(sql)
conn.commit()
def save(self, filename: str, interrogator_tags: str, vlm_tags: str) -> int:
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
with self._conn() as conn:
cur = conn.execute(
"INSERT INTO images (filename, interrogator_tags, vlm_tags, all_tags) VALUES (?, ?, ?, ?)",
(filename, interrogator_tags, vlm_tags, all_tags),
)
conn.commit()
return cur.lastrowid
def list(self, limit: int = 100, offset: int = 0) -> list:
with self._conn() as conn:
rows = conn.execute(
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at "
"FROM images ORDER BY id DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [self._row(r) for r in rows]
def total(self) -> int:
with self._conn() as conn:
return conn.execute("SELECT COUNT(*) FROM images").fetchone()[0]
def search(self, q: str) -> list:
pattern = f"%{q}%"
with self._conn() as conn:
rows = conn.execute(
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at "
"FROM images WHERE all_tags LIKE ? OR filename LIKE ? ORDER BY id DESC LIMIT 200",
(pattern, pattern),
).fetchall()
return [self._row(r) for r in rows]
def update(self, row_id: int, interrogator_tags: str, vlm_tags: str):
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
with self._conn() as conn:
conn.execute(
"UPDATE images SET interrogator_tags=?, vlm_tags=?, all_tags=? WHERE id=?",
(interrogator_tags, vlm_tags, all_tags, row_id),
)
conn.commit()
def delete(self, row_id: int):
with self._conn() as conn:
conn.execute("DELETE FROM images WHERE id=?", (row_id,))
conn.commit()
def export_csv(self) -> str:
with self._conn() as conn:
rows = conn.execute(
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at FROM images ORDER BY id"
).fetchall()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["id", "filename", "interrogator_tags", "vlm_tags", "all_tags", "created_at"])
writer.writerows(rows)
return buf.getvalue()
@staticmethod
def _row(r) -> dict:
return {
"id": r[0],
"filename": r[1] or "",
"interrogator_tags": r[2] or "",
"vlm_tags": r[3] or "",
"all_tags": r[4] or "",
"created_at": r[5] or "",
}
+402
View File
@@ -0,0 +1,402 @@
"""WD Tagger / DeepDanbooru / Ollama VLM 推論サービス。"""
import base64
import csv
import io
import json
import logging
import tempfile
import threading
import urllib.request
from pathlib import Path
from typing import Optional
import numpy as np
from PIL import Image, ImageOps, PngImagePlugin
logger = logging.getLogger(__name__)
_model_lock = threading.Lock()
class TaggerService:
def __init__(self, models_dir: Path, settings_file: Path):
self.models_dir = models_dir
self.settings_file = settings_file
self._cache: dict = {}
self._batch_state: dict = {
"running": False, "total": 0, "done": 0, "log": [], "stop": False,
}
# ── 設定 ──────────────────────────────────────────────────
def load_settings(self) -> dict:
if self.settings_file.exists():
try:
with open(self.settings_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def save_settings(self, data: dict):
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.settings_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _effective_models_dir(self) -> Path:
custom = self.load_settings().get("models_dir", "").strip()
if custom:
p = Path(custom)
if p.is_dir():
return p
return self.models_dir
# ── モデル一覧 ─────────────────────────────────────────────
def list_models(self) -> list:
mdir = self._effective_models_dir()
models = []
if not mdir.is_dir():
return models
for item in sorted(mdir.iterdir()):
if not item.is_dir():
continue
exts = {f.suffix.lower() for f in item.iterdir() if f.is_file()}
if ".onnx" in exts:
models.append({"name": item.name, "type": "onnx"})
elif ".h5" in exts:
models.append({"name": item.name, "type": "deepdanbooru"})
return models
# ── モデルロード ──────────────────────────────────────────
@staticmethod
def _validate_model_name(name: str) -> bool:
"""パストラバーサル防止: セパレータ・NUL・相対参照を拒否"""
return bool(name) and "/" not in name and "\\" not in name and "\x00" not in name and name not in (".", "..")
def _load_model(self, model_name: str) -> Optional[dict]:
if not self._validate_model_name(model_name):
logger.error("Invalid model name rejected: %r", model_name)
return None
if model_name in self._cache:
return self._cache[model_name]
with _model_lock:
if model_name in self._cache:
return self._cache[model_name]
mdir = self._effective_models_dir()
model_dir = mdir / model_name
if not model_dir.is_dir():
logger.error("Model dir not found: %s", model_dir)
return None
files = list(model_dir.iterdir())
onnx = next((f for f in files if f.suffix == ".onnx"), None)
h5 = next((f for f in files if f.suffix == ".h5"), None)
if onnx:
return self._load_onnx(model_name, onnx, model_dir)
if h5:
return self._load_deepdanbooru(model_name, h5, model_dir)
logger.error("No .onnx or .h5 in %s", model_dir)
return None
def _load_onnx(self, name: str, onnx_file: Path, model_dir: Path) -> Optional[dict]:
try:
import onnxruntime as ort
providers = []
try:
import torch
if torch.cuda.is_available():
providers.append("CUDAExecutionProvider")
except ImportError:
pass
providers.append("CPUExecutionProvider")
session = ort.InferenceSession(str(onnx_file), providers=providers)
labels = self._load_labels(model_dir)
data = {
"session": session,
"labels": labels,
"input_name": session.get_inputs()[0].name,
"input_shape": session.get_inputs()[0].shape,
"type": "onnx",
}
self._cache[name] = data
logger.info("ONNX loaded: %s (%d labels)", name, len(labels))
return data
except ImportError:
logger.error("onnxruntime not installed")
return None
except Exception as e:
logger.error("ONNX load error %s: %s", name, e)
return None
def _load_deepdanbooru(self, name: str, h5_file: Path, model_dir: Path) -> Optional[dict]:
try:
import tensorflow as tf
except ImportError:
logger.warning("TensorFlow not installed — DeepDanbooru unavailable")
return None
try:
model = tf.keras.models.load_model(str(h5_file))
labels = self._load_labels(model_dir)
data = {"session": model, "labels": labels, "type": "deepdanbooru"}
self._cache[name] = data
logger.info("DeepDanbooru loaded: %s (%d labels)", name, len(labels))
return data
except Exception as e:
logger.error("DeepDanbooru load error %s: %s", name, e)
return None
def _load_labels(self, model_dir: Path) -> list:
for fname in ("selected_tags.csv", "tags.csv", "tags.txt", "tags-general.txt"):
p = model_dir / fname
if not p.exists():
continue
try:
if p.suffix == ".csv":
with open(p, "r", encoding="utf-8-sig", newline="") as f:
reader = csv.reader(f)
next(reader, None)
return [row[1] for row in reader if len(row) > 1]
else:
with open(p, "r", encoding="utf-8") as f:
return [ln.strip() for ln in f if ln.strip()]
except Exception as e:
logger.warning("Label load failed %s: %s", p, e)
return []
# ── 推論(WD Tagger / DeepDanbooru) ────────────────────
def predict(self, image_b64: str, model_name: str, threshold: float = 0.35, char_threshold: float = 0.85) -> dict:
img = self._decode_b64(image_b64)
data = self._load_model(model_name)
if data is None:
return {"error": f"Model '{model_name}' could not be loaded"}
try:
if data["type"] == "deepdanbooru":
tags = self._predict_dd(img, data, threshold, char_threshold)
else:
tags = self._predict_onnx(img, data, threshold, char_threshold)
return {"tags": ", ".join(tags), "count": len(tags)}
except Exception as e:
logger.error("predict error: %s", e)
return {"error": str(e)}
def _predict_onnx(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
shape = data["input_shape"]
fmt = "NHWC"
size = (224, 224)
if len(shape) == 4:
if shape[1] == 3 and isinstance(shape[2], int) and shape[2] > 0:
fmt = "NCHW"
size = (int(shape[3]), int(shape[2]))
elif shape[3] == 3 and isinstance(shape[1], int) and shape[1] > 0:
fmt = "NHWC"
size = (int(shape[2]), int(shape[1]))
img = ImageOps.exif_transpose(img)
if img.mode != "RGB":
img = img.convert("RGB")
arr = np.array(img.resize(size, Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
if fmt == "NCHW":
arr = arr.transpose((2, 0, 1))
arr = np.expand_dims(arr, 0)
preds = data["session"].run(None, {data["input_name"]: arr})[0][0]
return self._filter_tags(preds, data["labels"], thr, char_thr)
def _predict_dd(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
img = ImageOps.exif_transpose(img)
if img.mode != "RGB":
img = img.convert("RGB")
arr = np.array(img.resize((512, 512), Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
arr = np.expand_dims(arr, 0)
preds = data["session"].predict(arr)[0]
return self._filter_tags(preds, data["labels"], thr, char_thr)
def _filter_tags(self, preds, labels: list, thr: float, char_thr: float) -> list:
tags = []
for i, score in enumerate(preds):
if i >= len(labels):
break
label = labels[i]
if float(score) >= (char_thr if label.startswith("character:") else thr):
tags.append(label.replace("character:", ""))
return tags
# ── Ollama VLM ────────────────────────────────────────────
def ollama_models(self, api_url: str) -> list:
try:
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
return sorted(m["name"] for m in data.get("models", []))
except Exception as e:
logger.error("ollama_models: %s", e)
return []
def ollama_predict(self, image_b64: str, api_url: str, model: str, prompt: str, max_tags: int = 40) -> dict:
payload = json.dumps({
"model": model,
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
"stream": False,
"options": {"temperature": 0.7, "num_ctx": 4096},
}).encode("utf-8")
try:
req = urllib.request.Request(
api_url.rstrip("/") + "/api/chat",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=180) as resp:
data = json.loads(resp.read().decode("utf-8"))
content = data.get("message", {}).get("content", "")
tags = [t.strip() for t in content.split(",") if t.strip()][:max_tags]
return {"tags": ", ".join(tags), "count": len(tags)}
except Exception as e:
logger.error("ollama_predict: %s", e)
return {"error": str(e)}
# ── ファイルメタデータ書込 ────────────────────────────────
_ALLOWED_WRITE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
def write_meta_to_file(self, image_path: str, tags: str) -> dict:
path = Path(image_path).resolve()
if path.suffix.lower() not in self._ALLOWED_WRITE_EXTS:
return {"error": "Unsupported file type"}
if not path.is_file():
return {"error": f"File not found: {image_path}"}
try:
import piexif
except ImportError:
piexif = None
try:
with Image.open(path) as img:
img.load()
ext = path.suffix.lower()
if ext in (".jpg", ".jpeg") and piexif:
self._write_jpeg(img, path, tags, piexif)
elif ext == ".png":
self._write_png(img, path, tags)
else:
self._write_sidecar(path, tags)
return {"ok": True}
except Exception as e:
logger.error("write_meta: %s", e)
return {"error": str(e)}
def _write_jpeg(self, img: Image.Image, path: Path, tags: str, piexif):
exif_data = img.info.get("exif")
if exif_data:
try:
exif_dict = piexif.load(exif_data)
except Exception:
exif_dict = {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}}
else:
exif_dict = {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}}
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = tags.encode("utf-8")
exif_bytes = piexif.dump(exif_dict)
with tempfile.NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp:
tmp_path = Path(tmp.name)
img.save(str(tmp_path), "JPEG", quality=95, exif=exif_bytes)
tmp_path.replace(path)
def _write_png(self, img: Image.Image, path: Path, tags: str):
meta = PngImagePlugin.PngInfo()
for k, v in img.info.items():
if isinstance(k, str) and isinstance(v, str):
meta.add_text(k, v)
meta.add_text("Tags", tags)
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
tmp_path = Path(tmp.name)
img.save(str(tmp_path), "PNG", pnginfo=meta)
tmp_path.replace(path)
def _write_sidecar(self, path: Path, tags: str):
sidecar = path.with_suffix(".tags.json")
with open(sidecar, "w", encoding="utf-8") as f:
json.dump({"tags": tags}, f, ensure_ascii=False, indent=2)
# ── バッチ処理 ────────────────────────────────────────────
def batch_status(self) -> dict:
s = self._batch_state
return {
"running": s["running"],
"total": s["total"],
"done": s["done"],
"log": s["log"][-50:],
}
def batch_stop(self):
self._batch_state["stop"] = True
def batch_start(self, folder: str, model_name: str, threshold: float, char_threshold: float,
use_ollama: bool, ollama_api: str, ollama_model: str, ollama_prompt: str,
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service) -> dict:
if self._batch_state["running"]:
return {"error": "Batch already running"}
folder_path = Path(folder)
if not folder_path.is_dir():
return {"error": f"Folder not found: {folder}"}
exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
files = sorted(f for f in folder_path.iterdir() if f.is_file() and f.suffix.lower() in exts)
if not files:
return {"error": "No image files found"}
self._batch_state = {
"running": True, "total": len(files), "done": 0, "log": [], "stop": False,
}
def _run():
for fpath in files:
if self._batch_state["stop"]:
self._batch_state["log"].append("Stopped.")
break
try:
with Image.open(fpath) as img:
img.load()
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
interrogator_tags = ""
if model_name:
res = self.predict(b64, model_name, threshold, char_threshold)
interrogator_tags = res.get("tags", "")
vlm_tags = ""
if use_ollama and ollama_model:
res2 = self.ollama_predict(b64, ollama_api, ollama_model, ollama_prompt, ollama_max_tags)
vlm_tags = res2.get("tags", "")
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
if save_db:
db_service.save(str(fpath), interrogator_tags, vlm_tags)
if write_file and all_tags:
self.write_meta_to_file(str(fpath), all_tags)
if write_txt and all_tags:
txt_path = fpath.with_suffix(".txt")
with open(txt_path, "w", encoding="utf-8") as f:
f.write(all_tags)
n = len([t for t in all_tags.split(",") if t.strip()])
self._batch_state["log"].append(f"✓ {fpath.name}: {n} tags")
except Exception as e:
self._batch_state["log"].append(f"✗ {fpath.name}: {e}")
self._batch_state["done"] += 1
self._batch_state["running"] = False
threading.Thread(target=_run, daemon=True).start()
return {"ok": True, "total": len(files)}
# ── ユーティリティ ────────────────────────────────────────
@staticmethod
def _decode_b64(b64: str) -> Image.Image:
return Image.open(io.BytesIO(base64.b64decode(b64)))
+205
View File
@@ -0,0 +1,205 @@
"""Wildcard file management service."""
import logging
import os
import platform
import shutil
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
ALLOWED_EXTS = {"txt", "yaml", "yml"}
class WildcardService:
def __init__(self, wildcard_dir: Path):
self.wildcard_dir = wildcard_dir
# Only mkdir if it is a real directory (not a junction/symlink)
if not self.wildcard_dir.exists() and not self._is_junction(self.wildcard_dir):
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# File CRUD
# ------------------------------------------------------------------
def list_wildcards(self) -> list[dict]:
"""Return sorted list of wildcard files recursively (follows junction/symlink)."""
if not self.wildcard_dir.exists():
return []
files = []
for path in sorted(self.wildcard_dir.rglob("*")):
if path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS:
rel = path.relative_to(self.wildcard_dir)
dir_posix = rel.parent.as_posix()
if dir_posix == ".":
dir_posix = ""
files.append({
"name": path.stem,
"filename": rel.as_posix(),
"ext": path.suffix.lstrip(".").lower(),
"size": path.stat().st_size,
"dir": dir_posix,
"wc_name": rel.with_suffix("").as_posix(),
})
return files
def get_content(self, filename: str) -> str:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if not path.is_file():
raise FileNotFoundError(f"File not found: {filename}")
return path.read_text(encoding="utf-8")
def save_file(self, filename: str, content: str) -> dict:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
rel = path.relative_to(self.wildcard_dir)
dir_posix = rel.parent.as_posix()
if dir_posix == ".":
dir_posix = ""
return {
"name": path.stem,
"filename": rel.as_posix(),
"ext": path.suffix.lstrip(".").lower(),
"size": path.stat().st_size,
"dir": dir_posix,
"wc_name": rel.with_suffix("").as_posix(),
}
def delete_file(self, filename: str) -> None:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if path.is_file():
path.unlink()
def _safe_path(self, filename: str) -> Path | None:
if not filename or not filename.strip():
return None
# Normalize to forward slashes and strip leading/trailing slashes
filename = filename.replace("\\", "/").strip("/")
# Validate each path component — block traversal and empty parts
parts = filename.split("/")
for p in parts:
if not p or p == ".." or p == ".":
return None
ext = Path(parts[-1]).suffix.lstrip(".").lower()
if ext not in ALLOWED_EXTS:
return None
return self.wildcard_dir.joinpath(*parts)
# ------------------------------------------------------------------
# Impact Pack integration
# ------------------------------------------------------------------
@staticmethod
def find_impact_pack_wildcards(comfyui_root: Path) -> Path | None:
"""Return Impact Pack wildcards directory path, or None if not installed."""
custom_nodes = comfyui_root / "custom_nodes"
if not custom_nodes.is_dir():
return None
for d in custom_nodes.iterdir():
if d.is_dir() and "impact-pack" in d.name.lower():
wildcards = d / "wildcards"
if wildcards.is_dir():
return wildcards
return None
def get_link_status(self, comfyui_root: Path) -> dict:
"""Return current Impact Pack integration status."""
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
is_linked = self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()
resolved_target = None
if is_linked:
try:
resolved_target = str(self.wildcard_dir.resolve())
except Exception:
pass
return {
"impact_pack_installed": impact_dir is not None,
"impact_pack_wildcards_dir": str(impact_dir) if impact_dir else None,
"wfs_wildcard_dir": str(self.wildcard_dir),
"is_linked": is_linked,
"link_target": resolved_target,
}
def create_link(self, comfyui_root: Path) -> dict:
"""Replace WFS wildcard dir with a junction/symlink → Impact Pack wildcards."""
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
if impact_dir is None:
raise RuntimeError("ComfyUI-Impact-Pack is not installed.")
if self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink():
raise RuntimeError("Link already exists.")
# Migrate existing WFS files to Impact Pack dir
migrated: list[str] = []
if self.wildcard_dir.is_dir():
for f in self.wildcard_dir.iterdir():
if f.is_file():
dest = impact_dir / f.name
if not dest.exists():
shutil.copy2(str(f), str(dest))
migrated.append(f.name)
shutil.rmtree(str(self.wildcard_dir))
# Create junction (Windows) or symlink (others)
self._create_junction_or_symlink(self.wildcard_dir, impact_dir)
logger.info("Wildcard link created: %s -> %s", self.wildcard_dir, impact_dir)
return {"migrated_files": migrated}
def remove_link(self) -> None:
"""Remove junction/symlink and restore WFS wildcard dir as a plain directory."""
if not (self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()):
raise RuntimeError("No link to remove.")
self._remove_junction_or_symlink(self.wildcard_dir)
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
logger.info("Wildcard link removed, restored plain dir: %s", self.wildcard_dir)
# ------------------------------------------------------------------
# Platform helpers
# ------------------------------------------------------------------
@staticmethod
def _is_junction(path: Path) -> bool:
"""Return True if path is a Windows directory junction."""
if platform.system() != "Windows":
return False
try:
import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x0400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
return attrs != 0xFFFFFFFF and bool(attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception:
return False
@staticmethod
def _create_junction_or_symlink(link: Path, target: Path) -> None:
if platform.system() == "Windows":
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"mklink /J failed: {result.stderr.strip()}")
else:
os.symlink(target, link, target_is_directory=True)
@staticmethod
def _remove_junction_or_symlink(path: Path) -> None:
if platform.system() == "Windows":
result = subprocess.run(
["cmd", "/c", "rmdir", str(path)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"rmdir failed: {result.stderr.strip()}")
else:
os.unlink(str(path))
+102 -20
View File
@@ -3,6 +3,24 @@
import os
import re
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
_CLIP_TYPE_TO_MODEL = {
"flux": "Flux",
"flux2": "Flux2",
"newbie": "NewBie",
"ovis": "Ovis",
"qwen_image": "Qwen",
"sd3": "SD3",
"sd3.5": "SD3",
"hidream_i": "HiDream",
"hidream_e": "HiDream",
"hidream": "HiDream",
"wan": "Wan",
"cosmos": "Cosmos",
"lumina": "Lumina",
"lumina2": "Z-IMAGE",
}
def _model_name_from_ui_node(node):
"""Extract model filename from UI format node (lowercase)."""
@@ -11,16 +29,65 @@ def _model_name_from_ui_node(node):
return val.lower() if isinstance(val, str) else ""
def _clip_type_from_ui_node(node):
"""Extract CLIPLoader type string from widgets_values (lowercase)."""
ntype = node.get("type", "")
vals = node.get("widgets_values", [])
if ntype == "CLIPLoader" and len(vals) > 1:
return str(vals[1]).lower() if isinstance(vals[1], str) else ""
if ntype in ("DualCLIPLoader", "TripleCLIPLoader") and len(vals) > 2:
return str(vals[2]).lower() if isinstance(vals[2], str) else ""
return ""
def _model_name_from_api_node(node):
"""Extract model filename from API format node (lowercase)."""
inputs = node.get("inputs", {})
for key in ("ckpt_name", "unet_name", "model_name"):
for key in ("ckpt_name", "unet_name", "model_name", "clip_name1", "clip_name"):
val = inputs.get(key, "")
if isinstance(val, str) and val:
return val.lower()
return ""
def _collect_all_ui_nodes(workflow_data):
"""Yield all UI-format nodes including those inside definitions.subgraphs."""
for node in workflow_data.get("nodes", []):
yield node
defs = workflow_data.get("definitions", {})
if isinstance(defs, dict):
for sg in defs.get("subgraphs", []):
if isinstance(sg, dict):
for node in sg.get("nodes", []):
yield node
def _detect_model_type_from_name(mn_base, model_types):
"""Detect model type from a model filename (already lowercased basename)."""
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
model_types.add("SD1.5")
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
model_types.add("SDXL")
elif re.search(r"flux[-_.]?2", mn_base):
model_types.add("Flux2")
elif "flux" in mn_base:
model_types.add("Flux")
elif re.search(r"sd[._-]?3", mn_base):
model_types.add("SD3")
elif "qwen" in mn_base:
model_types.add("Qwen")
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
model_types.add("Z-IMAGE")
elif "newbie" in mn_base:
model_types.add("NewBie")
elif "ovis" in mn_base:
model_types.add("Ovis")
elif "hidream" in mn_base:
model_types.add("HiDream")
elif "wan" in mn_base and re.search(r"wan[-_.]?\d|wan[-_]video", mn_base):
model_types.add("Wan")
def analyze_workflow(workflow_data, filename=""):
"""Analyze workflow JSON and return model types, input/output node counts."""
model_types = set()
@@ -36,23 +103,33 @@ def analyze_workflow(workflow_data, filename=""):
model_types.add("Qwen")
if "zimage" in fn or "z-image" in fn or "zit" in fn:
model_types.add("Z-IMAGE")
if "flux2" in fn:
if re.search(r"flux[-_.]?2", fn):
model_types.add("Flux2")
elif "flux" in fn:
model_types.add("Flux")
if "sd3" in fn:
model_types.add("SD3")
if "newbie" in fn:
model_types.add("NewBie")
if "ovis" in fn:
model_types.add("Ovis")
if "hidream" in fn:
model_types.add("HiDream")
if "wan" in fn and re.search(r"wan[-_.]?\d|wan[-_]video", fn):
model_types.add("Wan")
# Support both UI format (nodes array) and API format (node ID object)
raw_nodes = workflow_data.get("nodes", None)
if raw_nodes is not None:
# UI / app format: collect all nodes including subgraph definitions
node_iter = [
(
node.get("type", ""),
node.get("title", "").lower(),
_model_name_from_ui_node(node),
_clip_type_from_ui_node(node),
)
for node in raw_nodes
for node in _collect_all_ui_nodes(workflow_data)
]
else:
node_iter = []
@@ -62,33 +139,32 @@ def analyze_workflow(workflow_data, filename=""):
ntype = node.get("class_type", "")
title = node.get("_meta", {}).get("title", "").lower()
mn = _model_name_from_api_node(node)
node_iter.append((ntype, title, mn))
node_iter.append((ntype, title, mn, ""))
for ntype, title, mn in node_iter:
for ntype, title, mn, clip_type in node_iter:
if (
"CheckpointLoader" in ntype
or "UNETLoader" in ntype
or "UnetLoader" in ntype # UnetLoaderGGUF 等を含む
or "unet_name" in ntype
):
mn_base = os.path.basename(mn.replace("\\", "/"))
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
model_types.add("SD1.5")
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
model_types.add("SDXL")
elif "flux2" in mn_base:
model_types.add("Flux2")
elif "flux" in mn_base:
model_types.add("Flux")
elif re.search(r"sd[._-]?3", mn_base):
model_types.add("SD3")
elif "qwen" in mn_base:
model_types.add("Qwen")
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
model_types.add("Z-IMAGE")
_detect_model_type_from_name(mn_base, model_types)
# CLIPLoader 系: type フィールドまたはファイル名からモデル種別を検出
# QuadrupleCLIPLoader 等、type フィールドを持たない新型ローダーにも対応
if "CLIPLoader" in ntype:
if clip_type:
detected = _CLIP_TYPE_TO_MODEL.get(clip_type)
if detected:
model_types.add(detected)
mn_base = os.path.basename(mn.replace("\\", "/"))
if mn_base:
_detect_model_type_from_name(mn_base, model_types)
if "sdxl" in title:
model_types.add("SDXL")
if "flux2" in title:
if re.search(r"flux[-_.]?2", title):
model_types.add("Flux2")
elif "flux" in title:
model_types.add("Flux")
@@ -98,6 +174,12 @@ def analyze_workflow(workflow_data, filename=""):
model_types.add("Qwen")
if "zimage" in title or "z-image" in title or "zit" in title:
model_types.add("Z-IMAGE")
if "newbie" in title:
model_types.add("NewBie")
if "ovis" in title:
model_types.add("Ovis")
if "hidream" in title:
model_types.add("HiDream")
if ntype == "CLIPTextEncode" or "prompt" in title:
inputs["prompts"] += 1
+4 -1
View File
@@ -61,7 +61,7 @@ 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
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
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
@@ -71,5 +71,8 @@ class WorkflowStudio:
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
gallery_routes.setup_routes(app)
wildcard_routes.setup_routes(app)
tagger_routes.setup_routes(app)
gmic_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+4 -4
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-workflow-studio"
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
version = "0.2.7"
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.63"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
@@ -10,7 +10,7 @@ dependencies = ["jinja2"]
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
[tool.comfy]
PublisherId = "ketle-man"
PublisherId = "statsu"
DisplayName = "Workflow Studio"
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/icon.svg"
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/docs/thumb.png"
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
+21
View File
@@ -0,0 +1,21 @@
# ComfyUI-Workflow-Studio - Tagger tab dependencies
#
# numpy and Pillow are already bundled with ComfyUI and do not need to be installed.
# torch is managed by ComfyUI and must NOT be pinned here.
#
# Install into ComfyUI's embedded Python:
# python_embeded\python.exe -m pip install -r requirements.txt
#
# For GPU-accelerated ONNX inference, install onnxruntime-gpu instead of onnxruntime:
# python_embeded\python.exe -m pip install onnxruntime-gpu
# -- Required for WD Tagger / SwinV2 ONNX inference --
onnxruntime>=1.16
# -- Required for JPEG EXIF tag writing --
piexif>=1.1.3
# -- Optional: DeepDanbooru (.h5 model) support --
# Omit if you do not use DeepDanbooru models.
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
# tensorflow>=2.10
+241
View File
@@ -27,10 +27,12 @@
.wfm-gallery-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 4px;
}
.wfm-gallery-tree-title {
@@ -39,6 +41,13 @@
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1;
}
.wfm-gallery-tree-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
@@ -210,6 +219,43 @@
color: #fbbf24;
}
/* Feederグループボタン(サムネイルカード左上) */
.wfm-gallery-thumb-feeder-btn {
position: absolute;
top: 4px;
left: 4px;
background: rgba(0,0,0,0.5);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 11px;
font-weight: bold;
line-height: 1;
padding: 0;
cursor: pointer;
color: rgba(255,255,255,0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-feeder-btn {
opacity: 1;
}
.wfm-gallery-thumb-feeder-btn.active {
color: #38bdf8;
opacity: 1;
}
.wfm-gallery-thumb-feeder-btn:hover {
background: rgba(0,0,0,0.75);
color: #38bdf8;
}
/* テーブルビューのお気に入りボタン */
.wfm-gallery-table-fav-btn {
background: none;
@@ -369,6 +415,15 @@
flex-shrink: 0;
}
.wfm-gallery-preview-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.wfm-gallery-detail-img {
max-width: 100%;
max-height: 200px;
@@ -376,6 +431,43 @@
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;
@@ -592,3 +684,152 @@
.wfm-gallery-lightbox-close:hover {
background: rgba(255,255,255,0.2);
}
/* ── 画像比較ライトボックス ──────────────────────────────────── */
.wfm-lightbox-compare .wfm-lightbox-compare-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
max-width: 96vw;
max-height: 92vh;
overflow: auto;
}
.wfm-lightbox-compare-grid {
display: grid;
grid-template-columns: repeat(var(--compare-cols, 2), 1fr);
gap: 12px;
padding: 16px;
}
.wfm-lightbox-compare-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.wfm-lightbox-compare-img {
max-width: calc(92vw / var(--compare-cols, 2) - 12px);
max-height: 78vh;
object-fit: contain;
border-radius: 4px;
cursor: default;
}
.wfm-lightbox-compare-label {
color: rgba(255,255,255,0.75);
font-size: 12px;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-lightbox-compare-close {
position: fixed;
top: 16px;
right: 16px;
z-index: 10001;
}
/* ── 詳細パネル アクションボタン行 ───────────────────────────── */
.wfm-gallery-detail-action-row {
display: flex;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
flex-wrap: wrap;
}
/* ── 詳細パネル アクションボタン ─────────────────────────────── */
.wfm-gallery-action-btn {
padding: 4px 10px;
border-radius: 4px;
border: 1px solid;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
align-self: center;
margin: 2px 3px;
color: #fff;
}
.wfm-gallery-action-btn-green {
background: var(--wfm-success, #22c55e);
border-color: var(--wfm-success, #22c55e);
}
.wfm-gallery-action-btn-green:hover {
background: #16a34a;
border-color: #16a34a;
}
.wfm-gallery-action-btn-primary {
background: var(--wfm-primary);
border-color: var(--wfm-primary);
}
.wfm-gallery-action-btn-primary:hover {
background: var(--wfm-primary-hover);
border-color: var(--wfm-primary-hover);
}
.wfm-gallery-action-btn-tagger {
background: var(--wfm-accent, #7c5cfc);
border-color: var(--wfm-accent, #7c5cfc);
}
.wfm-gallery-action-btn-tagger:hover {
background: #6a4de0;
border-color: #6a4de0;
}
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
.wfm-gallery-file-actions {
display: flex;
gap: 6px;
padding-top: 4px;
}
.wfm-gallery-file-actions .wfm-btn {
flex: 1;
}
/* ── 移動先選択モーダル ──────────────────────────────────────── */
.wfm-gallery-move-modal {
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 8px;
padding: 20px;
min-width: 320px;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 12px;
}
.wfm-gallery-move-modal-title {
font-size: 14px;
font-weight: 600;
color: var(--wfm-text-primary);
}
.wfm-gallery-move-dest-sel {
width: 100%;
padding: 6px 8px;
font-size: 13px;
}
.wfm-gallery-move-modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
+462
View File
@@ -0,0 +1,462 @@
/* =============================================
Image Edit Tab
============================================= */
#wfm-tab-image-edit {
padding: 0;
overflow: hidden;
}
.ie-editor {
display: flex;
height: 100%;
overflow: hidden;
background: var(--wfm-bg);
}
/* =============================================
Left Toolbar
============================================= */
.ie-toolbar {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 5px;
background: var(--wfm-surface);
border-right: 1px solid var(--wfm-border);
width: 46px;
flex-shrink: 0;
}
.ie-tool-btn {
width: 34px;
height: 34px;
border: 1px solid transparent;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
color: var(--wfm-text);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, border-color 0.12s;
user-select: none;
position: relative;
}
.ie-tool-btn:hover {
background: var(--wfm-surface-hover);
border-color: var(--wfm-border);
}
.ie-tool-btn.active {
background: var(--wfm-primary);
border-color: var(--wfm-primary);
color: #fff;
}
.ie-tool-placeholder {
opacity: 0.38;
}
.ie-tool-placeholder::after {
content: "";
position: absolute;
bottom: 3px;
right: 3px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--wfm-text-secondary);
opacity: 0.5;
}
/* =============================================
Center column
============================================= */
.ie-center {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* Action bar */
.ie-action-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-bottom: 1px solid var(--wfm-border);
background: var(--wfm-surface);
flex-shrink: 0;
flex-wrap: wrap;
}
/* Tool options bar */
.ie-tool-options {
display: flex;
align-items: center;
gap: 14px;
padding: 5px 10px;
border-bottom: 1px solid var(--wfm-border);
background: var(--wfm-surface);
flex-shrink: 0;
flex-wrap: wrap;
min-height: 36px;
font-size: 12px;
}
.ie-opt-group {
display: flex;
align-items: center;
gap: 5px;
}
.ie-opt-group label {
color: var(--wfm-text-secondary);
white-space: nowrap;
}
.ie-opt-select {
font-size: 12px;
padding: 2px 5px;
background: var(--wfm-surface);
color: var(--wfm-text);
border: 1px solid var(--wfm-border);
border-radius: 4px;
cursor: pointer;
}
.ie-opt-input {
font-size: 12px;
padding: 2px 5px;
background: var(--wfm-surface);
color: var(--wfm-text);
border: 1px solid var(--wfm-border);
border-radius: 4px;
}
.ie-opt-active {
background: var(--wfm-primary) !important;
color: #fff !important;
border-color: var(--wfm-primary) !important;
}
/* Canvas wrap */
.ie-canvas-wrap {
flex: 1;
overflow: hidden;
position: relative;
background: #1a1a1a;
background-image:
linear-gradient(45deg, #222 25%, transparent 25%),
linear-gradient(-45deg, #222 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #222 75%),
linear-gradient(-45deg, transparent 75%, #222 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
}
.ie-canvas-container {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
line-height: 0;
box-shadow: 0 2px 20px rgba(0,0,0,0.6);
}
.ie-canvas-container canvas {
position: absolute;
top: 0;
left: 0;
display: block;
}
#ie-canvas-bg {
z-index: 1;
}
#ie-canvas-draw {
z-index: 2;
cursor: crosshair;
}
#ie-canvas-overlay {
z-index: 3;
cursor: default;
pointer-events: none; /* SelectTool.activate()でautoに変更 */
}
.ie-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--wfm-text-secondary);
font-size: 14px;
pointer-events: none;
gap: 8px;
}
.ie-placeholder-icon {
font-size: 40px;
opacity: 0.3;
}
/* Zoom bar */
.ie-zoom-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-top: 1px solid var(--wfm-border);
background: var(--wfm-surface);
font-size: 12px;
flex-shrink: 0;
color: var(--wfm-text-secondary);
}
#ie-zoom-label {
min-width: 38px;
text-align: right;
color: var(--wfm-text);
}
/* =============================================
Properties Pane (left of Layers panel)
============================================= */
.ie-props-pane {
width: 160px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--wfm-border);
background: var(--wfm-surface);
overflow: hidden;
}
.ie-props-header {
display: flex;
align-items: center;
padding: 5px 8px;
border-bottom: 1px solid var(--wfm-border);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--wfm-text-secondary);
flex-shrink: 0;
}
.ie-props-body {
flex: 1;
overflow-y: auto;
padding: 8px 6px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ie-props-row {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
}
.ie-props-row label {
color: var(--wfm-text-secondary);
white-space: nowrap;
}
.ie-props-row input[type="range"] {
width: 100%;
}
.ie-props-row span {
font-size: 11px;
color: var(--wfm-text);
text-align: right;
}
/* Mask layer item highlight */
.ie-layer-item[data-type="mask"] .ie-layer-type-icon {
color: #e25252;
}
/* =============================================
Right Panel (Layers)
============================================= */
.ie-right-panel {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--wfm-border);
background: var(--wfm-surface);
overflow: hidden;
}
.ie-layers-header {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 6px;
border-bottom: 1px solid var(--wfm-border);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--wfm-text-secondary);
flex-shrink: 0;
}
.ie-layers-header > span {
flex: 1;
}
.ie-layer-list {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.ie-layer-item {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 5px;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
user-select: none;
transition: background 0.1s;
}
.ie-layer-item:hover {
background: var(--wfm-surface-hover);
}
.ie-layer-item.active {
background: rgba(90, 110, 220, 0.22);
outline: 1px solid rgba(90, 110, 220, 0.4);
}
.ie-layer-thumb {
width: 30px;
height: 30px;
border: 1px solid var(--wfm-border);
border-radius: 3px;
flex-shrink: 0;
object-fit: cover;
background:
repeating-conic-gradient(#888 0% 25%, transparent 0% 50%)
0 0 / 8px 8px;
}
.ie-layer-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ie-layer-vis-btn {
background: none;
border: none;
cursor: pointer;
font-size: 13px;
padding: 0;
line-height: 1;
color: var(--wfm-text);
opacity: 0.5;
flex-shrink: 0;
transition: opacity 0.1s;
}
.ie-layer-vis-btn:hover {
opacity: 1;
}
.ie-opacity-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-top: 1px solid var(--wfm-border);
font-size: 12px;
flex-shrink: 0;
color: var(--wfm-text-secondary);
}
.ie-opacity-row input[type="range"] {
flex: 1;
}
#ie-layer-opacity-label {
min-width: 32px;
text-align: right;
color: var(--wfm-text);
}
/* =============================================
Text Tool Overlay
============================================= */
.ie-text-overlay {
position: absolute;
z-index: 100;
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: 6px;
padding: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.ie-text-textarea {
width: 200px;
resize: vertical;
background: var(--wfm-bg);
color: var(--wfm-text);
border: 1px solid var(--wfm-border);
border-radius: 4px;
padding: 6px;
font-size: 13px;
font-family: inherit;
min-height: 60px;
}
.ie-text-btn-row {
display: flex;
gap: 6px;
justify-content: flex-end;
}
/* =============================================
Scrollbar
============================================= */
.ie-layer-list::-webkit-scrollbar {
width: 4px;
}
.ie-layer-list::-webkit-scrollbar-thumb {
background: var(--wfm-border);
border-radius: 2px;
}
+2382 -73
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
/* ============================================================
Tagger Tab Styles
============================================================ */
/* ── サブタブナビ ────────────────────────────────────────────── */
.wfm-tagger-subtab-nav {
display: flex;
gap: 0;
border-bottom: 1px solid var(--wfm-border);
background: var(--wfm-bg-secondary);
flex-shrink: 0;
}
.wfm-tagger-subtab-btn {
padding: 8px 20px;
font-size: 13px;
font-weight: 500;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--wfm-text-secondary);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.wfm-tagger-subtab-btn:hover {
color: var(--wfm-text);
}
.wfm-tagger-subtab-btn.active {
color: var(--wfm-primary);
border-bottom-color: var(--wfm-primary);
}
/* ── サブタブ共通 ────────────────────────────────────────────── */
.wfm-tagger-subtab {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* ── セクション ─────────────────────────────────────────────── */
.wfm-tagger-section {
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
padding: 10px 12px;
margin-bottom: 10px;
}
.wfm-tagger-section-header {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
/* ── フォームレイアウト ─────────────────────────────────────── */
.wfm-tagger-form-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.wfm-tagger-form-row:last-child {
margin-bottom: 0;
}
.wfm-tagger-label {
font-size: 12px;
color: var(--wfm-text-secondary);
min-width: 80px;
white-space: nowrap;
}
.wfm-tagger-select {
flex: 1;
min-width: 0;
}
/* ── スライダー ─────────────────────────────────────────────── */
.wfm-tagger-slider {
flex: 1;
min-width: 0;
accent-color: var(--wfm-primary);
cursor: pointer;
}
.wfm-tagger-slider-val {
font-size: 12px;
color: var(--wfm-text-secondary);
min-width: 36px;
text-align: right;
}
/* ── Single サブタブ ─────────────────────────────────────────── */
.wfm-tagger-single-layout {
display: flex;
gap: 12px;
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
min-height: 0;
}
.wfm-tagger-image-pane {
width: 300px;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.wfm-tagger-image-sources {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.wfm-tagger-preview-wrap {
flex: 1;
min-height: 0;
background: var(--wfm-surface);
border: 2px dashed var(--wfm-border);
border-radius: var(--wfm-radius);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
transition: border-color 0.15s, background 0.15s;
cursor: pointer;
}
.wfm-tagger-preview-wrap.drag-over {
border-color: var(--wfm-primary);
background: rgba(74, 158, 255, 0.08);
}
.wfm-tagger-image-name {
font-size: 11px;
color: var(--wfm-text-secondary);
text-align: center;
word-break: break-all;
min-height: 16px;
}
.wfm-tagger-controls-pane {
flex: 1;
min-width: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.wfm-tagger-result-actions {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
/* ── Batch サブタブ ──────────────────────────────────────────── */
.wfm-tagger-batch-layout {
max-width: 900px;
}
.wfm-tagger-batch-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.wfm-tagger-progress-bar-wrap {
height: 6px;
background: var(--wfm-border);
border-radius: 3px;
overflow: hidden;
}
.wfm-tagger-progress-bar {
height: 100%;
background: var(--wfm-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
/* ── DB サブタブ ─────────────────────────────────────────────── */
.wfm-tagger-db-layout {
display: flex;
flex-direction: column;
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
min-height: 0;
}
.wfm-tagger-db-toolbar {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 10px;
flex-shrink: 0;
}
.wfm-tagger-db-table-wrap {
flex: 1;
overflow: auto;
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
min-height: 0;
}
.wfm-tagger-db-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.wfm-tagger-db-table th,
.wfm-tagger-db-table td {
padding: 6px 10px;
text-align: left;
border-bottom: 1px solid var(--wfm-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.wfm-tagger-db-table th {
background: var(--wfm-bg-secondary);
color: var(--wfm-text-secondary);
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
position: sticky;
top: 0;
z-index: 1;
}
.wfm-tagger-db-table tbody tr {
cursor: pointer;
transition: background 0.1s;
}
.wfm-tagger-db-table tbody tr:hover {
background: var(--wfm-surface-hover);
}
.wfm-tagger-db-table tbody tr.selected {
background: rgba(74, 158, 255, 0.15);
}
.wfm-tagger-db-edit-panel {
flex-shrink: 0;
margin-top: 10px;
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
padding: 10px 12px;
}
/* ── ユーティリティ ─────────────────────────────────────────── */
#wfm-tab-tagger {
display: none;
flex-direction: column;
height: calc(100vh - var(--wfm-header-height, 56px));
overflow: hidden;
}
#wfm-tab-tagger.active {
display: flex;
}
.wfm-placeholder {
color: var(--wfm-text-secondary);
font-size: 13px;
text-align: center;
padding: 20px;
pointer-events: none;
}
+597
View File
@@ -0,0 +1,597 @@
/**
* AI Tab - Translation, VLM, Settings
* Supports Ollama and LM Studio as backends
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage } from "./util.js";
const SETTINGS_KEY = "wfm_ai_settings";
const LANG_NAMES = {
ja: "Japanese",
en: "English",
zh: "Chinese",
};
// ============================================
// Settings persistence
// ============================================
function loadAiSettings() {
return readJsonStorage(SETTINGS_KEY);
}
function saveAiSettings(patch) {
const data = { ...loadAiSettings(), ...patch };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
return data;
}
const VLM_PROMPTS = {
describe: "Describe this image in detail.",
prompt: "Create a detailed Stable Diffusion image generation prompt based on this image. Output only the prompt text, nothing else.",
tags: "Generate a list of descriptive tags for this image. Output only comma-separated tags in English, nothing else.",
};
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
const b64 = e.target.result.split(",")[1];
resolve({ base64: b64, mimeType: file.type });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
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 }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).response || "";
} else {
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).choices?.[0]?.message?.content || "";
}
}
// ============================================
// URL validation
// ============================================
function isValidBackendUrl(url) {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
// ============================================
// API helpers
// ============================================
async function fetchModels(url, backend) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/tags`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return (data.models || []).map((m) => m.name);
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/models`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return (data.data || []).map((m) => m.id);
}
}
async function testConnection(url, backend) {
const models = await fetchModels(url, backend);
return models.length;
}
async function callLLM(url, backend, model, prompt) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, stream: false }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.response || "";
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content || "";
}
}
// ============================================
// 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}`;
}
// ============================================
// Translation tab
// ============================================
function initTranslateTab() {
const srcLangSel = document.getElementById("wfm-ai-src-lang");
const dstLangSel = document.getElementById("wfm-ai-dst-lang");
const swapBtn = document.getElementById("wfm-ai-trans-swap-btn");
const inputEl = document.getElementById("wfm-ai-trans-input");
const outputEl = document.getElementById("wfm-ai-trans-output");
const transBtn = document.getElementById("wfm-ai-trans-btn");
const copyBtn = document.getElementById("wfm-ai-trans-copy-btn");
const statusEl = document.getElementById("wfm-ai-trans-status");
if (!transBtn) return;
// Restore saved language selections
const saved = loadAiSettings();
if (saved.srcLang && srcLangSel) srcLangSel.value = saved.srcLang;
if (saved.dstLang && dstLangSel) dstLangSel.value = saved.dstLang;
// Persist language selections
srcLangSel?.addEventListener("change", () => saveAiSettings({ srcLang: srcLangSel.value }));
dstLangSel?.addEventListener("change", () => saveAiSettings({ dstLang: dstLangSel.value }));
// Swap languages
swapBtn?.addEventListener("click", () => {
const tmp = srcLangSel.value;
srcLangSel.value = dstLangSel.value;
dstLangSel.value = tmp;
saveAiSettings({ srcLang: srcLangSel.value, dstLang: dstLangSel.value });
// Swap text content too
const tmpText = inputEl.value;
inputEl.value = outputEl.value;
outputEl.value = tmpText;
});
// Translate
transBtn.addEventListener("click", async () => {
const text = inputEl.value.trim();
if (!text) {
showToast(t("aiToastNoText"), "error");
return;
}
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
if (!model) {
showToast(t("aiToastNoModel"), "error");
return;
}
transBtn.disabled = true;
statusEl.textContent = t("aiStatusTranslating");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
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";
} catch (err) {
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
showToast(t("aiToastTransFailed") + err.message, "error");
} finally {
transBtn.disabled = false;
}
});
// Copy translated text
copyBtn?.addEventListener("click", () => {
const text = outputEl.value;
if (!text) {
showToast(t("aiToastNoCopyText"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("aiToastCopied"), "success");
});
});
}
// ============================================
// Settings tab
// ============================================
async function refreshModels() {
const settings = loadAiSettings();
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
const url = document.getElementById("wfm-ai-backend-url")?.value?.trim() || "";
const modelSel = document.getElementById("wfm-ai-model-select");
const refreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
if (!modelSel) return;
if (refreshBtn) refreshBtn.disabled = true;
try {
const models = await fetchModels(url, backend);
modelSel.innerHTML = `<option value="">-- ${t("aiSettingsModelPlaceholder")} --</option>`;
models.forEach((name) => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
if (name === settings.model) opt.selected = true;
modelSel.appendChild(opt);
});
if (models.length === 0) showToast(t("aiToastNoModels"), "error");
} catch (err) {
showToast(t("aiToastModelsFailed") + err.message, "error");
} finally {
if (refreshBtn) refreshBtn.disabled = false;
}
}
function initSettingsTab() {
const saved = loadAiSettings();
// Restore saved values
const backendRadios = document.querySelectorAll("input[name='wfm-ai-backend']");
backendRadios.forEach((r) => {
if (r.value === (saved.backend || "ollama")) r.checked = true;
});
const urlInput = document.getElementById("wfm-ai-backend-url");
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
// Update URL placeholder when backend changes
backendRadios.forEach((r) => {
r.addEventListener("change", () => {
if (!urlInput) return;
const defaultUrl = r.value === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
if (!saved.backendUrl) urlInput.value = defaultUrl;
});
});
// Connection test
document.getElementById("wfm-ai-test-btn")?.addEventListener("click", async () => {
const testBtn = document.getElementById("wfm-ai-test-btn");
const resultEl = document.getElementById("wfm-ai-test-result");
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
const url = urlInput?.value?.trim() || "";
if (!isValidBackendUrl(url)) {
if (resultEl) {
resultEl.textContent = t("aiToastInvalidUrlInput");
resultEl.className = "wfm-ai-test-result wfm-ai-status-error";
}
return;
}
if (testBtn) testBtn.disabled = true;
if (resultEl) {
resultEl.textContent = t("aiStatusConnecting");
resultEl.className = "wfm-ai-test-result wfm-ai-status-working";
}
try {
const count = await testConnection(url, backend);
if (resultEl) {
resultEl.textContent = `${t("aiStatusConnectOk")} (${count} ${t("aiModels")})`;
resultEl.className = "wfm-ai-test-result wfm-ai-status-ok";
}
await refreshModels();
} catch (err) {
if (resultEl) {
resultEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
resultEl.className = "wfm-ai-test-result wfm-ai-status-error";
}
} finally {
if (testBtn) testBtn.disabled = false;
}
});
// Model refresh
document.getElementById("wfm-ai-model-refresh-btn")?.addEventListener("click", () => refreshModels());
// Free language inputs
const freeSrcInput = document.getElementById("wfm-ai-free-src-lang");
const freeDstInput = document.getElementById("wfm-ai-free-dst-lang");
if (freeSrcInput && saved.freeSrcLang) freeSrcInput.value = saved.freeSrcLang;
if (freeDstInput && saved.freeDstLang) freeDstInput.value = saved.freeDstLang;
// Save settings
document.getElementById("wfm-ai-settings-save-btn")?.addEventListener("click", () => {
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
const url = urlInput?.value?.trim() || "";
const model = document.getElementById("wfm-ai-model-select")?.value || "";
const freeSrcLang = freeSrcInput?.value?.trim() || "";
const freeDstLang = freeDstInput?.value?.trim() || "";
if (url && !isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
saveAiSettings({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
showToast(t("aiToastSettingsSaved"), "success");
});
// Load models on init if settings exist
if (saved.backendUrl && saved.backend) {
refreshModels().catch(() => {});
}
}
// ============================================
// Chat tab
// ============================================
async function callChat(url, backend, model, messages) {
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 {
const res = await fetch(`${url}/v1/chat/completions`, {
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()).choices?.[0]?.message?.content || "";
}
}
function appendChatBubble(messagesEl, role, content) {
const div = document.createElement("div");
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
div.textContent = content;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
function initChatTab() {
const messagesEl = document.getElementById("wfm-ai-chat-messages");
const inputEl = document.getElementById("wfm-ai-chat-input");
const sendBtn = document.getElementById("wfm-ai-chat-send-btn");
const clearBtn = document.getElementById("wfm-ai-chat-clear-btn");
const statusEl = document.getElementById("wfm-ai-chat-status");
if (!messagesEl || !inputEl || !sendBtn) return;
let chatHistory = [];
async function sendMessage() {
const text = inputEl.value.trim();
if (!text) return;
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
if (!model) {
showToast(t("aiToastNoModel"), "error");
return;
}
inputEl.value = "";
inputEl.style.height = "";
chatHistory.push({ role: "user", content: text });
appendChatBubble(messagesEl, "user", text);
sendBtn.disabled = true;
statusEl.textContent = t("aiStatusChatting");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
try {
const reply = await callChat(url, backend, model, chatHistory);
chatHistory.push({ role: "assistant", content: reply });
appendChatBubble(messagesEl, "assistant", reply);
statusEl.textContent = "";
statusEl.className = "wfm-ai-trans-status";
} catch (err) {
chatHistory.pop();
if (messagesEl.lastChild) messagesEl.removeChild(messagesEl.lastChild);
inputEl.value = text;
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
showToast(t("aiToastChatFailed") + err.message, "error");
} finally {
sendBtn.disabled = false;
}
}
sendBtn.addEventListener("click", sendMessage);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
clearBtn.addEventListener("click", () => {
chatHistory = [];
messagesEl.innerHTML = "";
statusEl.textContent = "";
statusEl.className = "wfm-ai-trans-status";
});
}
// ============================================
// VLM tab
// ============================================
function initVlmTab() {
const dropEl = document.getElementById("wfm-ai-vlm-drop");
const previewEl = document.getElementById("wfm-ai-vlm-preview");
const labelEl = document.getElementById("wfm-ai-vlm-label");
const fileInput = document.getElementById("wfm-ai-vlm-file");
const taskSel = document.getElementById("wfm-ai-vlm-task");
const runBtn = document.getElementById("wfm-ai-vlm-run");
const statusEl = document.getElementById("wfm-ai-vlm-status");
const resultEl = document.getElementById("wfm-ai-vlm-result");
const copyBtn = document.getElementById("wfm-ai-vlm-copy");
const wcInputs = document.getElementById("wfm-ai-wc-inputs");
const wcNameEl = document.getElementById("wfm-ai-wc-name");
const wcCountEl = document.getElementById("wfm-ai-wc-count");
if (!dropEl) return;
let vlmImage = null; // { base64, mimeType }
function updateTaskUI() {
const isWildcard = taskSel?.value === "wildcard";
dropEl.style.display = isWildcard ? "none" : "";
if (wcInputs) wcInputs.style.display = isWildcard ? "" : "none";
}
taskSel?.addEventListener("change", updateTaskUI);
updateTaskUI();
const loadImage = async (file) => {
if (!file || !file.type.startsWith("image/")) return;
vlmImage = await fileToBase64(file);
previewEl.src = `data:${vlmImage.mimeType};base64,${vlmImage.base64}`;
previewEl.style.display = "block";
labelEl.style.display = "none";
};
dropEl.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", e => { if (e.target.files[0]) loadImage(e.target.files[0]); });
dropEl.addEventListener("dragover", e => { e.preventDefault(); dropEl.classList.add("drag-over"); });
dropEl.addEventListener("dragleave", () => dropEl.classList.remove("drag-over"));
dropEl.addEventListener("drop", e => {
e.preventDefault();
dropEl.classList.remove("drag-over");
if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]);
});
runBtn.addEventListener("click", async () => {
const task = taskSel?.value || "describe";
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
if (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
if (task === "wildcard") {
const name = wcNameEl?.value.trim() || "";
const count = Math.max(1, parseInt(wcCountEl?.value) || 20);
if (!name) { showToast(t("aiToastWcNoName"), "error"); return; }
runBtn.disabled = true;
statusEl.textContent = t("aiStatusRunning");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
resultEl.value = "";
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);
resultEl.value = result.trim()
.split("\n")
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
.filter(l => l.length > 0)
.join("\n");
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";
showToast(t("aiToastVlmFailed") + err.message, "error");
} finally {
runBtn.disabled = false;
}
return;
}
if (!vlmImage) { showToast(t("aiToastNoImage"), "error"); return; }
runBtn.disabled = true;
statusEl.textContent = t("aiStatusRunning");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
resultEl.value = "";
try {
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType);
resultEl.value = result.trim();
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";
showToast(t("aiToastVlmFailed") + err.message, "error");
} finally {
runBtn.disabled = false;
}
});
copyBtn.addEventListener("click", () => {
const text = resultEl.value;
if (!text) { showToast(t("aiToastNoCopyText"), "error"); return; }
navigator.clipboard.writeText(text).then(() => showToast(t("aiToastCopied"), "success"));
});
}
// ============================================
// Export
// ============================================
export function initAiTab() {
initTranslateTab();
initChatTab();
initVlmTab();
initSettingsTab();
}
+247 -5
View File
@@ -4,6 +4,7 @@
*/
import { initI18n, t } from "./i18n.js";
import { getSettings } from "./util.js";
// Initialize i18n before anything else
initI18n();
@@ -90,7 +91,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
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" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -114,7 +115,7 @@ function applyI18nToHtml() {
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) {
listOpenComfyBtn.textContent = t("openInComfyUI");
listOpenComfyBtn.textContent = t("sendToCanvas");
if (listOpenComfyBtn.disabled) listOpenComfyBtn.title = t("selectCardFirst");
}
@@ -195,6 +196,31 @@ function applyI18nToHtml() {
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
});
const galleryBulkDeselect = document.getElementById("wfm-gallery-bulk-deselect");
if (galleryBulkDeselect) galleryBulkDeselect.textContent = t("galleryBulkDeselectAll");
const galleryBulkSelectAll = document.getElementById("wfm-gallery-bulk-select-all");
if (galleryBulkSelectAll) galleryBulkSelectAll.textContent = t("galleryBulkSelectAll");
const galleryBulkGroupSelect = document.getElementById("wfm-gallery-bulk-group-select");
if (galleryBulkGroupSelect) {
const opt = galleryBulkGroupSelect.querySelector("option[value='']");
if (opt) opt.textContent = t("galleryBulkAddToGroup");
}
const galleryBulkGroupAdd = document.getElementById("wfm-gallery-bulk-group-add");
if (galleryBulkGroupAdd) galleryBulkGroupAdd.textContent = t("galleryBulkAdd");
const galleryBulkGroupRemove = document.getElementById("wfm-gallery-bulk-group-remove");
if (galleryBulkGroupRemove) galleryBulkGroupRemove.textContent = t("galleryBulkGroupRemove");
const galleryBulkFav = document.getElementById("wfm-gallery-bulk-fav");
if (galleryBulkFav) galleryBulkFav.textContent = t("galleryBulkFavAll");
const galleryBulkUnfav = document.getElementById("wfm-gallery-bulk-unfav");
if (galleryBulkUnfav) galleryBulkUnfav.textContent = t("galleryBulkUnfavAll");
const galleryBulkMove = document.getElementById("wfm-gallery-bulk-move");
if (galleryBulkMove) galleryBulkMove.textContent = t("galleryBulkMoveTo");
const galleryBulkExport = document.getElementById("wfm-gallery-bulk-export");
if (galleryBulkExport) galleryBulkExport.textContent = t("galleryBulkExport");
const galleryBulkDelete = document.getElementById("wfm-gallery-bulk-delete");
if (galleryBulkDelete) galleryBulkDelete.textContent = t("galleryBulkDelete");
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
if (galleryOpenTaggerBtn) galleryOpenTaggerBtn.textContent = t("tabTagger");
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
@@ -224,6 +250,67 @@ function applyI18nToHtml() {
const presetCopyNegBtn = document.getElementById("wfm-preset-copy-neg-btn");
if (presetCopyNegBtn) presetCopyNegBtn.textContent = t("copyNegativePrompt");
// AI tab
document.querySelectorAll(".wfm-ai-subtab-btn").forEach(btn => {
if (btn.dataset.aiSubtab === "translate") btn.textContent = t("aiSubTabTranslate");
if (btn.dataset.aiSubtab === "vlm") btn.textContent = t("aiSubTabVlm");
if (btn.dataset.aiSubtab === "ai-settings") btn.textContent = t("aiSubTabSettings");
});
const aiLangMap = { ja: "aiLangJa", en: "aiLangEn", zh: "aiLangZh", free: "aiLangFree" };
[document.getElementById("wfm-ai-src-lang"), document.getElementById("wfm-ai-dst-lang")].forEach(sel => {
if (!sel) return;
sel.querySelectorAll("option").forEach(opt => {
const key = aiLangMap[opt.value];
if (key) opt.textContent = t(key);
});
});
const aiTransInput = document.getElementById("wfm-ai-trans-input");
if (aiTransInput) aiTransInput.placeholder = t("aiTransInputPlaceholder");
const aiTransOutput = document.getElementById("wfm-ai-trans-output");
if (aiTransOutput) aiTransOutput.placeholder = t("aiTransOutputPlaceholder");
const aiTransBtn = document.getElementById("wfm-ai-trans-btn");
if (aiTransBtn) aiTransBtn.textContent = t("aiTranslateBtn");
const aiTransCopyBtn = document.getElementById("wfm-ai-trans-copy-btn");
if (aiTransCopyBtn) aiTransCopyBtn.textContent = t("aiCopyBtn");
const aiVlmLabel = document.getElementById("wfm-ai-vlm-label");
if (aiVlmLabel) aiVlmLabel.textContent = t("aiVlmDropLabel");
const aiVlmTask = document.getElementById("wfm-ai-vlm-task");
if (aiVlmTask) {
aiVlmTask.querySelectorAll("option").forEach(opt => {
if (opt.value === "describe") opt.textContent = t("aiVlmDescribe");
if (opt.value === "prompt") opt.textContent = t("aiVlmPromptCreate");
});
}
const aiVlmRunBtn = document.getElementById("wfm-ai-vlm-run");
if (aiVlmRunBtn) aiVlmRunBtn.textContent = t("aiVlmRunBtn");
const aiVlmResult = document.getElementById("wfm-ai-vlm-result");
if (aiVlmResult) aiVlmResult.placeholder = t("aiVlmResultPlaceholder");
const aiVlmCopyBtn = document.getElementById("wfm-ai-vlm-copy");
if (aiVlmCopyBtn) aiVlmCopyBtn.textContent = t("aiCopyBtn");
const aiBackendTitle = document.getElementById("wfm-ai-settings-backend-title");
if (aiBackendTitle) aiBackendTitle.textContent = t("aiSettingsBackend");
const aiConnTitle = document.getElementById("wfm-ai-settings-conn-title");
if (aiConnTitle) aiConnTitle.textContent = t("aiSettingsConnection");
const aiTestBtn = document.getElementById("wfm-ai-test-btn");
if (aiTestBtn) aiTestBtn.textContent = t("aiSettingsTestBtn");
const aiModelTitle = document.getElementById("wfm-ai-settings-model-title");
if (aiModelTitle) aiModelTitle.textContent = t("aiSettingsModelSection");
const aiModelSel = document.getElementById("wfm-ai-model-select");
if (aiModelSel) {
const firstOpt = aiModelSel.querySelector("option[value='']");
if (firstOpt) firstOpt.textContent = t("aiSettingsModelPlaceholder");
}
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
if (aiFreeSrcLabel) aiFreeSrcLabel.textContent = t("aiSettingsInputLang");
const aiFreeDstLabel = document.getElementById("wfm-ai-free-dst-label");
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
// Group panel labels
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
if (propTitle) propTitle.textContent = t("properties");
@@ -265,28 +352,62 @@ function applyI18nToHtml() {
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
"wfm-help-gen-11": "helpGen11", "wfm-help-gen-12": "helpGen12",
"wfm-help-gen-13": "helpGen13", "wfm-help-gen-14": "helpGen14",
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
"wfm-help-gen-17": "helpGen17",
"wfm-help-feeder-title": "helpFeederTitle",
"wfm-help-feeder-desc": "helpFeederDesc",
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
"wfm-help-feeder-imgloop-desc": "helpFeederImgloopDesc",
"wfm-help-feeder-1": "helpFeeder1", "wfm-help-feeder-2": "helpFeeder2",
"wfm-help-feeder-3": "helpFeeder3", "wfm-help-feeder-4": "helpFeeder4",
"wfm-help-feeder-5": "helpFeeder5", "wfm-help-feeder-6": "helpFeeder6",
"wfm-help-feeder-7": "helpFeeder7", "wfm-help-feeder-8": "helpFeeder8",
"wfm-help-feeder-9": "helpFeeder9",
"wfm-help-feeder-gal-title": "helpFeederGalTitle",
"wfm-help-feeder-gal-desc": "helpFeederGalDesc",
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
"wfm-help-prompt-7": "helpPrompt7",
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
"wfm-help-prompt-9": "helpPrompt9",
"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-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",
"wfm-help-nodes-7": "helpNodes7",
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
"wfm-help-models-9": "helpModels9",
"wfm-help-models-9": "helpModels9", "wfm-help-models-10": "helpModels10",
"wfm-help-models-11": "helpModels11", "wfm-help-models-12": "helpModels12",
"wfm-help-gallery-title": "helpGalleryTitle",
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
"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-tagger-title": "helpTaggerTitle",
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
"wfm-help-tagger-5": "helpTagger5", "wfm-help-tagger-6": "helpTagger6",
"wfm-help-tagger-7": "helpTagger7", "wfm-help-tagger-8": "helpTagger8",
"wfm-help-tagger-9": "helpTagger9",
"wfm-help-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
@@ -294,12 +415,70 @@ function applyI18nToHtml() {
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-12": "helpSidepanel12",
"wfm-help-sidepanel-13": "helpSidepanel13",
"wfm-help-sidepanel-14": "helpSidepanel14", "wfm-help-sidepanel-15": "helpSidepanel15",
"wfm-help-sidepanel-16": "helpSidepanel16",
"wfm-help-sidepanel-17": "helpSidepanel17",
"wfm-help-ai-title": "helpAiTitle",
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-5": "helpAi5", "wfm-help-ai-6": "helpAi6",
"wfm-help-imageedit-title": "helpImageEditTitle",
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
"wfm-help-imageedit-text-title": "helpImageEditTextTitle",
"wfm-help-imageedit-export-title": "helpImageEditExportTitle",
"wfm-help-imageedit-kbd-title": "helpImageEditKbdTitle",
"wfm-help-imageedit-1": "helpImageEdit1", "wfm-help-imageedit-2": "helpImageEdit2", "wfm-help-imageedit-3": "helpImageEdit3",
"wfm-help-imageedit-4": "helpImageEdit4", "wfm-help-imageedit-5": "helpImageEdit5",
"wfm-help-imageedit-6": "helpImageEdit6", "wfm-help-imageedit-7": "helpImageEdit7",
"wfm-help-imageedit-8": "helpImageEdit8", "wfm-help-imageedit-9": "helpImageEdit9",
"wfm-help-imageedit-10": "helpImageEdit10", "wfm-help-imageedit-11": "helpImageEdit11",
"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-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",
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
"wfm-help-imageedit-30": "helpImageEdit30",
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
"wfm-help-imageedit-7d": "helpImageEdit7d",
"wfm-help-imageedit-8b": "helpImageEdit8b",
"wfm-help-imageedit-blur-title": "helpImageEditBlurTitle",
"wfm-help-imageedit-blur-1": "helpImageEditBlur1", "wfm-help-imageedit-blur-2": "helpImageEditBlur2",
"wfm-help-imageedit-blur-3": "helpImageEditBlur3", "wfm-help-imageedit-blur-4": "helpImageEditBlur4",
"wfm-help-imageedit-blur-5": "helpImageEditBlur5", "wfm-help-imageedit-blur-6": "helpImageEditBlur6",
"wfm-help-imageedit-bgremove-title": "helpImageEditBgRemoveTitle",
"wfm-help-imageedit-bgremove-1": "helpImageEditBgRemove1", "wfm-help-imageedit-bgremove-2": "helpImageEditBgRemove2",
"wfm-help-imageedit-bgremove-3": "helpImageEditBgRemove3", "wfm-help-imageedit-bgremove-4": "helpImageEditBgRemove4",
"wfm-help-imageedit-bgremove-5": "helpImageEditBgRemove5",
"wfm-help-imageedit-mask-title": "helpImageEditMaskTitle",
"wfm-help-imageedit-mask-1": "helpImageEditMask1", "wfm-help-imageedit-mask-2": "helpImageEditMask2",
"wfm-help-imageedit-mask-3": "helpImageEditMask3", "wfm-help-imageedit-mask-4": "helpImageEditMask4",
"wfm-help-imageedit-mask-5": "helpImageEditMask5", "wfm-help-imageedit-mask-6": "helpImageEditMask6",
"wfm-help-imageedit-mask-7": "helpImageEditMask7", "wfm-help-imageedit-mask-8": "helpImageEditMask8",
"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",
"wfm-help-imageedit-gmic-install-title": "helpImageEditGmicInstallTitle",
"wfm-help-imageedit-gmic-install-1": "helpImageEditGmicInstall1",
"wfm-help-imageedit-gmic-install-2": "helpImageEditGmicInstall2",
"wfm-help-imageedit-gmic-install-3": "helpImageEditGmicInstall3",
"wfm-help-imageedit-tabkbd-title": "helpImageEditTabKbdTitle",
"wfm-help-imageedit-tabkbd-1": "helpImageEdit22", "wfm-help-imageedit-tabkbd-2": "helpImageEdit23",
"wfm-help-imageedit-tabkbd-3": "helpImageEdit24", "wfm-help-imageedit-tabkbd-4": "helpImageEdit25",
"wfm-help-imageedit-tabkbd-5": "helpImageEdit26", "wfm-help-imageedit-tabkbd-6": "helpImageEdit27",
"wfm-help-imageedit-tabkbd-7": "helpImageEdit28", "wfm-help-imageedit-tabkbd-8": "helpImageEdit29",
"wfm-help-shortcuts-title": "helpShortcutsTitle",
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
"wfm-help-trouble-title": "helpTroubleTitle",
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
"wfm-help-trouble-7": "helpTrouble7",
"wfm-help-support-title": "helpSupportTitle",
"wfm-help-support-desc": "helpSupportDesc",
"wfm-help-github-desc": "helpGithubDesc",
@@ -310,6 +489,41 @@ function applyI18nToHtml() {
const el = document.getElementById(id);
if (el) el.textContent = t(key);
}
// Search box placeholder
const helpSearch = document.getElementById("wfm-help-search");
if (helpSearch) {
helpSearch.placeholder = t("helpSearchPlaceholder");
helpSearch.addEventListener("input", _onHelpSearch);
}
}
function _onHelpSearch(e) {
const q = e.target.value.trim().toLowerCase();
const navItems = document.querySelectorAll("[data-help-page]");
if (!q) {
navItems.forEach(btn => btn.classList.remove("search-hidden"));
return;
}
const matchedPages = new Set();
document.querySelectorAll(".wfm-help-page").forEach(page => {
const pageId = page.id.replace("wfm-help-page-", "");
if (page.textContent.toLowerCase().includes(q)) matchedPages.add(pageId);
});
let firstBtn = null;
navItems.forEach(btn => {
const pageId = btn.dataset.helpPage;
if (matchedPages.has(pageId)) {
btn.classList.remove("search-hidden");
if (!firstBtn) firstBtn = btn;
} else {
btn.classList.add("search-hidden");
}
});
if (firstBtn && !firstBtn.classList.contains("active")) firstBtn.click();
}
// ============================================
@@ -347,24 +561,52 @@ 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 } from "./settings-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } 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";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
// Apply saved textarea font size & JSON highlight colors
try {
const _s = getSettings();
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
applyJsonColors(_s.jsonColors);
} catch {}
function initHelpTab() {
document.querySelectorAll(".wfm-help-nav-item").forEach((btn) => {
btn.addEventListener("click", () => {
const page = btn.dataset.helpPage;
document.querySelectorAll(".wfm-help-nav-item").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".wfm-help-page").forEach((p) => p.classList.remove("active"));
document.getElementById(`wfm-help-page-${page}`)?.classList.add("active");
});
});
}
document.addEventListener("DOMContentLoaded", () => {
applyI18nToHtml();
initTabs();
initModal();
initHelpTab();
initSettingsTab(); // Settings first (applies saved URL)
initWorkflowTab();
initNodesTab();
initModelsTab();
initGenerateTab();
initPromptTab();
initMetadataTab();
initGalleryTab();
initAiTab();
initTaggerTab();
imageEditTab.init();
console.log("Workflow Studio: App initialized");
});
+37 -19
View File
@@ -88,7 +88,18 @@ export const comfyUI = {
try {
const info = await this.fetchObjectInfo(cls);
if (info?.[cls]?.input?.required?.[inputKey]) {
return info[cls].input.required[inputKey][0] || [];
const inputDef = info[cls].input.required[inputKey];
const first = inputDef[0];
// V1: [["model1", "model2"], {}]
if (Array.isArray(first)) return first;
// V2: ["COMBO", { "values": [...] }]
if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) {
return inputDef[1].values;
}
// V3 ComfyNode: ["COMBO", { "options": [...] }]
if (typeof first === "string" && Array.isArray(inputDef[1]?.options)) {
return inputDef[1].options;
}
}
} catch {}
}
@@ -121,17 +132,19 @@ export const comfyUI = {
},
async fetchDiffusionModels() {
return this._fetchModelList(
["UNETLoader", "UnetLoaderGGUF"],
"unet_name"
);
const [standard, gguf] = await Promise.all([
this._fetchModelList(["UNETLoader", "UnetLoaderGGUF"], "unet_name"),
this._fetchModelList(["LoaderGGUF", "LoaderGGUFAdvanced"], "gguf_name"),
]);
return [...new Set([...standard, ...gguf])];
},
async fetchTextEncoders() {
return this._fetchModelList(
["DualCLIPLoader", "CLIPLoader"],
"clip_name1"
);
const [dual, single] = await Promise.all([
this._fetchModelList(["DualCLIPLoader", "DualClipLoaderGGUF"], "clip_name1"),
this._fetchModelList(["CLIPLoader", "ClipLoaderGGUF"], "clip_name"),
]);
return [...new Set([...dual, ...single])];
},
async fetchHypernetworks() {
@@ -142,14 +155,17 @@ export const comfyUI = {
},
async fetchEmbeddings() {
// ComfyUI exposes embeddings via /embeddings endpoint
// Use WFS backend API (reliable, goes through same aiohttp server)
try {
const res = await fetch(`${this.baseUrl}/embeddings`);
const res = await fetch("/api/wfm/models/files?type=embedding");
if (res.ok) {
const list = await res.json();
return Array.isArray(list) ? list : [];
}
} catch {}
console.warn("[WFS] /api/wfm/models/files?type=embedding returned", res.status);
} catch (e) {
console.error("[WFS] fetchEmbeddings error:", e);
}
return [];
},
@@ -162,14 +178,16 @@ export const comfyUI = {
},
// Generation
async queuePrompt(workflow) {
async queuePrompt(workflow, extraData = null) {
const body = {
prompt: workflow,
client_id: this.clientId,
};
if (extraData) body.extra_data = extraData;
const res = await fetch(`${this.baseUrl}/prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: workflow,
client_id: this.clientId,
}),
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
@@ -269,8 +287,8 @@ export const comfyUI = {
throw new Error("Failed to connect WebSocket");
}
// Queue prompt
const result = await this.queuePrompt(workflow);
// Queue prompt — pass workflow as extra_pnginfo so SaveImage embeds it in PNG metadata
const result = await this.queuePrompt(workflow, { extra_pnginfo: { workflow } });
this.currentPromptId = result.prompt_id;
// Track progress
File diff suppressed because it is too large Load Diff
+330 -17
View File
@@ -295,16 +295,36 @@ export const comfyWorkflow = {
const nodeLinks = linkMap[node.id] || {};
const inputDefs = node.inputs || [];
const linkedInputNames = new Set();
// UI slot inputs with an actual link don't have a widget value entry in
// widgets_values (modern ComfyUI format). Track these separately so the
// widget-mapping loop won't advance wIdx for them.
const linkedSlotNames = new Set();
inputDefs.forEach((inp, idx) => {
if (nodeLinks[idx]) {
inputs[inp.name] = nodeLinks[idx];
linkedInputNames.add(inp.name);
linkedSlotNames.add(inp.name);
}
});
// 2. Map widgets_values to widget input names using object_info
const widgets = node.widgets_values || [];
if (widgets.length > 0) {
// Lora Loader (LoraManager): use __lm_widget_ids from node properties
// widgets_values: [autocomplete_meta_obj, text_str, loras_array]
// API format: {__lm_autocomplete_meta_text: obj, text: str, loras: {__value__: arr}}
const lmWidgetIds = node.properties?.__lm_widget_ids;
if (lmWidgetIds && Array.isArray(lmWidgetIds)) {
lmWidgetIds.forEach((name, idx) => {
if (idx >= widgets.length || linkedInputNames.has(name)) return;
let val = widgets[idx];
if (name === "loras" && Array.isArray(val)) {
val = { "__value__": val };
}
inputs[name] = val;
});
} else {
const widgetNames = _getWidgetInputNames(objectInfo, node.type);
if (widgetNames.length > 0) {
@@ -321,13 +341,15 @@ export const comfyWorkflow = {
const expectedType = widgetTypes[nIdx];
// Skip if this input is already linked
if (linkedInputNames.has(name)) {
// Still consume widget value(s) since widgets_values
// includes entries for linked inputs too
wIdx++;
// Also skip control_after_generate if present
if (wIdx < widgets.length && _isExtraWidgetValue(widgets[wIdx], expectedType)) {
if (!linkedSlotNames.has(name)) {
// Not a UI slot input — old-style linked input that still
// occupies a slot in widgets_values; consume it.
wIdx++;
if (wIdx < widgets.length && _isExtraWidgetValue(widgets[wIdx], expectedType)) {
wIdx++;
}
}
// UI slot inputs with a real link have no widget value entry — don't advance wIdx.
continue;
}
// Check if current widget value matches expected type
@@ -343,6 +365,24 @@ export const comfyWorkflow = {
wIdx++;
}
}
// For COMBO inputs, validate the value against the available choices.
// Dynamic COMBO options (e.g. Impact Pack's "Select to add Wildcard") may
// differ between the saved workflow and the current ComfyUI instance
// (e.g. "Select Wildcard 🟢 Full Cache" vs "Select Wildcard"), causing
// "Prompt outputs failed validation". Fall back to first choice when mismatch.
if (expectedType === "COMBO") {
const allInputDefs = {
...(objectInfo[node.type]?.input?.required || {}),
...(objectInfo[node.type]?.input?.optional || {}),
};
const spec = allInputDefs[name];
if (spec) {
const choices = Array.isArray(spec[0]) ? spec[0] : null;
if (choices && choices.length > 0 && !choices.includes(val)) {
val = choices[0];
}
}
}
inputs[name] = val;
}
} else {
@@ -356,6 +396,15 @@ export const comfyWorkflow = {
});
}
}
} // end else (non-LoraManager path)
}
// ImpactWildcardEncode / ImpactWildcardProcessor: widgets[0] is always wildcard_text.
// Always overwrite: object_info ordering may misalign when widget order differs between
// impact-pack versions; widgets[0] is stable across all known versions.
if ((node.type === "ImpactWildcardEncode" || node.type === "ImpactWildcardProcessor")
&& widgets.length > 0) {
inputs["wildcard_text"] = widgets[0];
}
api[nodeId] = {
@@ -441,12 +490,13 @@ export const comfyWorkflow = {
text_encoder_nodes: [],
diffusion_model_nodes: [],
controlnet_nodes: [],
hypernetwork_nodes: [],
all_nodes: [],
};
if (!workflow) return result;
// Build sampler connections for prompt classification
// === Pass 1: Collect sampler refs + all_nodes ===
const samplerPositiveRef = {};
const samplerNegativeRef = {};
@@ -462,40 +512,233 @@ export const comfyWorkflow = {
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
// KSamplerAdvanced uses noise_seed instead of seed
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
result.sampler_nodes.push({
id, type: ct, title,
seed: inputs.seed, steps: inputs.steps, cfg: inputs.cfg,
seed: inputs.seed ?? inputs.noise_seed,
seedKey,
steps: inputs.steps, cfg: inputs.cfg,
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
denoise: inputs.denoise,
});
}
}
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
// Nodes that pass CONDITIONING through without changing positive/negative role
const COND_PASSTHROUGH = new Set([
"ConditioningCombine", "ConditioningConcat", "ConditioningAverage",
"ConditioningSetTimestepRange",
// ConditioningZeroOut is intentionally excluded: it discards upstream text entirely
// (used as a no-text negative in ZIT/Lumina2 workflows). Propagating the negative
// role upstream would incorrectly mark the shared positive CLIPTextEncode as "unknown".
"ControlNetApply", "ControlNetApplyAdvanced",
"IPAdapterApply", "IPAdapterApplyFaceID",
"StyleModelApply",
]);
for (let iter = 0; iter < 5; iter++) {
for (const [id, node] of Object.entries(workflow)) {
if (!node?.class_type) continue;
const ct = node.class_type;
const inputs = node.inputs || {};
const isPos = !!samplerPositiveRef[id];
const isNeg = !!samplerNegativeRef[id];
if (!isPos && !isNeg) continue;
if (COND_PASSTHROUGH.has(ct)) {
for (const v of Object.values(inputs)) {
if (Array.isArray(v)) {
if (isPos) samplerPositiveRef[v[0]] = true;
if (isNeg) samplerNegativeRef[v[0]] = true;
}
}
}
// Propagate role through text encoder linked inputs (CLIPTextEncode → upstream text source)
if (ct === "CLIPTextEncode") {
const tv = inputs.text;
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
for (const key of ["text_g", "text_l"]) {
const tv = inputs[key];
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
const tv = inputs.wildcard_text;
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
// CLIPTextEncodeEditPlus — propagate role to its STRING inputs (text1, text2)
if (ct === "CLIPTextEncodeEditPlus") {
for (const key of ["text1", "text2"]) {
const tv = inputs[key];
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
}
}
// === Pass 2: Main analysis ===
for (const [id, node] of Object.entries(workflow)) {
if (!node?.class_type) continue;
const ct = node.class_type;
const inputs = node.inputs || {};
const title = node._meta?.title || ct;
if (ct.includes("CheckpointLoader")) {
const isPos = !!samplerPositiveRef[id];
const isNeg = !!samplerNegativeRef[id];
const getRole = () => {
if (isPos && !isNeg) return "positive";
if (isNeg && !isPos) return "negative";
if (/pos|正/i.test(title)) return "positive";
if (/neg|負/i.test(title)) return "negative";
return "unknown";
};
// --- Checkpoint nodes ---
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
result.checkpoint_nodes.push({
id, type: ct, title,
ckpt_name: inputs.ckpt_name,
});
}
// ImageMetadataPromptLoader — checkpoint + positive/negative prompts in one node
if (ct === "ImageMetadataPromptLoader") {
result.checkpoint_nodes.push({ id, type: ct, title, ckpt_name: inputs.ckpt_name });
if (typeof inputs.positive_text === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [positive]`, role: "positive",
text: inputs.positive_text, textKey: "positive_text",
});
}
if (typeof inputs.negative_text === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [negative]`, role: "negative",
text: inputs.negative_text, textKey: "negative_text",
});
}
}
// --- Prompt nodes ---
if (ct === "CLIPTextEncode") {
let role = "unknown";
if (samplerPositiveRef[id]) role = "positive";
else if (samplerNegativeRef[id]) role = "negative";
else if (/pos|正/i.test(title)) role = "positive";
else if (/neg|負/i.test(title)) role = "negative";
// Only add when text is a direct string (if linked, upstream node will be detected)
const textVal = inputs.text;
if (typeof textVal === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "text",
});
}
}
// SDXL dual-text encoder
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
const textG = inputs.text_g;
const textL = inputs.text_l;
// Add only if at least one text field is a direct string
if (typeof textG === "string" || typeof textL === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: typeof textG === "string" ? textG : (typeof textL === "string" ? textL : ""),
textKey: typeof textG === "string" ? "text_g" : "text_l",
});
}
}
// CLIPTextEncodeEditPlus — text_edit is the locally editable override; text1 is always a link.
// Skip when text_edit is empty: it means upstream nodes (ImpactWildcardEncode etc.) supply
// the actual prompt text and the edit field is just an optional local override.
if (ct === "CLIPTextEncodeEditPlus") {
const textVal = inputs.text_edit;
if (typeof textVal === "string" && textVal !== "") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "text_edit",
});
}
}
// Qwen-based image+text encoder
if (ct === "TextEncodeQwenImageEditPlus") {
result.prompt_nodes.push({
id, type: ct, title, role,
text: inputs.text,
id, type: ct, title, role: getRole(),
text: inputs.prompt || "",
textKey: "prompt",
});
}
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
if (inputs.text_positive !== undefined) {
result.prompt_nodes.push({
id, type: ct, title: `${title} [positive]`, role: "positive",
text: inputs.text_positive || "", textKey: "text_positive",
});
}
if (inputs.text_negative !== undefined) {
result.prompt_nodes.push({
id, type: ct, title: `${title} [negative]`, role: "negative",
text: inputs.text_negative || "", textKey: "text_negative",
});
}
}
// ImpactWildcardEncode / ImpactWildcardProcessor — treat wildcard_text as prompt
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
const textVal = inputs.wildcard_text;
if (typeof textVal === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "wildcard_text",
});
}
}
// WFS_PromptText — 2出力ノード。output[0]=positive / output[1]=negative
if (ct === "WFS_PromptText") {
if (typeof inputs.positive === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [positive]`, role: "positive",
text: inputs.positive, textKey: "positive",
});
}
if (typeof inputs.negative === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [negative]`, role: "negative",
text: inputs.negative, textKey: "negative",
});
}
}
// PrimitiveStringMultiline / PrimitiveString — when feeding a prompt node
if ((ct === "PrimitiveStringMultiline" || ct === "PrimitiveString") && (isPos || isNeg)) {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: inputs.value || "",
textKey: "value",
});
}
// --- Latent nodes ---
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
result.latent_nodes.push({
id, type: ct, title,
@@ -504,6 +747,7 @@ export const comfyWorkflow = {
});
}
// --- LoRA nodes ---
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
result.lora_nodes.push({
id, type: ct, title,
@@ -513,14 +757,51 @@ export const comfyWorkflow = {
});
}
// Power Lora Loader (rgthree) — dynamic lora_N inputs in API format
if (ct === "Power Lora Loader (rgthree)") {
for (const [k, v] of Object.entries(inputs)) {
if (/^lora_\d+$/.test(k) && typeof v === "string") {
result.lora_nodes.push({
id, type: ct, title,
lora_name: v,
strength_model: inputs[k.replace("lora_", "strength_")] ?? 1,
strength_clip: inputs[k.replace("lora_", "strength_clip_")] ?? 1,
});
}
}
}
// Lora Loader (LoraManager) — loras stored in inputs.loras.__value__
if (ct === "Lora Loader (LoraManager)") {
result.lora_nodes.push({ id, type: ct, title, is_lora_manager: true });
}
// ImageMetadataLoRALoader — up to 3 LoRA slots; skip "None" entries
if (ct === "ImageMetadataLoRALoader") {
for (let i = 1; i <= 3; i++) {
const loraName = inputs[`lora_${i}`];
if (typeof loraName === "string" && loraName !== "None") {
result.lora_nodes.push({
id, type: ct, title,
lora_name: loraName,
strength_model: inputs[`strength_model_${i}`] ?? 1.0,
strength_clip: inputs[`strength_clip_${i}`] ?? 1.0,
});
}
}
}
// --- VAE nodes ---
if (ct === "VAELoader") {
result.vae_nodes.push({ id, type: ct, title, vae_name: inputs.vae_name });
}
// --- Save/preview nodes ---
if (ct === "SaveImage" || ct === "PreviewImage") {
result.save_nodes.push({ id, type: ct, title });
}
// --- Load image nodes ---
if (ct === "LoadImage" || ct === "LoadImageMask"
|| (ct.toLowerCase().includes("load") && ct.toLowerCase().includes("image") && inputs.image !== undefined)) {
result.load_image_nodes.push({
@@ -528,26 +809,45 @@ export const comfyWorkflow = {
});
}
if (ct === "DualCLIPLoader" || ct === "CLIPLoader") {
// --- Text encoder (CLIP) nodes ---
if (ct === "DualCLIPLoader" || ct === "CLIPLoader" || ct === "ClipLoaderGGUF" || ct === "DualClipLoaderGGUF") {
result.text_encoder_nodes.push({
id, type: ct, title,
clip_name1: inputs.clip_name1 || inputs.clip_name,
clip_name2: inputs.clip_name2,
clip_type: inputs.type,
device: inputs.device,
});
}
// --- Diffusion model nodes ---
if (ct === "UNETLoader" || ct === "UnetLoaderGGUF") {
result.diffusion_model_nodes.push({
id, type: ct, title, unet_name: inputs.unet_name,
id, type: ct, title, unet_name: inputs.unet_name, inputKey: "unet_name",
});
}
if (ct === "LoaderGGUF" || ct === "LoaderGGUFAdvanced") {
result.diffusion_model_nodes.push({
id, type: ct, title, unet_name: inputs.gguf_name, inputKey: "gguf_name",
});
}
// --- ControlNet loader nodes ---
if (ct.includes("ControlNetLoader")) {
result.controlnet_nodes.push({
id, type: ct, title,
control_net_name: inputs.control_net_name,
});
}
// --- Hypernetwork loader nodes ---
if (ct === "HypernetworkLoader") {
result.hypernetwork_nodes.push({
id, type: ct, title,
hypernetwork_name: inputs.hypernetwork_name,
strength: inputs.strength ?? 1.0,
});
}
}
result.all_nodes.sort((a, b) => Number(a.id) - Number(b.id));
@@ -592,17 +892,30 @@ function _getWidgetMapping(nodeType) {
CheckpointLoaderSimple: ["ckpt_name"],
KSampler: ["seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
CLIPTextEncode: ["text"],
CLIPTextEncodeEditPlus: ["text_edit", "mode"],
ImageMetadataCheckpointLoader: ["ckpt_name", "vae_name", "_metadata_json"],
ImageMetadataPromptLoader: ["ckpt_name", "vae_name", "positive_text", "negative_text", "_metadata_json"],
ImageMetadataLoRALoader: ["lora_1", "strength_model_1", "strength_clip_1", "lora_2", "strength_model_2", "strength_clip_2", "lora_3", "strength_model_3", "strength_clip_3"],
EmptyLatentImage: ["width", "height", "batch_size"],
LoraLoader: ["lora_name", "strength_model", "strength_clip"],
VAELoader: ["vae_name"],
SaveImage: ["filename_prefix"],
LoadImage: ["image", "upload"],
UNETLoader: ["unet_name", "weight_dtype"],
LoaderGGUF: ["gguf_name"],
LoaderGGUFAdvanced: ["gguf_name", "dequant_dtype", "patch_dtype", "patch_on_device"],
ResolutionSelector: ["aspect_ratio", "megapixels"],
EmptySD3LatentImage: ["width", "height", "batch_size"],
CLIPLoader: ["clip_name", "type", "device"],
ClipLoaderGGUF: ["clip_name", "type"],
DualClipLoaderGGUF: ["clip_name1", "clip_name2", "type"],
FluxGuidance: ["guidance"],
CFGNorm: ["strength"],
// Impact Pack wildcard nodes — only map wildcard_text (index 0); remaining widgets
// vary between versions and include frontend-only extras, so we leave them unmapped.
ImpactWildcardProcessor: ["wildcard_text"],
ImpactWildcardEncode: ["wildcard_text"],
WFS_PromptText: ["positive", "negative"],
};
return mappings[nodeType] || null;
}
+859
View File
@@ -0,0 +1,859 @@
/**
* Feeder Tab - ImageFeeder node control (Image Loop mode)
* + WFS_GalleryFeeder node control (Gallery mode)
*/
import { comfyUI } from "./comfyui-client.js";
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { FEEDER_GROUP, ensureFeederGroup } from "./gallery-tab.js";
// ── Module State ──────────────────────────────────────────────────
const _s = {
dir: "", // current folder relative path (loop mode)
images: [], // filenames in current folder (loop mode)
selected: new Set(),// selected filenames (loop mode)
presets: {}, // {presetName: {directory, selected_files}}
running: false, // feeder run loop active
mode: localStorage.getItem("wfm_feeder_mode") || "loop", // "loop" | "gallery"
};
// Gallery mode state
const _gs = {
groups: [], // [{name}]
group: FEEDER_GROUP, // selected group name
images: [], // absolute paths in selected group
idx: 0, // current index
};
// ── Node helpers ──────────────────────────────────────────────────
function _feederNodes() {
const wf = comfyUI.currentWorkflow;
if (!wf) return [];
return Object.entries(wf)
.filter(([, n]) => n?.class_type === "ImageFeeder")
.map(([id, n]) => ({ id, title: n._meta?.title || `ImageFeeder` }));
}
function _galFeederNodes() {
const wf = comfyUI.currentWorkflow;
if (!wf) return [];
return Object.entries(wf)
.filter(([, n]) => n?.class_type === "WFS_GalleryFeeder")
.map(([id, n]) => ({ id, title: n._meta?.title || `GalleryFeeder` }));
}
export function refreshFeederNodeList() {
if (_s.mode === "gallery") {
_refreshGalNodeList();
} else {
const sel = document.getElementById("wfm-feeder-node-sel");
if (!sel) return;
const nodes = _feederNodes();
if (nodes.length === 0) {
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
return;
}
const prevVal = sel.value;
sel.innerHTML = nodes.map(n =>
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
).join("");
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
_loadFromNode(sel.value || nodes[0].id);
}
}
function _refreshGalNodeList() {
const sel = document.getElementById("wfm-feeder-gal-node-sel");
if (!sel) return;
const nodes = _galFeederNodes();
if (nodes.length === 0) {
sel.innerHTML = `<option value="">-- No WFS_GalleryFeeder node --</option>`;
return;
}
const prevVal = sel.value;
sel.innerHTML = nodes.map(n =>
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
).join("");
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
_loadFromGalNode(sel.value || nodes[0].id);
}
function _loadFromNode(nodeId) {
const wf = comfyUI.currentWorkflow;
if (!wf?.[nodeId]) return;
const inp = wf[nodeId].inputs || {};
_setVal("wfm-feeder-dir-input", inp.directory || "");
_setVal("wfm-feeder-sort", inp.sort_mode || "ascending");
_setVal("wfm-feeder-index", inp.index ?? 0);
_setVal("wfm-feeder-start", inp.start_index ?? 0);
_setVal("wfm-feeder-end", inp.end_index ?? 0);
_setVal("wfm-feeder-batch", inp.batch_size ?? 1);
_setVal("wfm-feeder-seed", inp.seed ?? 0);
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
_s.selected.clear();
try {
const arr = JSON.parse(inp.selected_files || "[]");
if (Array.isArray(arr)) arr.forEach(f => _s.selected.add(f));
} catch {}
const dir = inp.directory || "";
if (_s.dir !== dir) {
_s.dir = dir;
_loadImages(dir);
_highlightTreeRow(dir);
} else {
_refreshGridCbs();
}
_updateStatus();
}
function _loadFromGalNode(nodeId) {
const wf = comfyUI.currentWorkflow;
if (!wf?.[nodeId]) return;
const inp = wf[nodeId].inputs || {};
const group = inp.group_name || FEEDER_GROUP;
_setVal("wfm-feeder-gal-sort", inp.sort_mode || "filename_asc");
_setVal("wfm-feeder-gal-index", inp.index ?? 0);
_setVal("wfm-feeder-gal-seed", inp.seed ?? 0);
_gs.idx = inp.index ?? 0;
if (_gs.group !== group) {
_gs.group = group;
_setGalGroupSelect(group);
_loadGalImages(group);
}
}
function _applyToWorkflow() {
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
const wf = comfyUI.currentWorkflow;
if (!nodeId || !wf?.[nodeId]) {
showToast(t("feederNoNode"), "error");
return;
}
_applyToNode(nodeId);
showToast(t("appliedToWorkflow"), "success");
}
function _applyGalToWorkflow() {
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
const wf = comfyUI.currentWorkflow;
if (!nodeId || !wf?.[nodeId]) {
showToast(t("feederGalNoNode"), "error");
return;
}
_applyToGalNode(nodeId);
showToast(t("appliedToWorkflow"), "success");
}
// ── Run Loop ──────────────────────────────────────────────────────
function _setRunUI(running) {
const runBtn = document.getElementById("wfm-feeder-run-btn");
const stopBtn = document.getElementById("wfm-feeder-stop-btn");
if (runBtn) runBtn.disabled = running;
if (stopBtn) stopBtn.disabled = !running;
}
function _setGalRunUI(running) {
const runBtn = document.getElementById("wfm-feeder-gal-run-btn");
const stopBtn = document.getElementById("wfm-feeder-gal-stop-btn");
if (runBtn) runBtn.disabled = running;
if (stopBtn) stopBtn.disabled = !running;
}
function _applyToNode(nodeId) {
const wf = comfyUI.currentWorkflow;
const inp = wf[nodeId].inputs;
inp.directory = _s.dir;
inp.sort_mode = document.getElementById("wfm-feeder-sort")?.value || "ascending";
inp.index = parseInt(document.getElementById("wfm-feeder-index")?.value) || 0;
inp.start_index = parseInt(document.getElementById("wfm-feeder-start")?.value) || 0;
inp.end_index = parseInt(document.getElementById("wfm-feeder-end")?.value) || 0;
inp.batch_size = parseInt(document.getElementById("wfm-feeder-batch")?.value) || 1;
inp.seed = parseInt(document.getElementById("wfm-feeder-seed")?.value) || 0;
inp.use_selection = document.getElementById("wfm-feeder-use-sel")?.checked ?? true;
inp.selected_files = JSON.stringify([..._s.selected]);
}
function _applyToGalNode(nodeId) {
const wf = comfyUI.currentWorkflow;
const inp = wf[nodeId].inputs;
inp.group_name = _gs.group;
inp.index = _gs.idx;
inp.sort_mode = document.getElementById("wfm-feeder-gal-sort")?.value || "filename_asc";
inp.seed = parseInt(document.getElementById("wfm-feeder-gal-seed")?.value) || 0;
}
async function _startRun() {
if (_s.running) return;
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
showToast(t("feederNoNode"), "error");
return;
}
const wsOk = await comfyUI.connectWebSocket();
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
_s.running = true;
_setRunUI(true);
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
let _lastSync = null;
const _syncHandler = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "image_loop_node_sync" && String(msg.data?.node_id) === String(nodeId)) {
_lastSync = msg.data;
if (msg.data.thumbnail_path) {
const p = msg.data.thumbnail_path;
_showPreview(p, p.split("/").pop());
}
}
} catch {}
};
comfyUI.socket.addEventListener("message", _syncHandler);
let count = 0;
try {
while (_s.running) {
_lastSync = null;
_applyToNode(nodeId);
const wf = comfyUI.currentWorkflow;
const inp = wf[nodeId].inputs;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = `#${++count} 0%`;
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const { images, seed } = await comfyUI.generate(
{ ...wf },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `#${count} ${(pct * 100).toFixed(0)}%`;
},
}
);
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0 && resultImg) {
const blob = await comfyUI.getImageBlob(images[0]);
resultImg.src = URL.createObjectURL(blob);
resultImg.style.display = "block";
}
if (!_s.running) break;
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
if (control === "fixed") {
// index を変えない
} else if (_lastSync) {
const indexEl = document.getElementById("wfm-feeder-index");
if (indexEl) indexEl.value = _lastSync.next_index;
if (control === "increment" && !_lastSync.has_next) {
_s.running = false;
showToast(t("feederComplete", count), "success");
break;
}
}
}
} catch (err) {
if (_s.running) showToast(t("feederRunError", err.message), "error");
} finally {
comfyUI.socket?.removeEventListener("message", _syncHandler);
_s.running = false;
_setRunUI(false);
if (progressText) progressText.textContent = `Done (${count} generated)`;
}
}
async function _stopRun() {
_s.running = false;
try { await comfyUI.interrupt(); } catch {}
_setRunUI(false);
showToast(t("feederStopped"), "info");
}
// ── Gallery Mode Run Loop ─────────────────────────────────────────
async function _startGalRun() {
if (_s.running) return;
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
showToast(t("feederGalNoNode"), "error");
return;
}
if (_gs.images.length === 0) {
showToast(t("feederGalEmptyGroup"), "error");
return;
}
const wsOk = await comfyUI.connectWebSocket();
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
_s.running = true;
_setGalRunUI(true);
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 indexEl = document.getElementById("wfm-feeder-gal-index");
const total = _gs.images.length;
let count = 0;
try {
while (_s.running) {
_applyToGalNode(nodeId);
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = `#${++count} [${_gs.idx + 1}/${total}] 0%`;
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `#${count} [${_gs.idx + 1}/${total}] ${(pct * 100).toFixed(0)}%`;
},
}
);
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0 && resultImg) {
const blob = await comfyUI.getImageBlob(images[0]);
resultImg.src = URL.createObjectURL(blob);
resultImg.style.display = "block";
}
if (!_s.running) break;
const control = document.getElementById("wfm-feeder-gal-control-after")?.value || "loop";
const nextIdx = (_gs.idx + 1) % total;
const isLast = _gs.idx === total - 1;
if (control === "fixed") {
// idx を変えない
} else if (control === "increment" && isLast) {
_s.running = false;
showToast(t("feederComplete", count), "success");
break;
} else {
_gs.idx = nextIdx;
if (indexEl) indexEl.value = _gs.idx;
_highlightGalCard(_gs.images[_gs.idx]);
}
}
} catch (err) {
if (_s.running) showToast(t("feederRunError", err.message), "error");
} finally {
_s.running = false;
_setGalRunUI(false);
if (progressText) progressText.textContent = `Done (${count} generated)`;
}
}
async function _stopGalRun() {
_s.running = false;
try { await comfyUI.interrupt(); } catch {}
_setGalRunUI(false);
showToast(t("feederStopped"), "info");
}
// ── Folder Tree (loop mode) ───────────────────────────────────────
async function _loadTree() {
const container = document.getElementById("wfm-feeder-tree");
if (!container) return;
container.innerHTML = `<div class="wfm-feeder-tree-msg">Loading...</div>`;
try {
const res = await fetch("/image_loop/tree");
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_renderTree(data.tree || [], container);
} catch (err) {
container.innerHTML = `<div class="wfm-feeder-tree-msg">Cannot reach image-loop API</div>`;
console.warn("[Feeder] tree load failed:", err);
}
}
function _renderTree(items, container, depth = 0) {
if (depth === 0) {
container.innerHTML = "";
container.appendChild(_makeTreeRow("", "🏠 (root)", 0));
}
for (const item of items) {
const icon = item.children?.length > 0 ? "📂" : "📁";
container.appendChild(_makeTreeRow(item.path, `${icon} ${item.name}`, depth + 1));
if (item.children?.length > 0) _renderTree(item.children, container, depth + 1);
}
}
function _makeTreeRow(path, label, depth) {
const row = document.createElement("div");
row.className = "wfm-feeder-tree-row" + (_s.dir === path ? " active" : "");
row.style.paddingLeft = `${8 + depth * 12}px`;
row.dataset.path = path;
row.textContent = label;
row.title = path || "(root)";
row.addEventListener("click", () => _selectDir(path));
return row;
}
function _selectDir(path) {
if (_s.dir !== path) {
_s.selected.clear();
_s.dir = path;
_setVal("wfm-feeder-dir-input", path);
}
_highlightTreeRow(path);
_loadImages(path);
_updateStatus();
}
function _highlightTreeRow(path) {
document.querySelectorAll(".wfm-feeder-tree-row").forEach(el => {
el.classList.toggle("active", el.dataset.path === path);
});
}
// ── Image Grid (loop mode) ────────────────────────────────────────
async function _loadImages(dir) {
const grid = document.getElementById("wfm-feeder-grid");
if (!grid) return;
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
try {
const url = "/image_loop/images" + (dir ? `?dir=${encodeURIComponent(dir)}` : "");
const res = await fetch(url);
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_s.images = data.images || [];
_renderGrid();
} catch (err) {
_s.images = [];
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
}
_updateStatus();
}
function _renderGrid() {
const grid = document.getElementById("wfm-feeder-grid");
if (!grid) return;
if (_s.images.length === 0) {
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images found</div>`;
return;
}
grid.innerHTML = "";
for (const fname of _s.images) grid.appendChild(_makeCard(fname));
}
function _makeCard(fname) {
const relPath = _s.dir ? `${_s.dir}/${fname}` : fname;
const checked = _s.selected.has(fname);
const card = document.createElement("div");
card.className = "wfm-feeder-card" + (checked ? " selected" : "");
card.dataset.name = fname;
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "wfm-feeder-card-cb";
cb.checked = checked;
cb.addEventListener("change", e => {
e.stopPropagation();
if (cb.checked) _s.selected.add(fname);
else _s.selected.delete(fname);
card.classList.toggle("selected", cb.checked);
_updateStatus();
});
const img = document.createElement("img");
img.className = "wfm-feeder-card-img";
img.loading = "lazy";
img.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
const name = document.createElement("div");
name.className = "wfm-feeder-card-name";
name.textContent = fname;
name.title = fname;
card.append(cb, img, name);
card.addEventListener("click", e => {
if (e.target === cb) return;
_showPreview(relPath, fname);
});
return card;
}
function _refreshGridCbs() {
document.querySelectorAll(".wfm-feeder-card").forEach(card => {
const sel = _s.selected.has(card.dataset.name);
card.classList.toggle("selected", sel);
const cb = card.querySelector("input[type=checkbox]");
if (cb) cb.checked = sel;
});
}
function _selectAll() {
_s.images.forEach(f => _s.selected.add(f));
_refreshGridCbs();
_updateStatus();
}
function _deselectAll() {
_s.images.forEach(f => _s.selected.delete(f));
_refreshGridCbs();
_updateStatus();
}
function _updateStatus() {
const el = document.getElementById("wfm-feeder-status");
if (!el) return;
const inCurrent = _s.images.filter(f => _s.selected.has(f)).length;
el.textContent = `📂 ${_s.dir || "(root)"} | ${inCurrent} / ${_s.images.length} selected`;
}
// ── Gallery Mode: Group & Image Grid ─────────────────────────────
async function _loadGalGroups() {
try {
const res = await fetch("/wfm/gallery/groups");
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_gs.groups = data.groups || [];
_renderGalGroupSelect();
} catch (err) {
console.warn("[Feeder] loadGalGroups failed:", err);
}
}
function _renderGalGroupSelect() {
const sel = document.getElementById("wfm-feeder-gal-group");
if (!sel) return;
sel.innerHTML = _gs.groups
.map(g => `<option value="${g.name}"${g.name === _gs.group ? " selected" : ""}>${g.name}</option>`)
.join("");
if (!_gs.groups.find(g => g.name === _gs.group) && _gs.groups.length > 0) {
_gs.group = _gs.groups[0].name;
}
}
function _setGalGroupSelect(group) {
const sel = document.getElementById("wfm-feeder-gal-group");
if (sel) sel.value = group;
}
async function _loadGalImages(group) {
const grid = document.getElementById("wfm-feeder-gal-grid");
const statusEl = document.getElementById("wfm-feeder-gal-status");
if (!grid) return;
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
try {
const res = await fetch(`/wfm/gallery/groups/${encodeURIComponent(group)}/images`);
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_gs.images = data.images || [];
_gs.idx = 0;
const idxEl = document.getElementById("wfm-feeder-gal-index");
if (idxEl) idxEl.value = 0;
_renderGalGrid();
if (statusEl) statusEl.textContent = `${_gs.images.length} image(s) in "${group}"`;
} catch (err) {
_gs.images = [];
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
if (statusEl) statusEl.textContent = "";
}
}
function _renderGalGrid() {
const grid = document.getElementById("wfm-feeder-gal-grid");
if (!grid) return;
if (_gs.images.length === 0) {
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images in this group</div>`;
return;
}
grid.innerHTML = "";
_gs.images.forEach((absPath, i) => grid.appendChild(_makeGalCard(absPath, i)));
}
function _makeGalCard(absPath, idx) {
const fname = absPath.replace(/\\/g, "/").split("/").pop();
const card = document.createElement("div");
card.className = "wfm-feeder-card" + (idx === _gs.idx ? " selected" : "");
card.dataset.path = absPath;
card.dataset.idx = idx;
const img = document.createElement("img");
img.className = "wfm-feeder-card-img";
img.loading = "lazy";
img.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
const name = document.createElement("div");
name.className = "wfm-feeder-card-name";
name.textContent = fname;
name.title = fname;
card.append(img, name);
card.addEventListener("click", () => {
_gs.idx = idx;
const idxEl = document.getElementById("wfm-feeder-gal-index");
if (idxEl) idxEl.value = idx;
_highlightGalCard(absPath);
_showGalPreview(absPath, fname);
});
return card;
}
function _highlightGalCard(absPath) {
document.querySelectorAll("#wfm-feeder-gal-grid .wfm-feeder-card").forEach(c => {
c.classList.toggle("selected", c.dataset.path === absPath);
});
}
// ── Preview Panel ────────────────────────────────────────────────
async function _showPreview(relPath, fname) {
const imgEl = document.getElementById("wfm-feeder-preview-img");
const nameEl = document.getElementById("wfm-feeder-preview-name");
const infoEl = document.getElementById("wfm-feeder-preview-info");
if (imgEl) {
imgEl.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
imgEl.style.display = "block";
}
if (nameEl) nameEl.textContent = fname;
if (infoEl) infoEl.textContent = "Loading...";
try {
const res = await fetch(`/image_loop/image_info?path=${encodeURIComponent(relPath)}`);
if (res.ok && infoEl) {
const d = await res.json();
const kb = d.size_bytes ? (d.size_bytes / 1024).toFixed(1) + " KB" : "";
infoEl.textContent = `${d.width ?? "?"}×${d.height ?? "?"} px\n${kb}`;
}
} catch { if (infoEl) infoEl.textContent = ""; }
}
async function _showGalPreview(absPath, fname) {
const imgEl = document.getElementById("wfm-feeder-preview-img");
const nameEl = document.getElementById("wfm-feeder-preview-name");
const infoEl = document.getElementById("wfm-feeder-preview-info");
if (imgEl) {
imgEl.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
imgEl.style.display = "block";
}
if (nameEl) nameEl.textContent = fname;
if (infoEl) infoEl.textContent = "";
}
// ── Presets (loop mode) ───────────────────────────────────────────
async function _loadPresets() {
try {
const res = await fetch("/image_feeder/presets");
if (!res.ok) return;
_s.presets = await res.json();
} catch { _s.presets = {}; }
_renderPresets();
}
function _renderPresets() {
const sel = document.getElementById("wfm-feeder-preset-sel");
if (!sel) return;
const names = Object.keys(_s.presets).sort();
sel.innerHTML = `<option value="">-- Select preset --</option>` +
names.map(n => `<option value="${n}">${n}</option>`).join("");
}
async function _savePreset() {
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
if (!name) { showToast(t("enterPresetName"), "error"); return; }
const sel = _s.images.filter(f => _s.selected.has(f));
try {
const res = await fetch("/image_feeder/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
});
if (res.ok) {
showToast(t("presetSavedName", name), "success");
const nameInput = document.getElementById("wfm-feeder-preset-name");
if (nameInput) nameInput.value = "";
await _loadPresets();
const selEl = document.getElementById("wfm-feeder-preset-sel");
if (selEl) selEl.value = name;
} else { showToast(t("saveFailed"), "error"); }
} catch (err) { showToast(t("saveFailed", err.message), "error"); }
}
async function _applyPreset() {
const selEl = document.getElementById("wfm-feeder-preset-sel");
const name = selEl?.value;
if (!name || !_s.presets[name]) { showToast(t("selectPresetFirst"), "error"); return; }
const preset = _s.presets[name];
const dir = preset.directory || "";
_s.selected.clear();
(preset.selected_files || []).forEach(f => _s.selected.add(f));
_setVal("wfm-feeder-dir-input", dir);
if (_s.dir !== dir) {
_s.dir = dir;
_highlightTreeRow(dir);
await _loadImages(dir);
}
_refreshGridCbs();
_updateStatus();
showToast(t("presetLoaded", name), "success");
}
async function _deletePreset() {
const name = document.getElementById("wfm-feeder-preset-sel")?.value;
if (!name) return;
try {
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
showToast(t("presetDeleted", name), "info");
await _loadPresets();
} catch (err) { showToast(t("deleteFailed"), "error"); }
}
// ── Mode Switch ───────────────────────────────────────────────────
function _setMode(mode) {
_s.mode = mode;
localStorage.setItem("wfm_feeder_mode", mode);
const loopBtn = document.getElementById("wfm-feeder-mode-loop");
const galBtn = document.getElementById("wfm-feeder-mode-gallery");
const loopSec = document.getElementById("wfm-feeder-loop-section");
const galSec = document.getElementById("wfm-feeder-gal-section");
const loopLib = document.getElementById("wfm-feeder-loop-library");
const galLib = document.getElementById("wfm-feeder-gal-library");
const isGallery = mode === "gallery";
if (loopBtn) loopBtn.classList.toggle("wfm-btn-primary", !isGallery);
if (galBtn) galBtn.classList.toggle("wfm-btn-primary", isGallery);
if (loopSec) loopSec.style.display = isGallery ? "none" : "";
if (galSec) galSec.style.display = isGallery ? "" : "none";
if (loopLib) loopLib.style.display = isGallery ? "none" : "";
if (galLib) galLib.style.display = isGallery ? "" : "none";
if (isGallery) {
_loadGalGroups().then(() => _loadGalImages(_gs.group));
_refreshGalNodeList();
}
}
// ── Helpers ───────────────────────────────────────────────────────
function _setVal(id, val) {
const el = document.getElementById(id);
if (el) el.value = val ?? "";
}
function _setCheck(id, val) {
const el = document.getElementById(id);
if (el) el.checked = !!val;
}
// ── Init ─────────────────────────────────────────────────────────
export async function initFeederTab() {
// Mode toggle
document.getElementById("wfm-feeder-mode-loop")?.addEventListener("click", () => _setMode("loop"));
document.getElementById("wfm-feeder-mode-gallery")?.addEventListener("click", () => _setMode("gallery"));
// ── Image Loop mode events ──
document.getElementById("wfm-feeder-node-sel")?.addEventListener("change", e => {
_loadFromNode(e.target.value);
});
document.getElementById("wfm-feeder-apply-btn")?.addEventListener("click", _applyToWorkflow);
document.getElementById("wfm-feeder-dir-input")?.addEventListener("change", e => {
const val = e.target.value.trim();
if (_s.dir !== val) {
_s.dir = val;
_highlightTreeRow(val);
_loadImages(val);
}
});
document.getElementById("wfm-feeder-reload-tree")?.addEventListener("click", _loadTree);
document.getElementById("wfm-feeder-sel-all")?.addEventListener("click", _selectAll);
document.getElementById("wfm-feeder-desel-all")?.addEventListener("click", _deselectAll);
document.getElementById("wfm-feeder-preset-save")?.addEventListener("click", _savePreset);
document.getElementById("wfm-feeder-preset-load")?.addEventListener("click", _applyPreset);
document.getElementById("wfm-feeder-preset-del")?.addEventListener("click", _deletePreset);
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
// ── Gallery mode events ──
document.getElementById("wfm-feeder-gal-node-sel")?.addEventListener("change", e => {
_loadFromGalNode(e.target.value);
});
document.getElementById("wfm-feeder-gal-apply-btn")?.addEventListener("click", _applyGalToWorkflow);
document.getElementById("wfm-feeder-gal-group")?.addEventListener("change", e => {
_gs.group = e.target.value;
_gs.idx = 0;
_loadGalImages(_gs.group);
});
document.getElementById("wfm-feeder-gal-index")?.addEventListener("change", e => {
_gs.idx = Math.max(0, parseInt(e.target.value) || 0);
if (_gs.images[_gs.idx]) _highlightGalCard(_gs.images[_gs.idx]);
});
document.getElementById("wfm-feeder-gal-run-btn")?.addEventListener("click", _startGalRun);
document.getElementById("wfm-feeder-gal-stop-btn")?.addEventListener("click", _stopGalRun);
// 初期化
await ensureFeederGroup();
_setMode(_s.mode);
if (_s.mode === "loop") {
await Promise.all([_loadTree(), _loadPresets()]);
_selectDir("");
}
}
+841 -87
View File
File diff suppressed because it is too large Load Diff
+1754 -84
View File
File diff suppressed because it is too large Load Diff
+1537 -95
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
/**
* Image Edit Tab - Draw Tool
* Inspired by comfyui-mask-editor-one PaintTool.js
* Supports color drawing and erasing with a soft round brush.
*/
export class DrawTool {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas?.getContext("2d") ?? null;
this.brushSize = 20;
this.hardness = 0.8; // 0.0 = very soft, 1.0 = hard edge
this.opacity = 1.0;
this.color = "#ff0000";
this.mode = "draw"; // "draw" | "erase"
this._drawing = false;
this._lastX = 0;
this._lastY = 0;
// Cached stamp canvas
this._stamp = null;
this._stampSize = 0;
this._stampHard = 0;
this._stampColor = null;
this._onChange = null;
}
setCanvas(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this._stamp = null;
}
onChange(fn) { this._onChange = fn; }
activate() {
if (this.canvas) this.canvas.style.cursor = "none";
}
deactivate() {
this._drawing = false;
if (this.canvas) this.canvas.style.cursor = "";
}
onMouseDown(x, y) {
this._drawing = true;
this._lastX = x;
this._lastY = y;
this._paint(x, y);
}
onMouseMove(x, y) {
if (!this._drawing) return;
this._paintLine(this._lastX, this._lastY, x, y);
this._lastX = x;
this._lastY = y;
}
onMouseUp() {
if (this._drawing) {
this._drawing = false;
if (this._onChange) this._onChange();
}
}
onMouseLeave() {
if (this._drawing) this.onMouseUp();
}
_getStamp() {
if (
this._stamp &&
this._stampSize === this.brushSize &&
this._stampHard === this.hardness &&
this._stampColor === this.color
) return this._stamp;
const size = Math.max(1, Math.round(this.brushSize));
const sc = document.createElement("canvas");
sc.width = size;
sc.height = size;
const sctx = sc.getContext("2d");
const cx = size / 2, cy = size / 2, r = size / 2;
const innerR = r * (1 - Math.min(this.hardness, 0.99)) * 0.95;
const grd = sctx.createRadialGradient(cx, cy, innerR, cx, cy, r);
grd.addColorStop(0, this.color);
grd.addColorStop(1, this.color + "00");
sctx.fillStyle = grd;
sctx.beginPath();
sctx.arc(cx, cy, r, 0, Math.PI * 2);
sctx.fill();
this._stamp = sc;
this._stampSize = size;
this._stampHard = this.hardness;
this._stampColor = this.color;
return sc;
}
_paint(x, y) {
const stamp = this._getStamp();
const s = stamp.width;
this.ctx.save();
this.ctx.globalAlpha = this.opacity;
if (this.mode === "erase") {
this.ctx.globalCompositeOperation = "destination-out";
}
this.ctx.drawImage(stamp, x - s / 2, y - s / 2);
this.ctx.restore();
}
_paintLine(x0, y0, x1, y1) {
const dist = Math.hypot(x1 - x0, y1 - y0);
const spacing = Math.max(1, this.brushSize * 0.2);
const steps = Math.max(1, Math.ceil(dist / spacing));
for (let i = 1; i <= steps; i++) {
const t = i / steps;
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
}
}
static getCanvasPos(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Image Edit Tab - Layer & LayerManager
* Inspired by comfyui-mask-editor-one LayerManager.js
*/
export class Layer {
constructor(name, type, contentW, contentH) {
this.id = crypto.randomUUID();
this.name = name;
this.type = type; // 'draw' | 'image' | 'text'
// layer.canvas のサイズ = コンテンツのネイティブ解像度
this.canvas = document.createElement("canvas");
this.canvas.width = contentW;
this.canvas.height = contentH;
this.ctx = this.canvas.getContext("2d");
// 表示プロパティ
this.visible = true;
this.opacity = 1.0;
this.blendMode = "source-over";
// 変換プロパティ(キャンバス座標系での配置)
this.x = 0; // 左上X
this.y = 0; // 左上Y
this.displayW = contentW; // 表示幅
this.displayH = contentH; // 表示高さ
this.rotation = 0; // 度
this.flipX = false;
this.flipY = false;
this.textProps = null; // テキストレイヤー用プロパティ(再編集・再描画に使用)
this.locked = false; // ロック中は SelectTool の変形を禁止
this.maskApply = false; // マスクレイヤー: true=クリッピングマスクとして機能
this.operation = "add"; // マスクレイヤー合成モード: "add" | "subtract"
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
getThumbnailDataURL(size = 32) {
const thumb = document.createElement("canvas");
thumb.width = size;
thumb.height = size;
const ctx = thumb.getContext("2d");
ctx.fillStyle = "#aaa";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#fff";
for (let x = 0; x < size; x += 8)
for (let y = 0; y < size; y += 8)
if ((x / 8 + y / 8) % 2 === 0) ctx.fillRect(x, y, 8, 8);
ctx.drawImage(this.canvas, 0, 0, size, size);
return thumb.toDataURL("image/png");
}
toJSON() {
return {
id: this.id, name: this.name, type: this.type,
imageData: this.canvas.toDataURL("image/png"),
visible: this.visible, opacity: this.opacity, blendMode: this.blendMode,
x: this.x, y: this.y, displayW: this.displayW, displayH: this.displayH,
rotation: this.rotation, flipX: this.flipX, flipY: this.flipY,
textProps: this.textProps ?? null,
locked: this.locked ?? false,
maskApply: this.maskApply ?? false,
operation: this.operation ?? "add",
};
}
static fromJSON(json) {
const cw = json.contentW ?? json.displayW ?? 512;
const ch = json.contentH ?? json.displayH ?? 512;
const layer = new Layer(json.name, json.type, cw, ch);
layer.id = json.id;
layer.visible = json.visible ?? true;
layer.opacity = json.opacity ?? 1.0;
layer.blendMode = json.blendMode ?? "source-over";
layer.x = json.x ?? 0;
layer.y = json.y ?? 0;
layer.displayW = json.displayW ?? cw;
layer.displayH = json.displayH ?? ch;
layer.rotation = json.rotation ?? 0;
layer.flipX = json.flipX ?? false;
layer.flipY = json.flipY ?? false;
layer.textProps = json.textProps ?? null;
layer.locked = json.locked ?? false;
layer.maskApply = json.maskApply ?? false;
layer.operation = json.operation ?? "add";
if (json.imageData) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => { layer.ctx.drawImage(img, 0, 0, cw, ch); resolve(layer); };
img.onerror = () => resolve(layer);
img.src = json.imageData;
});
}
return Promise.resolve(layer);
}
/** 変換行列を ctx に適用してから drawImage できる状態にする */
static applyTransform(ctx, layer) {
const cx = layer.x + layer.displayW / 2;
const cy = layer.y + layer.displayH / 2;
ctx.translate(cx, cy);
if (layer.rotation) ctx.rotate(layer.rotation * Math.PI / 180);
ctx.scale(
(layer.flipX ? -1 : 1) * (layer.displayW / layer.canvas.width),
(layer.flipY ? -1 : 1) * (layer.displayH / layer.canvas.height),
);
}
}
export class LayerManager {
constructor(width, height) {
this.width = width;
this.height = height;
this.layers = []; // 先頭 = 最前面
this.activeIndex = 0;
this._listeners = [];
}
get activeLayer() { return this.layers[this.activeIndex] ?? null; }
/**
* @param {string} type "draw" | "image" | "text"
* @param {string} name
* @param {object} opts { contentW, contentH, displayW, displayH, x, y }
*/
addLayer(type = "draw", name = null, opts = {}) {
const n = name ?? `Layer ${this.layers.length + 1}`;
const cw = opts.contentW ?? this.width;
const ch = opts.contentH ?? this.height;
const layer = new Layer(n, type, cw, ch);
layer.displayW = opts.displayW ?? cw;
layer.displayH = opts.displayH ?? ch;
layer.x = opts.x ?? 0;
layer.y = opts.y ?? 0;
this.layers.splice(this.activeIndex, 0, layer);
this._emit("change");
return layer;
}
deleteLayer(id) {
if (this.layers.length <= 1) return;
const idx = this.layers.findIndex(l => l.id === id);
if (idx < 0) return;
this.layers.splice(idx, 1);
this.activeIndex = Math.max(0, Math.min(this.activeIndex, this.layers.length - 1));
this._emit("change");
}
setActive(id) {
const idx = this.layers.findIndex(l => l.id === id);
if (idx >= 0) { this.activeIndex = idx; this._emit("activeChange", this.layers[idx]); }
}
moveUp(id) {
const idx = this.layers.findIndex(l => l.id === id);
if (idx <= 0) return;
[this.layers[idx - 1], this.layers[idx]] = [this.layers[idx], this.layers[idx - 1]];
if (this.activeIndex === idx) this.activeIndex = idx - 1;
else if (this.activeIndex === idx - 1) this.activeIndex = idx;
this._emit("change");
}
moveDown(id) {
const idx = this.layers.findIndex(l => l.id === id);
if (idx < 0 || idx >= this.layers.length - 1) return;
[this.layers[idx], this.layers[idx + 1]] = [this.layers[idx + 1], this.layers[idx]];
if (this.activeIndex === idx) this.activeIndex = idx + 1;
else if (this.activeIndex === idx + 1) this.activeIndex = idx;
this._emit("change");
}
toggleVisible(id) {
const layer = this.layers.find(l => l.id === id);
if (layer) { layer.visible = !layer.visible; this._emit("change"); }
}
toggleLocked(id) {
const layer = this.layers.find(l => l.id === id);
if (layer) { layer.locked = !layer.locked; this._emit("change"); }
}
toggleMaskApply(id) {
const layer = this.layers.find(l => l.id === id);
if (layer && layer.type === "mask") { layer.maskApply = !layer.maskApply; this._emit("change"); }
}
toggleOperation(id) {
const layer = this.layers.find(l => l.id === id);
if (layer && layer.type === "mask") {
layer.operation = layer.operation === "add" ? "subtract" : "add";
this._emit("change");
}
}
setOpacity(id, opacity) {
const layer = this.layers.find(l => l.id === id);
if (layer) { layer.opacity = Math.max(0, Math.min(1, opacity)); this._emit("change"); }
}
/** 全レイヤーを下→上の順で target canvas に変換付きで合成 */
composite(target) {
const ctx = target.getContext("2d");
ctx.clearRect(0, 0, target.width, target.height);
for (let i = this.layers.length - 1; i >= 0; i--) {
const layer = this.layers[i];
if (!layer.visible) continue;
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();
}
}
on(event, fn) { this._listeners.push({ event, fn }); }
_emit(event, data) {
for (const l of this._listeners) if (l.event === event) l.fn(data);
}
toJSON() {
return { layers: this.layers.map(l => l.toJSON()), width: this.width, height: this.height };
}
async fromJSON(json) {
this.layers = await Promise.all((json.layers || []).map(lj => Layer.fromJSON(lj)));
this.activeIndex = 0;
this._emit("change");
}
}
+324
View File
@@ -0,0 +1,324 @@
/**
* Image Edit Tab - Mask Tool
* White-paint / erase brush for mask layers.
* paint mode: draws white (opaque) = mask present
* erase mode: destination-out (transparent) = mask absent
*
* Supports circular soft brush (built-in) and image brushes loaded
* from Mask Editor One's ABR brush library (optional).
*/
export class MaskTool {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas?.getContext("2d");
this.brushSize = 30;
this.hardness = 0.85;
this.spacing = 0.25; // stamp interval as fraction of brushSize (image brush only)
this.mode = "paint"; // "paint" | "erase"
// Image brush from ABR library (null = default circle brush)
this.brushImage = null;
this.brushName = "Circle";
this.angle = 0; // degrees [0, 359]
this.sizeJitter = false;
this.sizeJitterAmount = 0.5; // 0–1: fraction of brushSize that can be removed
this.rotationJitter = false;
this._drawing = false;
this._lastX = 0;
this._lastY = 0;
// Cached circle stamp
this._stamp = null;
this._stampSize = 0;
this._stampHard = 0;
this._stampMode = null;
// Cached image stamp
this._imgStamp = null;
this._imgStampSize = 0;
this._imgStampImg = null;
// Stroke buffer: prevents intra-stroke opacity accumulation (image brush only)
this._baseCanvas = null;
this._strokeCanvas = null;
this._strokeCtx = null;
this._onChange = null;
}
setCanvas(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this._stamp = null;
this._imgStamp = null;
}
onChange(fn) { this._onChange = fn; }
setImageBrush(img, name) {
this.brushImage = img;
this.brushName = name;
this._imgStamp = null;
}
clearImageBrush() {
this.brushImage = null;
this.brushName = "Circle";
this._stamp = null;
this._imgStamp = null;
}
activate() {
if (this.canvas) this.canvas.style.cursor = "none";
}
deactivate() {
this._drawing = false;
this._clearStrokeBuffer();
if (this.canvas) this.canvas.style.cursor = "";
}
onMouseDown(x, y) {
this._drawing = true;
this._lastX = x;
this._lastY = y;
if (this.brushImage) {
this._initStrokeBuffer();
this._paintToStroke(x, y);
this._mergeStroke();
} else {
this._paint(x, y);
}
}
onMouseMove(x, y) {
if (!this._drawing) return;
if (this.brushImage) {
this._paintLineToStroke(this._lastX, this._lastY, x, y);
this._mergeStroke();
} else {
this._paintLine(this._lastX, this._lastY, x, y);
}
this._lastX = x;
this._lastY = y;
}
onMouseUp() {
if (this._drawing) {
this._drawing = false;
this._clearStrokeBuffer();
if (this._onChange) this._onChange();
}
}
onMouseLeave() {
if (this._drawing) this.onMouseUp();
}
// ── Stroke buffer (image brush only) ─────────────────────────────
_initStrokeBuffer() {
const w = this.canvas.width;
const h = this.canvas.height;
this._baseCanvas = document.createElement("canvas");
this._baseCanvas.width = w;
this._baseCanvas.height = h;
this._baseCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
this._strokeCanvas = document.createElement("canvas");
this._strokeCanvas.width = w;
this._strokeCanvas.height = h;
this._strokeCtx = this._strokeCanvas.getContext("2d");
}
_clearStrokeBuffer() {
this._baseCanvas = null;
this._strokeCanvas = null;
this._strokeCtx = null;
}
_mergeStroke() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(this._baseCanvas, 0, 0);
ctx.globalCompositeOperation = this.mode === "erase" ? "destination-out" : "lighten";
ctx.drawImage(this._strokeCanvas, 0, 0);
ctx.globalCompositeOperation = "source-over";
}
_paintToStroke(x, y) {
const saved = this.ctx;
this.ctx = this._strokeCtx;
if (this.sizeJitter || this.rotationJitter) {
const savedSize = this.brushSize;
const savedAngle = this.angle;
const savedStamp = this._imgStamp;
const savedStampSize = this._imgStampSize;
if (this.sizeJitter) {
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
}
if (this.rotationJitter) {
this.angle = Math.random() * 360;
}
this._paintImageBrush(x, y);
this.brushSize = savedSize;
this.angle = savedAngle;
this._imgStamp = savedStamp;
this._imgStampSize = savedStampSize;
} else {
this._paintImageBrush(x, y);
}
this.ctx = saved;
}
_paintLineToStroke(x0, y0, x1, y1) {
const dist = Math.hypot(x1 - x0, y1 - y0);
const step = Math.max(1, this.brushSize * this.spacing);
const steps = Math.ceil(dist / step);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
}
}
// ── Circle brush ─────────────────────────────────────────────────
_getStamp() {
if (
this._stamp &&
this._stampSize === this.brushSize &&
this._stampHard === this.hardness &&
this._stampMode === this.mode
) return this._stamp;
const size = Math.max(1, Math.round(this.brushSize));
const sc = document.createElement("canvas");
sc.width = size;
sc.height = size;
const sctx = sc.getContext("2d");
const cx = size / 2, cy = size / 2, r = size / 2;
const color = this.mode === "paint" ? "#ffffff" : "#000000";
const colorAlpha = this.mode === "paint" ? "rgba(255,255,255,0)" : "rgba(0,0,0,0)";
const innerR = r * (1 - Math.min(this.hardness, 0.99)) * 0.95;
const grd = sctx.createRadialGradient(cx, cy, innerR, cx, cy, r);
grd.addColorStop(0, color);
grd.addColorStop(1, colorAlpha);
sctx.fillStyle = grd;
sctx.beginPath();
sctx.arc(cx, cy, r, 0, Math.PI * 2);
sctx.fill();
this._stamp = sc;
this._stampSize = size;
this._stampHard = this.hardness;
this._stampMode = this.mode;
return sc;
}
_paint(x, y) {
const stamp = this._getStamp();
const s = stamp.width;
this.ctx.save();
if (this.mode === "erase") {
this.ctx.globalCompositeOperation = "destination-out";
}
this.ctx.drawImage(stamp, x - s / 2, y - s / 2);
this.ctx.restore();
}
_paintLine(x0, y0, x1, y1) {
const dist = Math.hypot(x1 - x0, y1 - y0);
const spacing = Math.max(1, this.brushSize * 0.2);
const steps = Math.max(1, Math.ceil(dist / spacing));
for (let i = 1; i <= steps; i++) {
const t = i / steps;
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
}
}
// ── Image brush ──────────────────────────────────────────────────
_getImageStamp(size) {
size = Math.max(1, size);
if (
this._imgStamp &&
this._imgStampSize === size &&
this._imgStampImg === this.brushImage
) return this._imgStamp;
const img = this.brushImage;
const srcW = img.naturalWidth || img.width;
const srcH = img.naturalHeight || img.height;
const aspect = (srcW > 0 && srcH > 0) ? srcW / srcH : 1;
const stampH = size;
const stampW = Math.max(1, Math.round(size * aspect));
const sc = document.createElement("canvas");
sc.width = stampW;
sc.height = stampH;
const stx = sc.getContext("2d");
stx.drawImage(img, 0, 0, stampW, stampH);
const imgData = stx.getImageData(0, 0, stampW, stampH);
const d = imgData.data;
// Detect alpha channel (ABR-exported PNGs carry alpha = brush density)
let hasAlpha = false;
for (let i = 3; i < d.length; i += 4) {
if (d[i] < 250) { hasAlpha = true; break; }
}
let invertLum = false;
if (!hasAlpha) {
const corners = [0, stampW - 1, stampW * (stampH - 1), stampW * stampH - 1];
let bgLum = 0;
for (const ci of corners) {
const ii = ci * 4;
bgLum += (d[ii] * 0.299 + d[ii + 1] * 0.587 + d[ii + 2] * 0.114) / 255;
}
invertLum = (bgLum / corners.length) > 0.5;
}
// Convert to white pixels; alpha = brush density
for (let i = 0; i < d.length; i += 4) {
let alpha;
if (hasAlpha) {
alpha = d[i + 3] / 255;
} else {
const lum = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255;
alpha = invertLum ? 1 - lum : lum;
}
d[i] = d[i + 1] = d[i + 2] = 255;
d[i + 3] = Math.round(alpha * 255);
}
stx.putImageData(imgData, 0, 0);
this._imgStamp = sc;
this._imgStampSize = size;
this._imgStampImg = this.brushImage;
return sc;
}
_paintImageBrush(x, y) {
const stamp = this._getImageStamp(Math.round(this.brushSize));
this.ctx.globalCompositeOperation = "source-over";
if (this.angle) {
this.ctx.save();
this.ctx.translate(Math.round(x), Math.round(y));
this.ctx.rotate((this.angle * Math.PI) / 180);
this.ctx.drawImage(stamp, -stamp.width / 2, -stamp.height / 2);
this.ctx.restore();
} else {
this.ctx.drawImage(stamp, Math.round(x - stamp.width / 2), Math.round(y - stamp.height / 2));
}
this.ctx.globalCompositeOperation = "source-over";
}
}
+336
View File
@@ -0,0 +1,336 @@
/**
* Image Edit Tab - Select Tool
* Handles object selection, move, resize, rotate, flip on the overlay canvas.
*/
const HANDLE_R = 5; // ハンドル半径(px)
const ROTATE_DIST = 32; // 回転ハンドルまでの距離(px)
const MIN_SIZE = 10; // 最小表示サイズ(px)
export class SelectTool {
constructor() {
this._overlay = null;
this._octx = null;
this._layer = null; // 選択中レイヤー
this._dragMode = null; // "move"|"rotate"|"resize-tl"|"resize-tr"|"resize-br"|"resize-bl"
this._dragStart = null;
this._origTransform = null;
this._onChange = null;
this._cursor = "default";
}
setCanvas(overlayCanvas) {
this._overlay = overlayCanvas;
this._octx = overlayCanvas.getContext("2d");
}
onChange(fn) { this._onChange = fn; }
activate() {
if (this._overlay) this._overlay.style.pointerEvents = "auto";
this._drawOverlay();
}
deactivate() {
this._clearOverlay();
if (this._overlay) this._overlay.style.pointerEvents = "none";
}
clearSelection() {
this._layer = null;
this._dragMode = null;
this._clearOverlay();
}
setLayer(layer) {
this._layer = layer;
this._drawOverlay();
}
getSelectedLayer() { return this._layer; }
// ── マウスイベント ───────────────────────────
onMouseDown(x, y, layerMgr) {
if (this._layer) {
const hit = this._hitHandle(x, y);
// ロック中はハンドル操作を禁止(選択済み表示は維持)
if (hit && !this._layer.locked) {
this._startDrag(hit, x, y);
return hit;
}
}
// レイヤーヒットテスト(前面から)
for (let i = 0; i < layerMgr.layers.length; i++) {
const layer = layerMgr.layers[i];
if (!layer.visible) continue;
if (this._isPointInLayer(x, y, layer)) {
this._layer = layer;
this._drawOverlay();
// ロック中は選択のみ(移動ドラッグは開始しない)
if (!layer.locked) this._startDrag("move", x, y);
return "select";
}
}
// 空白クリック → 選択解除
this._layer = null;
this._clearOverlay();
return null;
}
onMouseMove(x, y) {
if (!this._dragMode || !this._layer) {
// カーソル変更のみ
this._updateCursor(x, y);
return;
}
const dx = x - this._dragStart.x;
const dy = y - this._dragStart.y;
const ot = this._origTransform;
if (this._dragMode === "move") {
this._layer.x = ot.x + dx;
this._layer.y = ot.y + dy;
} else if (this._dragMode === "rotate") {
const cx = ot.x + ot.displayW / 2;
const cy = ot.y + ot.displayH / 2;
const a0 = Math.atan2(this._dragStart.y - cy, this._dragStart.x - cx);
const a1 = Math.atan2(y - cy, x - cx);
this._layer.rotation = ot.rotation + (a1 - a0) * 180 / Math.PI;
} else if (this._dragMode.startsWith("resize-")) {
this._applyResize(dx, dy);
}
this._drawOverlay();
if (this._onChange) this._onChange("transforming");
}
onMouseUp() {
if (this._dragMode) {
this._dragMode = null;
this._dragStart = null;
this._origTransform = null;
if (this._onChange) this._onChange("transformEnd");
}
}
onMouseLeave() { this.onMouseUp(); }
// ── 反転 ─────────────────────────────────────
flipH() {
if (!this._layer) return;
this._layer.flipX = !this._layer.flipX;
this._drawOverlay();
if (this._onChange) this._onChange("transformEnd");
}
flipV() {
if (!this._layer) return;
this._layer.flipY = !this._layer.flipY;
this._drawOverlay();
if (this._onChange) this._onChange("transformEnd");
}
// ── 内部ユーティリティ ─────────────────────────
_startDrag(mode, x, y) {
this._dragMode = mode;
this._dragStart = { x, y };
this._origTransform = {
x: this._layer.x, y: this._layer.y,
displayW: this._layer.displayW, displayH: this._layer.displayH,
rotation: this._layer.rotation,
};
}
// 回転を考慮した4頂点を返す(name: "tl"|"tr"|"br"|"bl")
_getCorners(layer) {
const hw = layer.displayW / 2;
const hh = layer.displayH / 2;
const cx = layer.x + hw;
const cy = layer.y + hh;
const r = (layer.rotation ?? 0) * Math.PI / 180;
const cosR = Math.cos(r), sinR = Math.sin(r);
return [
{ name: "tl", lx: -hw, ly: -hh },
{ name: "tr", lx: hw, ly: -hh },
{ name: "br", lx: hw, ly: hh },
{ name: "bl", lx: -hw, ly: hh },
].map(p => ({
name: p.name,
x: cx + p.lx * cosR - p.ly * sinR,
y: cy + p.lx * sinR + p.ly * cosR,
}));
}
// 上辺中央から ROTATE_DIST だけ上に出た回転ハンドル
_getRotateHandle(layer) {
const hw = layer.displayW / 2;
const hh = layer.displayH / 2;
const cx = layer.x + hw;
const cy = layer.y + hh;
const r = (layer.rotation ?? 0) * Math.PI / 180;
const cosR = Math.cos(r), sinR = Math.sin(r);
const dist = hh + ROTATE_DIST;
// ローカル座標 (0, -dist) を回転: x' = dist*sin(r), y' = -dist*cos(r)
return {
x: cx + sinR * dist,
y: cy - cosR * dist,
};
}
_hitHandle(x, y) {
if (!this._layer) return null;
const rh = this._getRotateHandle(this._layer);
if (Math.hypot(x - rh.x, y - rh.y) <= HANDLE_R * 2.5) return "rotate";
const corners = this._getCorners(this._layer);
for (const c of corners) {
if (Math.hypot(x - c.x, y - c.y) <= HANDLE_R * 2.5) return `resize-${c.name}`;
}
if (this._isPointInLayer(x, y, this._layer)) return "move";
return null;
}
_isPointInLayer(x, y, layer) {
const hw = layer.displayW / 2;
const hh = layer.displayH / 2;
const cx = layer.x + hw;
const cy = layer.y + hh;
// 逆回転で点をローカル座標に変換
const r = -(layer.rotation ?? 0) * Math.PI / 180;
const cosR = Math.cos(r), sinR = Math.sin(r);
const lx = (x - cx) * cosR - (y - cy) * sinR;
const ly = (x - cx) * sinR + (y - cy) * cosR;
return Math.abs(lx) <= hw && Math.abs(ly) <= hh;
}
_applyResize(dx, dy) {
const ot = this._origTransform;
const mode = this._dragMode;
// 回転の逆行列でdx/dyをローカル座標に変換
const r = -(ot.rotation ?? 0) * Math.PI / 180;
const cosR = Math.cos(r), sinR = Math.sin(r);
const ldx = dx * cosR - dy * sinR;
const ldy = dx * sinR + dy * cosR;
let { x, y, displayW, displayH } = ot;
if (mode === "resize-tl") { x += ldx; y += ldy; displayW -= ldx; displayH -= ldy; }
else if (mode === "resize-tr") { y += ldy; displayW += ldx; displayH -= ldy; }
else if (mode === "resize-br") { displayW += ldx; displayH += ldy; }
else if (mode === "resize-bl") { x += ldx; displayW -= ldx; displayH += ldy; }
if (displayW < MIN_SIZE) { if (mode.includes("l")) x -= MIN_SIZE - displayW; displayW = MIN_SIZE; }
if (displayH < MIN_SIZE) { if (mode.includes("t")) y -= MIN_SIZE - displayH; displayH = MIN_SIZE; }
// 回転あり: x/yのずれを回転軸中心に合わせて再計算
// ローカル左上の変位を逆回転して戻す
const r2 = (ot.rotation ?? 0) * Math.PI / 180;
const cosR2 = Math.cos(r2), sinR2 = Math.sin(r2);
const ox = ot.x + ot.displayW / 2;
const oy = ot.y + ot.displayH / 2;
// 新しい左上 (ローカル -hw2, -hh2 を回転して絶対座標)
const hw2 = displayW / 2;
const hh2 = displayH / 2;
const dx2 = -hw2 * cosR2 + hh2 * sinR2; // ローカル(-hw2, -hh2)を回転
const dy2 = -hw2 * sinR2 - hh2 * cosR2;
// ... でも回転付きリサイズは複雑なので、回転がほぼ0のときのみシンプル版を使用
if (Math.abs(ot.rotation ?? 0) < 1) {
this._layer.x = x; this._layer.y = y;
}
// 回転あり時は displayW/H のみ更新(位置は維持)
this._layer.displayW = displayW;
this._layer.displayH = displayH;
}
_updateCursor(x, y) {
if (!this._overlay) return;
const hit = this._layer ? this._hitHandle(x, y) : null;
const cursorMap = {
"move": "move", "rotate": "crosshair",
"resize-tl": "nwse-resize", "resize-br": "nwse-resize",
"resize-tr": "nesw-resize", "resize-bl": "nesw-resize",
};
this._overlay.style.cursor = hit ? (cursorMap[hit] ?? "default") : "default";
}
// ── オーバーレイ描画 ───────────────────────────
_drawOverlay() {
this._clearOverlay();
if (!this._layer || !this._octx) return;
const ctx = this._octx;
const layer = this._layer;
const locked = !!layer.locked;
const color = locked ? "#e2a04a" : "#4a90e2"; // ロック中はオレンジ
const corners = this._getCorners(layer);
const rh = this._getRotateHandle(layer);
const tl = corners[0], tr = corners[1];
const topMid = { x: (tl.x + tr.x) / 2, y: (tl.y + tr.y) / 2 };
ctx.save();
// バウンディングボックス
ctx.setLineDash([5, 3]);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
corners.forEach((c, i) => i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y));
ctx.closePath();
ctx.stroke();
ctx.setLineDash([]);
if (!locked) {
// 回転ライン+ハンドル(ロック中は非表示)
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(topMid.x, topMid.y);
ctx.lineTo(rh.x, rh.y);
ctx.stroke();
ctx.fillStyle = "#fff";
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(rh.x, rh.y, HANDLE_R, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// コーナーハンドル
for (const c of corners) {
ctx.fillStyle = "#fff";
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(c.x - HANDLE_R, c.y - HANDLE_R, HANDLE_R * 2, HANDLE_R * 2);
ctx.fill();
ctx.stroke();
}
} else {
// ロック中: 中央に鍵アイコン
const cx = layer.x + layer.displayW / 2;
const cy = layer.y + layer.displayH / 2;
ctx.font = "18px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = color;
ctx.fillText("🔒", cx, cy);
}
ctx.restore();
}
_clearOverlay() {
if (this._octx && this._overlay) {
this._octx.clearRect(0, 0, this._overlay.width, this._overlay.height);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* ShapeTool — rect / ellipse / line / freeline drawing tool for Image Edit tab.
* Preview is drawn on the overlay canvas; committed shapes become new draw layers.
*/
export class ShapeTool {
constructor() {
// shape settings
this.shape = "rect";
this.rounded = false;
this.fillColor = "#ff0000";
this.fillNone = false;
this.strokeColor = "#000000";
this.strokeNone = true;
this.strokeWidth = 5;
this.opacity = 1.0;
// internal state
this._active = false;
this._canvas = null; // overlay canvas (preview)
this._ctx = null;
this._dragging = false;
this._dragStart = null;
this._curPt = null;
this._points = []; // freeline points
this._onChange = null;
}
// overlay canvas used for live preview
setCanvas(canvas) {
this._canvas = canvas;
this._ctx = canvas ? canvas.getContext("2d") : null;
}
onChange(cb) { this._onChange = cb; }
activate() {
this._active = true;
}
deactivate() {
this._active = false;
this._dragging = false;
this._clearOverlay();
if (this._canvas) this._canvas.style.cursor = "";
}
onMouseDown(x, y) {
if (!this._active) return;
this._dragging = true;
this._dragStart = { x, y };
this._curPt = { x, y };
this._points = this.shape === "freeline" ? [{ x, y }] : [];
}
onMouseMove(x, y) {
if (!this._dragging) return;
this._curPt = { x, y };
if (this.shape === "freeline") this._points.push({ x, y });
this._drawPreview(x, y);
}
onMouseUp() {
if (!this._dragging) return;
this._dragging = false;
this._clearOverlay();
this._commit();
}
onMouseLeave() {
if (!this._dragging) return;
this._dragging = false;
this._clearOverlay();
this._commit();
}
// ── private ───────────────────────────────────────
_clearOverlay() {
if (this._ctx && this._canvas) {
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
}
}
_drawPreview(curX, curY) {
if (!this._ctx || !this._canvas) return;
const ctx = this._ctx;
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
const s = this._dragStart;
ctx.save();
ctx.globalAlpha = this.opacity;
if (this.shape === "freeline") {
// freeline: always draw with stroke color (strokeNone has no meaning here)
if (this._points.length >= 2) {
ctx.strokeStyle = this.strokeColor;
ctx.lineWidth = this.strokeWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(this._points[0].x, this._points[0].y);
for (let i = 1; i < this._points.length; i++) ctx.lineTo(this._points[i].x, this._points[i].y);
ctx.stroke();
}
} else {
// dashed blue outline for rect / ellipse / line
ctx.strokeStyle = "#0077ff";
ctx.lineWidth = Math.max(1, this.strokeWidth || 1);
ctx.setLineDash([4, 2]);
const x1 = Math.min(s.x, curX), y1 = Math.min(s.y, curY);
const w = Math.abs(curX - s.x), h = Math.abs(curY - s.y);
if (this.shape === "ellipse") {
ctx.beginPath();
ctx.ellipse(x1 + w / 2, y1 + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
ctx.stroke();
} else if (this.shape === "line") {
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(curX, curY);
ctx.stroke();
} else {
// rect
if (this.rounded) {
const r = Math.min(w, h) * 0.15;
ctx.beginPath();
ctx.roundRect(x1, y1, w, h, r);
ctx.stroke();
} else {
ctx.strokeRect(x1, y1, w, h);
}
}
ctx.setLineDash([]);
}
ctx.restore();
}
_commit() {
const s = this._dragStart, c = this._curPt;
if (!s || !c) return;
const isFreehand = this.shape === "freeline";
const dx = Math.abs(c.x - s.x), dy = Math.abs(c.y - s.y);
if (!isFreehand && dx < 2 && dy < 2) return;
if (isFreehand && this._points.length < 2) return;
const isStrokeMandatory = ["line", "freeline"].includes(this.shape);
const shapeObj = {
kind: this.shape,
s: { ...s },
c: { ...c },
points: isFreehand ? this._points.map(p => ({ ...p })) : [],
fillColor: this.fillNone ? null : this.fillColor,
// line / freeline always use stroke; ignore strokeNone
strokeColor: (isStrokeMandatory || !this.strokeNone) ? this.strokeColor : null,
strokeWidth: (isStrokeMandatory || !this.strokeNone) ? this.strokeWidth : 0,
opacity: this.opacity,
rounded: this.rounded,
};
this._onChange?.(shapeObj);
}
// ── static renderer (used by onChange callback to bake shape into layer) ──
static drawShape(ctx, sh) {
const { kind, s, c, points, fillColor, strokeColor, strokeWidth, opacity, rounded } = sh;
ctx.save();
ctx.globalAlpha = opacity ?? 1;
if (kind === "freeline") {
if (strokeColor && strokeWidth > 0 && points.length >= 2) {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
}
} else if (kind === "ellipse") {
const x1 = Math.min(s.x, c.x), y1 = Math.min(s.y, c.y);
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
ctx.beginPath();
ctx.ellipse(x1 + w / 2, y1 + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
if (strokeColor && strokeWidth > 0) { ctx.strokeStyle = strokeColor; ctx.lineWidth = strokeWidth; ctx.stroke(); }
} else if (kind === "line") {
if (strokeColor && strokeWidth > 0) {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(c.x, c.y);
ctx.stroke();
}
} else {
// rect
const x1 = Math.min(s.x, c.x), y1 = Math.min(s.y, c.y);
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
if (rounded) {
const r = Math.min(w, h) * 0.15;
ctx.beginPath();
ctx.roundRect(x1, y1, w, h, r);
} else {
ctx.beginPath();
ctx.rect(x1, y1, w, h);
}
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
if (strokeColor && strokeWidth > 0) { ctx.strokeStyle = strokeColor; ctx.lineWidth = strokeWidth; ctx.stroke(); }
}
ctx.restore();
}
// coordinate helper (same signature as DrawTool.getCanvasPos)
static getCanvasPos(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Image Edit Tab - Text Tool
* Measures text bounding box, renders to an off-screen canvas,
* and calls onChange(clickX, clickY) so the caller can create
* a proper text layer with the exact content size.
*/
export const TEXT_FONTS = [
"Arial", "Arial Black", "Georgia", "Times New Roman",
"Courier New", "Verdana", "Trebuchet MS", "Impact",
"Comic Sans MS", "Tahoma",
];
const TEXT_PADDING = 4; // テキスト描画時の余白(px)
export class TextTool {
constructor(canvas) {
this.canvas = canvas;
this.text = "Hello";
this.fontFamily = "Arial";
this.fontSize = 64;
this.bold = false;
this.italic = false;
this.align = "left";
this.color = "#ffffff";
this._overlay = null;
this._onChange = null;
}
setCanvas(canvas) {
this._closeOverlay();
this.canvas = canvas;
}
onChange(fn) { this._onChange = fn; }
activate() {
if (this.canvas) this.canvas.style.cursor = "text";
}
deactivate() {
this._closeOverlay();
if (this.canvas) this.canvas.style.cursor = "";
}
onMouseDown(x, y) {
if (this._overlay) { this._closeOverlay(); return; }
this._showOverlay(x, y);
}
/** 既存テキスト再編集用: プロパティを外部でセット後に呼ぶ */
openAt(canvasX, canvasY) {
this._closeOverlay();
this._showOverlay(canvasX, canvasY);
}
onMouseMove() {}
onMouseLeave() {}
onMouseUp() {}
/** テキストのバウンディングボックスサイズのcanvasを返す */
createLayerData(clickX, clickY) {
const lines = this.text.split("\n");
const lineH = this.fontSize * 1.2;
const font = this._getCSSFont();
// measureText 用の仮canvas
const tmp = document.createElement("canvas");
const tmpCtx = tmp.getContext("2d");
tmpCtx.font = font;
let maxW = 0;
for (const line of lines) {
const w = line.length > 0 ? tmpCtx.measureText(line).width : this.fontSize * 0.3;
if (w > maxW) maxW = w;
}
const tw = Math.max(1, Math.ceil(maxW + TEXT_PADDING * 2));
const th = Math.max(1, Math.ceil(lines.length * lineH + TEXT_PADDING * 2));
const canvas = document.createElement("canvas");
canvas.width = tw;
canvas.height = th;
const ctx = canvas.getContext("2d");
ctx.font = font;
ctx.fillStyle = this.color;
ctx.textBaseline = "top";
ctx.textAlign = this.align;
let drawX = TEXT_PADDING;
if (this.align === "center") drawX = tw / 2;
else if (this.align === "right") drawX = tw - TEXT_PADDING;
lines.forEach((line, i) => {
ctx.fillText(line, drawX, TEXT_PADDING + i * lineH);
});
return {
canvas,
width: tw,
height: th,
x: clickX,
y: clickY,
};
}
// ── 内部 ──────────────────────────────────────
_getCSSFont() {
const parts = [];
if (this.italic) parts.push("italic");
if (this.bold) parts.push("bold");
parts.push(`${this.fontSize}px`);
parts.push(`"${this.fontFamily}", sans-serif`);
return parts.join(" ");
}
_showOverlay(canvasX, canvasY) {
const cv = this.canvas;
const rect = cv.getBoundingClientRect();
const scaleX = rect.width / cv.width;
const scaleY = rect.height / cv.height;
const cssX = Math.round(canvasX * scaleX);
const cssY = Math.round(canvasY * scaleY);
const container = cv.parentElement;
const overlay = document.createElement("div");
overlay.className = "ie-text-overlay";
overlay.style.left = cssX + "px";
overlay.style.top = cssY + "px";
const textarea = document.createElement("textarea");
textarea.className = "ie-text-textarea";
textarea.value = this.text;
textarea.rows = 3;
textarea.placeholder = "Enter text…";
overlay.appendChild(textarea);
const btnRow = document.createElement("div");
btnRow.className = "ie-text-btn-row";
const okBtn = document.createElement("button");
okBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
okBtn.textContent = "OK";
okBtn.onclick = () => {
this.text = textarea.value;
this._closeOverlay();
// drawCanvasには描画せず、呼び出し側でレイヤーとして処理する
if (this._onChange) this._onChange(canvasX, canvasY);
};
btnRow.appendChild(okBtn);
const cancelBtn = document.createElement("button");
cancelBtn.className = "wfm-btn wfm-btn-sm";
cancelBtn.textContent = "Cancel";
cancelBtn.onclick = () => this._closeOverlay();
btnRow.appendChild(cancelBtn);
overlay.appendChild(btnRow);
container.appendChild(overlay);
this._overlay = overlay;
textarea.focus();
textarea.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); okBtn.click(); }
else if (e.key === "Escape") this._closeOverlay();
});
}
_closeOverlay() {
if (this._overlay) { this._overlay.remove(); this._overlay = null; }
}
}
+770
View File
@@ -0,0 +1,770 @@
/**
* Metadata Tab
* Drop a PNG/WebP/JSON file to extract and display model/prompt metadata.
* Parsing logic adapted from model-and-prompt-from-metadata (workflow_utils.js).
*/
import { t } from "./i18n.js";
import { escapeHtml } from "./util.js";
// ── File size limit ───────────────────────────────────────────
const MAX_FILE_SIZE = 50 * 1024 * 1024;
// ── Sanitize JSON (NaN/Infinity → null) ──────────────────────
function sanitizeJSON(text) {
return text
.replace(/-Infinity\b/g, "null")
.replace(/\bInfinity\b/g, "null")
.replace(/\bNaN\b/g, "null");
}
// ── WebP EXIF ────────────────────────────────────────────────
async function readWebPEXIFChunk(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const ascii = new TextDecoder("latin1");
if (bytes.byteLength < 12) return null;
if (ascii.decode(bytes.slice(0, 4)) !== "RIFF") return null;
if (ascii.decode(bytes.slice(8, 12)) !== "WEBP") return null;
let offset = 12;
while (offset + 8 <= buffer.byteLength) {
const fourcc = ascii.decode(bytes.slice(offset, offset + 4));
const chunkSize = view.getUint32(offset + 4, true);
if (fourcc === "EXIF") return bytes.slice(offset + 8, offset + 8 + chunkSize);
offset += 8 + chunkSize;
if (chunkSize % 2 === 1) offset++;
}
return null;
}
function extractWorkflowFromEXIF(exifBytes) {
const utf8 = new TextDecoder("utf-8", { fatal: false });
const text = utf8.decode(exifBytes);
for (const key of ["workflow:", "prompt:"]) {
const idx = text.indexOf(key + "{");
if (idx < 0) continue;
let jsonStr = text.slice(idx + key.length);
const nullIdx = jsonStr.indexOf("\x00");
if (nullIdx >= 0) jsonStr = jsonStr.slice(0, nullIdx);
try { return JSON.parse(sanitizeJSON(jsonStr)); } catch {
const lb = jsonStr.lastIndexOf("}");
if (lb > 0) { try { return JSON.parse(sanitizeJSON(jsonStr.slice(0, lb + 1))); } catch {} }
}
}
return null;
}
// ── PNG text chunks ───────────────────────────────────────────
function findNull(arr, start = 0) {
for (let i = start; i < arr.length; i++) if (arr[i] === 0) return i;
return -1;
}
function parseTEXtChunk(data, latin1) {
const np = findNull(data);
if (np === -1) return null;
return { keyword: latin1.decode(data.slice(0, np)), text: latin1.decode(data.slice(np + 1)) };
}
function parseITXtChunk(data, latin1, utf8) {
const np = findNull(data);
if (np === -1) return null;
const keyword = latin1.decode(data.slice(0, np));
let pos = np + 3;
pos = findNull(data, pos); if (pos === -1) return null; pos++;
pos = findNull(data, pos); if (pos === -1) return null; pos++;
return { keyword, text: utf8.decode(data.slice(pos)) };
}
async function readAllPNGTextChunks(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return null;
const view = new DataView(buffer);
const latin1 = new TextDecoder("latin1");
const utf8 = new TextDecoder("utf-8");
let offset = 8;
const chunks = {};
while (offset + 12 <= buffer.byteLength) {
const length = view.getUint32(offset);
if (offset + 12 + length > buffer.byteLength) break;
const type = latin1.decode(bytes.slice(offset + 4, offset + 8));
const data = bytes.slice(offset + 8, offset + 8 + length);
if (type === "tEXt") { const c = parseTEXtChunk(data, latin1); if (c) chunks[c.keyword] = c.text; }
else if (type === "iTXt") { const c = parseITXtChunk(data, latin1, utf8); if (c) chunks[c.keyword] = c.text; }
offset += 12 + length;
}
return chunks;
}
// ── Workflow extraction helpers ───────────────────────────────
function collectUnique(arr) {
const seen = new Set(), out = [];
for (const v of arr) { if (v && typeof v === "string" && !seen.has(v)) { seen.add(v); out.push(v); } }
return out;
}
function collectAllNodes(workflow) {
if (!Array.isArray(workflow.nodes)) return [];
const all = [...workflow.nodes];
for (const sg of workflow.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) all.push(...sg.nodes);
return all;
}
const META_NODE_TYPES = new Set(["ImageMetadataCheckpointLoader", "ImageMetadataPromptLoader"]);
const VAE_NONE = "None";
function extractCheckpoints(wf) {
if (!wf || typeof wf !== "object") return [];
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => n.type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n.type)).map(n => n.widgets_values?.[0]));
return collectUnique(Object.values(wf).filter(n => n?.class_type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n?.class_type)).map(n => n.inputs?.ckpt_name));
}
function extractVAEs(wf) {
if (!wf || typeof wf !== "object") return [];
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).flatMap(n => { if (n.type === "VAELoader") return [n.widgets_values?.[0]]; if (META_NODE_TYPES.has(n.type ?? "")) { const v = n.widgets_values?.[1]; return v && v !== VAE_NONE ? [v] : []; } return []; }));
return collectUnique(Object.values(wf).flatMap(n => { if (!n || typeof n !== "object") return []; if (n.class_type === "VAELoader") return [n.inputs?.vae_name]; if (META_NODE_TYPES.has(n.class_type ?? "")) { const v = n.inputs?.vae_name; return v && v !== VAE_NONE ? [v] : []; } return []; }));
}
function extractDiffusionModels(wf) {
if (!wf || typeof wf !== "object") return [];
const UNET_TYPES = new Set(["UNETLoader", "UnetLoaderGGUF", "UNETLoaderGGUF"]);
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => UNET_TYPES.has(n.type)).map(n => n.widgets_values?.[0]));
return collectUnique(Object.values(wf).filter(n => UNET_TYPES.has(n?.class_type)).map(n => n.inputs?.unet_name));
}
function extractTextEncoders(wf) {
if (!wf || typeof wf !== "object") return [];
const names = [];
if (Array.isArray(wf.nodes)) {
for (const n of collectAllNodes(wf)) {
if (n.type === "CLIPLoader") { if (n.widgets_values?.[0]) names.push(n.widgets_values[0]); }
else if (n.type === "DualCLIPLoader") { [0,1].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
else if (n.type === "TripleCLIPLoader") { [0,1,2].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
else if (n.type === "QuadrupleCLIPLoader") { [0,1,2,3].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
}
} else {
for (const n of Object.values(wf)) {
if (!n || typeof n !== "object") continue;
const ct = n.class_type ?? "";
if (ct === "CLIPLoader") { if (n.inputs?.clip_name) names.push(n.inputs.clip_name); }
else if (ct === "DualCLIPLoader") { if (n.inputs?.clip_name1) names.push(n.inputs.clip_name1); if (n.inputs?.clip_name2) names.push(n.inputs.clip_name2); }
else if (ct === "TripleCLIPLoader") { ["clip_name1","clip_name2","clip_name3"].forEach(k => { if (n.inputs?.[k]) names.push(n.inputs[k]); }); }
else if (ct === "QuadrupleCLIPLoader") { ["clip_name1","clip_name2","clip_name3","clip_name4"].forEach(k => { if (n.inputs?.[k]) names.push(n.inputs[k]); }); }
}
}
return collectUnique(names);
}
function extractLoRAs(wf) {
if (!wf || typeof wf !== "object") return [];
const results = [], seen = new Set();
function add(name, sm, sc) {
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
seen.add(name);
const smNum = parseFloat(sm);
const scNum = parseFloat(sc);
results.push({ name, strength_model: isNaN(smNum) ? 1.0 : smNum, strength_clip: isNaN(scNum) ? 1.0 : scNum });
}
if (Array.isArray(wf.nodes)) {
for (const n of collectAllNodes(wf)) {
const type = n.type ?? "";
if (type === "LoraLoader") add(n.widgets_values?.[0], n.widgets_values?.[1], n.widgets_values?.[2]);
else if (type === "LoraLoaderModelOnly") add(n.widgets_values?.[0], n.widgets_values?.[1], 1.0);
else if (type === "ImageMetadataLoRALoader") { for (let i = 0; i < 3; i++) add(n.widgets_values?.[i*3], n.widgets_values?.[i*3+1], n.widgets_values?.[i*3+2]); }
else if (type === "Lora Loader (LoraManager)") { const list = n.widgets_values?.find(v => Array.isArray(v)); if (list) for (const l of list) { if (l?.active !== false) add(l?.name, l?.strength ?? 1.0, l?.clipStrength ?? l?.strength ?? 1.0); } }
}
} else {
for (const n of Object.values(wf)) {
if (!n || typeof n !== "object") continue;
const ct = n.class_type ?? "";
if (ct === "LoraLoader") add(n.inputs?.lora_name, n.inputs?.strength_model, n.inputs?.strength_clip);
else if (ct === "LoraLoaderModelOnly") add(n.inputs?.lora_name, n.inputs?.strength, 1.0);
else if (ct === "Lora Loader (LoraManager)") {
const lorasData = n.inputs?.loras;
const list = lorasData?.__value__ ?? (Array.isArray(lorasData) ? lorasData : null);
if (list) for (const l of list) { if (l?.active !== false) add(l?.name, l?.strength ?? 1.0, l?.clipStrength ?? l?.strength ?? 1.0); }
}
}
}
return results;
}
function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("TextEncode") || ct.includes("TextEncoderSD"); }
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
function extractPrompts(wf) {
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
}
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
function extractPromptsFromNodeSet(nodes, links) {
const nodeMap = new Map();
for (const n of nodes) nodeMap.set(n.id, n);
const linkOrigin = new Map(), linkSlot = new Map();
if (Array.isArray(links)) {
for (const lk of links) {
if (Array.isArray(lk)) {
linkOrigin.set(lk[0], lk[1]);
linkSlot.set(lk[0], lk[2] ?? 0);
} else if (lk && typeof lk === "object") {
const id = lk.id ?? lk[0], origin = lk.origin_id ?? lk[1], slot = lk.origin_slot ?? lk[2] ?? 0;
if (id != null && origin != null) { linkOrigin.set(id, origin); linkSlot.set(id, slot); }
}
}
}
const textMap = new Map();
for (const n of nodes) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const text = n.widgets_values?.[0];
if (text && typeof text === "string") {
textMap.set(n.id, text);
} else if (Array.isArray(n.inputs)) {
const textInput = n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt");
if (textInput?.link != null) {
const originId = linkOrigin.get(textInput.link);
const originSlot = linkSlot.get(textInput.link) ?? 0;
const srcNode = originId != null ? nodeMap.get(originId) : null;
if (srcNode) {
const srcType = srcNode.type ?? "";
if (isPromptStylerNode(srcType)) {
const v = srcNode.widgets_values?.[originSlot];
if (v && typeof v === "string") textMap.set(n.id, v);
} else {
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
if (v && typeof v === "string") textMap.set(n.id, v);
}
}
}
}
}
const pos = new Set(), neg = new Set();
let foundSampler = false;
for (const n of nodes) {
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
foundSampler = true;
for (const inp of n.inputs) {
if (!inp || inp.link == null) continue;
const originId = linkOrigin.get(inp.link);
if (originId == null) continue;
const text = textMap.get(originId);
if (!text) continue;
const name = inp.name ?? "";
if (name === "positive" || name.startsWith("positive")) pos.add(text);
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
}
}
if (!foundSampler) return null;
// SamplerCustomAdvanced などで positive/negative 直結がない場合、判別不能テキストとして返す
if (pos.size === 0 && neg.size === 0) {
const allTexts = [...textMap.values()].filter(t => t.trim());
if (allTexts.length > 0) return { positives: [], negatives: [], texts: allTexts };
return null;
}
return { positives: [...pos], negatives: [...neg] };
}
// MarkdownNote の **section** → - [name](url) パターンからモデルを抽出
// flux/qwen/z-image などのサブグラフ形式ワークフロー向け補完用
function extractMarkdownNoteModels(wf) {
const allNodes = [];
if (Array.isArray(wf.nodes)) allNodes.push(...wf.nodes);
for (const sg of wf.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) allNodes.push(...sg.nodes);
const result = { checkpoints: [], vaes: [], diffusionModels: [], textEncoders: [], loras: [] };
const seen = { checkpoints: new Set(), vaes: new Set(), diffusionModels: new Set(), textEncoders: new Set(), loras: new Set() };
function addU(arr, set, name) { if (name && typeof name === "string" && !set.has(name)) { set.add(name); arr.push(name); } }
for (const n of allNodes) {
if (n.type !== "MarkdownNote") continue;
const raw = n.widgets_values;
const text = Array.isArray(raw) ? raw[0] : (typeof raw === "string" ? raw : null);
if (!text) continue;
const sRe = /\*\*([^*\n]+)\*\*/g;
let sm;
while ((sm = sRe.exec(text)) !== null) {
const sec = sm[1].trim().toLowerCase().replace(/\s+/g, "_");
if (!["text_encoders", "diffusion_models", "vae", "checkpoints", "loras"].includes(sec)) continue;
const rest = text.slice(sm.index + sm[0].length);
const end = rest.search(/\n\*\*|\n##/);
const content = end >= 0 ? rest.slice(0, end) : rest;
const lRe = /^- \[([^\]]+)\]/gm;
let lm;
while ((lm = lRe.exec(content)) !== null) {
const name = lm[1].trim();
if (sec === "text_encoders") addU(result.textEncoders, seen.textEncoders, name);
else if (sec === "diffusion_models") addU(result.diffusionModels, seen.diffusionModels, name);
else if (sec === "vae") addU(result.vaes, seen.vaes, name);
else if (sec === "checkpoints") addU(result.checkpoints, seen.checkpoints, name);
else if (sec === "loras") addU(result.loras, seen.loras, name);
}
}
}
const hasAny = result.checkpoints.length || result.vaes.length || result.diffusionModels.length || result.textEncoders.length || result.loras.length;
return hasAny ? result : null;
}
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
function resolveLinkedText(wf, srcId, slot) {
const src = wf[String(srcId)];
if (!src || typeof src !== "object") return null;
const ct = src.class_type ?? "";
// PromptStyler系: slot 0 = text_positive, slot 1 = text_negative
if (isPromptStylerNode(ct)) {
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
return (v && typeof v === "string") ? v : null;
}
// 汎用テキストキー
const keys = slot === 0
? ["text_positive", "text", "text_g", "prompt"]
: ["text_negative", "text_l"];
for (const k of keys) {
const v = src.inputs?.[k];
if (v && typeof v === "string") return v;
}
return null;
}
function extractPromptsAPI(wf) {
const metaNodes = Object.values(wf).filter(n => n?.class_type === "ImageMetadataPromptLoader");
if (metaNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of metaNodes) { if (n.inputs?.positive_text) pos.add(n.inputs.positive_text); if (n.inputs?.negative_text) neg.add(n.inputs.negative_text); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
const textMap = new Map();
for (const [id, n] of Object.entries(wf)) {
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
if (raw && typeof raw === "string") {
textMap.set(id, raw);
} else if (Array.isArray(raw)) {
// リンク参照 [srcNodeId, slot] → 解決
const text = resolveLinkedText(wf, raw[0], raw[1] ?? 0);
if (text) textMap.set(id, text);
}
}
const pos = new Set(), neg = new Set();
let foundSampler = false;
for (const n of Object.values(wf)) {
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
foundSampler = true;
for (const [key, val] of Object.entries(n.inputs ?? {})) {
if (!Array.isArray(val)) continue;
const text = textMap.get(String(val[0]));
if (!text) continue;
if (key === "positive" || key.startsWith("positive")) pos.add(text);
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
}
}
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
return { positives: [...pos], negatives: [...neg] };
}
function extractPromptsLiteGraph(wf) {
const { nodes, links } = wf;
if (!Array.isArray(nodes)) return { positives: [], negatives: [] };
// 1. ImageMetadataPromptLoader (WFS専用)
const metaNodes = nodes.filter(n => n.type === "ImageMetadataPromptLoader");
if (metaNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of metaNodes) { const p = n.widgets_values?.[2], ng = n.widgets_values?.[3]; if (p) pos.add(p); if (ng) neg.add(ng); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
// 2. WFS_PromptText (WFS専用)
const wfsNodes = nodes.filter(n => n.type === "WFS_PromptText");
if (wfsNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of wfsNodes) { const p = n.widgets_values?.[0], ng = n.widgets_values?.[1]; if (p) pos.add(p); if (ng) neg.add(ng); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
// 3. トップレベルの CLIPTextEncode + KSampler
const topResult = extractPromptsFromNodeSet(nodes, links ?? []);
if (topResult) return topResult;
// 4. PrimitiveStringMultiline(flux2-klein / ernie など)- サブグラフ内も探す
const primTexts = [];
for (const n of collectAllNodes(wf)) {
if (n.type !== "PrimitiveStringMultiline") continue;
const t = Array.isArray(n.widgets_values) ? n.widgets_values[0] : n.widgets_values;
if (t && typeof t === "string" && t.trim()) primTexts.push(t.trim());
}
if (primTexts.length > 0) return { positives: [], negatives: [], texts: primTexts };
// 5. サブグラフ内の CLIPTextEncode + KSampler(z-image / qwen / flux など)
for (const sg of wf.definitions?.subgraphs ?? []) {
if (!Array.isArray(sg.nodes)) continue;
const sgResult = extractPromptsFromNodeSet(sg.nodes, sg.links ?? []);
if (sgResult) return sgResult;
}
// 6. PromptStyler フォールバック
const stylerPos = new Set(), stylerNeg = new Set();
for (const n of nodes) {
if (!isPromptStylerNode(n.type ?? "")) continue;
const vals = n.widgets_values ?? [];
for (let i = 0; i < vals.length; i++) {
if (typeof vals[i] !== "string" || !vals[i].trim()) continue;
if (i % 2 === 0) stylerPos.add(vals[i]);
else stylerNeg.add(vals[i]);
}
}
if (stylerPos.size > 0 || stylerNeg.size > 0) return { positives: [...stylerPos], negatives: [...stylerNeg] };
// 7. テキストエンコーダー全テキスト(最終フォールバック)- サブグラフも含む・判別不能
const all = [];
for (const n of collectAllNodes(wf)) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const t = n.widgets_values?.[0];
if (t && typeof t === "string" && t.trim()) all.push(t);
}
return { positives: [], negatives: [], texts: all };
}
// ── SD/Fooocus prompt extraction ──────────────────────────────
function parseSDAParameters(raw) {
const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const stepsMatch = text.match(/\nSteps:\s+\d/);
if (!stepsMatch) return null;
const paramsStart = stepsMatch.index + 1;
const promptSection = text.slice(0, paramsStart - 1);
const paramsLine = text.slice(paramsStart);
const negSep = "\nNegative prompt: ";
const negIdx = promptSection.indexOf(negSep);
let positive = "", negative = "";
if (negIdx !== -1) { positive = promptSection.slice(0, negIdx).trim(); negative = promptSection.slice(negIdx + negSep.length).trim(); }
else positive = promptSection.trim();
const params = {};
const re = /,?\s*([A-Za-z][A-Za-z0-9 ]*):\s*("(?:[^"\\]|\\.)*"|[^,]+)/g;
let m;
while ((m = re.exec(paramsLine)) !== null) params[m[1].trim()] = m[2].trim().replace(/^"|"$/g, "");
return { positive, negative, params };
}
function parseFooocusMetadata(raw) {
let obj; try { obj = JSON.parse(raw); } catch { return null; }
if (!obj?.base_model) return null;
const toArray = v => !v ? [] : Array.isArray(v) ? v.filter(Boolean) : [String(v)];
return { checkpoint: obj.base_model, vae: (obj.vae && obj.vae !== "Default") ? obj.vae : null, positives: toArray(obj.full_prompt ?? obj.prompt), negatives: toArray(obj.full_negative_prompt ?? obj.negative_prompt) };
}
// ── Master extractAllMetadata ─────────────────────────────────
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");
function fromWorkflow(wf, source) {
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)
const mdm = extractMarkdownNoteModels(wf);
if (mdm) {
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
if (!base.vaes.length) base.vaes = mdm.vaes;
if (!base.diffusionModels.length) base.diffusionModels = mdm.diffusionModels;
if (!base.textEncoders.length) base.textEncoders = mdm.textEncoders;
if (!base.loras.length) base.loras = mdm.loras;
}
return base;
}
if (isJSON) {
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
return wf ? fromWorkflow(wf, "comfyui") : null;
}
if (isWebP) {
const exif = await readWebPEXIFChunk(file);
if (!exif) return null;
const wf = extractWorkflowFromEXIF(exif);
return wf ? fromWorkflow(wf, "comfyui") : null;
}
// PNG
const chunks = await readAllPNGTextChunks(file);
if (!chunks) return null;
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.fooocus_scheme === "fooocus" && chunks.parameters) {
const f = parseFooocusMetadata(chunks.parameters);
if (!f) return null;
return { source: "fooocus", checkpoints: [f.checkpoint], vaes: f.vae ? [f.vae] : [], diffusionModels: [], textEncoders: [], loras: [], positives: f.positives, negatives: f.negatives };
}
if (chunks.parameters) {
const p = parseSDAParameters(chunks.parameters);
if (!p) return null;
const { positive, negative, params } = p;
const modelName = params["Model"];
if (!modelName) return null;
if (params["Module 2"] != null) {
const textEncoders = [];
for (let i = 2; i <= 9; i++) { const mod = params[`Module ${i}`]; if (!mod) break; textEncoders.push(mod); }
return { source: "sd_forge", checkpoints: [], vaes: params["Module 1"] ? [params["Module 1"]] : [], diffusionModels: [modelName], textEncoders, loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
}
const vaeValue = params["Module 1"] ?? params["VAE"] ?? null;
return { source: "sd", checkpoints: [modelName], vaes: vaeValue ? [vaeValue] : [], diffusionModels: [], textEncoders: [], loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
}
return null;
}
// ── UI helpers ────────────────────────────────────────────────
function buildModelItem(label) {
const el = document.createElement("div");
el.className = "wfm-meta-item";
el.title = label;
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(label)}</span>`;
return el;
}
function buildLoRAItem(lora) {
const el = document.createElement("div");
el.className = "wfm-meta-item";
el.title = lora.name;
const sm = lora.strength_model.toFixed(2);
const sc = lora.strength_clip.toFixed(2);
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(lora.name)}</span><span class="wfm-meta-item-badge">${sm}/${sc}</span>`;
return el;
}
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;
const typeBadge = type === "positive" ? `<span class="wfm-meta-badge-pos">POS</span>`
: type === "negative" ? `<span class="wfm-meta-badge-neg">NEG</span>`
: ``;
el.innerHTML = `${typeBadge}<span class="wfm-meta-item-name">${escapeHtml(snippet)}</span>`;
el.addEventListener("click", () => {
listEl.querySelectorAll(".wfm-meta-item-clickable").forEach(e => e.classList.remove("selected"));
el.classList.add("selected");
fullArea.value = full;
fullLabel.textContent = type === "positive" ? t("metaPromptPositive")
: type === "negative" ? t("metaPromptNegative")
: t("metaPromptText") || "Text";
});
return el;
}
function renderSection(sectionEl, listEl, items, buildFn) {
listEl.innerHTML = "";
if (items.length === 0) { sectionEl.classList.add("wfm-meta-section-empty"); return; }
sectionEl.classList.remove("wfm-meta-section-empty");
for (const item of items) listEl.appendChild(buildFn(item));
}
// ── External API ──────────────────────────────────────────────
let _externalHandleFile = null;
export async function loadFileIntoMetadataTab(file) {
document.querySelector('.wfm-tab[data-tab="metadata"]')?.click();
await new Promise(r => setTimeout(r, 0));
if (_externalHandleFile) await _externalHandleFile(file);
}
// ── Tab initialization ────────────────────────────────────────
export function initMetadataTab() {
const dropZone = document.getElementById("wfm-meta-drop");
const dropLabel = document.getElementById("wfm-meta-drop-label");
const previewImg = document.getElementById("wfm-meta-preview-img");
const fileInfo = document.getElementById("wfm-meta-file-info");
const fileInput = document.getElementById("wfm-meta-file-input");
const ckptSection = document.getElementById("wfm-meta-ckpt-section");
const vaeSection = document.getElementById("wfm-meta-vae-section");
const diffSection = document.getElementById("wfm-meta-diff-section");
const teSection = document.getElementById("wfm-meta-te-section");
const loraSection = document.getElementById("wfm-meta-lora-section");
const promptSection = document.getElementById("wfm-meta-prompt-section");
const ckptList = document.getElementById("wfm-meta-ckpt-list");
const vaeList = document.getElementById("wfm-meta-vae-list");
const diffList = document.getElementById("wfm-meta-diff-list");
const teList = document.getElementById("wfm-meta-te-list");
const loraList = document.getElementById("wfm-meta-lora-list");
const promptList = document.getElementById("wfm-meta-prompt-list");
const promptFull = document.getElementById("wfm-meta-prompt-full");
const promptFullLabel = document.getElementById("wfm-meta-prompt-full-label");
if (!dropZone) return;
// Apply i18n to section titles
const titleMap = {
"wfm-meta-ckpt-section": "metaSectionCkpt",
"wfm-meta-vae-section": "metaSectionVae",
"wfm-meta-diff-section": "metaSectionDiff",
"wfm-meta-te-section": "metaSectionTe",
"wfm-meta-lora-section": "metaSectionLora",
"wfm-meta-prompt-section": "metaSectionPrompt",
};
for (const [id, key] of Object.entries(titleMap)) {
const title = document.querySelector(`#${id} .wfm-meta-section-title`);
if (title) { const tx = t(key); if (tx && tx !== key) title.textContent = tx; }
}
// Apply i18n to format note
const i18nIds = {
"wfm-meta-format-note-title": "metaFormatNoteTitle",
"wfm-meta-fmt-comfyui": "metaFmtComfyui",
"wfm-meta-fmt-sdwebui": "metaFmtSdwebui",
"wfm-meta-fmt-fooocus": "metaFmtFooocus",
"wfm-meta-format-todo": "metaFormatTodo",
};
for (const [id, key] of Object.entries(i18nIds)) {
const el = document.getElementById(id);
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
}
// Apply i18n to help card
const helpIds = {
"wfm-help-metadata-title": "helpMetadataTitle",
"wfm-help-metadata-desc": "helpMetadataDesc",
"wfm-help-metadata-1": "helpMetadata1",
"wfm-help-metadata-2": "helpMetadata2",
"wfm-help-metadata-3": "helpMetadata3",
"wfm-help-metadata-4": "helpMetadata4",
"wfm-help-metadata-5": "helpMetadata5",
};
for (const [id, key] of Object.entries(helpIds)) {
const el = document.getElementById(id);
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
}
function clearAll() {
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => { if (l) l.innerHTML = ""; });
[ckptSection, vaeSection, diffSection, teSection, loraSection, promptSection].forEach(s => { if (s) s.classList.add("wfm-meta-section-empty"); });
if (promptFull) promptFull.value = "";
if (promptFullLabel) promptFullLabel.textContent = "";
}
async function handleFile(file) {
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
fileInfo.textContent = t("metaFileTooLarge");
fileInfo.style.color = "var(--wfm-warning)";
return;
}
fileInfo.textContent = t("metaParsing");
fileInfo.style.color = "var(--wfm-text-secondary)";
clearAll();
// Show preview for images
const isImage = file.type.startsWith("image/") || file.name.toLowerCase().match(/\.(png|webp|jpg|jpeg)$/);
if (isImage) {
const url = URL.createObjectURL(file);
previewImg.src = url;
previewImg.style.display = "block";
dropLabel.style.display = "none";
previewImg.onload = () => URL.revokeObjectURL(url);
} else {
previewImg.style.display = "none";
dropLabel.style.display = "flex";
}
let meta;
try {
meta = await extractAllMetadata(file);
} catch (err) {
console.error("[MetadataTab]", err);
fileInfo.textContent = t("metaParseError");
fileInfo.style.color = "var(--wfm-danger)";
return;
}
if (!meta) {
fileInfo.textContent = t("metaNoMetadata");
fileInfo.style.color = "var(--wfm-warning)";
return;
}
const sizeKB = (file.size / 1024).toFixed(1);
const sourceLabel = { comfyui: "ComfyUI", sd: "SD WebUI", sd_forge: "SD Forge", fooocus: "Fooocus" }[meta.source] ?? meta.source;
fileInfo.textContent = `${file.name} (${sizeKB} KB · ${sourceLabel})`;
fileInfo.style.color = "var(--wfm-text-secondary)";
renderSection(ckptSection, ckptList, meta.checkpoints, n => buildModelItem(n));
renderSection(vaeSection, vaeList, meta.vaes, n => buildModelItem(n));
renderSection(diffSection, diffList, meta.diffusionModels, n => buildModelItem(n));
renderSection(teSection, teList, meta.textEncoders, n => buildModelItem(n));
renderSection(loraSection, loraList, meta.loras, l => buildLoRAItem(l));
// Prompts
promptList.innerHTML = "";
const allPrompts = [
...meta.positives.map(p => ({ type: "positive", text: p })),
...meta.negatives.map(p => ({ type: "negative", text: p })),
...(meta.texts ?? []).map(p => ({ type: "text", text: p })),
];
if (allPrompts.length > 0) {
promptSection.classList.remove("wfm-meta-section-empty");
for (const { type, text } of allPrompts) {
promptList.appendChild(buildPromptItem(text, type, text, promptFull, promptFullLabel, promptList));
}
// Auto-select first positive
const firstPositive = promptList.querySelector(".wfm-meta-item-clickable");
if (firstPositive) firstPositive.click();
} else {
promptSection.classList.add("wfm-meta-section-empty");
}
}
// ── Prompt action buttons ─────────────────────────────────
function getPromptText() { return promptFull?.value ?? ""; }
function flashBtn(btn) {
btn.classList.add("wfm-meta-btn-flash");
setTimeout(() => btn.classList.remove("wfm-meta-btn-flash"), 600);
}
function setTextareaValue(id, text) {
const el = document.getElementById(id);
if (!el) return false;
el.value = text;
el.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
document.getElementById("wfm-meta-copy-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
navigator.clipboard.writeText(text).then(() => flashBtn(this));
});
document.getElementById("wfm-meta-genui-pos-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-prompt-pos-text", text)) flashBtn(this);
});
document.getElementById("wfm-meta-genui-neg-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-prompt-neg-text", text)) flashBtn(this);
});
document.getElementById("wfm-meta-preset-pos-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-preset-pos", text)) flashBtn(this);
});
document.getElementById("wfm-meta-preset-neg-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-preset-neg", text)) flashBtn(this);
});
_externalHandleFile = handleFile;
// Drag & Drop
dropZone.addEventListener("dragover", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", e => { e.stopPropagation(); dropZone.classList.remove("drag-over"); });
dropZone.addEventListener("drop", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); handleFile(e.dataTransfer.files?.[0]); });
dropZone.addEventListener("click", e => { if (e.target === previewImg) return; fileInput.click(); });
fileInput.addEventListener("change", () => { handleFile(fileInput.files?.[0]); fileInput.value = ""; });
// Prevent scroll passthrough in lists
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => {
if (l) l.addEventListener("wheel", e => e.stopPropagation(), { passive: true });
});
clearAll();
}
+1203 -137
View File
File diff suppressed because it is too large Load Diff
+242 -9
View File
@@ -5,6 +5,7 @@
import { showToast } from "./app.js";
import { comfyUI } from "./comfyui-client.js";
import { t } from "./i18n.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
// ── State ─────────────────────────────────────────────────
@@ -25,6 +26,8 @@ const state = {
activeSubView: "browser",
loaded: false,
currentPage: 0,
selectedNodes: new Set(),
lastSelectionIndex: -1,
};
// ── Helpers ───────────────────────────────────────────────
@@ -50,9 +53,6 @@ function categoryBadgeHtml(cat) {
return `<span class="wfm-badge wfm-badge-category">${cat}</span>`;
}
function escapeHtml(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── API ───────────────────────────────────────────────────
@@ -275,6 +275,9 @@ function createNodeCard(node) {
if (state.selectedNode && state.selectedNode.name === node.name) {
card.classList.add("wfm-card-selected");
}
if (state.selectedNodes.has(node.name)) {
card.classList.add("multi-selected");
}
card.dataset.nodeName = node.name;
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
@@ -296,7 +299,32 @@ function createNodeCard(node) {
e.stopPropagation();
toggleFavorite(node.name, e.currentTarget);
});
card.addEventListener("click", () => showNodeSidePanel(node));
card.addEventListener("click", (e) => {
const filtered = filterNodes();
const idx = filtered.findIndex(n => n.name === node.name);
if (e.shiftKey && state.lastSelectionIndex !== -1) {
const from = Math.min(state.lastSelectionIndex, idx);
const to = Math.max(state.lastSelectionIndex, idx);
for (let i = from; i <= to; i++) {
state.selectedNodes.add(filtered[i].name);
}
_applyNodeSelectionToDOM();
updateNodeBulkBar();
} else if (e.ctrlKey || e.metaKey) {
if (state.selectedNodes.has(node.name)) {
state.selectedNodes.delete(node.name);
card.classList.remove("multi-selected");
} else {
state.selectedNodes.add(node.name);
card.classList.add("multi-selected");
}
state.lastSelectionIndex = idx;
updateNodeBulkBar();
} else {
state.lastSelectionIndex = idx;
showNodeSidePanel(node);
}
});
return card;
}
@@ -321,6 +349,9 @@ function renderNodeTableView(grid, filtered) {
if (state.selectedNode && state.selectedNode.name === node.name) {
tr.classList.add("wfm-card-selected");
}
if (state.selectedNodes.has(node.name)) {
tr.classList.add("multi-selected");
}
const inputCount = Object.keys(node.input.required || {}).length +
Object.keys(node.input.optional || {}).length;
const outputCount = (node.output || []).length;
@@ -334,7 +365,31 @@ function renderNodeTableView(grid, filtered) {
e.stopPropagation();
toggleFavorite(node.name, e.currentTarget);
});
tr.addEventListener("click", () => showNodeSidePanel(node));
tr.addEventListener("click", (e) => {
const idx = filtered.findIndex(n => n.name === node.name);
if (e.shiftKey && state.lastSelectionIndex !== -1) {
const from = Math.min(state.lastSelectionIndex, idx);
const to = Math.max(state.lastSelectionIndex, idx);
for (let i = from; i <= to; i++) {
state.selectedNodes.add(filtered[i].name);
}
_applyNodeSelectionToDOM();
updateNodeBulkBar();
} else if (e.ctrlKey || e.metaKey) {
if (state.selectedNodes.has(node.name)) {
state.selectedNodes.delete(node.name);
tr.classList.remove("multi-selected");
} else {
state.selectedNodes.add(node.name);
tr.classList.add("multi-selected");
}
state.lastSelectionIndex = idx;
updateNodeBulkBar();
} else {
state.lastSelectionIndex = idx;
showNodeSidePanel(node);
}
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
@@ -353,6 +408,100 @@ async function toggleFavorite(nodeName, btnEl) {
btnEl.classList.toggle("active", newFav);
}
// ── 複数選択ユーティリティ ─────────────────────────────────
function _applyNodeSelectionToDOM() {
const grid = document.getElementById("wfm-nodes-grid");
if (!grid) return;
grid.querySelectorAll("[data-node-name]").forEach(el => {
el.classList.toggle("multi-selected", state.selectedNodes.has(el.dataset.nodeName));
});
}
function updateNodeBulkBar() {
const bar = document.getElementById("wfm-nodes-bulk-bar");
const countEl = document.getElementById("wfm-nodes-bulk-count");
if (!bar) return;
const count = state.selectedNodes.size;
if (count > 0) {
bar.style.display = "";
if (countEl) countEl.textContent = `${count} ${t("nodesBulkSelected")}`;
} else {
bar.style.display = "none";
}
const groupSel = document.getElementById("wfm-nodes-bulk-group-select");
if (groupSel) {
const current = groupSel.value;
const grpNames = Object.keys(state.nodeGroups).sort();
groupSel.innerHTML = `<option value="">${t("nodesBulkAddToGroup")}</option>` +
grpNames.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
if (grpNames.includes(current)) groupSel.value = current;
}
}
// ── 一括操作 ──────────────────────────────────────────────
async function bulkNodeAddToGroup(groupName) {
if (!groupName || state.selectedNodes.size === 0) return;
const nodeNames = [...state.selectedNodes];
if (!state.nodeGroups[groupName]) state.nodeGroups[groupName] = [];
let added = 0;
nodeNames.forEach(name => {
if (!state.nodeGroups[groupName].includes(name)) {
state.nodeGroups[groupName].push(name);
added++;
}
});
await saveNodeGroups(state.nodeGroups);
renderFilters();
if (state.selectedNode) renderSideGroups(state.selectedNode);
showToast(t("addedNImagesToGroup", added, groupName), "success");
}
async function bulkNodeRemoveFromGroup(groupName) {
if (!groupName || state.selectedNodes.size === 0) return;
if (!state.nodeGroups[groupName]) return;
const nodeNames = new Set(state.selectedNodes);
const before = state.nodeGroups[groupName].length;
state.nodeGroups[groupName] = state.nodeGroups[groupName].filter(n => !nodeNames.has(n));
const removed = before - state.nodeGroups[groupName].length;
if (state.nodeGroups[groupName].length === 0) delete state.nodeGroups[groupName];
await saveNodeGroups(state.nodeGroups);
renderFilters();
if (state.selectedNode) renderSideGroups(state.selectedNode);
showToast(t("removedNImagesFromGroup", removed, groupName), "success");
}
async function bulkNodeCreateAndAddToGroup(name) {
if (!name || state.selectedNodes.size === 0) return;
if (state.nodeGroups[name] !== undefined) {
showToast(t("groupExists"), "warning");
return;
}
state.nodeGroups[name] = [...state.selectedNodes];
await saveNodeGroups(state.nodeGroups);
renderFilters();
if (state.selectedNode) renderSideGroups(state.selectedNode);
updateNodeBulkBar();
showToast(t("groupCreated", name), "success");
}
async function bulkNodeSetFavorite(value) {
if (state.selectedNodes.size === 0) return;
const nodeNames = [...state.selectedNodes].filter(name => {
const meta = state.nodeMetadata[name];
return (meta?.favorite ?? false) !== value;
});
if (nodeNames.length === 0) return;
await Promise.all(nodeNames.map(name => saveNodeMetadata(name, { favorite: value })));
nodeNames.forEach(name => {
if (!state.nodeMetadata[name]) state.nodeMetadata[name] = {};
state.nodeMetadata[name].favorite = value;
});
renderNodeGrid();
showToast(value ? t("favoritedNImages", nodeNames.length) : t("unfavoritedNImages", nodeNames.length), "success");
}
// ── Side Panel ────────────────────────────────────────────
function showNodeSidePanel(node) {
@@ -586,7 +735,7 @@ function renderSideGroups(node) {
const name = input?.value?.trim();
if (!name) return;
if (state.nodeGroups[name]) {
showToast("Group already exists", "warning");
showToast(t("groupExists"), "warning");
return;
}
state.nodeGroups[name] = [node.name];
@@ -604,7 +753,7 @@ function renderSideGroups(node) {
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
if (state.nodeGroups[newName] !== undefined) {
showToast("Group name already exists", "warning");
showToast(t("groupNameExists"), "warning");
return;
}
state.nodeGroups[newName] = state.nodeGroups[oldName];
@@ -613,7 +762,7 @@ function renderSideGroups(node) {
if (state.groupFilter === oldName) state.groupFilter = newName;
renderSideGroups(node);
renderFilters();
showToast(`Renamed to "${newName}"`, "success");
showToast(t("renamedTo", newName), "success");
});
// グループ削除
@@ -627,7 +776,7 @@ function renderSideGroups(node) {
if (state.groupFilter === name) state.groupFilter = "";
renderSideGroups(node);
renderFilters();
showToast(`Group "${name}" deleted`, "success");
showToast(t("groupDeleted", name), "success");
});
}
@@ -1046,6 +1195,11 @@ export function initNodesTab() {
renderNodeGrid();
});
}
setupSearchClearBtn("wfm-nodes-search", "wfm-nodes-search-clear-btn", () => {
state.searchText = "";
state.currentPage = 0;
renderNodeGrid();
});
// Filters
document.getElementById("wfm-nodes-category-filter")?.addEventListener("change", e => {
@@ -1111,9 +1265,88 @@ export function initNodesTab() {
});
});
// 一括操作バー: テキストをi18nで設定
const bulkDeselBtn = document.getElementById("wfm-nodes-bulk-deselect");
if (bulkDeselBtn) bulkDeselBtn.textContent = t("nodesBulkDeselect");
const bulkGroupAddBtn = document.getElementById("wfm-nodes-bulk-group-add");
if (bulkGroupAddBtn) bulkGroupAddBtn.textContent = t("nodesBulkGroupAdd");
const bulkGroupRemBtn = document.getElementById("wfm-nodes-bulk-group-remove");
if (bulkGroupRemBtn) bulkGroupRemBtn.textContent = t("nodesBulkGroupRemove");
const bulkCreateAddBtn = document.getElementById("wfm-nodes-bulk-create-add");
if (bulkCreateAddBtn) bulkCreateAddBtn.textContent = t("nodesBulkCreateAdd");
const bulkFavBtn = document.getElementById("wfm-nodes-bulk-fav");
if (bulkFavBtn) bulkFavBtn.textContent = t("nodesBulkFavAll");
const bulkUnfavBtn = document.getElementById("wfm-nodes-bulk-unfav");
if (bulkUnfavBtn) bulkUnfavBtn.textContent = t("nodesBulkUnfavAll");
const bulkNewGroupInput = document.getElementById("wfm-nodes-bulk-new-group");
if (bulkNewGroupInput) bulkNewGroupInput.placeholder = t("nodesBulkNewGroup");
document.getElementById("wfm-nodes-bulk-deselect")?.addEventListener("click", () => {
state.selectedNodes.clear();
state.lastSelectionIndex = -1;
updateNodeBulkBar();
document.querySelectorAll("#wfm-nodes-grid .multi-selected").forEach(el => el.classList.remove("multi-selected"));
});
document.getElementById("wfm-nodes-bulk-group-add")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-nodes-bulk-group-select");
if (sel?.value) {
bulkNodeAddToGroup(sel.value);
} else {
showToast(t("selectGroupFirst"), "error");
}
});
document.getElementById("wfm-nodes-bulk-group-remove")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-nodes-bulk-group-select");
if (sel?.value) {
bulkNodeRemoveFromGroup(sel.value);
} else {
showToast(t("selectGroupFirst"), "error");
}
});
document.getElementById("wfm-nodes-bulk-create-add")?.addEventListener("click", () => {
const input = document.getElementById("wfm-nodes-bulk-new-group");
const name = input?.value?.trim();
if (!name) return;
bulkNodeCreateAndAddToGroup(name).then(() => { if (input) input.value = ""; });
});
document.getElementById("wfm-nodes-bulk-fav")?.addEventListener("click", () => bulkNodeSetFavorite(true));
document.getElementById("wfm-nodes-bulk-unfav")?.addEventListener("click", () => bulkNodeSetFavorite(false));
// Create node set button
document.getElementById("wfm-node-set-create-btn")?.addEventListener("click", showCreateSetModal);
// Clear filters button
const nodesClearBtn = document.getElementById("wfm-nodes-clear-filters-btn");
if (nodesClearBtn) {
nodesClearBtn.textContent = t("clearFilters");
nodesClearBtn.addEventListener("click", () => {
state.searchText = "";
state.categoryFilter = "";
state.packageFilter = "";
state.tagFilter = "";
state.groupFilter = "";
state.showFavoritesOnly = false;
state.currentPage = 0;
const searchInput = document.getElementById("wfm-nodes-search");
if (searchInput) searchInput.value = "";
const catFilter = document.getElementById("wfm-nodes-category-filter");
if (catFilter) catFilter.value = "";
const pkgFilter = document.getElementById("wfm-nodes-package-filter");
if (pkgFilter) pkgFilter.value = "";
const tagFilter = document.getElementById("wfm-nodes-tag-filter");
if (tagFilter) tagFilter.value = "";
const grpFilter = document.getElementById("wfm-nodes-group-filter");
if (grpFilter) grpFilter.value = "";
const favBtn = document.getElementById("wfm-nodes-fav-btn");
if (favBtn) favBtn.classList.remove("active");
renderNodeGrid();
});
}
// Lazy load on first tab click
document.querySelector('[data-tab="nodes"]')?.addEventListener("click", () => {
if (!state.loaded) loadNodesData();
+540 -74
View File
@@ -4,6 +4,7 @@
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage } from "./util.js";
// ============================================
// State
@@ -21,6 +22,7 @@ let pmSearchText = "";
let pmSelectedId = null; // currently selected preset id
let pmGroups = {}; // { groupName: [presetId, ...] }
const PM_GROUPS_KEY = "wfm_prompt_preset_groups";
const PROMPT_RESERVED_GROUPS = ["Batch"];
// ============================================
// Preset API helpers
@@ -91,15 +93,11 @@ async function migrateLocalStoragePresets() {
await apiCreatePreset({
name: p.name || "Untitled",
text: p.posText || p.text || "",
negText: p.negText || "",
category: "",
tags: [],
favorite: false,
});
// Note: negText is stored separately - we combine it
if (p.negText) {
// Create a separate entry for negative prompt reference
// or store in the text field with marker
}
}
localStorage.removeItem(PRESETS_KEY);
} catch { /* ignore migration errors */ }
@@ -114,15 +112,17 @@ async function loadAllPresets() {
promptPresets = await fetchPresets();
// Load groups from localStorage
try {
pmGroups = JSON.parse(localStorage.getItem(PM_GROUPS_KEY) || "{}");
} catch { pmGroups = {}; }
pmGroups = readJsonStorage(PM_GROUPS_KEY);
// Clean stale entries from groups
// 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) delete pmGroups[g];
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();
@@ -213,6 +213,25 @@ function matchesSearch(p) {
(p.tags || []).some(t => t.toLowerCase().includes(s));
}
function isInBatchPreset(id) {
return (pmGroups["Batch"] || []).includes(id);
}
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();
}
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" : "");
@@ -225,12 +244,15 @@ function createPmItem(preset) {
? `<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>
@@ -242,6 +264,13 @@ function createPmItem(preset) {
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();
@@ -331,7 +360,8 @@ function renderPmGroups(container) {
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
if (pmGroups[groupName].length === 0) delete pmGroups[groupName];
// 予約グループ (Batch) は空になってもキーを保持する
if (pmGroups[groupName].length === 0 && !PROMPT_RESERVED_GROUPS.includes(groupName)) delete pmGroups[groupName];
saveGroups();
renderPresetManager();
});
@@ -345,26 +375,70 @@ function renderPmGroups(container) {
}
// ============================================
// Ollama API helpers
// AI Assistant helpers (Ollama / LM Studio)
// ============================================
async function ollamaModels() {
const res = await fetch("/api/wfm/ollama/models");
return await res.json();
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
function loadPromptAiSettings() {
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
}
async function ollamaTest() {
const res = await fetch("/api/wfm/ollama/test", { method: "POST" });
return await res.json();
// バックエンド変更検知用: モデル一覧を取得した時点のバックエンド名を記録
let _promptAiBackendForModels = null;
function getPromptAiConfig() {
const s = loadPromptAiSettings();
const backend = s.backend || "ollama";
const url = (s.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
return { backend, url };
}
async function ollamaChat(model, messages) {
const res = await fetch("/api/wfm/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages }),
});
return await res.json();
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: 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 || "";
}
}
// ============================================
@@ -429,7 +503,7 @@ function clearChat() {
if (chat) {
chat.innerHTML = `
<div class="wfm-ollama-welcome">
<p>AI Assistant (Ollama)</p>
<p>AI Assistant</p>
<p>${t("assistantWelcome")}</p>
</div>
`;
@@ -493,6 +567,24 @@ async function sendMessage() {
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");
@@ -512,16 +604,11 @@ async function sendMessage() {
if (sendBtn) sendBtn.disabled = true;
try {
const data = await ollamaChat(model, ollamaState.chatHistory);
if (data.status === "success" && data.message) {
const reply = data.message.content;
addChatMessage("assistant", reply);
ollamaState.chatHistory.push({ role: "assistant", content: reply });
ollamaState.attachedImage = null;
updateAttachmentDisplay();
} else {
showToast(t("error") + ": " + (data.error || data.message || "No response"), "error");
}
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 {
@@ -564,12 +651,8 @@ async function sendTranslate(direction) {
};
const prompt = prompts[direction];
const data = await ollamaChat(model, [{ role: "user", content: prompt }]);
if (data.status === "success" && data.message) {
addChatMessage("assistant", data.message.content);
} else {
showToast(t("error") + ": No response", "error");
}
const reply = await chatWithAi(model, [{ role: "user", content: prompt }]);
addChatMessage("assistant", reply);
} catch (err) {
showToast(t("error") + ": " + err.message, "error");
} finally {
@@ -593,25 +676,255 @@ function applyToGenerateUI(text) {
showToast(t("appliedToGenerateUI"), "success");
}
// ============================================
// Wildcard API helpers
// ============================================
async function wcFetchFiles() {
try {
const res = await fetch("/api/wfm/wildcards");
return res.ok ? await res.json() : [];
} catch { return []; }
}
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; }
}
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; }
}
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";
}
async function wcRefreshFiles() {
wcFiles = await wcFetchFiles();
wcRenderFileList();
wcUpdateFilePicker();
}
// ============================================
// Wildcard file picker popup
// ============================================
function wcUpdateFilePicker() {
const picker = document.getElementById("wfm-wc-file-picker");
if (!picker) return;
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", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
picker.style.display = "none";
});
picker.appendChild(btn);
}
}
}
// ============================================
// Initialize
// ============================================
export function initPromptTab() {
// Ollama model refresh
// 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 data = await ollamaModels();
if (data.status === "success" && data.models) {
select.innerHTML = data.models
.map((m) => `<option value="${m.name}">${m.name}</option>`)
.join("");
if (status) {
status.textContent = `${data.models.length} models`;
status.className = "wfm-ollama-status connected";
}
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) {
@@ -631,15 +944,11 @@ export function initPromptTab() {
const status = document.getElementById("wfm-ollama-status");
if (status) { status.textContent = "Testing..."; status.className = "wfm-ollama-status"; }
try {
const data = await ollamaTest();
if (data.connected) {
if (status) { status.textContent = t("connected"); status.className = "wfm-ollama-status connected"; }
await refreshModels();
} else {
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
}
await fetchAiModels();
if (status) { status.textContent = t("connectedCheck"); status.className = "wfm-ollama-status connected"; }
await refreshModels();
} catch {
if (status) { status.textContent = t("error"); status.className = "wfm-ollama-status error"; }
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
}
});
@@ -750,18 +1059,14 @@ export function initPromptTab() {
});
if (created) {
promptPresets.push(created);
pmSelectedId = created.id;
showToast(t("presetSaved"), "success");
}
}
pmSelectedId = null;
renderPresetSelect();
renderPresetManager();
// Re-select
if (pmSelectedId && presetSelect) {
presetSelect.value = pmSelectedId;
}
if (presetSelect) presetSelect.value = "";
});
// Apply preset to GenerateUI
@@ -793,50 +1098,54 @@ export function initPromptTab() {
if (!name || !name.trim()) return;
const key = name.trim();
if (pmGroups[key]) {
showToast("Group already exists", "error");
showToast(t("groupExists"), "error");
return;
}
pmGroups[key] = [];
saveGroups();
renderPresetManager();
showToast(`Group "${key}" created`, "success");
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("Select a group first", "error");
showToast(t("selectGroupFirst"), "error");
return;
}
const id = presetSelect?.value;
if (!id) {
showToast("Select a preset first", "error");
showToast(t("selectPresetFirst"), "error");
return;
}
if (!pmGroups[groupName]) pmGroups[groupName] = [];
if (pmGroups[groupName].includes(id)) {
showToast("Already in this group", "info");
showToast(t("alreadyInGroup"), "info");
return;
}
pmGroups[groupName].push(id);
saveGroups();
renderPresetManager();
showToast("Added to group", "success");
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("Select a group first", "error");
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(`Group "${groupName}" deleted`, "success");
showToast(t("groupDeleted", groupName), "success");
});
// --- Preset Manager ---
@@ -853,4 +1162,161 @@ export function initPromptTab() {
pmSearchText = e.target.value.trim();
renderPresetManager();
});
document.getElementById("wfm-pm-batch-clear-btn")?.addEventListener("click", () => {
clearBatchPresets();
});
// ── Center column tab switching ──────────────────────────
document.querySelectorAll(".wfm-prompt-center-tab").forEach(btn => {
btn.addEventListener("click", () => {
const target = btn.dataset.ptab;
document.querySelectorAll(".wfm-prompt-center-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".wfm-prompt-center-pane").forEach(p => {
p.style.display = p.id === `wfm-ptab-${target}` ? "" : "none";
});
});
});
// ── 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();
}
+466 -45
View File
@@ -6,6 +6,8 @@ import { showToast } from "./app.js";
import { comfyUI } from "./comfyui-client.js";
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
import { getSettings, readJsonStorage } from "./util.js";
const SETTINGS_KEY = "wfm_settings";
function rgbToHex(rgb) {
@@ -15,11 +17,7 @@ function rgbToHex(rgb) {
}
function loadLocalSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
} catch {
return {};
}
return readJsonStorage(SETTINGS_KEY);
}
function saveLocalSettings(patch) {
@@ -314,6 +312,60 @@ function clearCustomOverrides() {
if (fontEl) fontEl.remove();
}
const TEXTAREA_FONT_SIZE_DEFAULT = 13;
const TEXTAREA_FONT_SIZE_STYLE_ID = "wfm-ta-font-size-style";
const TEXTAREA_FONT_SIZE_TARGETS = [
"#wfm-prompt-pos-text",
"#wfm-prompt-neg-text",
"#wfm-ollama-input",
"#wfm-preset-pos",
"#wfm-preset-neg",
"#wfm-wc-prompt",
"#wfm-wc-editor-content",
"#wfm-meta-prompt-full",
].join(",\n ");
export function applyTextareaFontSize(size) {
const px = Math.max(10, Math.min(28, parseInt(size) || TEXTAREA_FONT_SIZE_DEFAULT));
let el = document.getElementById(TEXTAREA_FONT_SIZE_STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = TEXTAREA_FONT_SIZE_STYLE_ID;
document.head.appendChild(el);
}
el.textContent = `${TEXTAREA_FONT_SIZE_TARGETS} { font-size: ${px}px !important; }`;
}
// JSON highlight color settings
const JSON_COLOR_STYLE_ID = "wfm-json-color-style";
const JSON_COLOR_DEFS = [
{ id: "base", label: () => t("jsonColorBase") || "通常テキスト", def: "#abb2bf" },
{ id: "yellow", label: () => t("jsonColorYellow") || "名前 / スケジューラ", def: "#e5c07b" },
{ id: "pink", label: () => t("jsonColorPink") || "タイトル", def: "#c678dd" },
{ id: "green", label: () => t("jsonColorGreen") || "Width / Height", def: "#98c379" },
{ id: "cyan", label: () => t("jsonColorCyan") || "プロンプト / テキスト", def: "#61afef" },
{ id: "red", label: () => t("jsonColorRed") || "画像 / ファイル", def: "#e06c75" },
];
export function applyJsonColors(colors) {
const vals = Object.fromEntries(JSON_COLOR_DEFS.map(d => [d.id, colors?.[d.id] || d.def]));
let el = document.getElementById(JSON_COLOR_STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = JSON_COLOR_STYLE_ID;
document.head.appendChild(el);
}
el.textContent = [
`.wfm-json-highlight { color: ${vals.base}; }`,
`.json-key-yellow, .json-val-yellow { color: ${vals.yellow}; }`,
`.json-key-pink, .json-val-pink { color: ${vals.pink}; }`,
`.json-key-green, .json-val-green { color: ${vals.green}; }`,
`.json-key-cyan, .json-val-cyan { color: ${vals.cyan}; }`,
`.json-key-red, .json-val-red { color: ${vals.red}; }`,
].join("\n");
}
export function applyTheme(themeId) {
if (themeId) {
document.documentElement.setAttribute("data-theme", themeId);
@@ -339,6 +391,11 @@ export async function initSettingsTab() {
const settings = loadLocalSettings();
const serverSettings = await loadServerSettings();
// Sync civitai_host to localStorage so other tabs can read it without an extra fetch
if (serverSettings.civitai_host) {
localStorage.setItem("wfm_civitai_host", serverSettings.civitai_host);
}
// Load current workflows dir info
let workflowsDirInfo = { current: "", default: "" };
try {
@@ -485,6 +542,41 @@ export async function initSettingsTab() {
</div>
</details>
<!-- Text Size -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("textSizeLabel") || "Text Size"}</summary>
<div class="wfm-form-group">
<label style="display:flex;align-items:center;justify-content:space-between;">
<span>${t("textSizeTextarea") || "Prompt / Chat Textarea"}</span>
<span id="wfm-ta-font-size-val" style="font-size:12px;color:var(--wfm-text-secondary);">${settings.textareaFontSize || TEXTAREA_FONT_SIZE_DEFAULT}px</span>
</label>
<input type="range" class="wfm-range" id="wfm-ta-font-size"
min="10" max="28" step="1"
value="${settings.textareaFontSize || TEXTAREA_FONT_SIZE_DEFAULT}"
style="width:100%;margin-top:4px;">
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("textSizeHint") || "Applies to: Generate UI prompts, AI Assistant chat, Preset prompts, Wildcard textarea, Metadata prompt preview"}
</small>
</div>
</details>
<!-- RAW JSON Colors -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("jsonColorsSection") || "RAW JSON Colors"}</summary>
<div class="wfm-form-group">
<div class="wfm-color-grid" id="wfm-json-color-grid">
${JSON_COLOR_DEFS.map(d => {
const saved = settings.jsonColors?.[d.id] || d.def;
return `<div class="wfm-color-item">
<label>${d.label()}</label>
<input type="color" data-json-color="${d.id}" value="${saved}">
</div>`;
}).join("")}
</div>
<button class="wfm-btn wfm-btn-sm" id="wfm-json-color-reset" style="margin-top:8px;">${t("jsonColorReset") || "デフォルトに戻す"}</button>
</div>
</details>
<!-- Workflow Data Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
@@ -545,28 +637,68 @@ export async function initSettingsTab() {
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
</details>
<!-- Ollama Settings -->
<!-- AI Assistant Settings (Prompt Tab) -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
<div class="wfm-form-group">
<label>${t("aiSettingsBackend")}</label>
<div class="wfm-ai-backend-row">
<label class="wfm-ai-radio-label">
<input type="radio" name="wfm-prompt-ai-backend" value="ollama"> Ollama
</label>
<label class="wfm-ai-radio-label">
<input type="radio" name="wfm-prompt-ai-backend" value="lmstudio"> LM Studio
</label>
</div>
</div>
<div class="wfm-form-group">
<label>${t("ollamaUrl")}</label>
<input type="text" class="wfm-input" id="wfm-settings-ollama-url"
value="${serverSettings.ollama_url || "http://localhost:11434"}"
<input type="text" class="wfm-input" id="wfm-settings-prompt-ai-url"
placeholder="http://localhost:11434">
</div>
<div class="wfm-form-group">
<label>${t("ollamaDefaultModel")}</label>
<div style="display:flex;gap:8px;">
<select class="wfm-select" id="wfm-settings-ollama-model" style="flex:1;">
<select class="wfm-select" id="wfm-settings-prompt-ai-model" style="flex:1;">
<option value="">${t("selectModel")}</option>
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-refresh">${t("refresh")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-refresh">${t("refresh")}</button>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-ollama-save">${t("saveOllama")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-test">${t("testConnection")}</button>
<span id="wfm-settings-ollama-status" style="font-size:12px;line-height:28px;"></span>
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-prompt-ai-save">${t("saveOllama")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-test">${t("testConnection")}</button>
<span id="wfm-settings-prompt-ai-status" style="font-size:12px;line-height:28px;"></span>
</div>
</details>
<!-- CivitAI Settings -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("civitaiApiKeySetting")}</summary>
<div class="wfm-form-group">
<label style="display:flex;align-items:center;gap:4px;">
${t("civitaiHostSetting")}
<span title="${t("civitaiHostHint")}" style="cursor:help;color:var(--wfm-text-secondary);font-size:13px;">ℹ</span>
</label>
<select class="wfm-select" id="wfm-settings-civitai-host" style="width:100%;">
<option value="civitai.com" ${(serverSettings.civitai_host || "civitai.com") === "civitai.com" ? "selected" : ""}>${t("civitaiHostCom")}</option>
<option value="civitai.red" ${serverSettings.civitai_host === "civitai.red" ? "selected" : ""}>${t("civitaiHostRed")}</option>
</select>
</div>
<div class="wfm-form-group">
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-bottom:8px;">
${t("civitaiApiKeyHint")}
</small>
<div style="display:flex;gap:8px;">
<input type="password" class="wfm-input" id="wfm-settings-civitai-api-key"
value="${serverSettings.civitai_api_key || ""}"
placeholder="${t("civitaiApiKeyPlaceholder")}"
style="flex:1;">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-civitai-api-key-save">
${t("civitaiApiKeySave")}
</button>
</div>
<div id="wfm-settings-civitai-api-key-status" style="font-size:12px;margin-top:4px;"></div>
</div>
</details>
@@ -606,6 +738,44 @@ export async function initSettingsTab() {
</div>
</details>
<!-- G'MIC Integration -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">G'MIC-Qt Integration</summary>
<div class="wfm-form-group">
<label>G'MIC-Qt Executable Path</label>
<div style="display:flex;gap:8px;">
<input type="text" class="wfm-input" id="wfm-settings-gmic-path"
value="${serverSettings.gmic_qt_path || ""}"
placeholder="C:\\path\\to\\gmic_qt.exe"
style="flex:1;">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-gmic-path-save">
Save
</button>
</div>
<div id="wfm-settings-gmic-path-status" style="font-size:12px;margin-top:4px;"></div>
</div>
</details>
<!-- Data Management -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
<p style="font-size:12px;color:var(--wfm-text-secondary);margin-bottom:12px;">${t("dataManagementHint")}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export">${t("exportData")}</button>
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
${t("importData")}
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
</label>
</div>
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
</details>
<!-- Wildcard Integration -->
<details class="wfm-settings-section" id="wfm-wildcard-integration-section">
<summary class="wfm-settings-summary">Wildcard Integration</summary>
<div id="wfm-wildcard-link-status" style="font-size:13px;color:var(--wfm-text-secondary);">Loading...</div>
</details>
<!-- Save Button -->
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
@@ -816,7 +986,7 @@ export async function initSettingsTab() {
setLang(lang);
saveLocalSettings({ ...loadLocalSettings(), uiLang: lang });
// Re-render the entire page to apply new language
showToast("Language changed. Reloading...", "success");
showToast(t("languageChanged"), "success");
setTimeout(() => location.reload(), 500);
});
@@ -912,56 +1082,122 @@ export async function initSettingsTab() {
}
});
// --- Ollama model list loader ---
async function loadOllamaModels() {
const select = document.getElementById("wfm-settings-ollama-model");
// --- AI Assistant (Prompt Tab) settings ---
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
const loadPromptAiCfg = () => {
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
};
const savePromptAiCfg = (patch) => {
const data = { ...loadPromptAiCfg(), ...patch };
localStorage.setItem(PROMPT_AI_KEY, JSON.stringify(data));
};
// Restore saved values into UI
const promptAiSaved = loadPromptAiCfg();
const promptAiBackendSaved = promptAiSaved.backend || "ollama";
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
r.checked = r.value === promptAiBackendSaved;
});
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
if (promptAiUrlEl) {
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
promptAiUrlEl.placeholder = promptAiBackendSaved === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
}
// Backend change → update URL placeholder
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
r.addEventListener("change", () => {
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
if (promptAiUrlEl) promptAiUrlEl.placeholder = backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
});
});
// Load models directly from the selected backend
async function loadPromptAiModels() {
const select = document.getElementById("wfm-settings-prompt-ai-model");
if (!select) return;
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
const url = (promptAiUrlEl?.value.trim() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
try {
const res = await fetch("/api/wfm/ollama/models");
const data = await res.json();
const models = data.models || [];
const savedModel = serverSettings.ollama_model || "llava";
let models = [];
if (backend === "ollama") {
const res = await fetch(`${url}/api/tags`);
models = (await res.json()).models?.map(m => m.name) || [];
} else {
const res = await fetch(`${url}/v1/models`);
models = ((await res.json()).data || []).map(m => m.id);
}
const saved = loadPromptAiCfg().model || "";
select.innerHTML = models.length
? models.map((m) => `<option value="${m.name}" ${m.name === savedModel ? "selected" : ""}>${m.name}</option>`).join("")
? models.map(name => `<option value="${name}" ${name === saved ? "selected" : ""}>${name}</option>`).join("")
: `<option value="">${t("noModelsFound")}</option>`;
} catch {
select.innerHTML = `<option value="">${t("failedLoadModels")}</option>`;
}
}
await loadOllamaModels();
await loadPromptAiModels();
// Refresh Ollama models
document.getElementById("wfm-settings-ollama-refresh")?.addEventListener("click", loadOllamaModels);
document.getElementById("wfm-settings-prompt-ai-refresh")?.addEventListener("click", loadPromptAiModels);
// Test Ollama connection
document.getElementById("wfm-settings-ollama-test")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-ollama-status");
statusEl.textContent = "Testing...";
statusEl.style.color = "var(--wfm-text-secondary)";
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
const url = (promptAiUrlEl?.value.trim() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
try {
const res = await fetch("/api/wfm/ollama/test", { method: "POST" });
const data = await res.json();
statusEl.textContent = data.connected ? t("ollamaConnected") : `${t("ollamaFailed")}: ${data.message}`;
statusEl.style.color = data.connected ? "var(--wfm-success)" : "var(--wfm-danger)";
let count = 0;
if (backend === "ollama") {
const res = await fetch(`${url}/api/tags`);
count = ((await res.json()).models || []).length;
} else {
const res = await fetch(`${url}/v1/models`);
count = ((await res.json()).data || []).length;
}
if (statusEl) { statusEl.textContent = `${t("ollamaConnected")} (${count} models)`; statusEl.style.color = "var(--wfm-success)"; }
await loadPromptAiModels();
} catch (err) {
statusEl.textContent = `${t("error")}: ${err.message}`;
statusEl.style.color = "var(--wfm-danger)";
if (statusEl) { statusEl.textContent = `${t("ollamaFailed")}: ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
}
});
// Save Ollama settings (to server)
document.getElementById("wfm-settings-ollama-save")?.addEventListener("click", async () => {
const ollamaUrl = document.getElementById("wfm-settings-ollama-url")?.value.trim() || "http://localhost:11434";
const ollamaModel = document.getElementById("wfm-settings-ollama-model")?.value || "";
document.getElementById("wfm-settings-prompt-ai-save")?.addEventListener("click", () => {
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
const url = promptAiUrlEl?.value.trim() || "";
const model = document.getElementById("wfm-settings-prompt-ai-model")?.value || "";
savePromptAiCfg({ backend, backendUrl: url, model });
showToast(t("ollamaSaved"), "success");
});
// CivitAI host select
document.getElementById("wfm-settings-civitai-host")?.addEventListener("change", async (e) => {
const host = e.target.value;
try {
await saveServerSettings({ ollama_url: ollamaUrl, ollama_model: ollamaModel });
serverSettings.ollama_url = ollamaUrl;
serverSettings.ollama_model = ollamaModel;
showToast(t("ollamaSaved"), "success");
await saveServerSettings({ civitai_host: host });
serverSettings.civitai_host = host;
localStorage.setItem("wfm_civitai_host", host);
showToast(t("civitaiHostSaved"), "success");
} catch (err) {
showToast(`${t("saveError")}: ${err.message}`, "error");
}
});
// Save CivitAI API key
document.getElementById("wfm-settings-civitai-api-key-save")?.addEventListener("click", async () => {
const apiKey = document.getElementById("wfm-settings-civitai-api-key")?.value.trim() || "";
const statusEl = document.getElementById("wfm-settings-civitai-api-key-status");
try {
await saveServerSettings({ civitai_api_key: apiKey });
serverSettings.civitai_api_key = apiKey;
showToast(t("civitaiApiKeySaved"), "success");
if (statusEl) { statusEl.textContent = ""; }
} catch (err) {
if (statusEl) {
statusEl.textContent = `${t("saveError")}: ${err.message}`;
statusEl.style.color = "var(--wfm-danger)";
}
}
});
// Test ComfyUI URL
document.getElementById("wfm-settings-test-url")?.addEventListener("click", async () => {
const urlInput = document.getElementById("wfm-settings-comfyui-url");
@@ -1002,7 +1238,7 @@ export async function initSettingsTab() {
// Clear default workflow
document.getElementById("wfm-settings-clear-wf")?.addEventListener("click", () => {
const s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
const s = getSettings();
delete s.defaultWorkflow;
delete s.defaultWorkflowData;
localStorage.setItem("wfm_settings", JSON.stringify(s));
@@ -1012,8 +1248,38 @@ export async function initSettingsTab() {
showToast(t("defaultWorkflowCleared"), "success");
});
// --- Textarea font size slider ---
document.getElementById("wfm-ta-font-size")?.addEventListener("input", (e) => {
const px = parseInt(e.target.value);
const valEl = document.getElementById("wfm-ta-font-size-val");
if (valEl) valEl.textContent = `${px}px`;
applyTextareaFontSize(px);
});
// --- JSON highlight color pickers ---
document.getElementById("wfm-json-color-grid")?.addEventListener("input", (e) => {
if (e.target.type !== "color") return;
const id = e.target.dataset.jsonColor;
const jsonColors = { ...(loadLocalSettings().jsonColors || {}), [id]: e.target.value };
saveLocalSettings({ jsonColors });
applyJsonColors(jsonColors);
});
document.getElementById("wfm-json-color-reset")?.addEventListener("click", () => {
const cur = loadLocalSettings();
delete cur.jsonColors;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyJsonColors(undefined);
document.querySelectorAll("#wfm-json-color-grid input[type=color]").forEach(inp => {
const def = JSON_COLOR_DEFS.find(d => d.id === inp.dataset.jsonColor);
if (def) inp.value = def.def;
});
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;
const patch = {
comfyuiUrl: document.getElementById("wfm-settings-comfyui-url")?.value.trim() || "",
eagleUrl: document.getElementById("wfm-settings-eagle-url")?.value.trim() || "http://localhost:41595",
@@ -1021,6 +1287,7 @@ export async function initSettingsTab() {
uiLang: getLang(),
summaryLang: getSummaryLang(),
theme: document.documentElement.getAttribute("data-theme") || "",
textareaFontSize: taFontSize,
};
saveLocalSettings(patch);
@@ -1036,4 +1303,158 @@ export async function initSettingsTab() {
if (savedUrl) {
comfyUI.updateUrl(savedUrl);
}
// --- Wildcard Integration ---
async function wcLoadLinkStatus() {
const el = document.getElementById("wfm-wildcard-link-status");
if (!el) return;
try {
const res = await fetch("/api/wfm/wildcards/link-status");
const data = await res.json();
wcRenderLinkStatus(el, data);
} catch (e) {
el.innerHTML = `<span style="color:var(--wfm-error,#f44336)">Error: ${e.message}</span>`;
}
}
function wcRenderLinkStatus(el, data) {
if (!data.impact_pack_installed) {
el.innerHTML = `
<p style="margin:0 0 8px;">ComfyUI-Impact-Pack がインストールされていません。</p>
<p style="margin:0;font-size:12px;color:var(--wfm-text-secondary);">
ワイルドカードを Impact Pack と共有するには、先に
<a href="https://github.com/ltdrdata/ComfyUI-Impact-Pack" target="_blank" rel="noopener"
style="color:var(--wfm-accent);">ComfyUI-Impact-Pack</a>
をインストールしてください。
</p>`;
return;
}
if (data.is_linked) {
el.innerHTML = `
<p style="margin:0 0 4px;color:var(--wfm-success,#4caf50);">✓ リンク済み</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
WFS wildcard → ${data.link_target || data.impact_pack_wildcards_dir}
</p>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-wc-remove-link">リンクを解除</button>
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
document.getElementById("wfm-wc-remove-link")?.addEventListener("click", async () => {
const msg = document.getElementById("wfm-wc-link-msg");
try {
const r = await fetch("/api/wfm/wildcards/remove-link", { method: "POST" });
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.statusText);
msg.textContent = "リンクを解除しました。";
msg.style.color = "var(--wfm-success,#4caf50)";
setTimeout(wcLoadLinkStatus, 800);
} catch (e) {
msg.textContent = "エラー: " + e.message;
msg.style.color = "var(--wfm-error,#f44336)";
}
});
} else {
el.innerHTML = `
<p style="margin:0 0 4px;">Impact Pack wildcards ディレクトリ:</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">${data.impact_pack_wildcards_dir}</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
WFS の wildcard ディレクトリを Impact Pack の wildcards ディレクトリへのリンクに置き換えます。
既存の WFS ワイルドカードファイルは Impact Pack ディレクトリへ移行されます。
</p>
<button class="wfm-btn wfm-btn-sm" id="wfm-wc-create-link">リンクを作成</button>
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
document.getElementById("wfm-wc-create-link")?.addEventListener("click", async () => {
const msg = document.getElementById("wfm-wc-link-msg");
try {
const r = await fetch("/api/wfm/wildcards/create-link", { method: "POST" });
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.statusText);
const migrated = d.migrated_files?.length
? ` (移行: ${d.migrated_files.join(", ")})`
: "";
msg.textContent = "リンクを作成しました。" + migrated;
msg.style.color = "var(--wfm-success,#4caf50)";
setTimeout(wcLoadLinkStatus, 800);
} catch (e) {
msg.textContent = "エラー: " + e.message;
msg.style.color = "var(--wfm-error,#f44336)";
}
});
}
}
// Load wildcard link status when section is opened
document.getElementById("wfm-wildcard-integration-section")?.addEventListener("toggle", (e) => {
if (e.target.open) wcLoadLinkStatus();
});
// --- Data Export ---
document.getElementById("wfm-settings-export")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-data-status");
try {
const res = await fetch("/api/wfm/settings/export");
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-data-export.json";
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)";
}
});
// --- Data Import ---
document.getElementById("wfm-settings-import-file")?.addEventListener("change", async (e) => {
const statusEl = document.getElementById("wfm-settings-data-status");
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const bundle = JSON.parse(text);
const res = await fetch("/api/wfm/settings/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bundle),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || res.statusText);
const imported = result.imported?.join(", ") || "-";
statusEl.textContent = t("importSuccess") + ": " + imported;
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
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 = "";
});
// Save G'MIC path setting
document.getElementById("wfm-settings-gmic-path-save")?.addEventListener("click", async () => {
const path = document.getElementById("wfm-settings-gmic-path")?.value.trim() || "";
const statusEl = document.getElementById("wfm-settings-gmic-path-status");
if (statusEl) {
statusEl.textContent = "Saving...";
statusEl.style.color = "var(--wfm-text-secondary)";
}
try {
await saveServerSettings({ gmic_qt_path: path });
serverSettings.gmic_qt_path = path;
showToast("G'MIC path saved successfully", "success");
if (statusEl) {
statusEl.textContent = "Saved successfully!";
statusEl.style.color = "var(--wfm-success)";
}
} catch (err) {
if (statusEl) {
statusEl.textContent = "Error: " + err.message;
statusEl.style.color = "var(--wfm-danger)";
}
}
});
}
+667
View File
@@ -0,0 +1,667 @@
/**
* Tagger Tab
* Handles WD Tagger / DeepDanbooru + Ollama VLM single/batch tagging.
*/
import { t } from "./i18n.js";
import { showToast } from "./app.js";
// ── 状態 ────────────────────────────────────────────────────────
const state = {
/** @type {string|null} 現在の画像 base64 (data URI prefix を除く) */
imageB64: null,
/** @type {string} 元のファイルパス (Galleryから来た場合) */
imagePath: "",
/** @type {string} 表示名 */
imageName: "",
/** @type {NodeJS.Timeout|null} バッチポーリングタイマー */
batchPollTimer: null,
/** @type {number|null} DB編集中の行ID */
dbEditId: null,
};
// ── 初期化 ──────────────────────────────────────────────────────
export function initTaggerTab() {
_applyI18n();
_setupSubtabs();
_setupSliders();
_setupOllamaToggle();
_setupSingleActions();
_setupBatchActions();
_setupDbActions();
_loadModels();
_loadSettings();
}
/** GalleryタブからTaggerタブへ画像を渡す */
export function openImageInTaggerTab(img) {
// タブを切り替え
document.querySelectorAll(".wfm-tab").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".wfm-tab-content").forEach(s => s.classList.remove("active"));
const btn = document.querySelector('.wfm-tab[data-tab="tagger"]');
const sec = document.getElementById("wfm-tab-tagger");
if (btn) btn.classList.add("active");
if (sec) sec.classList.add("active");
// 画像を読み込む (Gallery APIから取得)
if (!img?.path) return;
const url = `/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`;
fetch(url)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then(blob => {
const reader = new FileReader();
reader.onload = e => {
_setPreviewFromDataUrl(e.target.result, img.filename || img.path.split(/[\\/]/).pop());
state.imagePath = img.path;
};
reader.readAsDataURL(blob);
})
.catch(() => showToast(t("taggerNoImage"), "error"));
}
// ── i18n ─────────────────────────────────────────────────────────
function _applyI18n() {
const map = {
"wfm-tagger-from-gallery-btn": "taggerFromGallery",
"wfm-tagger-upload-label": "taggerUpload",
"wfm-tagger-threshold-label": "taggerThreshold",
"wfm-tagger-char-threshold-label": "taggerCharThreshold",
"wfm-tagger-generate-btn": "taggerGenerateTags",
"wfm-tagger-ollama-header-label": "taggerOllamaEnable",
"wfm-tagger-ollama-url-label": "taggerOllamaUrl",
"wfm-tagger-ollama-model-label": "taggerOllamaModel",
"wfm-tagger-ollama-prompt-label": "taggerOllamaPrompt",
"wfm-tagger-ollama-max-label": "taggerOllamaMaxTags",
"wfm-tagger-result-header": "taggerResult",
"wfm-tagger-send-genui-btn": "taggerSendToGenUI",
"wfm-tagger-send-prompt-btn": "taggerSendToPrompt",
"wfm-tagger-save-gallery-btn": "taggerSaveToGallery",
"wfm-tagger-write-file-btn": "taggerWriteToFile",
"wfm-tagger-save-db-btn": "taggerSaveToDB",
"wfm-tagger-batch-folder-header": "taggerBatchFolder",
"wfm-tagger-batch-threshold-label": "taggerThreshold",
"wfm-tagger-batch-char-threshold-label": "taggerCharThreshold",
"wfm-tagger-batch-url-label": "taggerOllamaUrl",
"wfm-tagger-batch-model-label": "taggerOllamaModel",
"wfm-tagger-batch-prompt-label": "taggerOllamaPrompt",
"wfm-tagger-batch-output-header": "taggerBatchOutput",
"wfm-tagger-batch-save-db-label": "taggerSaveToDB",
"wfm-tagger-batch-write-file-label": "taggerWriteToFile",
"wfm-tagger-batch-write-txt-label": "taggerBatchWriteTxt",
"wfm-tagger-batch-start-btn": "taggerBatchStart",
"wfm-tagger-batch-stop-btn": "taggerBatchStop",
"wfm-tagger-db-search-btn": "taggerDbSearch",
"wfm-tagger-db-show-all-btn": "taggerDbShowAll",
"wfm-tagger-db-export-btn": "taggerDbExport",
"wfm-tagger-db-th-file": "taggerDbFile",
"wfm-tagger-db-th-interrogator": "taggerDbInterrogator",
"wfm-tagger-db-th-vlm": "taggerDbVlm",
"wfm-tagger-db-th-date": "taggerDbDate",
"wfm-tagger-db-edit-interr-label": "taggerDbInterrogator",
"wfm-tagger-db-edit-vlm-label": "taggerDbVlm",
"wfm-tagger-db-save-btn": "taggerDbSave",
"wfm-tagger-db-delete-btn": "taggerDbDelete",
};
for (const [id, key] of Object.entries(map)) {
const el = document.getElementById(id);
if (el) el.textContent = t(key);
}
// サブタブボタン
const subtabKeys = ["taggerSingleTab", "taggerBatchTab", "taggerDbTab"];
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach((btn, i) => {
btn.textContent = t(subtabKeys[i] || "");
});
// プレースホルダー
const ph = document.getElementById("wfm-tagger-preview-placeholder");
if (ph) ph.textContent = t("taggerNoImage");
}
// ── サブタブ切り替え ─────────────────────────────────────────────
function _setupSubtabs() {
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
const tab = btn.dataset.taggerTab;
document.querySelectorAll(".wfm-tagger-subtab").forEach(s => {
s.style.display = s.id === `wfm-tagger-${tab}` ? "" : "none";
});
if (tab === "db") _dbLoad();
});
});
}
// ── スライダー ───────────────────────────────────────────────────
function _setupSliders() {
[
["wfm-tagger-threshold", "wfm-tagger-threshold-val"],
["wfm-tagger-char-threshold", "wfm-tagger-char-threshold-val"],
["wfm-tagger-batch-threshold", "wfm-tagger-batch-threshold-val"],
["wfm-tagger-batch-char-threshold", "wfm-tagger-batch-char-threshold-val"],
].forEach(([sliderId, valId]) => {
const slider = document.getElementById(sliderId);
const val = document.getElementById(valId);
if (!slider || !val) return;
val.textContent = slider.value;
slider.addEventListener("input", () => { val.textContent = slider.value; });
});
}
// ── Ollama トグル ────────────────────────────────────────────────
function _setupOllamaToggle() {
const chk = document.getElementById("wfm-tagger-ollama-enable");
const wrap = document.getElementById("wfm-tagger-ollama-settings");
if (chk && wrap) {
chk.addEventListener("change", () => {
wrap.style.display = chk.checked ? "" : "none";
});
wrap.style.display = chk.checked ? "" : "none";
}
}
// ── モデル一覧ロード ─────────────────────────────────────────────
async function _loadModels() {
try {
const res = await fetch("/wfm/tagger/models");
const data = await res.json();
const models = data.models || [];
["wfm-tagger-model", "wfm-tagger-batch-model"].forEach(selId => {
const sel = document.getElementById(selId);
if (!sel) return;
sel.innerHTML = `<option value="">${t("taggerNoModel")}</option>` +
models.map(m => `<option value="${m.name}">[${m.type}] ${m.name}</option>`).join("");
});
} catch (e) {
console.warn("tagger: _loadModels error", e);
}
}
// ── Ollamaモデル一覧ロード ───────────────────────────────────────
async function _loadOllamaModels(urlId, selId) {
const urlEl = document.getElementById(urlId);
const selEl = document.getElementById(selId);
if (!urlEl || !selEl) return;
const api = urlEl.value || "http://127.0.0.1:11434";
try {
const res = await fetch(`/wfm/tagger/ollama/models?api_url=${encodeURIComponent(api)}`);
const data = await res.json();
const models = data.models || [];
if (models.length === 0) {
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
} else {
selEl.innerHTML = models.map(m => `<option value="${m}">${m}</option>`).join("");
}
} catch {
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
}
}
// ── 設定の読み込み/保存 ─────────────────────────────────────────
async function _loadSettings() {
try {
const res = await fetch("/wfm/tagger/settings");
const data = await res.json();
if (data.ollama_url) {
const el = document.getElementById("wfm-tagger-ollama-url");
if (el) el.value = data.ollama_url;
}
if (data.batch_ollama_url) {
const el = document.getElementById("wfm-tagger-batch-ollama-url");
if (el) el.value = data.batch_ollama_url;
}
} catch {
// 初回は無視
}
}
async function _saveSettings() {
try {
await fetch("/wfm/tagger/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ollama_url: document.getElementById("wfm-tagger-ollama-url")?.value || "",
batch_ollama_url: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "",
}),
});
} catch {
// サイレントに失敗
}
}
// ── Single サブタブ ─────────────────────────────────────────────
function _setupSingleActions() {
// ファイルアップロード
document.getElementById("wfm-tagger-upload")?.addEventListener("change", e => {
const file = e.target.files?.[0];
if (!file) return;
_loadImageFile(file);
e.target.value = "";
});
// ドラッグ&ドロップ
const dropZone = document.getElementById("wfm-tagger-drop-zone");
if (dropZone) {
dropZone.addEventListener("dragover", e => {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", e => {
if (!dropZone.contains(e.relatedTarget)) {
dropZone.classList.remove("drag-over");
}
});
dropZone.addEventListener("drop", e => {
e.preventDefault();
dropZone.classList.remove("drag-over");
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith("image/")) {
_loadImageFile(file);
}
});
}
// タグ生成
document.getElementById("wfm-tagger-generate-btn")?.addEventListener("click", _generateTags);
// Ollamaリフレッシュ
document.getElementById("wfm-tagger-ollama-refresh-btn")?.addEventListener("click", () => {
_loadOllamaModels("wfm-tagger-ollama-url", "wfm-tagger-ollama-model");
});
// 結果アクション
document.getElementById("wfm-tagger-send-genui-btn")?.addEventListener("click", _sendToGenUI);
document.getElementById("wfm-tagger-send-prompt-btn")?.addEventListener("click", _sendToPrompt);
document.getElementById("wfm-tagger-save-gallery-btn")?.addEventListener("click", _saveToGallery);
document.getElementById("wfm-tagger-write-file-btn")?.addEventListener("click", _writeToFile);
document.getElementById("wfm-tagger-save-db-btn")?.addEventListener("click", _saveToDb);
}
function _loadImageFile(file) {
const reader = new FileReader();
reader.onload = e => {
_setPreviewFromDataUrl(e.target.result, file.name);
state.imagePath = "";
};
reader.readAsDataURL(file);
}
function _setPreviewFromDataUrl(dataUrl, name) {
state.imageB64 = dataUrl.replace(/^data:[^;]+;base64,/, "");
state.imageName = name;
const img = document.getElementById("wfm-tagger-preview");
const ph = document.getElementById("wfm-tagger-preview-placeholder");
const nameEl = document.getElementById("wfm-tagger-image-name");
if (img) { img.src = dataUrl; img.style.display = ""; }
if (ph) ph.style.display = "none";
if (nameEl) nameEl.textContent = name;
}
async function _generateTags() {
if (!state.imageB64) {
showToast(t("taggerNoImage"), "warning");
return;
}
const model = document.getElementById("wfm-tagger-model")?.value || "";
const threshold = parseFloat(document.getElementById("wfm-tagger-threshold")?.value || "0.35");
const charThreshold = parseFloat(document.getElementById("wfm-tagger-char-threshold")?.value || "0.85");
const useOllama = document.getElementById("wfm-tagger-ollama-enable")?.checked || false;
const ollamaUrl = document.getElementById("wfm-tagger-ollama-url")?.value || "http://127.0.0.1:11434";
const ollamaModel = document.getElementById("wfm-tagger-ollama-model")?.value || "";
const ollamaPrompt = document.getElementById("wfm-tagger-ollama-prompt")?.value || "";
const maxTags = parseInt(document.getElementById("wfm-tagger-ollama-max-tags")?.value || "40");
const generateBtn = document.getElementById("wfm-tagger-generate-btn");
if (generateBtn) { generateBtn.disabled = true; generateBtn.textContent = "..."; }
try {
let tags = "";
if (model) {
const res = await fetch("/wfm/tagger/predict", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_b64: state.imageB64, model, threshold, char_threshold: charThreshold }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
tags = data.tags || "";
}
if (useOllama && ollamaModel) {
const res2 = await fetch("/wfm/tagger/ollama/predict", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_b64: state.imageB64, api_url: ollamaUrl,
model: ollamaModel, prompt: ollamaPrompt, max_tags: maxTags,
}),
});
const data2 = await res2.json();
if (data2.error) throw new Error(data2.error);
const vlm = data2.tags || "";
tags = [tags, vlm].filter(Boolean).join(", ");
}
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
document.getElementById("wfm-tagger-result").value = tags;
showToast(t("taggerDone"), "success");
_saveSettings();
} catch (e) {
showToast(e.message, "error");
} finally {
if (generateBtn) { generateBtn.disabled = false; generateBtn.textContent = t("taggerGenerateTags"); }
}
}
function _sendToGenUI() {
const tags = document.getElementById("wfm-tagger-result")?.value || "";
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
const ta = document.getElementById("wfm-prompt-pos-text");
if (!ta) { showToast(t("taggerNoGenUI"), "warning"); return; }
const cur = ta.value;
ta.value = cur ? `${cur}, ${tags}` : tags;
ta.dispatchEvent(new Event("input", { bubbles: true }));
document.getElementById("wfm-prompt-pos-apply")?.click();
showToast(t("taggerSentToGenUI"), "success");
}
function _sendToPrompt() {
const tags = document.getElementById("wfm-tagger-result")?.value || "";
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
const ta = document.getElementById("wfm-preset-pos");
if (!ta) { showToast(t("taggerNoGalleryImage"), "warning"); return; }
const cur = ta.value;
ta.value = cur ? `${cur}, ${tags}` : tags;
ta.dispatchEvent(new Event("input", { bubbles: true }));
showToast(t("taggerSentToPrompt"), "success");
}
async function _saveToGallery() {
const tags = document.getElementById("wfm-tagger-result")?.value || "";
if (!tags || !state.imagePath) {
showToast(t("taggerNoGalleryImage"), "warning");
return;
}
try {
const tagsArray = tags.split(",").map(s => s.trim()).filter(Boolean);
const res = await fetch("/wfm/gallery/image/meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.imagePath, tags: tagsArray }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(t("taggerSavedToGallery"), "success");
} catch (e) {
showToast(e.message, "error");
}
}
async function _writeToFile() {
const tags = document.getElementById("wfm-tagger-result")?.value || "";
if (!tags || !state.imagePath) {
showToast(t("taggerNoGalleryImage"), "warning");
return;
}
try {
const res = await fetch("/wfm/tagger/write_meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.imagePath, tags }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(t("taggerWrittenToFile"), "success");
} catch (e) {
showToast(e.message, "error");
}
}
async function _saveToDb() {
const tags = document.getElementById("wfm-tagger-result")?.value || "";
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
try {
const res = await fetch("/wfm/tagger/db/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: state.imageName, interrogator_tags: tags, vlm_tags: "" }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(t("taggerSavedToDB"), "success");
} catch (e) {
showToast(e.message, "error");
}
}
// ── Batch サブタブ ───────────────────────────────────────────────
function _setupBatchActions() {
document.getElementById("wfm-tagger-batch-ollama-refresh-btn")?.addEventListener("click", () => {
_loadOllamaModels("wfm-tagger-batch-ollama-url", "wfm-tagger-batch-ollama-model");
});
document.getElementById("wfm-tagger-batch-start-btn")?.addEventListener("click", _batchStart);
document.getElementById("wfm-tagger-batch-stop-btn")?.addEventListener("click", _batchStop);
}
async function _batchStart() {
const folder = document.getElementById("wfm-tagger-batch-folder")?.value || "";
if (!folder) { showToast(t("taggerBatchFolder"), "warning"); return; }
const body = {
folder,
model: document.getElementById("wfm-tagger-batch-model")?.value || "",
threshold: parseFloat(document.getElementById("wfm-tagger-batch-threshold")?.value || "0.35"),
char_threshold: parseFloat(document.getElementById("wfm-tagger-batch-char-threshold")?.value || "0.85"),
use_ollama: document.getElementById("wfm-tagger-batch-ollama-enable")?.checked || false,
ollama_api: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "http://127.0.0.1:11434",
ollama_model: document.getElementById("wfm-tagger-batch-ollama-model")?.value || "",
ollama_prompt: document.getElementById("wfm-tagger-batch-ollama-prompt")?.value || "",
save_db: document.getElementById("wfm-tagger-batch-save-db")?.checked ?? true,
write_file: document.getElementById("wfm-tagger-batch-write-file")?.checked || false,
write_txt: document.getElementById("wfm-tagger-batch-write-txt")?.checked || false,
};
try {
const res = await fetch("/wfm/tagger/batch/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
document.getElementById("wfm-tagger-batch-start-btn").disabled = true;
document.getElementById("wfm-tagger-batch-stop-btn").disabled = false;
document.getElementById("wfm-tagger-batch-log").value = "";
_startBatchPoll();
} catch (e) {
showToast(e.message, "error");
}
}
async function _batchStop() {
await fetch("/wfm/tagger/batch/stop", { method: "POST" });
}
function _startBatchPoll() {
if (state.batchPollTimer) clearInterval(state.batchPollTimer);
state.batchPollTimer = setInterval(_pollBatch, 1000);
}
async function _pollBatch() {
try {
const res = await fetch("/wfm/tagger/batch/status");
const data = await res.json();
const total = data.total || 0;
const done = data.done || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const bar = document.getElementById("wfm-tagger-batch-progress-bar");
const txt = document.getElementById("wfm-tagger-batch-progress-text");
if (bar) bar.style.width = pct + "%";
if (txt) txt.textContent = `${done} / ${total} (${pct}%)`;
const logEl = document.getElementById("wfm-tagger-batch-log");
if (logEl && data.log?.length) {
logEl.value = data.log.join("\n");
logEl.scrollTop = logEl.scrollHeight;
}
if (!data.running) {
clearInterval(state.batchPollTimer);
state.batchPollTimer = null;
document.getElementById("wfm-tagger-batch-start-btn").disabled = false;
document.getElementById("wfm-tagger-batch-stop-btn").disabled = true;
showToast(t("taggerDone"), "success");
}
} catch {
// 無視
}
}
// ── DB サブタブ ──────────────────────────────────────────────────
function _setupDbActions() {
document.getElementById("wfm-tagger-db-search-btn")?.addEventListener("click", _dbSearch);
document.getElementById("wfm-tagger-db-show-all-btn")?.addEventListener("click", _dbLoad);
document.getElementById("wfm-tagger-db-export-btn")?.addEventListener("click", _dbExport);
document.getElementById("wfm-tagger-db-search-input")?.addEventListener("keydown", e => {
if (e.key === "Enter") _dbSearch();
});
document.getElementById("wfm-tagger-db-save-btn")?.addEventListener("click", _dbSave);
document.getElementById("wfm-tagger-db-delete-btn")?.addEventListener("click", _dbDelete);
}
async function _dbLoad() {
try {
const res = await fetch("/wfm/tagger/db/list?limit=200&offset=0");
const data = await res.json();
_renderDbTable(data.rows || []);
} catch (e) {
showToast(e.message, "error");
}
}
async function _dbSearch() {
const q = document.getElementById("wfm-tagger-db-search-input")?.value || "";
if (!q) { _dbLoad(); return; }
try {
const res = await fetch(`/wfm/tagger/db/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
_renderDbTable(data.rows || []);
} catch (e) {
showToast(e.message, "error");
}
}
function _renderDbTable(rows) {
const tbody = document.getElementById("wfm-tagger-db-tbody");
if (!tbody) return;
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
return;
}
tbody.innerHTML = rows.map(row => `
<tr data-id="${row.id}" title="${_esc(row.all_tags)}">
<td>${row.id}</td>
<td>${_esc(row.filename)}</td>
<td>${_esc(_truncate(row.interrogator_tags, 60))}</td>
<td>${_esc(_truncate(row.vlm_tags, 60))}</td>
<td>${_esc(row.created_at?.split("T")[0] || row.created_at || "")}</td>
</tr>
`).join("");
tbody.querySelectorAll("tr").forEach(tr => {
tr.addEventListener("click", () => {
tbody.querySelectorAll("tr").forEach(r => r.classList.remove("selected"));
tr.classList.add("selected");
const row = rows.find(r => r.id === parseInt(tr.dataset.id));
if (row) _openDbEdit(row);
});
});
}
function _openDbEdit(row) {
state.dbEditId = row.id;
const panel = document.getElementById("wfm-tagger-db-edit-panel");
const header = document.getElementById("wfm-tagger-db-edit-header");
const interrEl = document.getElementById("wfm-tagger-db-edit-interrogator");
const vlmEl = document.getElementById("wfm-tagger-db-edit-vlm");
if (!panel) return;
if (header) header.textContent = `ID: ${row.id} ${row.filename}`;
if (interrEl) interrEl.value = row.interrogator_tags;
if (vlmEl) vlmEl.value = row.vlm_tags;
panel.style.display = "";
}
async function _dbSave() {
if (state.dbEditId == null) return;
const interrogator_tags = document.getElementById("wfm-tagger-db-edit-interrogator")?.value || "";
const vlm_tags = document.getElementById("wfm-tagger-db-edit-vlm")?.value || "";
try {
const res = await fetch(`/wfm/tagger/db/${state.dbEditId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ interrogator_tags, vlm_tags }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(t("taggerDbSaved"), "success");
_dbLoad();
} catch (e) {
showToast(e.message, "error");
}
}
async function _dbDelete() {
if (state.dbEditId == null) return;
if (!confirm(`Delete DB record #${state.dbEditId}?`)) return;
try {
const res = await fetch(`/wfm/tagger/db/${state.dbEditId}`, { method: "DELETE" });
const data = await res.json();
if (data.error) throw new Error(data.error);
document.getElementById("wfm-tagger-db-edit-panel").style.display = "none";
state.dbEditId = null;
showToast(t("taggerDbDeleted"), "success");
_dbLoad();
} catch (e) {
showToast(e.message, "error");
}
}
function _dbExport() {
const a = document.createElement("a");
a.href = "/wfm/tagger/db/export";
a.download = "tagger_tags.csv";
a.click();
}
// ── ユーティリティ ───────────────────────────────────────────────
function _esc(str) {
return String(str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function _truncate(str, len) {
if (!str) return "";
return str.length > len ? str.slice(0, len) + "…" : str;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared utilities
*/
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
export function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;");
}
// localStorage からJSONを安全に読む(不正JSON・未設定時はfallback)
export function readJsonStorage(key, fallback = {}) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
// プラグイン共通設定 (wfm_settings)
export function getSettings() {
return readJsonStorage("wfm_settings");
}
/**
* 検索inputにオーバーレイXボタンを設定する。
* @param {string} inputId - 検索inputのID
* @param {string} clearBtnId - クリアボタンのID
* @param {Function} onClear - クリア時に呼び出すコールバック(inputは既に空になった後で呼ばれる)
*/
export function setupSearchClearBtn(inputId, clearBtnId, onClear) {
const input = document.getElementById(inputId);
const btn = document.getElementById(clearBtnId);
if (!input || !btn) return;
const sync = () => {
btn.style.display = input.value ? "flex" : "none";
};
input.addEventListener("input", sync);
btn.addEventListener("click", () => {
input.value = "";
btn.style.display = "none";
input.focus();
onClear();
});
// 初期値が入っている場合に備えて同期
sync();
}
+155 -48
View File
@@ -10,6 +10,12 @@ import { loadWorkflowIntoEditor } from "./generate-tab.js";
import { t, getSummaryPrompt } from "./i18n.js";
import { highlightJSON } from "./json-highlight.js";
import { openBadgeEditModal } from "./models-tab.js";
import { escapeHtml, getSettings, readJsonStorage, setupSearchClearBtn } from "./util.js";
// ============================================
// Constants
// ============================================
const WF_RESERVED_GROUPS = ["Batch"];
// ============================================
// State
@@ -19,34 +25,48 @@ const state = {
workflows: [],
activeBadge: "ALL",
searchText: "",
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
viewMode: localStorage.getItem("wfm_default_view") === "table" ? "table" : "thumb",
selectedWf: null,
groupFilter: "", // empty = all groups
currentPage: 0,
showBatchOnly: false,
};
function getBadgePalette() {
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
catch { return {}; }
return readJsonStorage("wfm_models_badge_palette");
}
function saveBadgePalette(palette) {
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ============================================
// Open in ComfyUI
// Open in ComfyUI / Send to Canvas
// ============================================
function sendToCanvas(workflowData) {
try {
// window.opener経由でComfyUIキャンバスに直接ロード(推奨)
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
window.opener.wfmReceiveWorkflow(workflowData);
showToast(t("workflowSentToCanvas"), "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");
} catch (err) {
showToast(t("errorWithMsg", err.message), "error");
}
}
function openInComfyUI(workflowData) {
// Open ComfyUI frontend in a new tab and load the workflow.
// Same-origin, so we can access the child window's app object.
@@ -88,10 +108,15 @@ const groups = {
load() {
try {
this._data = JSON.parse(localStorage.getItem("wfm_groups") || "{}");
this._data = readJsonStorage("wfm_groups");
} catch {
this._data = {};
}
let needsSave = false;
for (const g of WF_RESERVED_GROUPS) {
if (!this._data[g]) { this._data[g] = []; needsSave = true; }
}
if (needsSave) this.save();
return this._data;
},
save() {
@@ -117,11 +142,14 @@ const groups = {
},
deleteGroup(name) {
if (WF_RESERVED_GROUPS.includes(name)) return false;
delete this.data[name];
this.save();
return true;
},
renameGroup(oldName, newName) {
if (WF_RESERVED_GROUPS.includes(oldName)) return false;
if (!newName || this.data[newName] || !this.data[oldName]) return false;
this.data[newName] = this.data[oldName];
delete this.data[oldName];
@@ -154,6 +182,32 @@ const groups = {
},
};
// ============================================
// Batch helpers
// ============================================
function isInBatch(filename) {
return (groups.data["Batch"] || []).includes(filename);
}
function toggleBatch(filename) {
const batch = groups.data["Batch"] || [];
const idx = batch.indexOf(filename);
if (idx >= 0) { batch.splice(idx, 1); } else { batch.push(filename); }
groups.data["Batch"] = batch;
groups.save();
renderGrid();
}
function clearBatch() {
groups.data["Batch"] = [];
groups.save();
state.showBatchOnly = false;
renderModelFilters();
renderGrid();
showToast(t("wfBatchClear"), "success");
}
// ============================================
// API
// ============================================
@@ -250,6 +304,7 @@ function renderModelFilters() {
container.innerHTML = `
<button class="wfm-filter-btn ${state.activeBadge === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
<button class="wfm-filter-btn ${state.activeBadge === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">&#9733;</button>
<button class="wfm-filter-btn wfm-wf-batch-filter-btn${state.showBatchOnly ? " active" : ""}">B</button>
${badges.map((b) => {
const color = palette[b] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
@@ -259,6 +314,13 @@ function renderModelFilters() {
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
if (btn.classList.contains("wfm-wf-batch-filter-btn")) {
state.showBatchOnly = !state.showBatchOnly;
state.currentPage = 0;
renderModelFilters();
renderGrid();
return;
}
state.activeBadge = btn.dataset.filter;
state.currentPage = 0;
renderModelFilters();
@@ -278,6 +340,11 @@ function filterWorkflows() {
return false;
}
// Batch filter
if (state.showBatchOnly) {
if (!(groups.data["Batch"] || []).includes(wf.filename)) return false;
}
// Group filter
if (state.groupFilter) {
const grpFiles = groups.data[state.groupFilter] || [];
@@ -333,30 +400,24 @@ function renderGrid() {
card.dataset.filename = wf.filename;
const hasThumb = !!wf.thumbnail;
const badges = wfBadgesHtml(wf);
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
const favStar = wf.metadata.favorite ? "\u2605" : "\u2606";
const favClass = wf.metadata.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
if (state.viewMode === "thumb") {
card.innerHTML = `
<div class="wfm-card-thumb">${hasThumb ? `<img src="${wf.thumbnail}" loading="lazy" />` : `<span class="wfm-card-thumb-placeholder">${t("noImage")}</span>`}</div>
card.innerHTML = `
<div class="wfm-card-thumb">
${hasThumb ? `<img src="${wf.thumbnail}" loading="lazy" />` : `<span class="wfm-card-thumb-placeholder">${t("noImage")}</span>`}
<button class="wfm-batch-btn${isInBatch(wf.filename) ? ' active' : ''}" title="Batch">B</button>
</div>
<div class="wfm-card-body">
<div class="wfm-card-title" title="${wf.filename}">${wf.filename.replace(/\.json$/, "")}</div>
<div class="wfm-card-meta">${badges}</div>
</div>
<button class="${favClass}" title="${t("favorite")}">${favStar}</button>`;
} else {
// Card view: no thumbnail, more meta info
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
card.innerHTML = `
<div class="wfm-card-body">
<div class="wfm-card-title" title="${wf.filename}">${wf.filename.replace(/\.json$/, "")}</div>
<div class="wfm-card-meta">${badges}</div>
<div class="wfm-card-io">${io}</div>
<div class="wfm-card-tags">${tags}</div>
</div>
<button class="${favClass}" title="${t("favorite")}">${favStar}</button>`;
}
card.querySelector(".wfm-batch-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
toggleBatch(wf.filename);
});
card.querySelector(".wfm-fav-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
@@ -390,6 +451,7 @@ function renderTableView(grid, filtered) {
<th style="width:60px;">${t("tags")}</th>
<th class="wfm-table-th-memo">${t("memo")}</th>
<th class="wfm-table-th-summary">${t("summary")}</th>
<th style="width:30px;"></th>
</tr></thead>`;
const tbody = document.createElement("tbody");
@@ -414,7 +476,13 @@ function renderTableView(grid, filtered) {
<td class="wfm-table-td-io">${io}</td>
<td>${tags}</td>
<td class="wfm-table-td-memo" title="${(wf.metadata.memo || "").replace(/"/g, "&quot;")}">${wf.metadata.memo || ""}</td>
<td class="wfm-table-td-summary" title="${(wf.metadata.summary || "").replace(/"/g, "&quot;")}">${wf.metadata.summary || ""}</td>`;
<td class="wfm-table-td-summary" title="${(wf.metadata.summary || "").replace(/"/g, "&quot;")}">${wf.metadata.summary || ""}</td>
<td class="wfm-table-td-batch"><button class="wfm-batch-btn${isInBatch(wf.filename) ? ' active' : ''}" title="Batch">B</button></td>`;
tr.querySelector(".wfm-batch-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
toggleBatch(wf.filename);
});
tr.querySelector(".wfm-fav-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
@@ -447,7 +515,7 @@ async function toggleFavorite(wf, btnEl) {
btnEl.textContent = newVal ? "\u2605" : "\u2606";
btnEl.classList.toggle("active", newVal);
} catch (err) {
showToast("Error: " + err.message, "error");
showToast(t("errorWithMsg", err.message), "error");
}
}
@@ -623,7 +691,7 @@ function renderSideGroup(wf) {
groups.remove(filename, btn.dataset.group);
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Removed from "${btn.dataset.group}"`, "success");
showToast(t("removedFromGroupName", btn.dataset.group), "success");
});
});
@@ -633,7 +701,7 @@ function renderSideGroup(wf) {
if (!g) return;
groups.assign(filename, g);
renderSideGroup(wf);
showToast(`Added to "${g}"`, "success");
showToast(t("addedToGroupName", g), "success");
});
// グループ作成(作成後に現在のWFに追加)
@@ -642,36 +710,44 @@ function renderSideGroup(wf) {
const name = input?.value.trim();
if (!name) return;
if (!groups.createGroup(name)) {
showToast(`"${name}" already exists`, "warning");
showToast(t("nameAlreadyExists", name), "warning");
return;
}
groups.assign(filename, name);
input.value = "";
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" created`, "success");
showToast(t("groupCreated", name), "success");
});
// グループ名変更
el.querySelector("#wfm-grp-rename-btn")?.addEventListener("click", () => {
const oldName = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!oldName) return;
if (WF_RESERVED_GROUPS.includes(oldName)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
if (!groups.renameGroup(oldName, newName)) {
showToast("Rename failed", "error");
showToast(t("renameFailed"), "error");
return;
}
if (state.groupFilter === oldName) state.groupFilter = newName;
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Renamed to "${newName}"`, "success");
showToast(t("renamedTo", newName), "success");
});
// グループ削除
el.querySelector("#wfm-grp-delete-btn")?.addEventListener("click", () => {
const name = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!name) return;
if (WF_RESERVED_GROUPS.includes(name)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
if (!confirm(`Delete group "${name}"?`)) return;
groups.deleteGroup(name);
if (state.groupFilter === name) {
@@ -681,7 +757,7 @@ function renderSideGroup(wf) {
}
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" deleted`, "success");
showToast(t("groupDeleted", name), "success");
});
_refreshGroupFilter();
@@ -803,7 +879,7 @@ function openDetailModal(wf) {
</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("openInComfyUI")}</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">
@@ -841,7 +917,7 @@ function openDetailModal(wf) {
favBtn.classList.toggle("active", newVal);
renderGrid();
} catch (err) {
showToast("Error: " + err.message, "error");
showToast(t("errorWithMsg", err.message), "error");
}
});
titleEl.parentNode.insertBefore(favBtn, titleEl);
@@ -884,6 +960,8 @@ function openDetailModal(wf) {
showToast(t("renameError") + ": " + err.message, "error");
}
};
if (titleInput._commitRename) titleInput.removeEventListener("blur", titleInput._commitRename);
titleInput._commitRename = commitRename;
titleInput.addEventListener("blur", commitRename);
titleInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); titleInput.blur(); }
@@ -951,11 +1029,11 @@ function openDetailModal(wf) {
}
});
// Open in ComfyUI
// Send to Canvas
document.getElementById("wfm-detail-open-comfyui")?.addEventListener("click", async () => {
try {
const wfData = await getRawWorkflow(wf.filename);
openInComfyUI(wfData);
sendToCanvas(wfData);
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
@@ -972,7 +1050,7 @@ function openDetailModal(wf) {
apiData = await comfyWorkflow.convertUiToApi(wfData);
}
// Save to localStorage
const settings = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
const settings = getSettings();
settings.defaultWorkflow = wf.filename;
settings.defaultWorkflowData = apiData;
localStorage.setItem("wfm_settings", JSON.stringify(settings));
@@ -1122,7 +1200,7 @@ export function initWorkflowTab() {
showToast(msg, ng.length ? "error" : "success");
await loadWorkflows();
} catch (err) {
showToast("Import error: " + err.message, "error");
showToast(t("importErrorMsg", err.message), "error");
}
e.target.value = "";
});
@@ -1149,6 +1227,11 @@ export function initWorkflowTab() {
renderGrid();
});
}
setupSearchClearBtn("wfm-search", "wfm-search-clear-btn", () => {
state.searchText = "";
state.currentPage = 0;
renderGrid();
});
// Drag & drop on workflow grid
const grid = document.getElementById("wfm-workflow-grid");
@@ -1165,10 +1248,10 @@ export function initWorkflowTab() {
const data = await importFiles(files);
const ok =
data.results?.filter((r) => r.status === "success").length ?? 0;
showToast(`${ok} file(s) imported via drag & drop`, "success");
showToast(t("importedViaDrop", ok), "success");
await loadWorkflows();
} catch (err) {
showToast("Import error: " + err.message, "error");
showToast(t("importErrorMsg", err.message), "error");
}
});
}
@@ -1228,12 +1311,12 @@ export function initWorkflowTab() {
}
});
// Toolbar: Open in ComfyUI button
// 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);
openInComfyUI(wfData);
sendToCanvas(wfData);
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
@@ -1242,7 +1325,7 @@ export function initWorkflowTab() {
// Toolbar: Refresh button
document.getElementById("wfm-refresh-btn")?.addEventListener("click", async () => {
await loadWorkflows();
showToast("Refreshed", "success");
showToast(t("refreshed"), "success");
});
// Toolbar: ⚙ settings button (view settings panel)
@@ -1271,6 +1354,30 @@ export function initWorkflowTab() {
openBadgeEditModal(() => renderGrid());
});
// Toolbar: BC (batch clear) button
document.getElementById("wfm-wf-batch-clear-btn")?.addEventListener("click", () => {
clearBatch();
});
// Toolbar: Clear filters button
const wfClearBtn = document.getElementById("wfm-wf-clear-filters-btn");
if (wfClearBtn) {
wfClearBtn.textContent = t("clearFilters");
wfClearBtn.addEventListener("click", () => {
state.activeBadge = "ALL";
state.showBatchOnly = false;
state.searchText = "";
state.groupFilter = "";
state.currentPage = 0;
const searchInput = document.getElementById("wfm-search");
if (searchInput) searchInput.value = "";
const groupFilter = document.getElementById("wfm-group-filter");
if (groupFilter) groupFilter.value = "";
renderModelFilters();
renderGrid();
});
}
// Initial load
loadWorkflows();
}
+1559 -246
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
/**
* WFS_GalleryFeeder – ComfyUI canvas extension
* Adds "After Gen", Run, and Stop controls directly on the node widget area.
*/
import { app } from "../../scripts/app.js";
const FEEDER_GROUP = "__Feeder__";
// Per-node runtime state (keyed by node.id)
const _ns = new Map();
// ── Helpers ───────────────────────────────────────────────────────────
async function fetchGroupImages(groupName) {
try {
const res = await fetch(`/wfm/gallery/groups/${encodeURIComponent(groupName)}/images`);
if (!res.ok) return [];
return (await res.json()).images || [];
} catch { return []; }
}
function getOrCreate(nodeId) {
if (!_ns.has(nodeId)) {
_ns.set(nodeId, { running: false, stopFlag: false, idx: 0, images: [] });
}
return _ns.get(nodeId);
}
function getInputWidget(node, name) {
return node.widgets?.find(w => w.name === name);
}
function setWidgetValue(node, name, value) {
const w = getInputWidget(node, name);
if (!w) return;
w.value = value;
if (w.callback) w.callback(value);
app.canvas?.setDirty(true, true);
}
/** Resolves when the current prompt finishes (executing=null, interrupted), rejects on error. */
function waitForExecution() {
return new Promise((resolve, reject) => {
const cleanup = () => {
app.api.removeEventListener("executing", onExecuting);
app.api.removeEventListener("execution_error", onError);
app.api.removeEventListener("execution_interrupted", onInterrupted);
};
const onExecuting = ({ detail }) => { if (detail === null) { cleanup(); resolve(); } };
const onError = ({ detail }) => { cleanup(); reject(new Error(detail?.exception_message || "Execution error")); };
const onInterrupted = () => { cleanup(); resolve(); }; // treat interrupt as "done"
app.api.addEventListener("executing", onExecuting);
app.api.addEventListener("execution_error", onError);
app.api.addEventListener("execution_interrupted", onInterrupted);
});
}
function updateRunBtn(node) {
const st = getOrCreate(node.id);
if (node._wfsRunWidget) node._wfsRunWidget.name = st.running ? "⏳ Running..." : "▶ Run";
app.canvas?.setDirty(true, true);
}
function wfsToast(msg, severity = "warn") {
try {
const tm = app.extensionManager?.toast;
if (tm?.add) { tm.add({ severity, summary: "Gallery Feeder", detail: msg, life: 4000 }); return; }
} catch {}
console[severity === "error" ? "error" : "warn"]("[WFS_GalleryFeeder]", msg);
}
// ── Run / Stop ────────────────────────────────────────────────────────
async function startLoop(node) {
const st = getOrCreate(node.id);
if (st.running) return;
// Re-fetch images for the current group each Run
const groupName = getInputWidget(node, "group_name")?.value || FEEDER_GROUP;
const images = await fetchGroupImages(groupName);
if (images.length === 0) {
wfsToast(`No images in group "${groupName}"`, "warn");
return;
}
st.running = true;
st.stopFlag = false;
st.images = images;
st.idx = Math.max(0, Math.min(
parseInt(getInputWidget(node, "index")?.value) || 0,
images.length - 1
));
updateRunBtn(node);
// Queue first generation with current index
setWidgetValue(node, "index", st.idx);
app.queuePrompt(0, 1);
while (st.running && !st.stopFlag) {
try {
await waitForExecution();
} catch (err) {
if (st.running) wfsToast(err.message, "error");
break;
}
if (!st.running || st.stopFlag) break;
const afterGen = node._wfsAfterGenWidget?.value || "loop";
const total = st.images.length;
// Increment mode: stop after the last image
if (afterGen === "increment" && st.idx >= total - 1) break;
if (afterGen !== "fixed") {
st.idx = (st.idx + 1) % total;
setWidgetValue(node, "index", st.idx);
}
app.queuePrompt(0, 1);
}
st.running = false;
updateRunBtn(node);
}
function stopLoop(node) {
const st = getOrCreate(node.id);
if (!st.running) return;
st.stopFlag = true;
st.running = false;
try { app.api.interrupt(); } catch {}
updateRunBtn(node);
}
// ── Extension registration ────────────────────────────────────────────
app.registerExtension({
name: "WorkflowStudio.GalleryFeeder",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "WFS_GalleryFeeder") return;
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.call(this);
const node = this;
// ── After Gen selector ────────────────────────────────────
// serialize:false → not included in widgets_values / prompt inputs
const afterGenW = node.addWidget("combo", "wfs_after_gen", "loop", () => {}, {
values: ["loop", "increment", "fixed"],
serialize: false,
});
node._wfsAfterGenWidget = afterGenW;
// ── Run button ────────────────────────────────────────────
const runW = node.addWidget("button", "▶ Run", null, () => startLoop(node));
runW.serialize = false;
node._wfsRunWidget = runW;
// ── Stop button ───────────────────────────────────────────
const stopW = node.addWidget("button", "■ Stop", null, () => stopLoop(node));
stopW.serialize = false;
node._wfsStopWidget = stopW;
};
// Clean up when node is removed from graph
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
stopLoop(this);
_ns.delete(this.id);
origRemoved?.call(this);
};
},
});
File diff suppressed because it is too large Load Diff
+44 -33
View File
@@ -605,50 +605,61 @@ app.registerExtension({
};
injectStyles();
const applyButtonIcon = (button, className, getIconFn, extraStyles) => {
if (button.querySelector("svg")) return;
button.classList.add(className);
button.innerHTML = getIconFn();
button.style.borderRadius = "4px";
button.style.padding = "6px";
if (extraStyles) Object.assign(button.style, extraStyles);
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
};
const buttonConfigs = [
{ tooltip: BUTTON_TOOLTIP, className: "wfm-top-menu-button", getIcon: getWfmIcon, styles: { backgroundColor: "var(--primary-bg)" } },
{ tooltip: SNAPSHOT_TOOLTIP, className: "wfm-snapshot-button", getIcon: getSnapshotIcon, styles: null },
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
];
const replaceAllIcons = () => {
const applyIconToButton = (btn, cfg) => {
// Skip if the SVG icon is already in place
if (btn.querySelector("svg")) return;
btn.classList.add(cfg.className);
btn.innerHTML = cfg.getIcon();
btn.style.borderRadius = "4px";
btn.style.padding = "6px";
if (cfg.styles) Object.assign(btn.style, cfg.styles);
const svg = btn.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
};
// Initial application — retry until buttons are rendered by Vue
const pollUntilFound = () => {
let foundAny = false;
for (const cfg of buttonConfigs) {
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
applyButtonIcon(btn, cfg.className, cfg.getIcon, cfg.styles);
foundAny = true;
applyIconToButton(btn, cfg);
});
}
};
const waitAndObserve = () => {
const firstBtn = document.querySelector(`button[aria-label="${BUTTON_TOOLTIP}"]`);
if (!firstBtn) {
requestAnimationFrame(waitAndObserve);
return;
}
replaceAllIcons();
const container = firstBtn.closest(".actionbar-container") || firstBtn.parentElement;
if (container) {
new MutationObserver(() => replaceAllIcons()).observe(container, {
childList: true,
subtree: true,
});
if (!foundAny) {
requestAnimationFrame(pollUntilFound);
}
};
requestAnimationFrame(waitAndObserve);
requestAnimationFrame(pollUntilFound);
// MutationObserver: re-apply icons after Vue re-renders the action bar
// (e.g. when the Properties Panel is toggled inside a subgraph)
if (typeof MutationObserver !== "undefined") {
const observer = new MutationObserver(() => {
for (const cfg of buttonConfigs) {
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
// Only re-apply when Vue has reset innerHTML back to <i>
if (btn.querySelector("i")) {
applyIconToButton(btn, cfg);
}
});
}
});
const watchNode =
document.querySelector("[data-testid='action-bar-buttons']") ||
document.querySelector(".actionbar-container") ||
document.body;
observer.observe(watchNode, { childList: true, subtree: true });
window.__wfmIconObserver = observer;
}
},
});
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+599
View File
@@ -0,0 +1,599 @@
{
"id": "f11699d9-4b62-47bd-b501-e49d609d9810",
"revision": 0,
"last_node_id": 10,
"last_link_id": 10,
"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
]
},
{
"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": "CLIPTextEncode",
"pos": [
415,
186
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"flower"
]
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
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"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [
0,
0
],
"scale": 1
},
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+887
View File
@@ -0,0 +1,887 @@
{
"id": "831a2929-0536-4715-a530-72a8b91c00fd",
"revision": 0,
"last_node_id": 13,
"last_link_id": 21,
"nodes": [
{
"id": 12,
"type": "CLIPTextEncodeEditPlus",
"pos": [
1364.9859298350216,
-408.28982585532515
],
"size": [
300,
257
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"localized_name": "text1",
"name": "text1",
"type": "STRING",
"link": 18
},
{
"localized_name": "text2",
"name": "text2",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "text_edit",
"name": "text_edit",
"type": "STRING",
"widget": {
"name": "text_edit"
},
"link": null
},
{
"localized_name": "mode",
"name": "mode",
"type": "COMBO",
"widget": {
"name": "mode"
},
"link": null
}
],
"outputs": [
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"links": [
17
]
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"links": [
21
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "CLIPTextEncodeEditPlus"
},
"widgets_values": [
"blue flower",
"+af",
""
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
1742.6139298350226,
-576.3158258553248
],
"size": [
315,
474
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 15
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 5
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 6
},
{
"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": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "KSampler"
},
"widgets_values": [
1018108390999488,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 4,
"type": "VAEDecode",
"pos": [
2086.446929835019,
-568.6398258553247
],
"size": [
210,
46
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 20
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
12
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [
2326.33789983502,
-566.7928258553247
],
"size": [
210,
270
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 12
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 13,
"type": "ShowText|pysssss",
"pos": [
1363.0960298350246,
-578.3718258553248
],
"size": [
305.77,
111.08999999999992
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"link": 21
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"shape": 6,
"type": "STRING",
"links": null
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "ShowText|pysssss"
},
"widgets_values": [
"girl,sentai,sentai,flower petal,full body,long hair,long legs,white boots and white gloves,<lora:9_Toshikazu:0.4> Toshikazu,<lora:9_Iggym:0.3> Iggym, painterly, realistic, sketch, traditional media, \\(medium\\)\n<lora:9_Landuo941:0.3> Landuo941\n<lora:Manga122:0.2>,<lora:MayuMurataIllustrious:0.3> Mayu Murata\n<lora:9_Navybluesparrow:0.4> Navybluesparrow\n<lora:9_RiyokoIkeda:0.2> Riyoko Ikeda\n<lora:9_ShumiiberryAlt:0.2> Shumiiberry,\n<lora:Shuiyuan:0.2> 5huny4n_5ty13 \n<lora:test_mix:0.2>,,blue flower"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
992.022929835024,
79.15817414467442
],
"size": [
271.44000000000005,
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": [
6
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 1,
"type": "ImageMetadataLoRALoader",
"pos": [
987.5885798350228,
-582.1963058553249
],
"size": [
286,
565
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "metadata",
"name": "metadata",
"shape": 7,
"type": "METADATA",
"link": 3
},
{
"localized_name": "lora_1",
"name": "lora_1",
"type": "COMBO",
"widget": {
"name": "lora_1"
},
"link": null
},
{
"localized_name": "strength_model_1",
"name": "strength_model_1",
"type": "FLOAT",
"widget": {
"name": "strength_model_1"
},
"link": null
},
{
"localized_name": "strength_clip_1",
"name": "strength_clip_1",
"type": "FLOAT",
"widget": {
"name": "strength_clip_1"
},
"link": null
},
{
"localized_name": "lora_2",
"name": "lora_2",
"type": "COMBO",
"widget": {
"name": "lora_2"
},
"link": null
},
{
"localized_name": "strength_model_2",
"name": "strength_model_2",
"type": "FLOAT",
"widget": {
"name": "strength_model_2"
},
"link": null
},
{
"localized_name": "strength_clip_2",
"name": "strength_clip_2",
"type": "FLOAT",
"widget": {
"name": "strength_clip_2"
},
"link": null
},
{
"localized_name": "lora_3",
"name": "lora_3",
"type": "COMBO",
"widget": {
"name": "lora_3"
},
"link": null
},
{
"localized_name": "strength_model_3",
"name": "strength_model_3",
"type": "FLOAT",
"widget": {
"name": "strength_model_3"
},
"link": null
},
{
"localized_name": "strength_clip_3",
"name": "strength_clip_3",
"type": "FLOAT",
"widget": {
"name": "strength_clip_3"
},
"link": null
}
],
"outputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"links": [
15
]
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"links": [
14,
16
]
}
],
"properties": {
"Node name for S&R": "ImageMetadataLoRALoader",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"temp\\Manga122.safetensors",
0.2,
0.2,
"temp\\MayuMurataIllustrious.safetensors",
0.3,
0.3,
"temp\\Shuiyuan.safetensors",
0.2,
0.2,
""
]
},
{
"id": 2,
"type": "ImageMetadataPromptLoader",
"pos": [
626.8189298350237,
-586.5118258553252
],
"size": [
286,
709
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
},
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": null
},
{
"localized_name": "positive_text",
"name": "positive_text",
"type": "STRING",
"widget": {
"name": "positive_text"
},
"link": null
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"widget": {
"name": "negative_text"
},
"link": null
},
{
"localized_name": "_metadata_json",
"name": "_metadata_json",
"type": "STRING",
"widget": {
"name": "_metadata_json"
},
"link": null
}
],
"outputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"links": [
1
]
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"links": [
2
]
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"links": [
20
]
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "positive_text",
"name": "positive_text",
"type": "STRING",
"links": [
18
]
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"links": []
},
{
"localized_name": "metadata",
"name": "metadata",
"type": "METADATA",
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "ImageMetadataPromptLoader",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"anime\\CHEYENNE_v24HyperIllustration.safetensors",
"SDXL-VAE-FP16-Fix.safetensors",
"girl,sentai,sentai,flower petal,full body,long hair,long legs,white boots and white gloves,<lora:9_Toshikazu:0.4> Toshikazu,<lora:9_Iggym:0.3> Iggym, painterly, realistic, sketch, traditional media, \\(medium\\)\n<lora:9_Landuo941:0.3> Landuo941\n<lora:Manga122:0.2>,<lora:MayuMurataIllustrious:0.3> Mayu Murata\n<lora:9_Navybluesparrow:0.4> Navybluesparrow\n<lora:9_RiyokoIkeda:0.2> Riyoko Ikeda\n<lora:9_ShumiiberryAlt:0.2> Shumiiberry,\n<lora:Shuiyuan:0.2> 5huny4n_5ty13 \n<lora:test_mix:0.2>,",
"helmet,mask",
"{\"loras\":[{\"name\":\"9_Toshikazu\",\"strength_model\":0.4,\"strength_clip\":0.4},{\"name\":\"9_Iggym\",\"strength_model\":0.3,\"strength_clip\":0.3},{\"name\":\"9_Landuo941\",\"strength_model\":0.3,\"strength_clip\":0.3},{\"name\":\"Manga122\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"MayuMurataIllustrious\",\"strength_model\":0.3,\"strength_clip\":0.3},{\"name\":\"9_Navybluesparrow\",\"strength_model\":0.4,\"strength_clip\":0.4},{\"name\":\"9_RiyokoIkeda\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"9_ShumiiberryAlt\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"Shuiyuan\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"test_mix\",\"strength_model\":0.2,\"strength_clip\":0.2}]}",
""
]
},
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
1360.6159298350235,
-96.8748258553254
],
"size": [
301.4950151367186,
88
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
5
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
}
],
"links": [
[
1,
2,
0,
1,
0,
"MODEL"
],
[
2,
2,
1,
1,
1,
"CLIP"
],
[
3,
2,
7,
1,
2,
"METADATA"
],
[
5,
10,
0,
3,
2,
"CONDITIONING"
],
[
6,
5,
0,
3,
3,
"LATENT"
],
[
7,
3,
0,
4,
0,
"LATENT"
],
[
12,
4,
0,
9,
0,
"IMAGE"
],
[
14,
1,
1,
10,
0,
"CLIP"
],
[
15,
1,
0,
3,
0,
"MODEL"
],
[
16,
1,
1,
12,
0,
"CLIP"
],
[
17,
12,
0,
3,
1,
"CONDITIONING"
],
[
18,
2,
5,
12,
1,
"STRING"
],
[
20,
2,
2,
4,
1,
"VAE"
],
[
21,
12,
1,
13,
0,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

+719
View File
@@ -0,0 +1,719 @@
{
"id": "03047124-7d5d-4eb4-b458-558d6dec9b5e",
"revision": 0,
"last_node_id": 28,
"last_link_id": 50,
"nodes": [
{
"id": 8,
"type": "VAEEncode",
"pos": [
551.8454158175873,
-685.9812381649755
],
"size": [
242.296875,
96
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": 49
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 34
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "VAEEncode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": []
},
{
"id": 22,
"type": "VAELoader",
"pos": [
75.4452850000009,
-464.22850100000056
],
"size": [
270,
58
],
"flags": {},
"order": 0,
"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": [
34,
35
]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"SDXL-VAE-FP16-Fix.safetensors"
]
},
{
"id": 5,
"type": "VAEDecode",
"pos": [
1312.282348239462,
-683.1137137997414
],
"size": [
254.390625,
96
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 11
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 35
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
50
]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": []
},
{
"id": 7,
"type": "KSampler",
"pos": [
1000.0439480441502,
-674.780789922787
],
"size": [
270,
589.3125
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 36
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 37
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 10
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 5
},
{
"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",
"links": [
11
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
240074286219855,
"randomize",
30,
8,
"euler",
"karras",
0.7
]
},
{
"id": 24,
"type": "CLIPTextEncode",
"pos": [
528.6156850000002,
-509.49460100000033
],
"size": [
400,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 38
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
37
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"flower,anime girl"
]
},
{
"id": 11,
"type": "CLIPTextEncode",
"pos": [
526.8870184488446,
-252.08752270664039
],
"size": [
400,
200
],
"flags": {
"collapsed": false
},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 9
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
10
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
""
]
},
{
"id": 9,
"type": "CheckpointLoaderSimple",
"pos": [
70.30033916275676,
-694.6312926669282
],
"size": [
270,
154.65625
],
"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",
"links": [
36
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
9,
38
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": []
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"anime\\IllustriousStyle_v22.safetensors"
]
},
{
"id": 28,
"type": "ImageFeeder",
"pos": [
76.77559983502367,
-338.79391585532534
],
"size": [
253.923,
319.923
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "directory",
"name": "directory",
"type": "STRING",
"widget": {
"name": "directory"
},
"link": null
},
{
"localized_name": "sort_mode",
"name": "sort_mode",
"type": "COMBO",
"widget": {
"name": "sort_mode"
},
"link": null
},
{
"localized_name": "index",
"name": "index",
"type": "INT",
"widget": {
"name": "index"
},
"link": null
},
{
"localized_name": "start_index",
"name": "start_index",
"type": "INT",
"widget": {
"name": "start_index"
},
"link": null
},
{
"localized_name": "end_index",
"name": "end_index",
"type": "INT",
"widget": {
"name": "end_index"
},
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "use_selection",
"name": "use_selection",
"type": "BOOLEAN",
"widget": {
"name": "use_selection"
},
"link": null
},
{
"localized_name": "selected_files",
"name": "selected_files",
"type": "STRING",
"widget": {
"name": "selected_files"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
49
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "ImageFeeder"
},
"widgets_values": [
"test",
"ascending",
0,
0,
0,
1,
908906407258686,
"randomize",
true,
"[\"00030-2656222951.png\",\"00063-2334831609.png\",\"02121-3711614362.png\"]",
""
]
},
{
"id": 4,
"type": "PreviewImage",
"pos": [
1603.3762031816507,
-693.7018206825535
],
"size": [
284.5062499999997,
301.77375000000023
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 50
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": []
}
],
"links": [
[
5,
8,
0,
7,
3,
"LATENT"
],
[
9,
9,
1,
11,
0,
"CLIP"
],
[
10,
11,
0,
7,
2,
"CONDITIONING"
],
[
11,
7,
0,
5,
0,
"LATENT"
],
[
34,
22,
0,
8,
1,
"VAE"
],
[
35,
22,
0,
5,
1,
"VAE"
],
[
36,
9,
0,
7,
0,
"MODEL"
],
[
37,
24,
0,
7,
1,
"CONDITIONING"
],
[
38,
9,
1,
24,
0,
"CLIP"
],
[
49,
28,
0,
8,
0,
"IMAGE"
],
[
50,
5,
0,
4,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ue_links": [],
"links_added_by_ue": [],
"linearData": {
"inputs": [
[
"9",
"ckpt_name"
],
[
"3",
"pose_editor_3d_widget"
],
[
"3",
"timer_interval"
]
],
"outputs": []
}
},
"version": 0.4
}

Some files were not shown because too many files have changed in this diff Show More