Compare commits

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 14:29:24 +09:00
ketle-manandClaude Sonnet 5 65ef71b4ad release: v0.3.77 — Gallery SVG support, Chat SVG generation without a workflow
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:19:19 +09:00
ketle-manandClaude Sonnet 5 49019dfb05 DEVLOGに含まれるユーザー固有のパスを汎用表現に置き換え
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:41:28 +09:00
ketle-manandClaude Sonnet 5 78d9bba904 release: v0.3.76 — Comic Creator I2I execution bridge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:29:37 +09:00
ketle-manandClaude Sonnet 5 f8435ed770 release: v0.3.75 — Lemonade Server backend support, Bypass/Mute node fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 10:04:09 +09:00
ketle-manandClaude Sonnet 5 b2ef333dfe release: v0.3.74 — Chat image attachment (TOOLS drop zone) + I2I generation
Shares the TOOLS pane's existing image drop zone as the Chat pane's
attachment mechanism: images are sent to the LLM as vision input and,
when generate_image is called, used as the base for image-to-image
generation via a new Chat I2I Generation dedicated workflow setting.
Also adjusts chat bubble image sizing and documents verified models
(Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:25:53 +09:00
ketle-manandClaude Sonnet 5 dfb33e831d release: v0.3.73 — Chat image generation via Tool Calling, Default Checkpoint safeguard
Adds LLM Tool Calling-driven image generation to the AI TOOL Chat pane
(generate_image tool, GenerateUI pipeline reuse, dedicated workflow option,
allow-toggle), plus a Settings > Default Checkpoint safeguard and a fix for
convertUiToApi() silently substituting missing checkpoints without warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 13:03:06 +09:00
ketle-man 64c21b1224 release: v0.3.72 — Comic Creator Inpaint integration, cross-realm Blob fix 2026-07-24 10:54:17 +09:00
ketle-manandClaude Sonnet 5 8f43412e96 release: v0.3.71 — Inpaint機能追加(GenerateUI マスクスロット・Image Edit Inpaintタブ・Mask Editor One対応・専用ワークフロー)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:40:13 +09:00
ketle-manandClaude Sonnet 5 b1e665b46b release: v0.3.70 — Ollama summary fix, Image Edit Layer 1 + mask overlay, GenerateUI prompt revert & weight editing
- Workflow AI summary: use the Ollama URL/model saved in Settings instead of a
  hardcoded default model tag that caused a 404 against locally installed models
- Image Edit: New button now auto-adds an empty Layer 1 (matching image-load behavior)
- Image Edit: mask overlay is now shown only for the active mask layer
- GenerateUI Input: Revert button (↺) next to Apply reverts the textarea to the
  node's currently applied value
- GenerateUI Input: Ctrl+Up/Down adjusts prompt emphasis weight in (text:weight)
  syntax by ±0.05, A1111/native ComfyUI style
- Comic Creator I2I integration (window._wfmReceiveImageForI2I) carried over from
  v0.3.69 that had been implemented but not yet committed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 00:59:41 +09:00
ketle-manandClaude Sonnet 5 335ee517cc release: v0.3.69 — Lora batch fixes, prompt auto-sync, Comic Creator Send CC
Lora batch generation:
- Fix `Prompt outputs failed validation` errors for LoRAs that actually
  exist. Root cause: ComfyUI on Windows returns `\`-separated LoRA enum
  values while WFS tracked LoRAs internally with `/`. Added a shared
  `comfyEditor.resolveLoraName()` helper and applied it consistently to
  Lora Single apply, Stack apply, the Models tab GenUI Model button, and
  Lora batch generation.
- Add a pre-flight check before batch runs on standard LoraLoader
  workflows: warns if a selected LoRA isn't yet recognized by ComfyUI's
  model list (suggests Refresh/restart) instead of silently failing
  each queued prompt.
- Batch-applied LoRAs now auto-append their `<lora:...>` syntax and
  CivitAI trained words to the Positive prompt, rebuilt from the
  original prompt each time (not accumulated), and restore the prompt
  to its pre-batch state on completion, stop, or error.

Models tab:
- Add a Close button to the model detail modal's button row (next to
  Save / GenUI PP·NP / Delete) to cut down on mouse travel during
  repeated batch edits.

Gallery tab:
- Add Send CC button (shown only when embedded as an iframe inside
  ComfyUI Comic Creator, renamed from "Comic Creater") that sends the
  selected image into Comic Creator's selected panel/overlay via its
  existing insertImageFromUrl(url) helper.

Help / docs:
- New help entry helpGen19 (EN/JA/ZH) describing the Lora batch prompt
  sync, restore, and pre-flight check behavior.
- README and DEVLOG updated for v0.3.69.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:21:49 +09:00
ketle-manandClaude Sonnet 5 8509eee527 release: v0.3.68 — MASK EDITOR ONE section always visible
Show the MASK EDITOR ONE / MASK EDITOR ONE (COLOR) properties section
at all times in Draw and Mask Paint mode, instead of hiding it when no
ABR brushes are imported yet. Header text is green when brushes are
available and gray otherwise, with the Select button disabled until a
brush exists. Prompted by a report that ABR brushes "don't show up" —
SAM3/BiRefNet were confirmed working; the real cause was an empty
brushes/ folder hiding the whole section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:18:10 +09:00
ketle-manandClaude Sonnet 4.6 202deab5d6 release: v0.3.67 — Alt+Click Apply & Generate
GenerateUI タブの Apply ボタンを Alt+Click すると Apply と Generate を
1操作で実行できる機能を追加。

- Positive Prompt / Checkpoint / KSampler 等の Apply ボタン対象
- Apply 成功時のみ生成をトリガー(ノード未存在時は何もしない)
- カスタムイベント (wfm:apply-and-generate) 経由で循環参照を回避
- ボタンに title 属性でヒントを表示
- ヘルプ helpGen18 を EN/JA/ZH で追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 22:15:03 +09:00
ketle-manandClaude Sonnet 4.6 8a86895262 release: v0.3.66 — Mask ツール 5サブツール追加(Color / Alpha / Text / Vector / Shape)
- Image Edit — Mask Tool: 5つの新サブツールを追加(すべて Mask Editor One 不要)
  - Color: クリック位置の色類似度でマスク選択(Tolerance / Feather / Mode)
  - Alpha: 画像の透明チャンネルをマスクとして抽出(Threshold / Invert / Extract Alpha)
  - Text: テキストスタンプマスク(Font / Size / Bold / Italic / Align / Mode、Ctrl+Enter確定)
  - Vector: Catmull-Rom スプラインポリゴン(Enter 確定 / Backspace 取消 / Esc リセット)
  - Shape: 矩形・楕円ドラッグ描画(Shift で正方形/正円、青点線プレビュー)
- Image Edit — MaskEditorOneTools.js 新規作成(5クラス + MASK_TEXT_FONTS 定数)
- Image Edit — DrawTool: ABRブラシ対応(spacing / angle / sizeJitter / rotationJitter)
- Image Edit — LayerManager: マスクレイヤーサムネイルをグレースケール(黒背景)表示に変更
- Image Edit — レイヤーアイコンの明度改善(opacity 0.5 → 0.85)
- Help — Support ページに ComfyUI Image Feeder・Mask Editor One リポジトリリンク追加
- Help — ヘルプ i18n 更新(英 / 日 / 中 3言語、mask-9〜mask-13 新規追加)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 17:28:35 +09:00
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
66 changed files with 25959 additions and 1414 deletions
+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 に反映されない。
+3607
View File
File diff suppressed because it is too large Load Diff
+631 -56
View File
File diff suppressed because it is too large Load Diff
+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.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+5
View File
@@ -69,3 +69,8 @@ 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"
+103 -2
View File
@@ -3,6 +3,7 @@
import asyncio
import json
import logging
import os
import urllib.request
import urllib.error
@@ -17,6 +18,84 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/eagle/test", handle_test)
def _resolve_local_path(filename, subfolder, type_name):
"""filename/subfolder/type を ComfyUI 出力ディレクトリ上の実パスに解決する。
ComfyUI と Eagle が同一マシン上で動作している場合のみ有効なパスが返る
(別マシンの場合は None を返し、呼び出し側は addFromURL にフォールバックする)。"""
try:
import folder_paths # type: ignore
except Exception:
return None
base_dir = folder_paths.get_directory_by_type(type_name or "output")
if not base_dir:
return None
base_dir = os.path.abspath(base_dir)
filename = os.path.basename(filename or "")
if not filename:
return None
full_dir = os.path.abspath(os.path.join(base_dir, subfolder or ""))
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
if os.path.commonpath((os.path.normcase(full_dir), os.path.normcase(base_dir))) != os.path.normcase(base_dir):
# ディレクトリトラバーサル対策: base_dir 配下以外は拒否
return None
full_path = os.path.join(full_dir, filename)
if not os.path.isfile(full_path):
return None
return full_path
def _resolve_absolute_svg_path(abs_path):
"""comfyui-tosvg の Save SVG String ノードなどが返す絶対パスを検証する。
ComfyUI管理下(output/input/temp)ディレクトリ配下の実在する .svg ファイルのみ許可する
(任意の絶対パスをそのまま信頼すると、外部からこのAPIを叩かれた際に
ローカルの任意ファイルを送信させられてしまうため)。"""
if not abs_path or not abs_path.lower().endswith(".svg"):
return None
try:
import folder_paths # type: ignore
except Exception:
return None
abs_path = os.path.abspath(abs_path)
if not os.path.isfile(abs_path):
return None
for type_name in ("output", "temp", "input"):
base_dir = folder_paths.get_directory_by_type(type_name)
if not base_dir:
continue
base_dir = os.path.abspath(base_dir)
try:
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
if os.path.commonpath((os.path.normcase(abs_path), os.path.normcase(base_dir))) == os.path.normcase(base_dir):
return abs_path
except ValueError:
# Windowsで異なるドライブレターの場合 commonpath が例外を投げる
continue
return None
def _eagle_add_from_path(eagle_url, file_path, name, tags):
"""Proxy request to Eagle's addFromPath API (runs in thread).
SVG は ComfyUI の /view がセキュリティ上 application/octet-stream +
attachment で返すため、addFromURL 経由だと正しい種別で保存されないことがある。
ComfyUI と Eagle が同一マシン上にある場合はローカルパスを直接渡す方が確実。"""
eagle_url = eagle_url.rstrip("/")
payload = json.dumps({"path": file_path, "name": name, "tags": tags}).encode("utf-8")
req = urllib.request.Request(
f"{eagle_url}/api/item/addFromPath",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
def _eagle_add(eagle_url, image_url, name, tags):
"""Proxy request to Eagle API (runs in thread)."""
eagle_url = eagle_url.rstrip("/")
@@ -71,11 +150,33 @@ async def handle_add(request: web.Request) -> web.Response:
image_url = body.get("url", "")
name = body.get("name", "image.png")
tags = body.get("tags", [])
filename = body.get("filename", "")
subfolder = body.get("subfolder", "")
file_type = body.get("type", "output")
local_path_hint = body.get("localPath", "")
if not image_url:
if not image_url and not local_path_hint:
return web.json_response({"status": "error", "message": "No URL provided"}, status=400)
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
# SVG は /view が application/octet-stream + attachment で返すため、
# ローカルファイルが解決できる場合は addFromPath で直接渡す
local_path = None
if local_path_hint:
# comfyui-tosvg の Save SVG String ノードなど、絶対パスが既知の場合
local_path = _resolve_absolute_svg_path(local_path_hint)
elif (filename or name or "").lower().endswith(".svg"):
local_path = _resolve_local_path(filename or name, subfolder, file_type)
if not local_path and not image_url:
return web.json_response(
{"status": "error", "message": f"Could not resolve local SVG path: {local_path_hint}"},
status=400,
)
if local_path:
result = await asyncio.to_thread(_eagle_add_from_path, eagle_url, local_path, name, tags)
else:
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
return web.json_response(result)
except urllib.error.URLError as e:
logger.error("Eagle URL error: %s", e)
+222 -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,26 +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)
# ──────────────────────────────────────────────────────────────
@@ -69,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,
@@ -77,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)
@@ -127,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()
@@ -152,6 +260,56 @@ async def toggle_favorite(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
_SAVE_EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
"image/svg+xml": ".svg",
}
async def save_image_to_gallery(request: web.Request) -> web.Response:
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
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)
# data URL から MIME タイプを判定し、拡張子を決定する
raw = image_data
mime = "image/png"
if raw.startswith("data:"):
header, _, raw = raw.partition(",")
m = re.match(r"data:([^;]+)", header)
if m:
mime = m.group(1)
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
# ファイル名サニタイズ(OSで使えない文字を除去)
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
if not safe.lower().endswith(ext):
safe += ext
image_bytes = base64.b64decode(raw)
save_path = _service._allowed_root / safe
save_path.write_bytes(image_bytes)
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
return web.json_response({"ok": True, "path": str(save_path)})
except Exception as e:
logger.error("save_image_to_gallery error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
@@ -174,10 +332,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:
@@ -192,6 +355,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})
@@ -224,6 +389,28 @@ async def list_group_images(request: web.Request) -> web.Response:
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)
# ──────────────────────────────────────────────────────────────
# フォルダ・ファイル操作
# ──────────────────────────────────────────────────────────────
@@ -280,3 +467,37 @@ async def move_images_route(request: web.Request) -> web.Response:
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)
+73 -2
View File
@@ -31,6 +31,9 @@ def setup_routes(app: web.Application):
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 ─────────────────────────────────────────
@@ -344,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)
@@ -383,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
@@ -392,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)
@@ -566,3 +574,66 @@ async def handle_delete_models(request: web.Request) -> web.Response:
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)
+3 -2
View File
@@ -82,7 +82,8 @@ async def handle_chat(request: web.Request) -> web.Response:
try:
body = await request.json()
cfg = _get_ollama_config()
model = body.get("model", cfg["model"])
model = body.get("model") or cfg["model"]
base_url = (body.get("url") or cfg["url"]).rstrip("/")
messages = body.get("messages", [])
def _chat():
@@ -92,7 +93,7 @@ async def handle_chat(request: web.Request) -> web.Response:
"stream": False,
}).encode("utf-8")
url = f"{cfg['url']}/api/chat"
url = f"{base_url}/api/chat"
req = urllib.request.Request(
url,
data=payload,
+38 -2
View File
@@ -23,6 +23,7 @@ _DATA_FILES = [
"prompts.json",
"model_metadata.json",
"gallery_metadata.json",
"tagger_settings.json",
]
@@ -36,6 +37,7 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
app.router.add_get("/api/wfm/settings/export", handle_export)
app.router.add_post("/api/wfm/settings/import", handle_import)
app.router.add_get("/api/wfm/styles", handle_get_styles)
async def handle_get(request: web.Request) -> web.Response:
@@ -179,13 +181,20 @@ def _load_data_file(filename: str):
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 not None:
bundle[filename] = data
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
@@ -234,3 +243,30 @@ async def handle_import(request: web.Request) -> web.Response:
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)
+242 -65
View File
@@ -4,6 +4,7 @@ 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
@@ -44,9 +45,16 @@ def _get_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."""
@@ -54,6 +62,8 @@ class CivitaiService:
def __init__(self):
self._cache = None
# ── キャッシュ管理 ────────────────────────────────────────
def _load_cache(self):
if self._cache is not None:
return self._cache
@@ -75,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."""
@@ -94,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, context=_get_ssl_context()) 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):
@@ -139,96 +261,149 @@ 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."""
@@ -243,11 +418,13 @@ class CivitaiService:
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()
+44
View File
@@ -117,6 +117,18 @@ 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 {
@@ -124,6 +136,19 @@ class GalleryMetadataStore:
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)
@@ -140,3 +165,22 @@ class GalleryMetadataStore:
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)
+178 -6
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
@@ -14,7 +16,7 @@ from typing import Any
logger = logging.getLogger(__name__)
# サポートする画像拡張子
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
# メタデータ保存ファイル (gallery専用)
from .gallery_metadata import GalleryMetadataStore
@@ -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,
@@ -385,14 +465,42 @@ class GalleryService:
# ──────────────────────────────────────────────────────────────
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}
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
@@ -410,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:
@@ -418,12 +528,26 @@ 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)
# ──────────────────────────────────────────────────────────────
# 画像配信
@@ -540,3 +664,51 @@ 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
# SVG はベクター画像でPillowが開けないため、元ファイルをそのまま返す
# (<img>タグはブラウザ側で任意サイズにスケーリングして表示する)
if p.suffix.lower() == ".svg":
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 # フォールバック: 元ファイル
+246 -5
View File
@@ -2,6 +2,8 @@
import json
import logging
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -9,6 +11,8 @@ 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"]
@@ -17,11 +21,13 @@ _PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".previe
_SIDECAR_EXTENSIONS = [
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp",
".json", ".civitai.info", ".info",
".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",
@@ -68,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")
@@ -109,6 +116,60 @@ class ModelsService:
self._save_metadata(data)
return entry
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
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()
raw = data.get("_groups", {})
@@ -122,16 +183,55 @@ class ModelsService:
raw = {}
if model_type:
return raw.get(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] = 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.
@@ -219,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
@@ -274,3 +374,144 @@ class ModelsService:
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)))
+21 -7
View File
@@ -3,6 +3,17 @@
import os
import re
# プロンプトを直接持つノードタイプ(CLIPTextEncode以外の複合ノード含む)
_PROMPT_NODE_TYPES = {
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner",
"CLIPTextEncodeEditPlus", "TextEncodeQwenImageEditPlus", "TextEncodeMageFlowEdit",
"SDXLPromptStyler", "SDXLPromptStylerAdvanced", "ImageMetadataPromptLoader",
"WFS_PromptText", "ImpactWildcardEncode", "ImpactWildcardProcessor",
}
# 画像を最終出力として保存するノードタイプ
_OUTPUT_IMAGE_NODE_TYPES = {"SaveImage", "SaveImageAdvanced"}
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
_CLIP_TYPE_TO_MODEL = {
"flux": "Flux",
@@ -18,6 +29,7 @@ _CLIP_TYPE_TO_MODEL = {
"wan": "Wan",
"cosmos": "Cosmos",
"lumina": "Lumina",
"lumina2": "Z-IMAGE",
}
@@ -180,25 +192,27 @@ def analyze_workflow(workflow_data, filename=""):
if "hidream" in title:
model_types.add("HiDream")
if ntype == "CLIPTextEncode" or "prompt" in title:
if ntype in _PROMPT_NODE_TYPES or "prompt" in title:
inputs["prompts"] += 1
if ntype in ("LoadImage", "LoadImageMask"):
inputs["images"] += 1
if ntype == "LoadVideo" or "load video" in title:
inputs["videos"] += 1
if ntype == "SaveImage":
if ntype in _OUTPUT_IMAGE_NODE_TYPES:
outputs["images"] += 1
if ntype in ("SaveVideo", "VH_VideoCombine"):
outputs["videos"] += 1
# Detect workflow format
if raw_nodes is not None:
# App format: UI-based structure with definitions (subgraphs) or linearMode
# App format: simplified web-app-style UI (linearMode) or .app.json filename.
# A workflow that merely *contains* subgraphs (definitions.subgraphs) is still a
# normal node graph (comfyui-workflow.js's convertUiToApi flattens subgraphs before
# conversion), so it's treated as "ui" here too — must match detectFormat() in
# static/js/comfyui-workflow.js or the App/UI badge shown to the user disagrees
# with what GenerateUI actually does with the file.
extra = workflow_data.get("extra", {})
if (
"definitions" in workflow_data
or extra.get("linearMode") is True
):
if extra.get("linearMode") is True:
wf_format = "app"
elif filename and filename.lower().endswith(".app.json"):
wf_format = "app"
+3 -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, wildcard_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)
@@ -72,5 +72,7 @@ class WorkflowStudio:
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")
+2 -2
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.3.18"
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.78"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
+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
+186
View File
@@ -219,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;
@@ -378,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;
@@ -385,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;
@@ -602,6 +685,109 @@
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 {
+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.85;
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;
}
+986 -85
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;
}
+452 -27
View File
@@ -1,10 +1,14 @@
/**
* AI Tab - Translation, VLM, Settings
* Supports Ollama and LM Studio as backends
* Supports Ollama, LM Studio, and Lemonade as backends
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
import { comfyUI } from "./comfyui-client.js";
import { comfyEditor } from "./comfyui-editor.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
const SETTINGS_KEY = "wfm_ai_settings";
@@ -19,11 +23,7 @@ const LANG_NAMES = {
// ============================================
function loadAiSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
} catch {
return {};
}
return readJsonStorage(SETTINGS_KEY);
}
function saveAiSettings(patch) {
@@ -199,7 +199,7 @@ function initTranslateTab() {
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
@@ -288,12 +288,11 @@ function initSettingsTab() {
const urlInput = document.getElementById("wfm-ai-backend-url");
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
// Update URL placeholder when backend changes
// Switch URL to the new backend's default when the user changes backend
backendRadios.forEach((r) => {
r.addEventListener("change", () => {
if (!urlInput) return;
const defaultUrl = r.value === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
if (!saved.backendUrl) urlInput.value = defaultUrl;
urlInput.value = getAiBackendDefaultUrl(r.value);
});
});
@@ -344,6 +343,47 @@ function initSettingsTab() {
if (freeSrcInput && saved.freeSrcLang) freeSrcInput.value = saved.freeSrcLang;
if (freeDstInput && saved.freeDstLang) freeDstInput.value = saved.freeDstLang;
// Chat image generation — dedicated workflow selection (T2I)
const chatGenCheckbox = document.getElementById("wfm-ai-chatgen-dedicated-checkbox");
const chatGenRow = document.getElementById("wfm-ai-chatgen-dedicated-row");
const chatGenSelect = document.getElementById("wfm-ai-chatgen-dedicated-select");
if (chatGenCheckbox) chatGenCheckbox.checked = !!saved.chatGenDedicatedEnabled;
if (chatGenRow) chatGenRow.style.display = saved.chatGenDedicatedEnabled ? "" : "none";
chatGenCheckbox?.addEventListener("change", () => {
if (chatGenRow) chatGenRow.style.display = chatGenCheckbox.checked ? "" : "none";
});
// Chat I2I generation — dedicated workflow selection
const chatGenI2ICheckbox = document.getElementById("wfm-ai-chatgen-i2i-dedicated-checkbox");
const chatGenI2IRow = document.getElementById("wfm-ai-chatgen-i2i-dedicated-row");
const chatGenI2ISelect = document.getElementById("wfm-ai-chatgen-i2i-dedicated-select");
if (chatGenI2ICheckbox) chatGenI2ICheckbox.checked = !!saved.chatGenI2IDedicatedEnabled;
if (chatGenI2IRow) chatGenI2IRow.style.display = saved.chatGenI2IDedicatedEnabled ? "" : "none";
chatGenI2ICheckbox?.addEventListener("change", () => {
if (chatGenI2IRow) chatGenI2IRow.style.display = chatGenI2ICheckbox.checked ? "" : "none";
});
if (chatGenSelect) {
fetch(`${comfyUI.baseUrl}/api/wfm/workflows`)
.then((r) => (r.ok ? r.json() : []))
.then((list) => {
const filenames = (list || []).map((w) => w.filename).filter(Boolean);
const optionsHtml = `<option value="">-- select workflow --</option>` +
filenames.map((f) => `<option value="${f}">${f}</option>`).join("");
chatGenSelect.innerHTML = optionsHtml;
if (saved.chatGenDedicatedFilename) chatGenSelect.value = saved.chatGenDedicatedFilename;
if (chatGenI2ISelect) {
chatGenI2ISelect.innerHTML = optionsHtml;
if (saved.chatGenI2IDedicatedFilename) chatGenI2ISelect.value = saved.chatGenI2IDedicatedFilename;
}
})
.catch(() => {});
}
// Save settings
document.getElementById("wfm-ai-settings-save-btn")?.addEventListener("click", () => {
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
@@ -351,13 +391,21 @@ function initSettingsTab() {
const model = document.getElementById("wfm-ai-model-select")?.value || "";
const freeSrcLang = freeSrcInput?.value?.trim() || "";
const freeDstLang = freeDstInput?.value?.trim() || "";
const chatGenDedicatedEnabled = !!chatGenCheckbox?.checked;
const chatGenDedicatedFilename = chatGenSelect?.value || "";
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
if (url && !isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
saveAiSettings({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
saveAiSettings({
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
chatGenDedicatedEnabled, chatGenDedicatedFilename,
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
});
showToast(t("aiToastSettingsSaved"), "success");
});
@@ -367,31 +415,374 @@ function initSettingsTab() {
}
}
// ============================================
// Chat tab
// ============================================
const IMAGE_GEN_TOOLS = [{
type: "function",
function: {
name: "generate_image",
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image.",
parameters: {
type: "object",
properties: {
prompt: { type: "string", description: "Positive prompt describing the desired image." },
negative_prompt: { type: "string", description: "What to avoid in the image. Optional." },
},
required: ["prompt"],
},
},
}];
// chatHistory の内部共通形式 {role, content, images?:[{base64,mimeType}]} を
// バックエンドごとのワイヤーフォーマットへ変換する(callVLM() と同じ規約)
function _formatMessagesForBackend(messages, backend) {
return messages.map((m) => {
if (!m.images?.length) return { role: m.role, content: m.content };
if (backend === "ollama") {
return { role: m.role, content: m.content, images: m.images.map((i) => i.base64) };
}
return {
role: m.role,
content: [
{ type: "text", text: m.content },
...m.images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mimeType};base64,${i.base64}` } })),
],
};
});
}
async function callChat(url, backend, model, messages, tools) {
const formattedMessages = _formatMessagesForBackend(messages, backend);
if (backend === "ollama") {
const body = { model, messages: formattedMessages, stream: false };
if (tools) body.tools = tools;
const res = await fetch(`${url}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const message = (await res.json()).message || {};
return { content: message.content || "", toolCalls: message.tool_calls || null };
} else {
const body = { model, messages: formattedMessages, stream: false };
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const message = (await res.json()).choices?.[0]?.message || {};
return { content: message.content || "", toolCalls: message.tool_calls || null };
}
}
function _parseToolArgs(toolCall) {
const raw = toolCall.function?.arguments ?? toolCall.arguments;
if (typeof raw === "string") {
try { return JSON.parse(raw); } catch { return {}; }
}
return raw || {};
}
async function _loadDedicatedChatGenWorkflow(filename) {
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
let workflow = await resp.json();
const format = comfyWorkflow.detectFormat(workflow, filename);
if (format === "ui") {
workflow = await comfyWorkflow.convertUiToApi(workflow);
} else if (format !== "api") {
throw new Error("Unsupported or unrecognized workflow format");
}
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
}
async function generateImageFromChat(prompt, negativePrompt) {
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
const settings = loadAiSettings();
const isI2I = !!_toolsImage;
let workflow, analysis;
if (isI2I && settings.chatGenI2IDedicatedEnabled && settings.chatGenI2IDedicatedFilename) {
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenI2IDedicatedFilename));
} else if (!isI2I && settings.chatGenDedicatedEnabled && settings.chatGenDedicatedFilename) {
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenDedicatedFilename));
} else {
if (!comfyUI.currentWorkflow || !comfyUI.currentAnalysis) throw new Error(t("aiChatNoWorkflowLoaded"));
workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
analysis = comfyUI.currentAnalysis;
}
if (isI2I) {
if (!(analysis.load_image_nodes?.length > 0)) throw new Error(t("aiChatNoLoadImageNode"));
await comfyEditor.applyImageToSlot(_toolsImage.file, 0, { workflow, analysis });
}
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
const result = await window._wfmGenerateTab.generate(workflow);
const images = result?.images || [];
if (images.length === 0) throw new Error(t("aiChatNoImageProduced"));
const img = images[0];
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
}
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
function _extractSvgCode(text) {
if (!text) return null;
const fenced = text.match(/```(?:svg|xml)?\s*([\s\S]*?<svg[\s\S]*?<\/svg>)[\s\S]*?```/i);
if (fenced) return fenced[1].trim();
const bare = text.match(/<svg[\s\S]*?<\/svg>/i);
if (bare) return bare[0].trim();
return null;
}
// SVGコードをプレビュー表示し、Galleryへ保存するボタンを追加する。
// <img src="data:..."> でのレンダリングはブラウザがスクリプト実行を無効化するため安全。
function _appendSvgPreview(container, svgCode) {
const wrapper = document.createElement("div");
wrapper.className = "wfm-ai-chat-svg-wrapper";
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgCode)))}`;
const img = document.createElement("img");
img.src = dataUrl;
img.className = "wfm-ai-chat-svg-img";
img.alt = "Generated SVG";
wrapper.appendChild(img);
const saveBtn = document.createElement("button");
saveBtn.className = "wfm-btn wfm-btn-sm";
saveBtn.textContent = t("aiChatSaveSvgToGallery");
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
try {
const res = await fetch("/wfm/gallery/image/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: `chat_svg_${Date.now()}.svg`, imageData: dataUrl }),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
showToast(t("aiChatSvgSaved"), "success");
} catch (e) {
showToast(t("aiChatSvgSaveFailed") + e.message, "error");
} finally {
saveBtn.disabled = false;
}
});
wrapper.appendChild(saveBtn);
container.appendChild(wrapper);
}
function appendChatBubble(messagesEl, role, content, opts = {}) {
const div = document.createElement("div");
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
if (content) div.textContent = content;
if (opts.imageUrl) {
const img = document.createElement("img");
img.src = opts.imageUrl;
img.className = "wfm-ai-chat-img";
div.appendChild(img);
}
if (opts.attachThumb) {
const img = document.createElement("img");
img.src = opts.attachThumb;
img.className = "wfm-ai-chat-attach-thumb";
div.appendChild(img);
}
if (role === "assistant" && content) {
const svgCode = _extractSvgCode(content);
if (svgCode) _appendSvgPreview(div, svgCode);
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
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");
const imgGenToggle = document.getElementById("wfm-ai-chat-imggen-toggle");
if (!messagesEl || !inputEl || !sendBtn) return;
if (imgGenToggle) {
const savedToggle = loadAiSettings();
imgGenToggle.checked = savedToggle.chatImageGenEnabled !== false;
imgGenToggle.addEventListener("change", () => {
saveAiSettings({ chatImageGenEnabled: imgGenToggle.checked });
});
}
document.getElementById("wfm-ai-chat-attachment-clear")?.addEventListener("click", () => {
_clearToolsImage();
});
let chatHistory = [];
async function sendMessage() {
const text = inputEl.value.trim();
if (!text) return;
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
if (!model) {
showToast(t("aiToastNoModel"), "error");
return;
}
inputEl.value = "";
inputEl.style.height = "";
const userEntry = { role: "user", content: text };
if (_toolsImage) userEntry.images = [{ base64: _toolsImage.base64, mimeType: _toolsImage.mimeType }];
chatHistory.push(userEntry);
appendChatBubble(messagesEl, "user", text, _toolsImage ? { attachThumb: `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}` } : {});
sendBtn.disabled = true;
statusEl.textContent = t("aiStatusChatting");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
try {
const tools = imgGenToggle?.checked !== false ? IMAGE_GEN_TOOLS : undefined;
const reply = await callChat(url, backend, model, chatHistory, tools);
const toolCall = reply.toolCalls?.find(
(tc) => (tc.function?.name || tc.name) === "generate_image"
);
if (toolCall) {
chatHistory.push({ role: "assistant", content: reply.content || "" });
if (reply.content) appendChatBubble(messagesEl, "assistant", reply.content);
statusEl.textContent = t("aiStatusGeneratingImage");
const args = _parseToolArgs(toolCall);
try {
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
} catch (genErr) {
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
}
} else {
chatHistory.push({ role: "assistant", content: reply.content });
appendChatBubble(messagesEl, "assistant", reply.content);
}
statusEl.textContent = "";
statusEl.className = "wfm-ai-trans-status";
} catch (err) {
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";
});
}
// ============================================
// TOOLS ↔ Chat shared image attachment
// ============================================
let _toolsImage = null; // { file: File, base64: string, mimeType: string }
function _syncAttachmentUI() {
const vlmPreview = document.getElementById("wfm-ai-vlm-preview");
const vlmLabel = document.getElementById("wfm-ai-vlm-label");
const vlmClear = document.getElementById("wfm-ai-vlm-clear");
const chatAttach = document.getElementById("wfm-ai-chat-attachment");
const chatThumb = document.getElementById("wfm-ai-chat-attachment-thumb");
const chatLabel = document.getElementById("wfm-ai-chat-attachment-label");
if (_toolsImage) {
const dataUrl = `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}`;
if (vlmPreview) { vlmPreview.src = dataUrl; vlmPreview.style.display = "block"; }
if (vlmLabel) vlmLabel.style.display = "none";
if (vlmClear) vlmClear.style.display = "flex";
if (chatAttach) chatAttach.style.display = "flex";
if (chatThumb) chatThumb.src = dataUrl;
if (chatLabel) chatLabel.textContent = t("aiChatAttachmentLabel");
} else {
if (vlmPreview) { vlmPreview.src = ""; vlmPreview.style.display = "none"; }
if (vlmLabel) vlmLabel.style.display = "";
if (vlmClear) vlmClear.style.display = "none";
if (chatAttach) chatAttach.style.display = "none";
if (chatThumb) chatThumb.src = "";
}
}
function _clearToolsImage() {
_toolsImage = null;
_syncAttachmentUI();
}
// ============================================
// VLM tab
// ============================================
function initVlmTab() {
const dropEl = document.getElementById("wfm-ai-vlm-drop");
const previewEl = document.getElementById("wfm-ai-vlm-preview");
const labelEl = document.getElementById("wfm-ai-vlm-label");
const clearEl = document.getElementById("wfm-ai-vlm-clear");
const fileInput = document.getElementById("wfm-ai-vlm-file");
const taskSel = document.getElementById("wfm-ai-vlm-task");
const runBtn = document.getElementById("wfm-ai-vlm-run");
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";
const { base64, mimeType } = await fileToBase64(file);
_toolsImage = { file, base64, mimeType };
_syncAttachmentUI();
};
dropEl.addEventListener("click", () => fileInput.click());
@@ -403,28 +794,61 @@ function initVlmTab() {
dropEl.classList.remove("drag-over");
if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]);
});
clearEl?.addEventListener("click", (e) => {
e.stopPropagation();
_clearToolsImage();
if (fileInput) fileInput.value = "";
});
runBtn.addEventListener("click", async () => {
if (!vlmImage) { showToast(t("aiToastNoImage"), "error"); return; }
const task = taskSel?.value || "describe";
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
if (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
const task = taskSel?.value || "describe";
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 (!_toolsImage) { 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);
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
resultEl.value = result.trim();
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
@@ -450,6 +874,7 @@ function initVlmTab() {
export function initAiTab() {
initTranslateTab();
initChatTab();
initVlmTab();
initSettingsTab();
}
+174 -12
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", metadata: "tabMetadata", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp", ai: "tabAi" };
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");
@@ -284,6 +310,16 @@ function applyI18nToHtml() {
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
const aiChatImgGenLabel = document.getElementById("wfm-ai-chat-imggen-label-text");
if (aiChatImgGenLabel) aiChatImgGenLabel.textContent = t("aiChatImgGenToggle");
const aiChatGenTitle = document.getElementById("wfm-ai-settings-chatgen-title");
if (aiChatGenTitle) aiChatGenTitle.textContent = t("aiSettingsChatGenTitle");
const aiChatGenDedicatedLabel = document.getElementById("wfm-ai-chatgen-dedicated-label");
if (aiChatGenDedicatedLabel) aiChatGenDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
const aiChatGenI2ITitle = document.getElementById("wfm-ai-settings-chatgen-i2i-title");
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
// Group panel labels
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
@@ -326,14 +362,25 @@ 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-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-gen-18": "helpGen18",
"wfm-help-gen-19": "helpGen19",
"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",
@@ -344,22 +391,34 @@ function applyI18nToHtml() {
"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-10": "helpModels10",
"wfm-help-models-11": "helpModels11",
"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-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",
@@ -370,15 +429,71 @@ function applyI18nToHtml() {
"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-ai-7": "helpAi7",
"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-7e": "helpImageEdit7e",
"wfm-help-imageedit-8b": "helpImageEdit8b",
"wfm-help-imageedit-blur-title": "helpImageEditBlurTitle",
"wfm-help-imageedit-blur-1": "helpImageEditBlur1", "wfm-help-imageedit-blur-2": "helpImageEditBlur2",
"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-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
"wfm-help-imageedit-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-trouble-8": "helpTrouble8",
"wfm-help-support-title": "helpSupportTitle",
"wfm-help-support-desc": "helpSupportDesc",
"wfm-help-github-desc": "helpGithubDesc",
@@ -389,6 +504,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();
}
// ============================================
@@ -431,26 +581,36 @@ 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
// Apply saved textarea font size & JSON highlight colors
try {
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
const _s = getSettings();
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
} catch {}
// Apply saved JSON highlight colors
try {
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
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();
@@ -460,6 +620,8 @@ document.addEventListener("DOMContentLoaded", () => {
initMetadataTab();
initGalleryTab();
initAiTab();
initTaggerTab();
imageEditTab.init();
console.log("Workflow Studio: App initialized");
});
+55 -21
View File
@@ -90,11 +90,16 @@ export const comfyUI = {
if (info?.[cls]?.input?.required?.[inputKey]) {
const inputDef = info[cls].input.required[inputKey];
const first = inputDef[0];
// V1: [["model1", "model2"], {}]
if (Array.isArray(first)) return first;
// Newer ComfyUI format: ["COMBO", { "values": [...] }]
// 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 {}
}
@@ -127,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() {
@@ -148,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 [];
},
@@ -168,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(() => ({}));
@@ -228,7 +240,13 @@ export const comfyUI = {
});
const res = await fetch(`${this.baseUrl}/view?${params}`);
if (!res.ok) throw new Error(`Failed to fetch image: HTTP ${res.status}`);
return await res.blob();
const blob = await res.blob();
// /view はSVGをセキュリティ上application/octet-streamで返すため、
// <img>で表示できるよう拡張子から正しいMIMEタイプへ付け替える
if (imageData.filename?.toLowerCase().endsWith(".svg") && blob.type !== "image/svg+xml") {
return blob.slice(0, blob.size, "image/svg+xml");
}
return blob;
},
async uploadImage(file, filename, overwrite = true) {
@@ -275,8 +293,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
@@ -288,6 +306,9 @@ export const comfyUI = {
// Extract images from SaveImage outputs only
const images = [];
// comfyui-tosvg の "Save SVG String" ノードは images ではなく
// saved_svg(ファイル名) / path(絶対パス) という独自キーで返す
const svgOutputs = [];
const outputs = history.outputs || {};
for (const nodeOutput of Object.values(outputs)) {
if (nodeOutput.images) {
@@ -295,10 +316,23 @@ export const comfyUI = {
images.push(img);
}
}
if (nodeOutput.saved_svg) {
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
const joinIfCharArray = (v) =>
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
? v.join("")
: v;
svgOutputs.push({
filename: joinIfCharArray(nodeOutput.saved_svg),
path: joinIfCharArray(nodeOutput.path) || "",
});
}
}
onComplete?.(images, seed);
return { images, seed };
return { images, seed, svgOutputs };
} catch (err) {
onError?.(err);
throw err;
File diff suppressed because it is too large Load Diff
+413 -23
View File
@@ -5,6 +5,50 @@
// Cache for /object_info (loaded once, shared)
let _objectInfoCache = null;
// convertUiToApi() の COMBO フォールバック(未一致時に選択肢の先頭へ無警告で差し替え)が
// ckpt_name に対して発生した場合の記録。呼び出し元が読み込み直後に確認して警告表示できるよう、
// 直近の convertUiToApi() 呼び出し分だけを保持する。
let _lastCheckpointSubstitutions = [];
// convertUiToApi() で除外された Bypass(mode:4) / Mute(mode:2) ノードの記録。
// API形式には mode 情報が残らないため、呼び出し元がRaw JSON表示時に
// 「このワークフローにはバイパス/ミュート中のノードがあった」と伝えられるよう、
// 直近の convertUiToApi() 呼び出し分だけを保持する。
let _lastBypassedNodes = [];
let _lastMutedNodes = [];
/**
* Trace a link's source through any Bypass-mode (mode 4) nodes, matching each bypassed
* node's output slot to an input slot of the same type and following the connection
* upstream (recursively, for chained bypasses). Mirrors ComfyUI's own Bypass behavior,
* where a bypassed node is removed but its wires are reconnected end-to-end.
* Returns null when no pass-through source exists (the matching input is unconnected),
* in which case the caller should treat the input as unlinked.
*/
function _resolveBypassSource(nodeById, linkMap, nodeId, slot, visited) {
const idStr = String(nodeId);
const node = nodeById[idStr];
if (!node || node.mode !== 4) return [idStr, slot];
if (visited.has(idStr)) return null; // circular bypass chain guard
visited.add(idStr);
const outputs = node.outputs || [];
const inputs = node.inputs || [];
const outType = outputs[slot]?.type;
let inIdx = -1;
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
inIdx = slot;
} else {
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
}
if (inIdx === -1) return null;
const upstream = linkMap[idStr][inIdx];
if (!upstream) return null;
return _resolveBypassSource(nodeById, linkMap, upstream[0], upstream[1], visited);
}
async function _loadObjectInfo() {
if (_objectInfoCache) return _objectInfoCache;
try {
@@ -37,7 +81,7 @@ function _getWidgetInputNames(objectInfo, classType) {
names.push(name);
} else if (typeof type === "string") {
const upper = type.toUpperCase();
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
names.push(name);
}
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
@@ -51,7 +95,7 @@ function _getWidgetInputNames(objectInfo, classType) {
names.push(name);
} else if (typeof type === "string") {
const upper = type.toUpperCase();
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN") {
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
names.push(name);
}
}
@@ -62,7 +106,7 @@ function _getWidgetInputNames(objectInfo, classType) {
/**
* Get ordered widget input types for a node class (parallel to _getWidgetInputNames).
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", or "COMBO".
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", or "COMFY_DYNAMICCOMBO_V3".
*/
function _getWidgetInputTypes(objectInfo, classType) {
const info = objectInfo[classType];
@@ -77,7 +121,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
types.push("COMBO");
} else if (typeof type === "string") {
const upper = type.toUpperCase();
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
types.push(upper);
}
}
@@ -88,7 +132,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
types.push("COMBO");
} else if (typeof type === "string") {
const upper = type.toUpperCase();
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
types.push(upper);
}
}
@@ -96,6 +140,19 @@ function _getWidgetInputTypes(objectInfo, classType) {
return types;
}
/**
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
*/
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
const info = objectInfo[classType];
const spec = info?.input?.required?.[widgetName] || info?.input?.optional?.[widgetName];
const options = spec?.[1]?.options;
if (!Array.isArray(options)) return [];
const opt = options.find((o) => o.key === selectedKey) || options[0];
return Object.keys(opt?.inputs?.required || {});
}
/**
* Detect if a widget value is a frontend-only extra (like "control_after_generate").
* These values appear in widgets_values but are not in object_info inputs.
@@ -123,10 +180,17 @@ function _isExtraWidgetValue(val, expectedType) {
*
* Strategy: expand internal nodes into the parent, redirect parent links through
* the boundary ports, and convert internal links from object to array format.
* Widget values in internal nodes are preserved as-is (they are synced at save time
* by ComfyUI frontend). convertUiToApi handles widget mapping via object_info.
*
* Widget values: the outer subgraph node's own widgets_values hold the values actually
* set via the subgraph's collapsed form (what the user sees/edits). The internal nodes'
* widgets_values, stored in the subgraph template (definitions.subgraphs[].nodes), are
* only stale defaults from when the template was authored — ComfyUI does NOT keep them
* synced in saved workflow JSON. Un-linked outer widget inputs are therefore copied onto
* the matching internal node's widgets_values (via objectInfo to get the correct index,
* since the internal node's real class type is known). Linked outer inputs are left to
* the "Redirect parent links" step above, which takes priority.
*/
function _flattenSubgraphs(workflow) {
function _flattenSubgraphs(workflow, objectInfo) {
const defs = workflow.definitions?.subgraphs;
if (!defs || defs.length === 0) return workflow;
@@ -192,6 +256,12 @@ function _flattenSubgraphs(workflow) {
}
// === Redirect parent links ===
// Track which {internal node, input slot} pairs end up resolved by a real outer
// link after redirection — needed below to tell apart "boundary-only" links
// (prompt/model-name ports that are actually widget values, see injection step)
// from ports that truly carry a link end-to-end (e.g. width/height wired to a
// ResolutionSelector node).
const redirectedTargets = new Set();
for (const link of wf.links) {
if (link[3] === node.id) {
const dstSlot = link[4];
@@ -204,6 +274,7 @@ function _flattenSubgraphs(workflow) {
if (target) {
link[3] = target.targetNodeId;
link[4] = target.targetSlot;
redirectedTargets.add(`${target.targetNodeId}:${target.targetSlot}`);
}
}
if (link[1] === node.id) {
@@ -217,12 +288,82 @@ function _flattenSubgraphs(workflow) {
}
// === Add internal nodes (remapped IDs) ===
const remappedByOrigId = {};
const normalizedNodeIds = new Set();
for (const iNode of sgDef.nodes) {
const remapped = JSON.parse(JSON.stringify(iNode));
remapped.id = nodeIdRemap[iNode.id];
// Some subgraph-template nodes store widgets_values in the "legacy full" format
// (one entry per widget input, including linked ones), while convertUiToApi's
// widget-mapping loop assumes the "modern" format (linked widget inputs have no
// entry at all). Detect the full format by comparing lengths and strip the
// entries belonging to inputs that are *actually* linked end-to-end (per
// redirectedTargets above) so downstream mapping stays index-aligned. Boundary
// links that terminate in a plain widget value (e.g. prompt text) are NOT
// stripped — their value lives in the outer node's widgets_values instead and
// is injected below.
const allWidgetInputs = (remapped.inputs || []).filter((i) => i.widget);
if (remapped.widgets_values && remapped.widgets_values.length === allWidgetInputs.length) {
const linkedIdx = new Set();
allWidgetInputs.forEach((inp, i) => {
const slotIdx = remapped.inputs.indexOf(inp);
if (redirectedTargets.has(`${remapped.id}:${slotIdx}`)) linkedIdx.add(i);
});
if (linkedIdx.size > 0) {
remapped.widgets_values = remapped.widgets_values.filter((_, i) => !linkedIdx.has(i));
normalizedNodeIds.add(remapped.id);
}
}
addedNodes.push(remapped);
remappedByOrigId[iNode.id] = remapped;
}
// === Inject outer subgraph-node widget values into internal nodes ===
// Only un-linked widget inputs — linked ones are already redirected above.
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
outerWidgetInputs.forEach((inp, widgetIdx) => {
if (inp.link != null) return;
if (widgetIdx >= (node.widgets_values || []).length) return;
const outerValue = node.widgets_values[widgetIdx];
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
if (portIdx === -1) return;
const target = inputPortTargets[portIdx];
if (!target) return;
const origInternalId = String(target.targetNodeId).split(":").pop();
const remappedNode = remappedByOrigId[origInternalId];
if (!remappedNode) return;
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
if (!targetInputDef?.widget) return;
// When the internal node's widgets_values was normalized above (stripped of
// linked-slot entries), the index must be computed against that same reduced
// set of widget names — object_info's full name order (which still includes
// e.g. width/height) would be off by however many entries were stripped.
let iWidgetIdx;
if (normalizedNodeIds.has(remappedNode.id)) {
const remainingNames = (remappedNode.inputs || [])
.filter((i) => i.widget)
.filter((i) => {
const slotIdx = remappedNode.inputs.indexOf(i);
return !redirectedTargets.has(`${remappedNode.id}:${slotIdx}`);
})
.map((i) => i.name);
iWidgetIdx = remainingNames.indexOf(targetInputDef.name);
} else {
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
iWidgetIdx = widgetNames.indexOf(targetInputDef.name);
}
if (iWidgetIdx === -1) return;
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
remappedNode.widgets_values[iWidgetIdx] = outerValue;
});
// === Add internal links (object format → array format) ===
for (const iLink of (sgDef.links || [])) {
if (iLink.origin_id === -10 || iLink.target_id === -20) continue;
@@ -253,8 +394,11 @@ export const comfyWorkflow = {
detectFormat(workflow, filename) {
if (!workflow || typeof workflow !== "object") return "unknown";
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) {
// App format: UI-based structure with definitions (subgraphs) or linearMode
if (workflow.definitions || workflow.extra?.linearMode === true) return "app";
// App format: simplified web-app-style UI (linearMode) or .app.json filename.
// A workflow that merely *contains* subgraphs (definitions.subgraphs) is still
// a normal node graph — convertUiToApi() flattens subgraphs before conversion,
// so it's treated as "ui" rather than blocked as unsupported "app" format.
if (workflow.extra?.linearMode === true) return "app";
if (filename && /\.app\.json$/i.test(filename)) return "app";
return "ui";
}
@@ -267,14 +411,30 @@ export const comfyWorkflow = {
/**
* Convert UI-format workflow to API-format using /object_info for accurate widget mapping.
*/
getLastCheckpointSubstitutions() {
return _lastCheckpointSubstitutions;
},
getLastBypassedNodes() {
return _lastBypassedNodes;
},
getLastMutedNodes() {
return _lastMutedNodes;
},
async convertUiToApi(workflow) {
_lastCheckpointSubstitutions = [];
_lastBypassedNodes = [];
_lastMutedNodes = [];
if (!workflow.nodes || !workflow.links) return {};
// Flatten subgraphs before conversion
const flatWorkflow = _flattenSubgraphs(workflow);
const objectInfo = await _loadObjectInfo();
// Flatten subgraphs before conversion (needs objectInfo to correctly map outer
// subgraph-node widget values onto internal nodes' widgets_values indices)
const flatWorkflow = _flattenSubgraphs(workflow, objectInfo);
// Build link map: dstNode -> { dstSlot -> [srcNodeId, srcSlot] }
const linkMap = {};
for (const link of flatWorkflow.links) {
@@ -282,10 +442,23 @@ export const comfyWorkflow = {
if (!linkMap[dstNode]) linkMap[dstNode] = {};
linkMap[dstNode][dstSlot] = [String(srcNode), srcSlot];
}
const nodeById = {};
for (const n of flatWorkflow.nodes) nodeById[String(n.id)] = n;
const api = {};
for (const node of flatWorkflow.nodes) {
if (node.mode === 4) continue; // muted/bypassed
// Mute (mode 2): excluded, no pass-through — matches ComfyUI itself, where
// muting a node with required downstream dependents is expected to error.
// Bypass (mode 4): excluded here too, but its wires are rerouted below via
// _resolveBypassSource() so downstream nodes reconnect to its upstream source.
if (node.mode === 2) {
_lastMutedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
continue;
}
if (node.mode === 4) {
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
continue;
}
// Skip note/display-only nodes that are unknown to ComfyUI backend
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
const nodeId = String(node.id);
@@ -295,10 +468,17 @@ 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];
const resolved = _resolveBypassSource(nodeById, linkMap, nodeLinks[idx][0], nodeLinks[idx][1], new Set());
if (!resolved) return; // Bypass chain with no upstream source — leave unlinked
inputs[inp.name] = resolved;
linkedInputNames.add(inp.name);
linkedSlotNames.add(inp.name);
}
});
@@ -336,13 +516,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
@@ -358,6 +540,22 @@ export const comfyWorkflow = {
wIdx++;
}
}
// DynamicCombo (e.g. SaveImageAdvanced's "format"): val is the selected
// option's key (e.g. "png"). Per comfy_api/latest/_io.py
// DynamicCombo._expand_schema_for_dynamic, the backend looks up flat
// dot-prefixed keys ("format", "format.bit_depth", ...) in the raw
// inputs dict and assembles them into a single dict itself at execution
// time — it does NOT accept a pre-built dict under the "format" key.
if (expectedType === "COMFY_DYNAMICCOMBO_V3") {
inputs[name] = val;
const subNames = _getDynamicComboSubNames(objectInfo, node.type, name, val);
for (const subName of subNames) {
if (wIdx >= widgets.length) break;
inputs[`${name}.${subName}`] = widgets[wIdx];
wIdx++;
}
continue;
}
// For COMBO inputs, validate the value against the available choices.
// Dynamic COMBO options (e.g. Impact Pack's "Select to add Wildcard") may
// differ between the saved workflow and the current ComfyUI instance
@@ -372,6 +570,14 @@ export const comfyWorkflow = {
if (spec) {
const choices = Array.isArray(spec[0]) ? spec[0] : null;
if (choices && choices.length > 0 && !choices.includes(val)) {
if (name === "ckpt_name") {
_lastCheckpointSubstitutions.push({
nodeId: String(node.id),
title: node.title || node.type,
original: val,
replaced: choices[0],
});
}
val = choices[0];
}
}
@@ -392,6 +598,14 @@ 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] = {
class_type: node.type,
inputs,
@@ -472,9 +686,12 @@ export const comfyWorkflow = {
vae_nodes: [],
save_nodes: [],
load_image_nodes: [],
inpaint_encode_nodes: [],
mask_editor_one_nodes: [],
text_encoder_nodes: [],
diffusion_model_nodes: [],
controlnet_nodes: [],
hypernetwork_nodes: [],
all_nodes: [],
};
@@ -513,7 +730,10 @@ export const comfyWorkflow = {
// Nodes that pass CONDITIONING through without changing positive/negative role
const COND_PASSTHROUGH = new Set([
"ConditioningCombine", "ConditioningConcat", "ConditioningAverage",
"ConditioningZeroOut", "ConditioningSetTimestepRange",
"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",
@@ -561,6 +781,16 @@ export const comfyWorkflow = {
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;
}
}
}
}
}
@@ -591,6 +821,23 @@ export const comfyWorkflow = {
});
}
// 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") {
@@ -618,6 +865,19 @@ export const comfyWorkflow = {
}
}
// 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({
@@ -627,6 +887,23 @@ export const comfyWorkflow = {
});
}
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
// unlike KSampler-derived getRole() since both wires originate from this node)
if (ct === "TextEncodeMageFlowEdit") {
if (typeof inputs.prompt === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [positive]`, role: "positive",
text: inputs.prompt, textKey: "prompt",
});
}
if (typeof inputs.negative_prompt === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [negative]`, role: "negative",
text: inputs.negative_prompt, textKey: "negative_prompt",
});
}
}
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
if (inputs.text_positive !== undefined) {
@@ -654,6 +931,22 @@ export const comfyWorkflow = {
}
}
// 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({
@@ -672,6 +965,20 @@ export const comfyWorkflow = {
});
}
// TextEncodeMageFlowEdit also carries width/height/batch_size directly
// (it builds the latent internally instead of a separate EmptyLatentImage node).
// width/height are commonly wired to a ResolutionSelector-style helper node
// (array link ref) rather than a direct number — leave those as undefined so
// the settings-tab UI falls back to its default instead of rendering a link ref.
if (ct === "TextEncodeMageFlowEdit") {
result.latent_nodes.push({
id, type: ct, title,
width: typeof inputs.width === "number" ? inputs.width : undefined,
height: typeof inputs.height === "number" ? inputs.height : undefined,
batch_size: inputs.batch_size,
});
}
// --- LoRA nodes ---
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
result.lora_nodes.push({
@@ -696,6 +1003,26 @@ export const comfyWorkflow = {
}
}
// 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 });
@@ -714,19 +1041,42 @@ export const comfyWorkflow = {
});
}
// --- VAE Encode (for Inpainting) nodes ---
if (ct === "VAEEncodeForInpaint") {
result.inpaint_encode_nodes.push({
id, type: ct, title,
grow_mask_by: inputs.grow_mask_by,
});
}
// --- Mask Editor One nodes (comfyui-mask-editor-one) ---
// 画像とマスクをノード自身が内包する(LoadImageを介さない)インペイント画像ソース。
// image出力はサーバー側キャッシュ(bg_image_b64、node_idキー)から、mask出力は
// layer_data(JSON文字列)ウィジェットの各レイヤーのアルファチャンネルから合成される。
if (ct === "MaskEditorOne") {
result.mask_editor_one_nodes.push({ id, type: ct, title });
}
// --- Text encoder (CLIP) nodes ---
if (ct === "DualCLIPLoader" || ct === "CLIPLoader") {
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",
});
}
@@ -737,6 +1087,33 @@ export const comfyWorkflow = {
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,
});
}
}
// --- Post-pass: flag LoadImage nodes whose MASK output (slot 1) is wired to something
// downstream (directly, or via a pass-through node like MaskEditorNode). These are the
// "inpaint-capable" slots — image + mask both come from the same node's two outputs, so
// painting a mask for them only requires re-uploading that one node's image with the mask
// baked into the alpha channel (matches ComfyUI's native LoadImage alpha→MASK extraction). ---
const loadImageIds = new Set(result.load_image_nodes.filter(n => n.type === "LoadImage").map(n => n.id));
if (loadImageIds.size > 0) {
for (const node of Object.values(workflow)) {
if (!node?.inputs) continue;
for (const v of Object.values(node.inputs)) {
if (Array.isArray(v) && v.length === 2 && Number(v[1]) === 1 && loadImageIds.has(String(v[0]))) {
const target = result.load_image_nodes.find(n => n.id === String(v[0]));
if (target) target.mask_used = true;
}
}
}
}
result.all_nodes.sort((a, b) => Number(a.id) - Number(b.id));
@@ -781,17 +1158,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;
}
+387 -38
View File
@@ -1,17 +1,29 @@
/**
* Feeder Tab - ImageFeeder node control with embedded image library
* 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
images: [], // filenames in current folder
selected: new Set(),// selected filenames (across all interactions)
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 ──────────────────────────────────────────────────
@@ -24,12 +36,40 @@ function _feederNodes() {
.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() {
const sel = document.getElementById("wfm-feeder-node-sel");
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 = _feederNodes();
const nodes = _galFeederNodes();
if (nodes.length === 0) {
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
sel.innerHTML = `<option value="">-- No WFS_GalleryFeeder node --</option>`;
return;
}
const prevVal = sel.value;
@@ -37,7 +77,7 @@ export function refreshFeederNodeList() {
`<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);
_loadFromGalNode(sel.value || nodes[0].id);
}
function _loadFromNode(nodeId) {
@@ -54,7 +94,6 @@ function _loadFromNode(nodeId) {
_setVal("wfm-feeder-seed", inp.seed ?? 0);
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
// Restore selected_files
_s.selected.clear();
try {
const arr = JSON.parse(inp.selected_files || "[]");
@@ -72,15 +111,44 @@ function _loadFromNode(nodeId) {
_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("No ImageFeeder node selected", "error");
showToast(t("feederNoNode"), "error");
return;
}
_applyToNode(nodeId);
showToast("Applied to workflow", "success");
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 ──────────────────────────────────────────────────────
@@ -92,6 +160,13 @@ function _setRunUI(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;
@@ -106,20 +181,28 @@ function _applyToNode(nodeId) {
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("No workflow loaded", "error"); return; }
if (!comfyUI.connected) { showToast("Not connected to ComfyUI", "error"); 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("No ImageFeeder node selected", "error");
showToast(t("feederNoNode"), "error");
return;
}
// WebSocket を先に接続してノード sync メッセージを受け取れるようにする
const wsOk = await comfyUI.connectWebSocket();
if (!wsOk) { showToast("WebSocket connection failed", "error"); return; }
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
_s.running = true;
_setRunUI(true);
@@ -128,13 +211,16 @@ async function _startRun() {
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
// image_loop_node_sync メッセージを捕捉するリスナー
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 {}
};
@@ -152,7 +238,6 @@ async function _startRun() {
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = `#${++count} 0%`;
// 右ペインのseed設定を使用(KSamplerに適用される)
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
@@ -168,7 +253,6 @@ async function _startRun() {
}
);
// 右ペインのseed表示を更新(通常の生成と同じ挙動)
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
@@ -182,7 +266,6 @@ async function _startRun() {
if (!_s.running) break;
// control after generate: ノードから返された next_index を反映
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
if (control === "fixed") {
// index を変えない
@@ -191,14 +274,13 @@ async function _startRun() {
if (indexEl) indexEl.value = _lastSync.next_index;
if (control === "increment" && !_lastSync.has_next) {
_s.running = false;
showToast(`Feeder complete (${count} generated)`, "success");
showToast(t("feederComplete", count), "success");
break;
}
// loop: has_next=false でも次は index=0 からなので続行
}
}
} catch (err) {
if (_s.running) showToast("Feeder run error: " + err.message, "error");
if (_s.running) showToast(t("feederRunError", err.message), "error");
} finally {
comfyUI.socket?.removeEventListener("message", _syncHandler);
_s.running = false;
@@ -211,10 +293,107 @@ async function _stopRun() {
_s.running = false;
try { await comfyUI.interrupt(); } catch {}
_setRunUI(false);
showToast("Feeder stopped", "info");
showToast(t("feederStopped"), "info");
}
// ── Folder Tree ───────────────────────────────────────────────────
// ── 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");
@@ -271,7 +450,7 @@ function _highlightTreeRow(path) {
});
}
// ── Image Grid ────────────────────────────────────────────────────
// ── Image Grid (loop mode) ────────────────────────────────────────
async function _loadImages(dir) {
const grid = document.getElementById("wfm-feeder-grid");
@@ -362,8 +541,6 @@ function _deselectAll() {
_updateStatus();
}
// ── Status Bar ───────────────────────────────────────────────────
function _updateStatus() {
const el = document.getElementById("wfm-feeder-status");
if (!el) return;
@@ -371,6 +548,104 @@ function _updateStatus() {
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) {
@@ -395,7 +670,20 @@ async function _showPreview(relPath, fname) {
} catch { if (infoEl) infoEl.textContent = ""; }
}
// ── Presets ──────────────────────────────────────────────────────
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 {
@@ -416,7 +704,7 @@ function _renderPresets() {
async function _savePreset() {
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
if (!name) { showToast("Enter a preset name", "error"); return; }
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", {
@@ -425,20 +713,20 @@ async function _savePreset() {
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
});
if (res.ok) {
showToast(`Preset saved: ${name}`, "success");
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("Save failed", "error"); }
} catch (err) { showToast("Save error: " + err.message, "error"); }
} 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("Select a preset first", "error"); return; }
if (!name || !_s.presets[name]) { showToast(t("selectPresetFirst"), "error"); return; }
const preset = _s.presets[name];
const dir = preset.directory || "";
@@ -453,7 +741,7 @@ async function _applyPreset() {
}
_refreshGridCbs();
_updateStatus();
showToast(`Preset loaded: ${name}`, "success");
showToast(t("presetLoaded", name), "success");
}
async function _deletePreset() {
@@ -461,9 +749,37 @@ async function _deletePreset() {
if (!name) return;
try {
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
showToast(`Preset deleted: ${name}`, "info");
showToast(t("presetDeleted", name), "info");
await _loadPresets();
} catch (err) { showToast("Delete error", "error"); }
} 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 ───────────────────────────────────────────────────────
@@ -480,6 +796,11 @@ function _setCheck(id, 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);
});
@@ -506,5 +827,33 @@ export async function initFeederTab() {
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
await Promise.all([_loadTree(), _loadPresets()]);
// ── 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("");
}
}
+732 -103
View File
File diff suppressed because it is too large Load Diff
+1242 -120
View File
File diff suppressed because it is too large Load Diff
+1240 -119
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
/**
* Image Edit Tab - Draw Tool
* Inspired by comfyui-mask-editor-one PaintTool.js
* Supports color drawing and erasing with a soft round brush.
* Also supports image brushes loaded from Mask Editor One's ABR brush library.
*/
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.spacing = 0.25; // stamp interval as fraction of brushSize (image brush only)
this.angle = 0; // degrees [0, 359]
this.sizeJitter = false;
this.sizeJitterAmount = 0.5;
this.rotationJitter = false;
// Image brush from ABR library (null = default circle brush)
this.brushImage = null;
this.brushName = "Circle";
this._drawing = false;
this._lastX = 0;
this._lastY = 0;
// Cached circle stamp
this._stamp = null;
this._stampSize = 0;
this._stampHard = 0;
this._stampColor = null;
// Cached image stamp
this._imgStamp = null;
this._imgStampSize = 0;
this._imgStampImg = null;
this._imgStampColor = 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.save();
if (this.mode === "erase") {
ctx.globalCompositeOperation = "destination-out";
} else {
ctx.globalCompositeOperation = "source-over";
}
ctx.globalAlpha = this.opacity;
ctx.drawImage(this._strokeCanvas, 0, 0);
ctx.restore();
}
_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._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);
}
}
// ── Image brush ──────────────────────────────────────────────────
_getImageStamp(size) {
size = Math.max(1, size);
if (
this._imgStamp &&
this._imgStampSize === size &&
this._imgStampImg === this.brushImage &&
this._imgStampColor === this.color
) 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;
}
// Parse target color
const hex = this.color.replace("#", "");
const cr = parseInt(hex.slice(0, 2), 16);
const cg = parseInt(hex.slice(2, 4), 16);
const cb = parseInt(hex.slice(4, 6), 16);
// Convert to target color 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] = cr; d[i + 1] = cg; d[i + 2] = cb;
d[i + 3] = Math.round(alpha * 255);
}
stx.putImageData(imgData, 0, 0);
this._imgStamp = sc;
this._imgStampSize = size;
this._imgStampImg = this.brushImage;
this._imgStampColor = this.color;
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";
}
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,
};
}
}
+242
View File
@@ -0,0 +1,242 @@
/**
* 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");
if (this.type === "mask") {
// マスクレイヤー: 黒背景に白マスクのグレースケール表示
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, size, size);
ctx.drawImage(this.canvas, 0, 0, size, size);
} else {
// 通常レイヤー: チェッカーボード背景(透過表示用)
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");
}
}
+529
View File
@@ -0,0 +1,529 @@
/**
* Image Edit Tab - Mask Editor One 互換追加ツール
* Color, Alpha, Text, Vector, Shape の各マスクツール。
* マスクレイヤー canvas に直接白(add)または destination-out(erase)で描画する。
*/
// ── Color Tool ───────────────────────────────────────────────────────────────
// bgCanvas(背景合成画像)のクリック色に近いピクセルをマスク選択する。
export class MaskColorTool {
constructor() {
this.tolerance = 40; // 0-255
this.feather = 5; // 0-100 (tolerance に対する%)
this.mode = "add"; // "add" | "subtract"
this._canvas = null;
this._ctx = null;
this._bgCanvas = null;
this._onChange = null;
}
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
setBgCanvas(canvas) { this._bgCanvas = canvas; }
onChange(fn) { this._onChange = fn; }
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; }
onMouseDown(x, y) { this._selectByColor(Math.round(x), Math.round(y)); }
onMouseMove() {}
onMouseUp() {}
onMouseLeave() {}
_selectByColor(px, py) {
if (!this._bgCanvas || !this._ctx || !this._canvas) return;
const bgCtx = this._bgCanvas.getContext("2d");
const bw = this._bgCanvas.width;
const bh = this._bgCanvas.height;
if (bw === 0 || bh === 0) return;
const srcData = bgCtx.getImageData(0, 0, bw, bh);
const dw = this._canvas.width;
const dh = this._canvas.height;
const sx = bw / dw;
const sy = bh / dh;
const bpx = Math.min(bw - 1, Math.round(px * sx));
const bpy = Math.min(bh - 1, Math.round(py * sy));
const idx = (bpy * bw + bpx) * 4;
if (idx < 0 || idx + 3 >= srcData.data.length) return;
const tr = srcData.data[idx];
const tg = srcData.data[idx + 1];
const tb = srcData.data[idx + 2];
const outData = new Uint8ClampedArray(dw * dh * 4);
const tol = this.tolerance;
for (let dy = 0; dy < dh; dy++) {
for (let dx = 0; dx < dw; dx++) {
const bx = Math.min(bw - 1, Math.round(dx * sx));
const by = Math.min(bh - 1, Math.round(dy * sy));
const si = (by * bw + bx) * 4;
const r = srcData.data[si];
const g = srcData.data[si + 1];
const b = srcData.data[si + 2];
const dist = Math.sqrt((r - tr) ** 2 + (g - tg) ** 2 + (b - tb) ** 2);
let alpha;
if (tol <= 0) {
alpha = dist === 0 ? 255 : 0;
} else {
const fStart = tol * (1 - this.feather / 100);
if (dist <= fStart) alpha = 255;
else if (dist <= tol) alpha = Math.round(255 * (1 - (dist - fStart) / (tol - fStart)));
else alpha = 0;
}
const j = (dy * dw + dx) * 4;
outData[j] = outData[j + 1] = outData[j + 2] = 255;
outData[j + 3] = alpha;
}
}
const tmp = document.createElement("canvas");
tmp.width = dw; tmp.height = dh;
tmp.getContext("2d").putImageData(new ImageData(outData, dw, dh), 0, 0);
const ctx = this._ctx;
ctx.globalCompositeOperation = this.mode === "subtract" ? "destination-out" : "lighten";
ctx.drawImage(tmp, 0, 0);
ctx.globalCompositeOperation = "source-over";
if (this._onChange) this._onChange();
}
}
// ── Alpha Tool ───────────────────────────────────────────────────────────────
// 元画像のアルファチャンネルをマスクとして抽出する。
export class MaskAlphaTool {
constructor() {
this.threshold = 128; // 0-255
this.invert = false;
this._canvas = null;
this._ctx = null;
this._srcImg = null; // HTMLImageElement or HTMLCanvasElement
this._onChange = null;
}
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
setSourceImage(img) { this._srcImg = img; }
onChange(fn) { this._onChange = fn; }
activate() { if (this._canvas) this._canvas.style.cursor = "default"; }
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; }
onMouseDown() {}
onMouseMove() {}
onMouseUp() {}
onMouseLeave() {}
extract() {
if (!this._srcImg || !this._ctx || !this._canvas) return;
const w = this._canvas.width;
const h = this._canvas.height;
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
tmp.getContext("2d").drawImage(this._srcImg, 0, 0, w, h);
const src = tmp.getContext("2d").getImageData(0, 0, w, h);
const out = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < src.data.length; i += 4) {
let a = src.data[i + 3];
if (this.threshold > 0) a = a >= this.threshold ? 255 : 0;
if (this.invert) a = 255 - a;
out[i] = out[i + 1] = out[i + 2] = 255;
out[i + 3] = a;
}
this._ctx.clearRect(0, 0, w, h);
this._ctx.putImageData(new ImageData(out, w, h), 0, 0);
if (this._onChange) this._onChange();
}
}
// ── Text Tool ────────────────────────────────────────────────────────────────
// クリック位置にテキストを白(add)または消去(erase)でスタンプする。
export const MASK_TEXT_FONTS = [
"Arial", "Arial Black", "Georgia", "Times New Roman",
"Courier New", "Verdana", "Trebuchet MS", "Impact",
"Comic Sans MS", "Helvetica", "Tahoma",
];
export class MaskTextTool {
constructor() {
this.text = "Hello";
this.fontFamily = "Arial";
this.fontSize = 64;
this.bold = false;
this.italic = false;
this.align = "left";
this.mode = "add";
this._canvas = null;
this._ctx = null;
this._overlay = null;
this._onChange = null;
}
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
onChange(fn) { this._onChange = fn; }
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
deactivate() { this._closeOverlay(); if (this._canvas) this._canvas.style.cursor = ""; }
onMouseDown(x, y) {
if (this._overlay) { this._closeOverlay(); return; }
this._showOverlay(x, y);
}
onMouseMove() {}
onMouseUp() {}
onMouseLeave() {}
_getFont() {
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 cssX = Math.round(canvasX * (rect.width / cv.width)) + rect.left;
const cssY = Math.round(canvasY * (rect.height / cv.height)) + rect.top;
const overlay = document.createElement("div");
overlay.style.cssText = [
`position:fixed;left:${cssX}px;top:${cssY}px;`,
"background:var(--wfm-surface,#2a2a2a);",
"border:1px solid var(--wfm-border,#444);",
"border-radius:4px;padding:6px;z-index:99999;",
"box-shadow:0 4px 16px rgba(0,0,0,0.5);min-width:200px;",
].join("");
const ta = document.createElement("textarea");
ta.value = this.text;
ta.rows = 3;
ta.placeholder = "Enter text…";
ta.style.cssText = [
"width:100%;resize:vertical;box-sizing:border-box;",
"background:var(--wfm-bg,#1a1a1a);color:var(--wfm-text,#ddd);",
"border:1px solid var(--wfm-border,#444);border-radius:3px;",
"padding:4px;font-size:12px;",
].join("");
overlay.appendChild(ta);
const btnRow = document.createElement("div");
btnRow.style.cssText = "display:flex;gap:4px;margin-top:4px;";
const okBtn = document.createElement("button");
okBtn.textContent = "Stamp";
okBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
okBtn.style.flex = "1";
okBtn.onclick = () => {
if (ta.value.trim()) { this.text = ta.value; this._stamp(canvasX, canvasY); }
this._closeOverlay();
};
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.className = "wfm-btn wfm-btn-sm";
cancelBtn.style.flex = "1";
cancelBtn.onclick = () => this._closeOverlay();
btnRow.appendChild(okBtn);
btnRow.appendChild(cancelBtn);
overlay.appendChild(btnRow);
const hint = document.createElement("div");
hint.textContent = "Ctrl+Enter: Stamp / Esc: Cancel";
hint.style.cssText = "font-size:10px;color:var(--wfm-text-secondary,#888);margin-top:3px;";
overlay.appendChild(hint);
ta.addEventListener("keydown", e => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); okBtn.click(); }
else if (e.key === "Escape") cancelBtn.click();
e.stopPropagation();
});
overlay.addEventListener("mousedown", e => e.stopPropagation());
document.body.appendChild(overlay);
this._overlay = overlay;
ta.focus(); ta.select();
}
_closeOverlay() {
if (this._overlay) { this._overlay.remove(); this._overlay = null; }
}
_stamp(x, y) {
const ctx = this._ctx;
ctx.save();
ctx.font = this._getFont();
ctx.textAlign = this.align;
ctx.textBaseline = "top";
if (this.mode === "erase") {
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0,0,0,1)";
} else {
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "white";
}
const lines = this.text.split("\n");
const lineHeight = this.fontSize * 1.2;
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], x, y + i * lineHeight);
ctx.restore();
if (this._onChange) this._onChange();
}
}
// ── Vector Tool ──────────────────────────────────────────────────────────────
// クリックでポイント追加、Catmull-Rom スプラインで閉じたパスを塗りつぶす。
const VECTOR_CLOSE_R = 12;
export class MaskVectorTool {
constructor() {
this.mode = "add";
this._canvas = null;
this._ctx = null;
this._previewCanvas = null;
this._points = [];
this._hoverX = null;
this._hoverY = null;
this.onBeforeCommit = null;
this._onChange = null;
}
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
setPreviewCanvas(canvas) { this._previewCanvas = canvas; }
onChange(fn) { this._onChange = fn; }
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; this._renderPreview(); }
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; this._clearPreview(); }
reset() { this._points = []; this._hoverX = null; this._hoverY = null; this._clearPreview(); }
onMouseDown(x, y) {
if (this._points.length >= 3) {
const f = this._points[0];
if (Math.hypot(x - f.x, y - f.y) <= VECTOR_CLOSE_R) { this._commitPath(true); return; }
}
this._points.push({ x, y });
this._renderPreview();
}
onMouseMove(x, y) { this._hoverX = x; this._hoverY = y; this._renderPreview(); }
onMouseLeave() { this._hoverX = null; this._hoverY = null; this._renderPreview(); }
onMouseUp() {}
onKeyDown(e) {
if (e.key === "Escape") {
e.preventDefault(); this.reset();
} else if (e.key === "Enter" && this._points.length >= 2) {
e.preventDefault(); this._commitPath(false);
} else if ((e.key === "Backspace" || e.key === "Delete") && this._points.length > 0) {
e.preventDefault(); this._points.pop(); this._renderPreview();
}
}
_commitPath(closed) {
if (this._points.length < 2) return;
if (this.onBeforeCommit) this.onBeforeCommit();
const ctx = this._ctx;
ctx.save();
if (this.mode === "erase") {
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0,0,0,1)";
} else {
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "white";
}
ctx.beginPath();
this._buildSpline(ctx, this._points, closed);
ctx.fill();
ctx.restore();
this.reset();
if (this._onChange) this._onChange();
}
_buildSpline(ctx, pts, closed) {
const n = pts.length;
if (n === 0) return;
if (n === 1) { ctx.moveTo(pts[0].x, pts[0].y); return; }
if (n === 2) {
ctx.moveTo(pts[0].x, pts[0].y); ctx.lineTo(pts[1].x, pts[1].y);
if (closed) ctx.closePath(); return;
}
let ext;
if (closed) {
ext = [pts[n - 1], ...pts, pts[0], pts[1]];
} else {
ext = [
{ x: 2 * pts[0].x - pts[1].x, y: 2 * pts[0].y - pts[1].y },
...pts,
{ x: 2 * pts[n-1].x - pts[n-2].x, y: 2 * pts[n-1].y - pts[n-2].y },
];
}
ctx.moveTo(ext[1].x, ext[1].y);
const count = closed ? n : n - 1;
for (let i = 0; i < count; i++) {
const [p0, p1, p2, p3] = [ext[i], ext[i+1], ext[i+2], ext[i+3]];
ctx.bezierCurveTo(
p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
p2.x, p2.y,
);
}
if (closed) ctx.closePath();
}
_renderPreview() {
if (!this._previewCanvas) return;
const pctx = this._previewCanvas.getContext("2d");
const w = this._previewCanvas.width, h = this._previewCanvas.height;
pctx.clearRect(0, 0, w, h);
const pts = this._points, hx = this._hoverX, hy = this._hoverY;
if (pts.length === 0 && hx === null) return;
if (pts.length >= 2) {
pctx.save();
pctx.strokeStyle = "rgba(255,210,40,0.9)"; pctx.lineWidth = 1.5; pctx.setLineDash([]);
pctx.beginPath(); this._buildSpline(pctx, pts, false); pctx.stroke();
pctx.restore();
}
if (pts.length >= 1 && hx !== null) {
const canClose = pts.length >= 3 && Math.hypot(hx - pts[0].x, hy - pts[0].y) <= VECTOR_CLOSE_R;
pctx.save();
if (canClose) {
pctx.strokeStyle = "rgba(255,100,100,0.55)"; pctx.lineWidth = 1.5; pctx.setLineDash([4, 3]);
pctx.beginPath(); this._buildSpline(pctx, pts, true); pctx.stroke();
} else {
pctx.strokeStyle = "rgba(255,210,40,0.5)"; pctx.lineWidth = 1; pctx.setLineDash([4, 3]);
pctx.beginPath();
pctx.moveTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
pctx.lineTo(hx, hy); pctx.stroke();
}
pctx.restore();
}
for (let i = 0; i < pts.length; i++) {
const p = pts[i], isFirst = i === 0;
const closeable = isFirst && pts.length >= 3 && hx !== null && Math.hypot(hx - p.x, hy - p.y) <= VECTOR_CLOSE_R;
pctx.beginPath();
pctx.arc(p.x, p.y, closeable ? 7 : (isFirst ? 5 : 4), 0, Math.PI * 2);
pctx.fillStyle = closeable ? "rgba(255,80,80,0.95)" : (isFirst ? "rgba(255,210,40,0.95)" : "rgba(255,210,40,0.8)");
pctx.strokeStyle = "rgba(0,0,0,0.6)"; pctx.lineWidth = 1;
pctx.fill(); pctx.stroke();
}
if (hx !== null) {
const onFirst = pts.length >= 3 && Math.hypot(hx - pts[0].x, hy - pts[0].y) <= VECTOR_CLOSE_R;
if (!onFirst) {
pctx.beginPath(); pctx.arc(hx, hy, 3, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255,0.6)"; pctx.strokeStyle = "rgba(200,200,200,0.5)"; pctx.lineWidth = 1;
pctx.fill(); pctx.stroke();
}
}
}
_clearPreview() {
if (!this._previewCanvas) return;
const c = this._previewCanvas;
c.getContext("2d").clearRect(0, 0, c.width, c.height);
}
}
// ── Shape Tool ───────────────────────────────────────────────────────────────
// ドラッグで矩形または楕円をマスクとして描画する(Shift = 正方形/正円)。
export class MaskShapeTool {
constructor() {
this.mode = "add";
this.shape = "rect"; // "rect" | "ellipse"
this._canvas = null;
this._ctx = null;
this._previewCanvas = null;
this._pCtx = null;
this._startX = 0;
this._startY = 0;
this._curX = 0;
this._curY = 0;
this._curShift = false;
this._drawing = false;
this._onChange = null;
}
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
setPreviewCanvas(canvas) { this._previewCanvas = canvas; this._pCtx = canvas.getContext("2d"); }
onChange(fn) { this._onChange = fn; }
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
deactivate() { this._drawing = false; this._clearPreview(); if (this._canvas) this._canvas.style.cursor = ""; }
onMouseDown(x, y) {
this._startX = x; this._startY = y;
this._curX = x; this._curY = y; this._curShift = false;
this._drawing = true;
this._drawPreview(x, y, false);
}
onMouseMove(x, y, e) {
if (!this._drawing) return;
this._curX = x; this._curY = y; this._curShift = e?.shiftKey ?? false;
this._drawPreview(x, y, this._curShift);
}
// x/y を省略した場合は最後に記録した座標を使用(mouseupイベント非経由での呼び出し対応)
onMouseUp(x, y, e) {
if (!this._drawing) return;
const cx = x ?? this._curX;
const cy = y ?? this._curY;
const shift = e?.shiftKey ?? this._curShift;
this._drawing = false;
this._clearPreview();
this._commit(cx, cy, shift);
if (this._onChange) this._onChange();
}
onMouseLeave() {}
_getRect(x, y, shift) {
const sx = this._startX, sy = this._startY;
if (shift) {
const half = Math.max(Math.abs(x - sx), Math.abs(y - sy));
return { x: sx - half, y: sy - half, w: half * 2, h: half * 2 };
}
return { x: Math.min(sx, x), y: Math.min(sy, y), w: Math.abs(x - sx), h: Math.abs(y - sy) };
}
_drawPreview(x, y, shift) {
if (!this._pCtx) return;
const pc = this._previewCanvas;
this._pCtx.clearRect(0, 0, pc.width, pc.height);
const { x: rx, y: ry, w, h } = this._getRect(x, y, shift);
if (w < 1 || h < 1) return;
this._pCtx.save();
this._pCtx.strokeStyle = "rgba(0,180,255,0.9)"; this._pCtx.lineWidth = 1.5; this._pCtx.setLineDash([5, 3]);
this._pCtx.beginPath();
if (this.shape === "rect") this._pCtx.rect(rx, ry, w, h);
else this._pCtx.ellipse(rx + w / 2, ry + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
this._pCtx.stroke(); this._pCtx.restore();
}
_clearPreview() {
if (!this._pCtx) return;
this._pCtx.clearRect(0, 0, this._previewCanvas.width, this._previewCanvas.height);
}
_commit(x, y, shift) {
const { x: rx, y: ry, w, h } = this._getRect(x, y, shift);
if (w < 1 || h < 1) return;
const ctx = this._ctx;
ctx.save();
ctx.globalCompositeOperation = this.mode === "erase" ? "destination-out" : "source-over";
ctx.fillStyle = "white";
ctx.beginPath();
if (this.shape === "rect") ctx.rect(rx, ry, w, h);
else ctx.ellipse(rx + w / 2, ry + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
ctx.fill(); ctx.restore();
}
}
+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; }
}
}
+66 -14
View File
@@ -5,6 +5,8 @@
*/
import { t } from "./i18n.js";
import { escapeHtml } from "./util.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
// ── File size limit ───────────────────────────────────────────
const MAX_FILE_SIZE = 50 * 1024 * 1024;
@@ -154,7 +156,9 @@ function extractLoRAs(wf) {
function add(name, sm, sc) {
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
seen.add(name);
results.push({ name, strength_model: typeof sm === "number" ? sm : 1.0, strength_clip: typeof sc === "number" ? sc : 1.0 });
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)) {
@@ -170,6 +174,11 @@ function extractLoRAs(wf) {
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;
@@ -236,11 +245,22 @@ function extractPromptsFromNodeSet(nodes, links) {
if (!inp || inp.link == null) continue;
const originId = linkOrigin.get(inp.link);
if (originId == null) continue;
const name = inp.name ?? "";
const isPos = name === "positive" || name.startsWith("positive");
const isNeg = name === "negative" || name.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
// directly, so textMap (single text per node) can't represent both roles.
const srcNode = nodeMap.get(originId);
if (srcNode?.type === "TextEncodeMageFlowEdit") {
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
const text = srcNode.widgets_values?.[widgetIdx];
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
continue;
}
const text = textMap.get(originId);
if (!text) continue;
const name = inp.name ?? "";
if (name === "positive" || name.startsWith("positive")) pos.add(text);
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
if (isPos) pos.add(text); else neg.add(text);
}
}
if (!foundSampler) return null;
@@ -338,10 +358,20 @@ function extractPromptsAPI(wf) {
foundSampler = true;
for (const [key, val] of Object.entries(n.inputs ?? {})) {
if (!Array.isArray(val)) continue;
const isPos = key === "positive" || key.startsWith("positive");
const isNeg = key === "negative" || key.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
// directly, so textMap (single text per node) can't represent both roles.
const srcNode = wf[String(val[0])];
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
continue;
}
const text = textMap.get(String(val[0]));
if (!text) continue;
if (key === "positive" || key.startsWith("positive")) pos.add(text);
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
if (isPos) pos.add(text); else neg.add(text);
}
}
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
@@ -443,10 +473,22 @@ async function extractAllMetadata(file) {
const isJSON = file.type === "application/json" || name.endsWith(".json");
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
function fromWorkflow(wf, source) {
async function fromWorkflow(originalWf, source) {
let wf = originalWf;
// サブグラフを持つワークフローは、外側のサブグラフノードに実際に設定された値
// (ユーザーが見ている値)が内部ノードのwidgets_valuesへ反映されていないことがある
// (テンプレートの古いデフォルト値のまま)。convertUiToApi() はこの注入も含めて
// 正しく解決するため、そちらを経由してから抽出する。通常のワークフローには影響しない。
if (Array.isArray(wf?.nodes) && wf.definitions?.subgraphs?.length > 0) {
try {
const apiWf = await comfyWorkflow.convertUiToApi(wf);
if (apiWf && Object.keys(apiWf).length > 0) wf = apiWf;
} catch { /* 変換失敗時は元のUI形式のまま抽出(既存ロジックにフォールバック) */ }
}
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)
const mdm = extractMarkdownNoteModels(wf);
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)。
// API変換後は nodes 情報が失われるため、常に元のUI形式ワークフローから抽出する。
const mdm = extractMarkdownNoteModels(originalWf);
if (mdm) {
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
if (!base.vaes.length) base.vaes = mdm.vaes;
@@ -459,20 +501,20 @@ async function extractAllMetadata(file) {
if (isJSON) {
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
return wf ? fromWorkflow(wf, "comfyui") : null;
return wf ? await fromWorkflow(wf, "comfyui") : null;
}
if (isWebP) {
const exif = await readWebPEXIFChunk(file);
if (!exif) return null;
const wf = extractWorkflowFromEXIF(exif);
return wf ? fromWorkflow(wf, "comfyui") : null;
return wf ? await fromWorkflow(wf, "comfyui") : null;
}
// PNG
const chunks = await readAllPNGTextChunks(file);
if (!chunks) return null;
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
if (chunks.fooocus_scheme === "fooocus" && chunks.parameters) {
const f = parseFooocusMetadata(chunks.parameters);
@@ -497,7 +539,6 @@ async function extractAllMetadata(file) {
}
// ── UI helpers ────────────────────────────────────────────────
function escapeHtml(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function buildModelItem(label) {
const el = document.createElement("div");
@@ -543,6 +584,15 @@ function renderSection(sectionEl, listEl, items, buildFn) {
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");
@@ -736,6 +786,8 @@ export function initMetadataTab() {
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"); });
+747 -167
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();
+168 -82
View File
@@ -4,6 +4,7 @@
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage, getAiBackendDefaultUrl } 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 || getAiBackendDefaultUrl(backend)).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 / Lemonade (OpenAI-compatible): convert Ollama-style messages (with .images) to OpenAI content arrays
const openAiMessages = messages.map(msg => {
if (msg.images?.length) {
return {
role: msg.role,
content: [
{ type: "text", text: msg.content },
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
],
};
}
return { role: msg.role, content: msg.content };
});
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages: openAiMessages, stream: false }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).choices?.[0]?.message?.content || "";
}
}
// ============================================
@@ -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 {
@@ -825,20 +908,23 @@ function wcUpdateFilePicker() {
// ============================================
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) {
@@ -858,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"; }
}
});
@@ -977,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
@@ -1020,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 ---
@@ -1081,6 +1163,10 @@ export function initPromptTab() {
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", () => {
@@ -1122,7 +1208,7 @@ export function initPromptTab() {
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("Enter a positive integer", "error"); return; }
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
const template = "{" + num + "$$|}";
const start = ta.selectionStart;
wcInsertAtCursor(ta, template);
@@ -1160,7 +1246,7 @@ export function initPromptTab() {
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast("Sent to Positive Prompt", "success");
showToast(t("sentToPositive"), "success");
});
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
@@ -1169,7 +1255,7 @@ export function initPromptTab() {
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast("Sent to Negative Prompt", "success");
showToast(t("sentToNegative"), "success");
});
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
@@ -1197,22 +1283,22 @@ export function initPromptTab() {
const ext = extSelect?.value || "txt";
const content = contentTA?.value || "";
if (!rawName) { showToast("Enter a filename", "error"); return; }
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("Invalid path. Use folder/name format, each part: letters, numbers, -, _, . only", "error");
showToast(t("invalidPathFormat"), "error");
return;
}
const name = rawName;
const filename = `${name}.${ext}`;
const saved = await wcSaveFile(filename, content);
if (saved) {
showToast(`Saved: ${filename}`, "success");
showToast(t("savedAs", filename), "success");
wcCloseEditor();
wcRefreshFiles();
} else {
showToast("Failed to save file", "error");
showToast(t("saveFailed"), "error");
}
});
@@ -1221,7 +1307,7 @@ export function initPromptTab() {
if (!wcEditingFilename) return;
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
await wcDeleteFile(wcEditingFilename);
showToast(`Deleted: ${wcEditingFilename}`, "success");
showToast(t("deletedName", wcEditingFilename), "success");
wcCloseEditor();
wcRefreshFiles();
});
+252 -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, getAiBackendDefaultUrl } 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) {
@@ -393,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 {
@@ -634,28 +637,95 @@ export async function initSettingsTab() {
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
</details>
<!-- Ollama Settings -->
<!-- Default Checkpoint -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("defaultCheckpointSettings")}</summary>
<div class="wfm-form-group">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="wfm-settings-default-ckpt-enabled" ${serverSettings.default_checkpoint_enabled ? "checked" : ""}>
<span id="wfm-settings-default-ckpt-enable-label">${t("defaultCheckpointEnable")}</span>
</label>
</div>
<div class="wfm-form-group">
<label id="wfm-settings-default-ckpt-model-label">${t("defaultCheckpointModel")}</label>
<div style="display:flex;gap:8px;">
<select class="wfm-select" id="wfm-settings-default-ckpt-select" style="flex:1;">
<option value="">${t("selectModel")}</option>
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-default-ckpt-refresh">${t("refresh")}</button>
</div>
<small id="wfm-settings-default-ckpt-hint" style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("defaultCheckpointHint")}
</small>
</div>
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-default-ckpt-save">${t("save")}</button>
</details>
<!-- AI Assistant Settings (Prompt Tab) -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
<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>
<label class="wfm-ai-radio-label">
<input type="radio" name="wfm-prompt-ai-backend" value="lemonade"> Lemonade
</label>
</div>
</div>
<div class="wfm-form-group">
<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>
@@ -695,6 +765,24 @@ 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>
@@ -925,7 +1013,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);
});
@@ -1021,56 +1109,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 = getAiBackendDefaultUrl(promptAiBackendSaved);
}
// Backend change → switch URL to the new backend's default
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
r.addEventListener("change", () => {
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
});
});
// 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() || getAiBackendDefaultUrl(backend)).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() || getAiBackendDefaultUrl(backend)).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");
@@ -1083,6 +1237,35 @@ export async function initSettingsTab() {
statusEl.style.color = ok ? "var(--wfm-success)" : "var(--wfm-danger)";
});
// Default Checkpoint
const defaultCkptSelect = document.getElementById("wfm-settings-default-ckpt-select");
async function loadDefaultCkptOptions() {
if (!defaultCkptSelect) return;
try {
const list = await comfyUI.fetchCheckpoints();
const saved = serverSettings.default_checkpoint_name || "";
defaultCkptSelect.innerHTML = `<option value="">${t("selectModel")}</option>` +
(list || []).map((m) => `<option value="${m}" ${m === saved ? "selected" : ""}>${m}</option>`).join("");
} catch { /* ignore */ }
}
loadDefaultCkptOptions();
document.getElementById("wfm-settings-default-ckpt-refresh")?.addEventListener("click", loadDefaultCkptOptions);
document.getElementById("wfm-settings-default-ckpt-save")?.addEventListener("click", async () => {
const enabled = !!document.getElementById("wfm-settings-default-ckpt-enabled")?.checked;
const ckptName = defaultCkptSelect?.value || "";
try {
await saveServerSettings({ default_checkpoint_enabled: enabled, default_checkpoint_name: ckptName });
serverSettings.default_checkpoint_enabled = enabled;
serverSettings.default_checkpoint_name = ckptName;
showToast(t("aiToastSettingsSaved"), "success");
} catch (err) {
showToast(`${t("saveError")}: ${err.message}`, "error");
}
});
// Test Eagle URL (via server proxy to avoid CORS)
document.getElementById("wfm-settings-test-eagle")?.addEventListener("click", async () => {
const urlInput = document.getElementById("wfm-settings-eagle-url");
@@ -1111,7 +1294,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));
@@ -1306,4 +1489,28 @@ export async function initSettingsTab() {
// 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;
}
+66
View File
@@ -0,0 +1,66 @@
/**
* 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");
}
// AIバックエンド(Ollama/LM Studio/Lemonade)ごとのデフォルトURL
export const AI_BACKEND_DEFAULT_URLS = {
ollama: "http://localhost:11434",
lmstudio: "http://localhost:1234",
lemonade: "http://localhost:13305",
};
export function getAiBackendDefaultUrl(backend) {
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
}
/**
* 検索inputにオーバーレイXボタンを設定する。
* @param {string} inputId - 検索inputのID
* @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();
}
+245 -50
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("workflowSentToCanvasDirect"), "success");
return;
}
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
const fmt = comfyWorkflow.detectFormat(workflowData);
if (fmt === "api") {
showToast(t("apiFormatCanvasNoOpener"), "error");
return;
}
localStorage.setItem("wfm_pending_workflow", JSON.stringify(workflowData));
showToast(t("workflowSentToCanvas"), "success");
} 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
// ============================================
@@ -216,6 +270,85 @@ async function getRawWorkflow(filename) {
return await res.json();
}
// ============================================
// AI Summary — structured extraction (not raw JSON dump)
// ============================================
// analyzeWorkflow() の結果をコンパクトなテキストに整形する。
// 生JSONをそのままLLMに渡すと、MarkdownNoteの長文やUI座標情報などのノイズで
// 文字数制限に達し、肝心のノード構成情報(サブグラフ内含む)が切り捨てられやすい。
function _buildStructuredSummaryText(analysis) {
const lines = [];
if (analysis.checkpoint_nodes.length) {
lines.push(`Checkpoint: ${analysis.checkpoint_nodes.map((n) => n.ckpt_name).filter(Boolean).join(", ")}`);
}
if (analysis.diffusion_model_nodes.length) {
lines.push(`Diffusion Model (UNET): ${analysis.diffusion_model_nodes.map((n) => n.unet_name).filter(Boolean).join(", ")}`);
}
if (analysis.text_encoder_nodes.length) {
lines.push(`Text Encoder (CLIP): ${analysis.text_encoder_nodes.map((n) => [n.clip_name1, n.clip_name2].filter(Boolean).join("+")).join(", ")}`);
}
if (analysis.vae_nodes.length) {
lines.push(`VAE: ${analysis.vae_nodes.map((n) => n.vae_name).filter(Boolean).join(", ")}`);
}
if (analysis.lora_nodes.length) {
lines.push(`LoRA: ${analysis.lora_nodes.map((n) => {
if (n.is_lora_manager) return "(LoraManager)";
const strengths = n.strength_clip !== undefined
? `model:${n.strength_model}, clip:${n.strength_clip}`
: `model:${n.strength_model}`;
return `${n.lora_name} (${strengths})`;
}).join(", ")}`);
}
if (analysis.controlnet_nodes.length) {
lines.push(`ControlNet: ${analysis.controlnet_nodes.map((n) => n.control_net_name).filter(Boolean).join(", ")}`);
}
if (analysis.hypernetwork_nodes.length) {
lines.push(`Hypernetwork: ${analysis.hypernetwork_nodes.map((n) => n.hypernetwork_name).filter(Boolean).join(", ")}`);
}
const positives = analysis.prompt_nodes.filter((n) => n.role === "positive").map((n) => n.text).filter(Boolean);
const negatives = analysis.prompt_nodes.filter((n) => n.role === "negative").map((n) => n.text).filter(Boolean);
if (positives.length) lines.push(`Positive Prompt: ${positives.join(" / ")}`);
if (negatives.length) lines.push(`Negative Prompt: ${negatives.join(" / ")}`);
if (analysis.sampler_nodes.length) {
const s = analysis.sampler_nodes[0];
lines.push(`Sampler: steps=${s.steps}, cfg=${s.cfg}, sampler=${s.sampler_name}, scheduler=${s.scheduler}, denoise=${s.denoise}`);
}
if (analysis.latent_nodes.length) {
const l = analysis.latent_nodes[0];
lines.push(`Latent size: ${l.width ?? "?"}x${l.height ?? "?"}, batch=${l.batch_size ?? "?"}`);
}
if (analysis.all_nodes.length) {
const nodeTypes = [...new Set(analysis.all_nodes.map((n) => n.type))];
lines.push(`Node types used (${analysis.all_nodes.length} nodes total): ${nodeTypes.join(", ")}`);
}
return lines.join("\n");
}
// ワークフロー(UI/API/サブグラフ入りいずれの形式でも)から要約用テキストを組み立てる。
// 変換・解析に失敗した場合は、従来通り生JSON先頭部分にフォールバックする。
async function _buildSummarySourceText(wfJson, filename) {
try {
const fmt = comfyWorkflow.detectFormat(wfJson, filename);
let apiWf = wfJson;
if (fmt === "ui") {
apiWf = await comfyWorkflow.convertUiToApi(wfJson);
} else if (fmt !== "api") {
return JSON.stringify(wfJson).substring(0, 4000);
}
const analysis = comfyWorkflow.analyzeWorkflow(apiWf);
const text = _buildStructuredSummaryText(analysis);
return text || JSON.stringify(wfJson).substring(0, 4000);
} catch {
return JSON.stringify(wfJson).substring(0, 4000);
}
}
// ============================================
// Rendering Helpers
// ============================================
@@ -250,6 +383,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 +393,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 +419,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 +479,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 +530,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 +555,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 +594,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 +770,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 +780,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 +789,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 +836,7 @@ function renderSideGroup(wf) {
}
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" deleted`, "success");
showToast(t("groupDeleted", name), "success");
});
_refreshGroupFilter();
@@ -803,7 +958,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 +996,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 +1039,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 +1108,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 +1129,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));
@@ -1050,11 +1207,20 @@ function openDetailModal(wf) {
btn.textContent = t("summarizing");
try {
const wfJson = await getRawWorkflow(wf.filename);
const promptText = getSummaryPrompt() + JSON.stringify(wfJson).substring(0, 4000);
const sourceText = await _buildSummarySourceText(wfJson, wf.filename);
const promptText = getSummaryPrompt() + sourceText;
const aiCfg = readJsonStorage("wfm_prompt_ai_settings", {});
if (aiCfg.backend && aiCfg.backend !== "ollama") {
throw new Error(t("summarizeOllamaOnly"));
}
const chatRes = await fetch("/api/wfm/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: promptText }] }),
body: JSON.stringify({
url: aiCfg.backendUrl || undefined,
model: aiCfg.model || undefined,
messages: [{ role: "user", content: promptText }],
}),
});
const chatData = await chatRes.json();
if (chatData.error) throw new Error(chatData.error);
@@ -1122,7 +1288,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 +1315,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 +1336,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 +1399,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 +1413,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 +1442,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();
}
+1136 -283
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);
};
},
});
+464 -14
View File
@@ -43,9 +43,11 @@ const state = {
wfLoaded: false,
// Prompts sub-tabs
promptSubTab: "prompt-all", // "prompt-all" | "prompt-favorites" | "prompt-categories"
promptSubTab2: null, // "prompt-groups" | null
promptList: [], // full prompt array from API
promptFavorites: [], // favorite === true
promptCategories: [], // unique category strings
promptGroups: {}, // { groupName: [promptId, ...] } from localStorage
promptLoaded: false,
// Models sub-tabs
modelSubTab: "model-all", // "model-all" | "model-favorites" | "model-groups"
@@ -59,7 +61,7 @@ const state = {
infoSubTab: "info-model", // "info-model" | "info-lora" | "info-prompt"
infoMeta: null, // parsed metadata from dropped file
// AI tab
aiSubTab: "ai-translate", // "ai-translate" | "ai-vlm" | "ai-settings"
aiSubTab: "ai-translate", // "ai-translate" | "ai-chat" | "ai-vlm" | "ai-settings"
};
// ============================================
@@ -143,6 +145,15 @@ const loadPromptData = async () => {
if (c) catSet.add(c);
}
state.promptCategories = [...catSet].sort();
try {
state.promptGroups = JSON.parse(localStorage.getItem("wfm_prompt_preset_groups") || "{}");
} catch { state.promptGroups = {}; }
const validIds = new Set(prompts.map(p => p.id));
for (const g of Object.keys(state.promptGroups)) {
state.promptGroups[g] = (state.promptGroups[g] || []).filter(id => validIds.has(id));
}
state.promptLoaded = true;
};
@@ -230,6 +241,52 @@ const fetchWorkflowRaw = async (filename) => {
} catch { return null; }
};
// Returns true if the JSON is in ComfyUI API format (dict of nodeId→{class_type, inputs})
// rather than the UI format (has nodes[] and links[] arrays).
const isApiWorkflowFormat = (data) => {
if (!data || typeof data !== "object" || Array.isArray(data)) return false;
if (Array.isArray(data.nodes)) return false;
const keys = Object.keys(data);
return keys.length > 0 && keys.every((k) => data[k]?.class_type);
};
// Minimal API→UI conversion so app.loadGraphData() can handle API-format workflows.
// Nodes are arranged in a grid; link types use "*" since object_info is not queried here.
const convertApiToUiWorkflow = (api) => {
const nodes = [];
const links = [];
let linkId = 1;
const sortedIds = Object.keys(api).sort((a, b) => Number(a) - Number(b));
sortedIds.forEach((id, idx) => {
const node = api[id];
const col = idx % 5;
const row = Math.floor(idx / 5);
const uiNode = {
id: Number(id),
type: node.class_type,
title: node._meta?.title || node.class_type,
pos: [col * 300 + 50, row * 250 + 50],
size: [250, 200],
inputs: [],
outputs: [],
widgets_values: [],
mode: 0,
};
for (const [key, val] of Object.entries(node.inputs || {})) {
if (Array.isArray(val) && val.length === 2 && typeof val[0] === "string" && typeof val[1] === "number") {
uiNode.inputs.push({ name: key, type: "*", link: linkId });
links.push([linkId, Number(val[0]), val[1], Number(id), uiNode.inputs.length - 1, "*"]);
linkId++;
} else {
uiNode.widgets_values.push(val);
}
}
nodes.push(uiNode);
});
const maxNodeId = Math.max(0, ...sortedIds.map(Number));
return { nodes, links, groups: [], config: {}, extra: {}, version: 0.4, last_node_id: maxNodeId, last_link_id: linkId };
};
const loadWfData = async () => {
const workflows = await fetchWorkflows();
state.wfList = workflows.filter(w => w.filename !== ".index.json");
@@ -351,7 +408,7 @@ const placePromptNode = (posText, negText, promptName, pos) => {
}
node.pos = pos || [100, 100];
if (promptName) node.title = promptName;
node.title = "Wfs Prompt";
graph.add(node);
@@ -478,9 +535,21 @@ const placeLoraMgrNode = (loras, pos) => {
node.pos = pos || centerPos;
graph.add(node);
if (node.widgets) {
const loraList = loras.map(l => ({ name: l.name, strength: l.strength_model ?? 1.0, clipStrength: l.strength_clip ?? l.strength_model ?? 1.0, active: true }));
const loraList = loras.map(l => ({ name: l.name, strength: l.strength_model ?? l.strength ?? 1.0, clipStrength: l.strength_clip ?? l.strength_model ?? l.strength ?? 1.0, active: true }));
const w = node.widgets.find(ww => Array.isArray(ww.value) || ww.name === "loras");
if (w) { w.value = loraList; if (w.callback) w.callback(loraList); }
const textW = node.widgets.find(ww => ww.name === "text");
if (textW) {
textW.value = loraList
.filter(l => l.active !== false)
.map(l => {
const s = parseFloat(l.strength) || 1.0;
const c = parseFloat(l.clipStrength) || s;
return s === c ? `<lora:${l.name}:${s}>` : `<lora:${l.name}:${s}:${c}>`;
})
.join(" ");
if (textW.callback) textW.callback(textW.value);
}
}
app.canvas.setDirty(true, true);
showToast(`Placed: Lora Loader (${loras.length} loras)`, "success");
@@ -490,6 +559,30 @@ const placeLoraMgrNode = (loras, pos) => {
// Workflow canvas loading
// ============================================
/**
* Load workflow JSON onto the ComfyUI canvas.
* Delegates to app.handleFile() so ComfyUI's native path handles both UI and API
* formats (identical to dragging a file from Explorer). Falls back to
* convertApiToUiWorkflow() + loadGraphData() if handleFile is unavailable.
*/
const loadDataOnCanvas = async (data) => {
if (typeof app?.handleFile === "function") {
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
const file = new File([blob], "workflow.json", { type: "application/json" });
await app.handleFile(file);
} else {
if (isApiWorkflowFormat(data)) data = convertApiToUiWorkflow(data);
await app.loadGraphData(data);
}
};
// ============================================
// Cross-window receiver for SPA Send-to-Canvas
// ============================================
window.wfmReceiveWorkflow = async (data) => {
await loadDataOnCanvas(data);
};
const loadWorkflowOnCanvas = async (filename) => {
const displayName = filename.replace(/\.json$/i, "");
showToast(`Loading "${displayName}"...`, "info");
@@ -499,7 +592,7 @@ const loadWorkflowOnCanvas = async (filename) => {
return;
}
try {
await app.loadGraphData(data);
await loadDataOnCanvas(data);
showToast(`Loaded: ${displayName}`, "success");
} catch (err) {
showToast("Failed to load: " + err.message, "error");
@@ -525,7 +618,8 @@ const installCanvasDropHandler = () => {
e.dataTransfer.types.includes("application/x-wfm-prompt") ||
e.dataTransfer.types.includes("application/x-wfm-model") ||
e.dataTransfer.types.includes("application/x-wfm-lora-multi") ||
e.dataTransfer.types.includes("application/x-wfm-clip-text")) {
e.dataTransfer.types.includes("application/x-wfm-clip-text") ||
e.dataTransfer.types.includes("application/x-wfm-pending")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
@@ -572,6 +666,31 @@ const installCanvasDropHandler = () => {
return;
}
// Handle pending workflow drop (from Gallery / Workflow tab "Send to Canvas")
const pendingRaw = e.dataTransfer.getData("application/x-wfm-pending");
if (pendingRaw) {
e.preventDefault();
(async () => {
try {
const wfData = JSON.parse(pendingRaw);
await loadDataOnCanvas(wfData);
localStorage.removeItem("wfm_pending_workflow");
if (panelEl) {
const titleEl = panelEl.querySelector(".wfm-nlp-title");
if (titleEl) {
titleEl.draggable = false;
titleEl.classList.remove("wfm-nlp-title-pending");
titleEl.title = "Workflow Studio Library";
}
}
showToast("Workflow loaded on canvas", "success");
} catch (err) {
showToast("Failed to load workflow: " + err.message, "error");
}
})();
return;
}
// Handle workflow drop
const wfRaw = e.dataTransfer.getData("application/x-wfm-workflow");
if (wfRaw) {
@@ -740,6 +859,28 @@ const createPanel = () => {
});
});
// Pending workflow drag-to-canvas
const titleEl = panel.querySelector(".wfm-nlp-title");
const updateTitlePendingState = () => {
const hasPending = !!localStorage.getItem("wfm_pending_workflow");
titleEl.draggable = hasPending;
titleEl.classList.toggle("wfm-nlp-title-pending", hasPending);
titleEl.title = hasPending
? "Drag to canvas to load workflow"
: "Workflow Studio Library";
};
updateTitlePendingState();
panel.addEventListener("mouseenter", updateTitlePendingState);
window.addEventListener("storage", (e) => {
if (e.key === "wfm_pending_workflow") updateTitlePendingState();
});
titleEl.addEventListener("dragstart", (e) => {
const pendingRaw = localStorage.getItem("wfm_pending_workflow");
if (!pendingRaw) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/x-wfm-pending", pendingRaw);
});
// Close
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
@@ -810,6 +951,7 @@ const rebuildSubTabs = () => {
if (state.topTab === "ai") {
const row1Tabs = [
{ key: "ai-translate", label: "Translation" },
{ key: "ai-chat", label: "Chat" },
{ key: "ai-vlm", label: "TOOLS" },
{ key: "ai-settings", label: "Settings" },
];
@@ -892,8 +1034,11 @@ const rebuildSubTabs = () => {
{ key: "prompt-favorites", label: "\u2605 Favorites" },
{ key: "prompt-categories", label: "\ud83d\udcc1 Categories" },
];
const row2Tabs = [
{ key: "prompt-groups", label: "\ud83d\udcc1 Groups" },
];
const activeKey = state.promptSubTab;
const activeKey = state.promptSubTab2 || state.promptSubTab;
for (const t of row1Tabs) {
const btn = document.createElement("button");
@@ -902,13 +1047,26 @@ const rebuildSubTabs = () => {
btn.textContent = t.label;
btn.addEventListener("click", () => {
state.promptSubTab = t.key;
state.promptSubTab2 = null;
updateAllActive();
btn.classList.add("active");
renderContent();
});
row1.appendChild(btn);
}
// No row2 tabs for prompts
for (const t of row2Tabs) {
const btn = document.createElement("button");
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (activeKey === t.key ? " active" : "");
btn.dataset.subtab = t.key;
btn.textContent = t.label;
btn.addEventListener("click", () => {
state.promptSubTab2 = t.key;
updateAllActive();
btn.classList.add("active");
renderContent();
});
row2.appendChild(btn);
}
} else if (state.topTab === "workflows") {
const row1Tabs = [
{ key: "wf-all", label: "All" },
@@ -1040,10 +1198,12 @@ const renderContent = () => {
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
return;
}
switch (state.promptSubTab) {
const promptKey = state.promptSubTab2 || state.promptSubTab;
switch (promptKey) {
case "prompt-all": renderPromptAll(content); break;
case "prompt-favorites": renderPromptFavorites(content); break;
case "prompt-categories": renderPromptCategories(content); break;
case "prompt-groups": renderPromptGroups(content); break;
}
} else if (state.topTab === "workflows") {
if (!state.wfLoaded) {
@@ -1529,6 +1689,32 @@ const renderModelGroups = async (container) => {
const item = createModelItem(name, type);
listEl.appendChild(item);
}
if (modelType === "lora" && filtered.length >= 1) {
const sep = document.createElement("div");
sep.className = "wfm-nlp-info-section-title";
sep.style.marginTop = "6px";
listEl.appendChild(sep);
const multiItem = document.createElement("div");
multiItem.className = "wfm-nlp-item wfm-nlp-model-item";
multiItem.draggable = true;
multiItem.title = `Drag to canvas to place Lora Loader (LoraManager) with all ${filtered.length} loras`;
multiItem.innerHTML = `<div class="wfm-nlp-item-row"><div class="wfm-nlp-item-body"><div class="wfm-nlp-item-label">All ${filtered.length} LoRAs</div><div class="wfm-nlp-item-sub">→ Lora Loader (LoraManager)</div></div></div>`;
const buildLoraList = () => filtered.map(n => {
const stem = n.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
return { name: stem, strength: 1.0, clipStrength: 1.0, active: true };
});
multiItem.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/x-wfm-lora-multi", JSON.stringify({ loras: buildLoraList() }));
multiItem.classList.add("dragging");
});
multiItem.addEventListener("dragend", () => multiItem.classList.remove("dragging"));
multiItem.addEventListener("dblclick", (e) => { e.stopPropagation(); placeLoraMgrNode(buildLoraList()); });
listEl.appendChild(multiItem);
}
section.appendChild(listEl);
container.appendChild(section);
}
@@ -1722,6 +1908,55 @@ const renderPromptCategories = (container) => {
}
};
const renderPromptGroups = (container) => {
container.innerHTML = "";
const groupNames = Object.keys(state.promptGroups).sort();
if (groupNames.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
No groups.<br><small>Create groups in Workflow Studio</small>
</div>`;
return;
}
const promptMap = new Map(state.promptList.map(p => [p.id, p]));
let hasAny = false;
for (const groupName of groupNames) {
const ids = state.promptGroups[groupName] || [];
const prompts = ids.map(id => promptMap.get(id)).filter(Boolean);
const filtered = prompts.filter(matchesPromptSearch);
if (state.searchText && filtered.length === 0) continue;
hasAny = true;
const section = document.createElement("div");
section.className = "wfm-nlp-group-section";
const header = document.createElement("div");
header.className = "wfm-nlp-group-header collapsed";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${filtered.length}</span>`;
header.addEventListener("click", () => {
const list = section.querySelector(".wfm-nlp-group-list");
list.style.display = list.style.display === "none" ? "block" : "none";
header.classList.toggle("collapsed");
});
section.appendChild(header);
const list = document.createElement("div");
list.className = "wfm-nlp-group-list";
list.style.display = "none";
for (const p of filtered) {
list.appendChild(createDraggablePromptItem(p));
}
section.appendChild(list);
container.appendChild(section);
}
if (!hasAny) {
container.innerHTML = `<div class="wfm-nlp-empty">No matches.</div>`;
}
};
// ============================================
// Render – Node sub-tabs (existing)
// ============================================
@@ -2217,7 +2452,9 @@ function _extractLoRAs(wf) {
function add(name, sm, sc) {
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
seen.add(name);
results.push({ name, strength_model: typeof sm === "number" ? sm : 1.0, strength_clip: typeof sc === "number" ? sc : 1.0 });
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)) {
@@ -2233,6 +2470,11 @@ function _extractLoRAs(wf) {
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;
@@ -2761,6 +3003,14 @@ const renderInfoTab = (container) => {
const AI_SETTINGS_KEY = "wfm_ai_settings";
const AI_LANG_NAMES = { ja: "Japanese", en: "English", zh: "Chinese" };
const AI_BACKEND_DEFAULT_URLS = {
ollama: "http://localhost:11434",
lmstudio: "http://localhost:1234",
lemonade: "http://localhost:13305",
};
function getAiBackendDefaultUrl(backend) {
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
}
function isValidAiUrl(url) {
try {
@@ -2836,6 +3086,24 @@ async function aiCallVLM(url, backend, model, prompt, base64Image, mimeType) {
}
}
async function aiCallChat(url, backend, model, messages) {
if (backend === "ollama") {
const r = await fetch(`${url}/api/chat`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages, stream: false }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).message?.content || "";
} else {
const r = await fetch(`${url}/v1/chat/completions`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages, stream: false }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).choices?.[0]?.message?.content || "";
}
}
async function aiFetchModels(url, backend) {
if (backend === "ollama") {
const r = await fetch(`${url}/api/tags`);
@@ -2877,6 +3145,16 @@ const renderAiTab = (container) => {
</div>
<textarea id="wfm-nlp-ai-output" class="wfm-nlp-ai-textarea wfm-nlp-ai-output" placeholder="Translation result..." readonly></textarea>
</div>
<!-- Chat sub -->
<div id="wfm-nlp-ai-chat" class="wfm-nlp-ai-pane" style="display:none;">
<div class="wfm-nlp-ai-chat-msgs" id="wfm-nlp-ai-chat-msgs"></div>
<textarea id="wfm-nlp-ai-chat-input" class="wfm-nlp-ai-textarea wfm-nlp-ai-chat-input" placeholder="Type a message..."></textarea>
<div class="wfm-nlp-ai-actions">
<button id="wfm-nlp-ai-chat-send" class="wfm-nlp-ai-btn wfm-nlp-ai-btn-primary">Send</button>
<button id="wfm-nlp-ai-chat-clear" class="wfm-nlp-ai-btn">Clear</button>
<span id="wfm-nlp-ai-chat-status" class="wfm-nlp-ai-status"></span>
</div>
</div>
<!-- TOOLS sub -->
<div id="wfm-nlp-ai-vlm" class="wfm-nlp-ai-pane" style="display:none;">
<div class="wfm-nlp-ai-vlm-drop" id="wfm-nlp-ai-vlm-drop">
@@ -2884,11 +3162,22 @@ const renderAiTab = (container) => {
<span id="wfm-nlp-ai-vlm-label" class="wfm-nlp-ai-vlm-label">Drop PNG / JPG / WebP</span>
<input type="file" id="wfm-nlp-ai-vlm-file" accept="image/png,image/jpeg,image/webp" style="display:none;">
</div>
<div id="wfm-nlp-ai-wc-inputs" style="display:none;flex-direction:column;gap:5px;">
<div class="wfm-nlp-ai-row">
<span class="wfm-nlp-ai-lbl">Name</span>
<input type="text" id="wfm-nlp-ai-wc-name" class="wfm-nlp-ai-input" style="flex:1;" placeholder="e.g. sports">
</div>
<div class="wfm-nlp-ai-row">
<span class="wfm-nlp-ai-lbl">Count</span>
<input type="number" id="wfm-nlp-ai-wc-count" class="wfm-nlp-ai-input wfm-nlp-ai-wc-count" value="20" min="1" max="200">
</div>
</div>
<div class="wfm-nlp-ai-row">
<select id="wfm-nlp-ai-vlm-task" class="wfm-nlp-ai-sel" style="flex:1;">
<option value="describe">Describe image</option>
<option value="prompt">Create prompt</option>
<option value="tags">Create tags</option>
<option value="wildcard">Create wildcards</option>
</select>
<button id="wfm-nlp-ai-vlm-run" class="wfm-nlp-ai-btn wfm-nlp-ai-btn-primary">Run</button>
</div>
@@ -2903,6 +3192,7 @@ const renderAiTab = (container) => {
<div class="wfm-nlp-ai-radio-row">
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="ollama"> Ollama</label>
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lmstudio"> LM Studio</label>
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lemonade"> Lemonade</label>
</div>
</div>
<div class="wfm-nlp-ai-sec">
@@ -2946,7 +3236,7 @@ const renderAiTab = (container) => {
const renderAiSubContent = () => {
if (!panelEl) return;
const panes = { "ai-translate": "wfm-nlp-ai-translate", "ai-vlm": "wfm-nlp-ai-vlm", "ai-settings": "wfm-nlp-ai-settings" };
const panes = { "ai-translate": "wfm-nlp-ai-translate", "ai-chat": "wfm-nlp-ai-chat", "ai-vlm": "wfm-nlp-ai-vlm", "ai-settings": "wfm-nlp-ai-settings" };
for (const [key, id] of Object.entries(panes)) {
const el = panelEl.querySelector(`#${id}`);
if (el) el.style.display = state.aiSubTab === key ? "flex" : "none";
@@ -2963,6 +3253,13 @@ const setupAiHandlers = (container) => {
if (urlInput && cfg.backendUrl) urlInput.value = cfg.backendUrl;
else if (urlInput) urlInput.value = "http://localhost:11434";
// Backend change → switch URL to the new backend's default
backendRadios.forEach(r => {
r.addEventListener("change", () => {
if (urlInput) urlInput.value = getAiBackendDefaultUrl(r.value);
});
});
const freeSrcInput = container.querySelector("#wfm-nlp-ai-free-src");
const freeDstInput = container.querySelector("#wfm-nlp-ai-free-dst");
if (freeSrcInput && cfg.freeSrcLang) freeSrcInput.value = cfg.freeSrcLang;
@@ -2992,7 +3289,7 @@ const setupAiHandlers = (container) => {
const c = loadAiCfg();
const backend = c.backend || "ollama";
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
const model = c.model;
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
if (!model) { showToast("Please select a model in Settings", "error"); return; }
@@ -3101,6 +3398,67 @@ const setupAiHandlers = (container) => {
// Auto-load models if settings exist
if (cfg.backendUrl && cfg.backend) refreshModels().catch(() => {});
// ---- Chat handlers ----
let chatHistory = [];
const chatMsgsEl = container.querySelector("#wfm-nlp-ai-chat-msgs");
const chatInputEl = container.querySelector("#wfm-nlp-ai-chat-input");
const chatSendBtn = container.querySelector("#wfm-nlp-ai-chat-send");
const chatClearBtn= container.querySelector("#wfm-nlp-ai-chat-clear");
const chatStatusEl= container.querySelector("#wfm-nlp-ai-chat-status");
const appendChatBubble = (role, content) => {
const div = document.createElement("div");
div.className = `wfm-nlp-ai-chat-msg wfm-nlp-ai-chat-msg-${role}`;
div.textContent = content;
chatMsgsEl.appendChild(div);
chatMsgsEl.scrollTop = chatMsgsEl.scrollHeight;
};
const sendChatMessage = async () => {
const text = chatInputEl?.value?.trim();
if (!text) return;
const c = loadAiCfg();
const backend = c.backend || "ollama";
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
const model = c.model;
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
if (!model) { showToast("Please select a model in Settings", "error"); return; }
chatInputEl.value = "";
chatHistory.push({ role: "user", content: text });
appendChatBubble("user", text);
chatSendBtn.disabled = true;
chatStatusEl.textContent = "Thinking...";
chatStatusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
try {
const reply = await aiCallChat(url, backend, model, chatHistory);
chatHistory.push({ role: "assistant", content: reply });
appendChatBubble("assistant", reply);
chatStatusEl.textContent = "";
chatStatusEl.className = "wfm-nlp-ai-status";
} catch (err) {
chatHistory.pop();
if (chatMsgsEl.lastChild) chatMsgsEl.removeChild(chatMsgsEl.lastChild);
chatInputEl.value = text;
chatStatusEl.textContent = `エラー: ${err.message}`;
chatStatusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
} finally {
chatSendBtn.disabled = false;
}
};
chatSendBtn?.addEventListener("click", sendChatMessage);
chatInputEl?.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
});
chatClearBtn?.addEventListener("click", () => {
chatHistory = [];
if (chatMsgsEl) chatMsgsEl.innerHTML = "";
if (chatStatusEl) { chatStatusEl.textContent = ""; chatStatusEl.className = "wfm-nlp-ai-status"; }
});
// ---- VLM handlers ----
let vlmImage = null; // { base64, mimeType }
@@ -3108,10 +3466,22 @@ const setupAiHandlers = (container) => {
const vlmPreview = container.querySelector("#wfm-nlp-ai-vlm-preview");
const vlmLabel = container.querySelector("#wfm-nlp-ai-vlm-label");
const vlmFile = container.querySelector("#wfm-nlp-ai-vlm-file");
const vlmTaskSel = container.querySelector("#wfm-nlp-ai-vlm-task");
const vlmRunBtn = container.querySelector("#wfm-nlp-ai-vlm-run");
const vlmStatus = container.querySelector("#wfm-nlp-ai-vlm-status");
const vlmResult = container.querySelector("#wfm-nlp-ai-vlm-result");
const vlmCopy = container.querySelector("#wfm-nlp-ai-vlm-copy");
const wcInputs = container.querySelector("#wfm-nlp-ai-wc-inputs");
const wcNameEl = container.querySelector("#wfm-nlp-ai-wc-name");
const wcCountEl = container.querySelector("#wfm-nlp-ai-wc-count");
const updateVlmTaskUI = () => {
const isWc = vlmTaskSel?.value === "wildcard";
if (vlmDrop) vlmDrop.style.display = isWc ? "none" : "";
if (wcInputs) wcInputs.style.display = isWc ? "flex" : "none";
};
vlmTaskSel?.addEventListener("change", updateVlmTaskUI);
updateVlmTaskUI();
const loadVlmImage = async (file) => {
if (!file || !file.type.startsWith("image/")) return;
@@ -3140,15 +3510,45 @@ const setupAiHandlers = (container) => {
};
vlmRunBtn?.addEventListener("click", async () => {
if (!vlmImage) { showToast("Please drop an image", "error"); return; }
const task = vlmTaskSel?.value || "describe";
const c = loadAiCfg();
const backend = c.backend || "ollama";
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
const model = c.model;
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
if (!model) { showToast("Please select a model in Settings", "error"); return; }
const task = container.querySelector("#wfm-nlp-ai-vlm-task")?.value || "describe";
if (task === "wildcard") {
const name = wcNameEl?.value?.trim() || "";
const count = Math.max(1, parseInt(wcCountEl?.value) || 20);
if (!name) { showToast("Please enter a wildcard name", "error"); return; }
vlmRunBtn.disabled = true;
vlmStatus.textContent = "Running...";
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
vlmResult.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 aiCallLLM(url, backend, model, prompt);
vlmResult.value = result.trim()
.split("\n")
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
.filter(l => l.length > 0)
.join("\n");
vlmStatus.textContent = "完了";
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-ok";
} catch (err) {
vlmStatus.textContent = `エラー: ${err.message}`;
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
} finally {
vlmRunBtn.disabled = false;
}
return;
}
if (!vlmImage) { showToast("Please drop an image", "error"); return; }
vlmRunBtn.disabled = true;
vlmStatus.textContent = "Running...";
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
@@ -3304,6 +3704,16 @@ const injectStyles = () => {
font-size: 14px;
flex: 1;
}
.wfm-nlp-title-pending {
cursor: grab;
color: #66aaff;
}
.wfm-nlp-title-pending::after {
content: " ●";
font-size: 7px;
color: #4caf50;
vertical-align: super;
}
.wfm-nlp-close {
background: none;
border: none;
@@ -4005,6 +4415,46 @@ const injectStyles = () => {
color: var(--descrip-text, #999);
pointer-events: none;
}
/* Chat */
.wfm-nlp-ai-chat-msgs {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
padding: 2px;
}
.wfm-nlp-ai-chat-msg {
max-width: 92%;
padding: 6px 8px;
border-radius: 6px;
font-size: 11px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.wfm-nlp-ai-chat-msg-user {
align-self: flex-end;
background: var(--p-button-background, #4a9eff);
color: #fff;
}
.wfm-nlp-ai-chat-msg-assistant {
align-self: flex-start;
background: var(--comfy-input-bg, #2a2a2a);
border: 1px solid var(--border-color, #4e4e4e);
color: var(--input-text, #ddd);
}
.wfm-nlp-ai-chat-input {
min-height: 50px;
max-height: 100px;
flex: none;
flex-shrink: 0;
}
/* Wildcard count */
.wfm-nlp-ai-wc-count {
width: 60px;
}
`;
document.head.appendChild(style);
};
+41 -13
View File
@@ -611,27 +611,55 @@ app.registerExtension({
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
];
const replaceButtonIcons = () => {
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) => {
foundAny = true;
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";
}
applyIconToButton(btn, cfg);
});
}
if (!foundAny) {
requestAnimationFrame(replaceButtonIcons);
requestAnimationFrame(pollUntilFound);
}
};
requestAnimationFrame(replaceButtonIcons);
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;
}
},
});
+665
View File
@@ -0,0 +1,665 @@
{
"id": "6d392e45-b20b-4dee-a422-0b4a18e645cd",
"revision": 0,
"last_node_id": 35,
"last_link_id": 102,
"nodes": [
{
"id": 8,
"type": "VAEDecode",
"pos": [
1351.4570000000006,
197.9979999999999
],
"size": [
210,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 42
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 84
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
22
]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.18",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": []
},
{
"id": 29,
"type": "CheckpointLoaderSimple",
"pos": [
-35,
161
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
80
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
81,
82
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
84,
90
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.18",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"anime\\merge\\hayochamixIDXL_v11+_blueberryPlus_v10-amanatsuIllustrious_v11_x0.1_trainDifference.safetensors"
]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
330,
369
],
"size": [
412.2799987792969,
88
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 82
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.18",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"w\n"
]
},
{
"id": 32,
"type": "LoadImage",
"pos": [
-20.402179424849777,
348.01722821269084
],
"size": [
282.798828125,
314
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
},
"link": null
},
{
"localized_name": "choose file to upload",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
101
]
},
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": [
102
]
}
],
"properties": {
"Node name for S&R": "LoadImage",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
},
"image": "clipspace/clipspace-painted-masked-1784801792090.png [input]"
},
"widgets_values": [
"clipspace/clipspace-painted-masked-1784801792090.png [input]",
"image"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
997,
174
],
"size": [
315,
474
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 80
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 72
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
42
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.18",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
1008854893628621,
"randomize",
30,
4,
"ddim",
"simple",
1
]
},
{
"id": 26,
"type": "VAEEncodeForInpaint",
"pos": [
759,
169
],
"size": [
226.8000030517578,
98
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": 101
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 90
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 102
},
{
"localized_name": "grow_mask_by",
"name": "grow_mask_by",
"type": "INT",
"widget": {
"name": "grow_mask_by"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
72
]
}
],
"properties": {
"Node name for S&R": "VAEEncodeForInpaint",
"cnr_id": "comfy-core",
"ver": "0.3.18",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
6
]
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1362.0710000000008,
369.32099999999986
],
"size": [
210,
270
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 22
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "SaveImage",
"cnr_id": "comfy-core",
"ver": "0.3.18",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
326.97,
160
],
"size": [
422.8500061035156,
164.30999755859375
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 81
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.18",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"smile"
]
}
],
"links": [
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
22,
8,
0,
9,
0,
"IMAGE"
],
[
42,
3,
0,
8,
0,
"LATENT"
],
[
72,
26,
0,
3,
3,
"LATENT"
],
[
80,
29,
0,
3,
0,
"MODEL"
],
[
81,
29,
1,
6,
0,
"CLIP"
],
[
82,
29,
1,
7,
0,
"CLIP"
],
[
84,
29,
2,
8,
1,
"VAE"
],
[
90,
29,
2,
26,
1,
"VAE"
],
[
101,
32,
0,
26,
0,
"IMAGE"
],
[
102,
32,
1,
26,
2,
"MASK"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9229599817706418,
"offset": [
365.6203139679832,
118.10373545614928
]
},
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

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

After

Width:  |  Height:  |  Size: 250 KiB