Compare commits

...
14 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 4.6 817b76ed5d Merge feature/gallery-tab into main (v0.2.7)
Gallery tab, Group UI unification across all tabs, Settings 2-column layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:57:23 +09:00
ketle-manandClaude Sonnet 4.6 34c6e8d49e v0.2.7: Gallery tab, Group UI unification, Settings 2-column layout
- Gallery tab: image browser with Thumbnail/Table views, multi-select,
  server-side group/tag/favorite filtering, group rename support,
  os.scandir() + mtime folder cache for 6,000+ image libraries
- Group UI unified across Workflow, Nodes, Gallery tabs to match Models
  panel pattern (Current Groups / Add to Group / Create / Manage with Rename)
- Settings 2-column layout: Theme panel fixed on right (50% width, sticky);
  all other settings flow continuously on left without visual gap
- Gallery output directory configurable from Settings tab
- Help tab: Gallery tab documentation card added
- README and DEVLOG updated for v0.2.7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:57:16 +09:00
ketle-manandClaude Sonnet 4.6 f9f83ed9b2 Settings: 2-column layout with Theme panel fixed on the right
- Left column (480px): Language, Workflow folder, Gallery output, ComfyUI,
  Ollama, Default workflow, Eagle, Save button
- Right column (flex:1, sticky top): Theme selector + customizer only,
  max-height with overflow-y:auto so it stays visible while scrolling left col
- Add .wfm-settings-layout, .wfm-settings-left-col, .wfm-settings-right-col
  CSS classes; remove max-width:700px constraint from container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:56:29 +09:00
ketle-manandClaude Sonnet 4.6 a6100add6c Help: add Gallery tab documentation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:52:04 +09:00
ketle-manandClaude Sonnet 4.6 5108f710a6 Unify group UI labels across Workflow, Nodes, Gallery tabs
- Workflow Groups tab: remove redundant "Groups" label and workflow name
  (already shown in side panel header); start directly with Current Groups
- Nodes Groups tab: rename "Groups" section header to "Current Groups"
- Gallery Group tab button: rename "Group" → "Groups"
- Gallery section titles: remove text-transform:uppercase + letter-spacing,
  match Models style (13px font-weight:600, title-case labels)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:49:25 +09:00
ketle-manandClaude Sonnet 4.6 6756708f34 Unify group UI to Models standard across Nodes and Workflow tabs
Nodes (renderSideGroups):
- Replace "Remove from Group" text buttons with × icon buttons
- Rename "Create Group" button to "Create", create also assigns node to new group
- Add "Manage Groups" section with Rename (✎) and Delete (×) buttons
- Use el.querySelector() scope to avoid global ID conflicts on re-render

Workflow (renderSideGroup — new, replaces sidePanelGroupUpdate + refreshGroupSelects):
- Rewrite Group tab as fully dynamic JS-rendered panel matching Models layout
- Sections: Current Groups (× remove), Add to Group, Create New Group, Manage Groups
- Create also assigns current workflow to new group
- _refreshGroupFilter() handles toolbar filter select updates separately
- Remove all static HTML from Group tab in index.html (JS-rendered container only)
- Remove old event listeners (wfm-group-add-btn, assign-btn, remove-btn, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:13:40 +09:00
ketle-manandClaude Sonnet 4.6 e0cc44e56d Gallery: unify group UI to Models standard + performance improvements
Backend:
- gallery_metadata.py: add rename_group() + get_group_member_set() for fast server-side filtering
- gallery_service.py: replace stat()/iterdir() with os.scandir() (single syscall for size+mtime),
  add folder-level in-memory cache (invalidates on folder mtime change or 30s TTL),
  add server-side group_filter to list_images() using set lookup (eliminates full transfer on group filter)
- gallery_routes.py: add PUT /wfm/gallery/groups/{name} rename endpoint,
  pass group_filter param to list_images()

Frontend:
- gallery-tab.js: group filter now server-side (no more full JSON transfer on group switch),
  replace static group panel with renderDetailGroup() — Models-style dynamic UI:
  Current Groups (×remove), Add to Group (assign select), Create Group (creates+assigns),
  Manage Groups (rename via prompt + delete), add API.groupRename, add escapeHtml()
- templates/index.html: Gallery Group tab replaced with JS-rendered container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:55:07 +09:00
ketle-manandClaude Sonnet 4.6 3ce51dd5f6 Gallery: remove tree toggle, add group filter, multi-select with bulk operations
- Remove folder tree collapse toggle (panel is always visible)
- Remove output path display from tree panel
- Add group filter select in toolbar (client-side filtering)
- Add multi-select: Ctrl+click on thumbnails and table rows (amber highlight)
- Add bulk operation bar: Deselect All, Add to Group, Favorite All, Unfavorite All
- Move Fav column to leftmost position in table view (matches other tabs)
- Extract toggleFavoriteInPlace() to avoid full re-render on favorite toggle
- Remove CSS transition from tree panel (performance improvement)
- renderGroupSelect() now also populates bulk group select and group filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:08:04 +09:00
ketle-manandClaude Sonnet 4.6 43d7e03d72 Security: fix path traversal, PNG chunk OOM, JPEG read limit in gallery
- GalleryService._check_path_allowed(): resolve() + relative_to(allowed_root)
  blocks access to any path outside the configured output directory
- allowed_root set on first list_folder_tree() call; updated on settings change
  via settings_routes -> gallery_service.update_output_root()
- _read_png_metadata: skip chunks > 32MB to prevent OOM on crafted PNGs
- _read_jpeg_metadata: cap file read at 64MB; stop after first COM segment
- serve_image / get_image_metadata / list_images / extract_workflow:
  all resolve() paths and check against allowed_root before opening

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:01:49 +09:00
ketle-manandClaude Sonnet 4.6 370bc6aa6f Gallery: move tree toggle to fixed left bar, workflow-only metadata view
- Folder tree toggle button moved to independent 20px bar at far left
  always visible regardless of tree panel state (panel hides/shows entirely)
- Metadata tab now shows workflow JSON only in full-height <pre> block
  with toolbar (status label + Copy Workflow button) at top
- Removed old metadata key/value row rendering, replaced with renderWorkflowJson()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:49:39 +09:00
ketle-manandClaude Sonnet 4.6 ae85378e34 Fix gallery: tree collapse button visibility, split Metadata into separate tab
- Tree panel: overflow:visible on panel, ov:hidden only on tree scroll area
  so the toggle button remains clickable at 32px collapsed width
- Detail panel: Metadata moved from Info tab into its own 'Metadata' tab
  (Info / Metadata / Group — 3 tabs)
- JS toggleTreePanel sets explicit max-width to prevent layout bleed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:36:27 +09:00
ketle-manandClaude Sonnet 4.6 8bdb487a83 Fix Gallery tab: settings output path, tree toggle, card favorites, remove thumb filename
1. Output folder setting moved to Settings tab (Workflow Data Folder section)
   - New API: GET/POST /api/wfm/settings/output-dir
   - Auto-detects ComfyUI default output via folder_paths
   - gallery-tab.js reads from settings API on init
   - Settings change dispatches wfm-output-dir-changed event to live-reload tree

2. Fix folder tree collapse/expand toggle
   - Replaced CSS .collapsed class with direct JS style control
   - Expand now correctly restores tree, output label, and title

3. Favorite toggle moved to thumbnail card (hover button, top-right)
   - Removed favorite button from detail panel Info tab
   - Added ★/☆ toggle button on both thumb cards and table rows
   - Detail panel no longer has updateFavBtn / toggleFavorite logic

4. Remove filename text from thumbnail cards (shown in tooltip on hover)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:15:26 +09:00
ketle-manandClaude Sonnet 4.6 cc9a961a50 Add Gallery tab: 3-column image browser for ComfyUI output folder (Phase 1)
- New Gallery tab with folder tree / image grid / detail panel layout
- Backend: gallery_service.py (folder tree, image list, PNG metadata extraction, groups)
- Backend: gallery_metadata.py (persistent metadata store for tags, memo, favorites, groups)
- Backend: gallery_routes.py (REST API endpoints)
- Frontend: gallery-tab.js (folder tree, thumbnail/table view, lightbox, tag/memo/group management)
- Frontend: gallery-tab.css (3-column layout, thumb cards, lightbox, detail panel)
- i18n: EN/JA/ZH keys for Gallery tab
- PNG embedded workflow extraction with clipboard copy support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:33:47 +09:00
ketle-manandClaude Sonnet 4.6 40955f2822 Improve UI layout, fix badge persistence, add folder filter (v0.2.6)
- Fix workflow badge not saved/loaded after restart (workflow_service.py)
- Fix workflow badge filter buttons to show palette-defined badges with colors
- Make side panels always visible (remove toggle) in Workflow/Model/Node tabs
- Replace pagination with full scroll display in all three tabs
- Add folder (subdir) filter to Models tab toolbar
- Remove dir/ext badges from Model card view (visible in side panel)
- Resize thumbnail cards to 178x200px, fix grid layout crush with grid-auto-rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:12:31 +09:00
23 changed files with 4772 additions and 363 deletions
+137
View File
@@ -1,5 +1,102 @@
# DEVLOG - ComfyUI-Workflow-Studio
## 2026-04-06: v0.2.6 UI改善・バッジ保存修正・フォルダフィルター追加
### 概要
- ワークフロー/モデル/ノードタブのサイドパネルを常時表示固定に変更
- グリッドをページネーションからスクロール全件表示に変更
- ワークフロータブのバッジが再起動後に消える問題を修正
- バッジフィルターボタンをパレット定義バッジで統一(ワークフロータブ)
- モデルタブにフォルダ(サブディレクトリ)フィルターを追加
- モデルカードビューから拡張子・フォルダバッジを削除(詳細パネルで確認可能)
- サムネイルカードサイズを 178×200px に調整、グリッドレイアウトを修正
### 変更内容
#### `py/services/workflow_service.py` — バッジ保存修正
**`save_metadata` のキーリストに `badges` を追加:**
- 変更前: `("tags", "memo", "summary", "modelTypesOverride", "favorite")`
- 変更後: `("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges")`
**`list_workflows` のレスポンスに `badges` フィールドを追加:**
- `metadata` dict に `"badges": meta.get("badges", [])` を追加
- これにより再起動後もバッジが保持される
#### `static/js/workflow-tab.js` — バッジフィルター・サイドパネル・スクロール
**バッジフィルターをパレット定義バッジで表示:**
- `renderModelFilters()`: `getAllBadges()`(ワークフローに付いているバッジのみ)→ `getBadgePalette()`(パレット全バッジ)に変更
- フィルターボタンにバッジカラーを適用
**サイドパネル常時表示化:**
- `showSidePanel()`: `panel.style.display = "flex"` を削除
- `closeSidePanel()`: `panel.style.display = "none"` を削除、タイトルをクリアするのみ
- ×ボタンのイベントリスナー削除
**ページネーション廃止・スクロール全件表示:**
- `WF_PER_PAGE` 定数削除
- `updateWfPagination()` 関数削除
- `renderGrid()`: ページスライス処理を削除、全件描画に変更
#### `static/js/models-tab.js` — サイドパネル・スクロール・フォルダフィルター・カードビュー
**サイドパネル常時表示化:**
- `showSidePanel()`: `panel.style.display = "flex"` を削除
- `closeSidePanel()`: `panel.style.display = "none"` を削除
**ページネーション廃止・スクロール全件表示:**
- `MODELS_PER_PAGE` 定数削除、`updatePagination()` 関数削除
- `renderThumbView()` / `renderCardView()` / `renderTableView()`: `totalPages` 引数削除、全件描画に変更
**フォルダフィルター追加:**
- `state.dirFilter: ""` 追加
- `filterModels()` に `dirFilter` による絞り込みを追加
- `renderDirFilter()` 関数追加(現在のモデルタイプのサブディレクトリ一覧を `<select>` に反映)
- モデルロード時・モデルタイプ切替時に呼び出し
- `wfm-models-dir-filter` change イベントリスナー追加
**モデルカードビューからバッジ削除:**
- `renderCardView()`: `dir` バッジ・`ext` バッジを削除。ユーザーバッジ・タグのみ表示
#### `static/js/nodes-tab.js` — サイドパネル・スクロール
**サイドパネル常時表示化:**
- `showNodeSidePanel()`: `panel.style.display = "flex"` を削除
- `closeNodeSidePanel()`: `panel.style.display = "none"` を削除
**ページネーション廃止・スクロール全件表示:**
- `NODES_PER_PAGE` / `renderPagination()` / `clearPagination()` / `scrollGridToTop()` 削除
- `renderNodeGrid()`: 全件描画に変更
#### `templates/index.html` — サイドパネル・ページネーション・フォルダフィルター
- 3タブ(ワークフロー/ノード/モデル)のサイドパネルから `style="display:none"` と×ボタンを削除
- ページネーション用 `<div>` を3箇所から削除
- モデルタブのツールバーに `<select id="wfm-models-dir-filter">` を追加(TagとGroupの間)
#### `static/css/main.css` — グリッド・カードサイズ・サイドパネル
**グリッド・カードサイズ変更:**
- グリッド列幅: `161px` → `178px`
- カード幅: `161px` → `178px`
- サムネイル: `161×162px` → `178×200px`
- `grid-auto-rows: max-content` / `align-items: start` / `justify-items: start` 追加(行高さ圧縮防止)
**カードビュー:**
- グリッド列幅: `minmax(180px, 1fr)` → `repeat(auto-fill, 220px)` 固定幅に変更(横伸び防止)
- カード: `flex-direction: row` / `min-height: 56px` / `width: 220px` 固定
**サイドパネル:**
- `.wfm-side-panel-close` スタイル削除
- `.wfm-side-panel-empty` クラス追加(選択前の空状態表示用)
#### `static/js/i18n.js` — フォルダフィルター翻訳追加
- `modelsAllDirs`: 英語 `"All Folders"` / 日本語 `"すべてのフォルダ"` / 中国語 `"所有文件夹"` を追加
---
## 2026-04-04: v0.2.5 追加修正
### 概要
@@ -962,3 +1059,43 @@ GitHub公開・ComfyUI Manager登録・初回リリース
- README.md 作成(スクリーンショット4枚付き)
- ComfyUI Manager 登録PR: https://github.com/Comfy-Org/ComfyUI-Manager/pull/2706
- GitHub Release v0.1.0: https://github.com/ketle-man/ComfyUI-Workflow-Studio/releases/tag/v0.1.0
---
## 2026-04-13: Gallery タブ追加 & 全タブ Group UI 統一
### 概要
- ギャラリータブ(Phase 1)実装:6800枚超の画像ブラウザ
- Workflow / Nodes / Gallery の Group UI を Models タブ基準に統一
- Settings タブを2カラムレイアウトに変更(テーマを右固定)
### Gallery タブ機能
- **3カラム表示** — サムネイル / テーブル切り替え、Fav列を左端に
- **サーバーサイドフィルタ** — グループ・お気に入り・タグによる server-side filtering
- **マルチセレクト** — Ctrl+クリックで複数選択、Bulk Bar でまとめて操作
- **グループ管理** — 作成・Rename・削除(Models パネルと同一UIパターン)
- **os.scandir() + フォルダキャッシュ** — 6800枚環境でのパフォーマンス改善
- **出力フォルダ設定** — Settings タブからギャラリー参照フォルダを変更可能
### Group UI 統一
- **Workflow タブ** — `renderSideGroup()` を新設し Models 4 セクションパターンに変更(Current Groups / Add to Group / Create New Group / Manage Groups)。冗長なヘッダーブロック削除
- **Nodes タブ** — `renderSideGroups()` を全面書き換え。`×` ボタン・"Add" ボタン・"Rename (✎)" 追加
- **Gallery タブ** — `renderDetailGroup()` を新設、同パターン適用
### Settings 2カラムレイアウト
- テーマパネルを右側(`float:right; width:50%`)に固定
- 左側に UI言語〜保存ボタンを連続配置(ギャップなし)
- `position:sticky; top:0` でテーマパネルをスクロールに追従
### 変更ファイル
- `py/services/gallery_metadata.py` — `rename_group()`, `get_group_member_set()` 追加
- `py/services/gallery_service.py` — `_FolderCache`, `_scan_folder()`, `group_filter` 対応
- `py/routes/gallery_routes.py` — `PUT /wfm/gallery/groups/{name}` 追加
- `static/js/gallery-tab.js` — マルチセレクト・サーバーサイドフィルタ・renderDetailGroup
- `static/js/nodes-tab.js` — `renderSideGroups()` 全面書き換え
- `static/js/workflow-tab.js` — `renderSideGroup()` 新設、旧ハンドラ削除
- `static/js/settings-tab.js` — 2カラムレイアウト HTML 構造変更
- `static/css/gallery-tab.css` — multi-selected・bulk-bar・セクションタイトルスタイル
- `static/css/main.css` — Settings レイアウト CSS(float:right ベース 50:50)
- `templates/index.html` — Gallery "Group"→"Groups"、各タブ static HTML → JS動的生成、Gallery ヘルプカード追加
+23 -3
View File
@@ -5,7 +5,7 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.2.5-green)
![Version](https://img.shields.io/badge/version-0.2.7-green)
## Screenshots
@@ -21,9 +21,9 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|:---:|:---:|
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| Help & Support Tab | |
| Gallery Tab | Help & Support Tab |
|:---:|:---:|
| ![Help](docs/screenshot_help.png) | |
| ![Gallery](docs/screenshot_gallery.png) | ![Help](docs/screenshot_help.png) |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|:---:|:---:|
@@ -73,15 +73,28 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
### Settings Tab
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
- **Theme customization** — override colors (background, surface, text, primary, accent), add background patterns (horizontal/vertical/diagonal stripes, polka dot, checkerboard, custom SVG tiling with color/opacity/scale/gap controls), and select from 16 fonts including Japanese display fonts (Google Fonts)
- **Workflows directory** — configure which folder to scan for workflows
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
- **Eagle connection** — set Eagle API endpoint for auto-save
- **Ollama connection** — configure Ollama server URL
- **Default workflow** — set a workflow to auto-load on startup
- **Language** — English / Japanese / Chinese
### Gallery Tab (v0.2.7)
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch tag/group/favorite operations
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
- **Favorites** — star images inline without reopening the detail panel
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
- **Output folder configurable** — set the scanned output folder from Settings tab
- **Performance** — with folder-level mtime cache (30s TTL) for fast incremental refresh
### Nodes Tab (v0.1.7)
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
- **Search & Filter** — full-text search, filter by category, package, tags, groups, and favorites
@@ -194,6 +207,13 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### v0.2.7
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
- **Settings 2-column layout** — Theme panel fixed on the right (50% width, sticky); all other settings flow continuously on the left with no visual gap
- **Gallery output directory setting** — configure the output folder scanned by Gallery tab from Settings
- **Help tab** — Gallery tab description card added
### v0.2.5
- **Sidebar Category/Package sub-tabs** — added 📂 Category and 🧩 Package sub-tabs to the Nodes tab in Workflow Studio Library; each shows a dropdown to filter nodes by category or custom node package (reads `/object_info` for accurate package detection)
- **Sidebar theme settings** — added ⚙ button in the panel header to customize background, sub-header background, text, border, and secondary text colors with live preview; settings saved to localStorage
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2100 900" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="2100" y2="900" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="50%" stop-color="#16213e"/>
<stop offset="100%" stop-color="#1a1a2e"/>
</linearGradient>
<linearGradient id="wStroke" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4a9eff"/>
<stop offset="100%" stop-color="#7b68ee"/>
</linearGradient>
<linearGradient id="sWave" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7b68ee"/>
<stop offset="100%" stop-color="#4a9eff"/>
</linearGradient>
<linearGradient id="textGrad" x1="0" y1="0" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#e0e0ff"/>
<stop offset="100%" stop-color="#b0b0d0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="2100" height="900" fill="url(#bg)"/>
<!-- Subtle grid -->
<line x1="0" y1="225" x2="2100" y2="225" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="450" x2="2100" y2="450" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="675" x2="2100" y2="675" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<!-- Decorative wave lines in background -->
<path d="M0 750 Q262 700 525 750 Q787 800 1050 750 Q1312 700 1575 750 Q1837 800 2100 750" stroke="#4a9eff" stroke-opacity="0.08" stroke-width="2" fill="none"/>
<path d="M0 780 Q262 730 525 780 Q787 830 1050 780 Q1312 730 1575 780 Q1837 830 2100 780" stroke="#7b68ee" stroke-opacity="0.06" stroke-width="2" fill="none"/>
<!-- W+S Logo (left side) -->
<g transform="translate(180, 180)">
<!-- W -->
<path d="M60 80 L120 280 L180 130 L240 280 L300 80" stroke="url(#wStroke)" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- S wave -->
<path d="M80 340 Q140 300 200 340 Q260 380 320 340" stroke="url(#sWave)" stroke-width="18" stroke-linecap="round" fill="none"/>
</g>
<!-- Title text -->
<text x="720" y="380" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="120" font-weight="700" fill="url(#textGrad)" letter-spacing="-2">Workflow Studio</text>
<!-- Subtitle -->
<text x="720" y="470" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="42" fill="#8888aa" letter-spacing="1">Workflow Management &amp; Generation UI for ComfyUI</text>
<!-- Feature pills -->
<g font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="28" fill="#a0a0c0">
<rect x="720" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
<text x="820" y="559" text-anchor="middle" fill="#4a9eff">Workflows</text>
<rect x="940" y="530" width="200" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
<text x="1040" y="559" text-anchor="middle" fill="#7b68ee">GenerateUI</text>
<rect x="1160" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
<text x="1260" y="559" text-anchor="middle" fill="#4a9eff">AI Prompts</text>
<rect x="1380" y="530" width="180" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
<text x="1470" y="559" text-anchor="middle" fill="#7b68ee">Themes</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="100%" stop-color="#16213e"/>
</linearGradient>
<linearGradient id="wStroke" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4a9eff"/>
<stop offset="100%" stop-color="#7b68ee"/>
</linearGradient>
<linearGradient id="sWave" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7b68ee"/>
<stop offset="100%" stop-color="#4a9eff"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="400" height="400" rx="48" fill="url(#bg)"/>
<!-- Subtle grid pattern -->
<line x1="0" y1="100" x2="400" y2="100" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="200" x2="400" y2="200" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="300" x2="400" y2="300" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="100" y1="0" x2="100" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="200" y1="0" x2="200" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="300" y1="0" x2="300" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<!-- W letterform -->
<path d="M80 110 L140 290 L200 150 L260 290 L320 110" stroke="url(#wStroke)" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- S wave -->
<path d="M100 330 Q150 295 200 330 Q250 365 300 330" stroke="url(#sWave)" stroke-width="20" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+219
View File
@@ -0,0 +1,219 @@
"""
Gallery Routes - ギャラリータブ用APIエンドポイント
"""
import json
import mimetypes
import logging
from pathlib import Path
from aiohttp import web
from ..config import DATA_DIR
from ..services.gallery_service import GalleryService
logger = logging.getLogger(__name__)
_service = GalleryService(DATA_DIR)
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_post("/wfm/gallery/image/meta", save_image_meta)
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/{name}/add", add_to_group)
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
# ──────────────────────────────────────────────────────────────
# フォルダツリー
# ──────────────────────────────────────────────────────────────
async def list_folders(request: web.Request) -> web.Response:
root = request.rel_url.query.get("root", "")
if not root:
return web.json_response({"error": "root parameter required"}, status=400)
try:
tree = _service.list_folder_tree(root)
return web.json_response(tree)
except Exception as e:
logger.error("list_folders error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# 画像一覧
# ──────────────────────────────────────────────────────────────
async def list_images(request: web.Request) -> web.Response:
folder = request.rel_url.query.get("folder", "")
search = request.rel_url.query.get("search", "")
sort_by = request.rel_url.query.get("sort", "date_desc")
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
tag_filter = request.rel_url.query.get("tag", "")
group_filter = request.rel_url.query.get("group", "")
if not folder:
return web.json_response({"error": "folder parameter required"}, status=400)
try:
images = _service.list_images(
folder,
search=search,
sort_by=sort_by,
favorite_only=favorite_only,
tag_filter=tag_filter,
group_filter=group_filter,
)
return web.json_response({"images": images, "total": len(images)})
except Exception as e:
logger.error("list_images error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# 画像メタデータ
# ──────────────────────────────────────────────────────────────
async def get_image_meta(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.json_response({"error": "path required"}, status=400)
try:
meta = _service.get_image_metadata(path)
return web.json_response(meta)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def get_image_workflow(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.json_response({"error": "path required"}, status=400)
try:
wf = _service.extract_workflow_from_metadata(path)
if wf is None:
return web.json_response({"workflow": None, "has_workflow": False})
return web.json_response({"workflow": wf, "has_workflow": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def serve_image(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.Response(status=400)
img_path = _service.serve_image(path)
if img_path is None:
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(img_path))
return web.FileResponse(img_path, headers={
"Content-Type": content_type or "image/png",
"Cache-Control": "max-age=3600",
})
async def save_image_meta(request: web.Request) -> web.Response:
try:
body = await request.json()
img_path = body.get("path", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
data = {k: v for k, v in body.items() if k != "path"}
ok = _service.save_image_meta(img_path, data)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def toggle_favorite(request: web.Request) -> web.Response:
try:
body = await request.json()
img_path = body.get("path", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
new_val = _service.toggle_favorite(img_path)
return web.json_response({"favorite": new_val})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
async def list_groups(request: web.Request) -> web.Response:
return web.json_response({"groups": _service.list_groups()})
async def create_group(request: web.Request) -> web.Response:
try:
body = await request.json()
name = body.get("name", "").strip()
if not name:
return web.json_response({"error": "name required"}, status=400)
ok = _service.create_group(name)
if not ok:
return web.json_response({"error": "Group already exists"}, status=409)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def rename_group(request: web.Request) -> web.Response:
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
try:
old_name = request.match_info.get("name", "")
body = await request.json()
new_name = body.get("new_name", "").strip()
if not old_name or not new_name:
return web.json_response({"error": "old_name and new_name required"}, status=400)
ok = _service.rename_group(old_name, new_name)
if not ok:
return web.json_response({"error": "Rename failed (not found or name conflict)"}, status=400)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def delete_group(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
ok = _service.delete_group(name)
return web.json_response({"ok": ok})
async def add_to_group(request: web.Request) -> web.Response:
try:
name = request.match_info.get("name", "")
body = await request.json()
img_path = body.get("path", "")
ok = _service.add_to_group(img_path, name)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def remove_from_group(request: web.Request) -> web.Response:
try:
name = request.match_info.get("name", "")
body = await request.json()
img_path = body.get("path", "")
ok = _service.remove_from_group(img_path, name)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def list_group_images(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
images = _service.list_images_in_group(name)
return web.json_response({"images": images})
+59
View File
@@ -20,6 +20,8 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings", handle_post)
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
async def handle_get(request: web.Request) -> web.Response:
@@ -92,3 +94,60 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error setting workflows dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _get_comfyui_output_dir() -> str:
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
try:
import folder_paths # type: ignore
return str(folder_paths.get_output_directory())
except Exception:
pass
# fallback: config.pyのCOMFYUI_ROOTから推測
from ..config import PLUGIN_DIR
comfyui_root = PLUGIN_DIR.parent.parent
output_dir = comfyui_root / "output"
if output_dir.is_dir():
return str(output_dir)
return ""
async def handle_get_output_dir(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/output-dir - Get gallery output directory."""
try:
server_settings = await asyncio.to_thread(_service.load)
saved = server_settings.get("gallery_output_dir", "").strip()
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
return web.json_response({
"current": saved or default_dir,
"default": default_dir,
"saved": saved,
})
except Exception as e:
logger.error("Error getting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_set_output_dir(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/output-dir - Save gallery output directory."""
try:
body = await request.json()
new_dir = body.get("gallery_output_dir", "").strip()
await asyncio.to_thread(_service.update, {"gallery_output_dir": new_dir})
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
resolved = new_dir or default_dir
# GalleryServiceの許可ルートを更新
from ..routes.gallery_routes import _service as gallery_service
gallery_service.update_output_root(resolved)
return web.json_response({
"status": "ok",
"current": resolved,
"default": default_dir,
"saved": new_dir,
})
except Exception as e:
logger.error("Error setting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
+125
View File
@@ -0,0 +1,125 @@
"""
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class GalleryMetadataStore:
"""
gallery_metadata.json の構造:
{
"images": {
"<abs_path>": {
"favorite": bool,
"tags": [str, ...],
"memo": str,
"groups": [str, ...]
}
},
"groups": [
{"name": str}
]
}
"""
def __init__(self, file_path: Path):
self.file_path = file_path
self._data: dict = {"images": {}, "groups": []}
self._load()
def _load(self):
if self.file_path.exists():
try:
with open(self.file_path, "r", encoding="utf-8") as f:
loaded = json.load(f)
self._data = {
"images": loaded.get("images", {}),
"groups": loaded.get("groups", []),
}
except Exception as e:
logger.warning("GalleryMetadataStore: load error: %s", e)
def _save_to_disk(self):
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logger.error("GalleryMetadataStore: save error: %s", e)
return False
def _normalize_path(self, image_path: str) -> str:
return str(Path(image_path)).replace("\\", "/")
def get(self, image_path: str) -> dict:
key = self._normalize_path(image_path)
return dict(self._data["images"].get(key, {}))
def save(self, image_path: str, data: dict) -> bool:
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
# ── グループ管理 ──────────────────────────────────────────────
def list_groups(self) -> list[dict]:
return list(self._data.get("groups", []))
def create_group(self, name: str) -> bool:
groups = self._data.get("groups", [])
if any(g["name"] == name for g in groups):
return False
groups.append({"name": name})
self._data["groups"] = groups
return self._save_to_disk()
def rename_group(self, old_name: str, new_name: str) -> bool:
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
groups = self._data.get("groups", [])
if not any(g["name"] == old_name for g in groups):
return False
if any(g["name"] == new_name for g in groups):
return False # 新名前が既に存在
# グループリスト更新
for g in groups:
if g["name"] == old_name:
g["name"] = new_name
break
# 全画像のgroupsフィールドを更新
for meta in self._data["images"].values():
img_groups = meta.get("groups", [])
if old_name in img_groups:
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
return self._save_to_disk()
def delete_group(self, name: str) -> bool:
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
self._data["groups"] = groups
# 画像のgroupsからも削除
for meta in self._data["images"].values():
if name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != name]
return self._save_to_disk()
def list_images_in_group(self, group_name: str) -> list[str]:
result = []
for key, meta in self._data["images"].items():
if group_name in meta.get("groups", []):
result.append(key)
return result
def get_group_member_set(self, group_name: str) -> set:
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
return {
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
+427
View File
@@ -0,0 +1,427 @@
"""
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
"""
import json
import logging
import mimetypes
import os
import re
import struct
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# サポートする画像拡張子
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
# メタデータ保存ファイル (gallery専用)
from .gallery_metadata import GalleryMetadataStore
# PNGチャンク1個の最大許容サイズ (32MB)
_PNG_CHUNK_MAX = 32 * 1024 * 1024
# JPEGを読む際の最大ファイルサイズ (64MB)
_JPEG_READ_MAX = 64 * 1024 * 1024
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
_CACHE_TTL = 30.0
class _FolderCache:
"""フォルダ単位の画像スキャン結果キャッシュ。
フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。"""
def __init__(self):
# folder_path -> (mtime, scan_time, [(name, path_str, size, mtime_f), ...])
self._cache: dict[str, tuple[float, float, list]] = {}
def get(self, folder: Path) -> list | None:
key = str(folder)
if key not in self._cache:
return None
cached_mtime, scan_time, entries = self._cache[key]
# TTL チェック
if time.monotonic() - scan_time > _CACHE_TTL:
return None
# フォルダ自体のmtimeチェック
try:
current_mtime = folder.stat().st_mtime
except OSError:
return None
if current_mtime != cached_mtime:
return None
return entries
def set(self, folder: Path, entries: list):
key = str(folder)
try:
folder_mtime = folder.stat().st_mtime
except OSError:
folder_mtime = 0.0
self._cache[key] = (folder_mtime, time.monotonic(), entries)
def invalidate(self, folder: Path):
self._cache.pop(str(folder), None)
class GalleryService:
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
self._allowed_root: Path | None = None
self._folder_cache = _FolderCache()
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 _check_path_allowed(self, path: Path) -> bool:
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
if self._allowed_root is None:
resolved = path.resolve()
return resolved == path.resolve()
try:
path.resolve().relative_to(self._allowed_root)
return True
except ValueError:
return False
# ──────────────────────────────────────────────────────────────
# フォルダツリー
# ──────────────────────────────────────────────────────────────
def list_folder_tree(self, root_path: str) -> dict:
"""outputフォルダのフォルダツリーを返す"""
root = Path(root_path).resolve()
if not root.is_dir():
return {"error": f"Directory not found: {root_path}"}
if self._allowed_root is None:
self._allowed_root = root
def build_tree(path: Path, rel_base: Path) -> dict:
rel = str(path.relative_to(rel_base)).replace("\\", "/")
image_count = 0
children = []
try:
with os.scandir(path) as it:
for entry in it:
if entry.is_file(follow_symlinks=True):
if Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
image_count += 1
elif entry.is_dir(follow_symlinks=True) and not entry.name.startswith("."):
children.append(entry.name)
except PermissionError:
pass
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
return {
"name": path.name,
"path": rel if rel != "." else "",
"abs_path": str(path).replace("\\", "/"),
"image_count": image_count,
"children": children_nodes,
}
return build_tree(root, root)
# ──────────────────────────────────────────────────────────────
# 画像一覧
# ──────────────────────────────────────────────────────────────
def _scan_folder(self, folder: Path) -> list[tuple[str, str, int, float]]:
"""os.scandir() で画像ファイルを1回のシステムコールで列挙。
キャッシュがあればそれを返す。
Returns: [(name, abs_path_str, size, mtime), ...]
"""
cached = self._folder_cache.get(folder)
if cached is not None:
return cached
entries = []
try:
with os.scandir(folder) as it:
for entry in it:
if not entry.is_file(follow_symlinks=True):
continue
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
continue
try:
st = entry.stat(follow_symlinks=True)
entries.append((
entry.name,
str(Path(entry.path).resolve()).replace("\\", "/"),
st.st_size,
st.st_mtime,
))
except OSError:
continue
except PermissionError:
pass
self._folder_cache.set(folder, entries)
return entries
def list_images(
self,
folder_path: str,
search: str = "",
sort_by: str = "date_desc",
favorite_only: bool = False,
tag_filter: str = "",
group_filter: str = "",
) -> list[dict]:
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
folder = Path(folder_path).resolve()
if not folder.is_dir():
return []
if not self._check_path_allowed(folder):
logger.warning("list_images: path not allowed: %s", folder)
return []
# グループフィルタ: サーバーサイドで先にパスSetを取得(転送量削減)
group_member_set: set | None = None
if group_filter:
group_member_set = self.metadata_store.get_group_member_set(group_filter)
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
raw_entries = self._scan_folder(folder)
results = []
for name, abs_path, size, mtime in raw_entries:
# グループフィルタ(サーバーサイド)
if group_member_set is not None and abs_path not in group_member_set:
continue
meta = self.metadata_store.get(abs_path)
# お気に入りフィルタ(サーバーサイド、早期スキップ)
if favorite_only and not meta.get("favorite", False):
continue
# タグフィルタ(サーバーサイド、早期スキップ)
tags = meta.get("tags", [])
if tag_filter and tag_filter not in tags:
continue
ext = Path(name).suffix.lower()
item = {
"filename": name,
"path": abs_path,
"size": size,
"mtime": mtime,
"ext": ext,
"favorite": meta.get("favorite", False),
"tags": tags,
"memo": meta.get("memo", ""),
"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)
):
continue
results.append(item)
# ソート
if sort_by == "name_asc":
results.sort(key=lambda x: x["filename"].lower())
elif sort_by == "name_desc":
results.sort(key=lambda x: x["filename"].lower(), reverse=True)
elif sort_by == "date_asc":
results.sort(key=lambda x: x["mtime"])
else: # date_desc (default)
results.sort(key=lambda x: x["mtime"], reverse=True)
return results
# ──────────────────────────────────────────────────────────────
# 画像メタデータ
# ──────────────────────────────────────────────────────────────
def get_image_metadata(self, image_path: str) -> dict:
"""PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す"""
path = Path(image_path).resolve()
if not path.is_file():
return {"error": "File not found"}
if not self._check_path_allowed(path):
return {"error": "Access denied"}
embedded = {}
ext = path.suffix.lower()
if ext == ".png":
embedded = self._read_png_metadata(path)
elif ext in {".jpg", ".jpeg"}:
embedded = self._read_jpeg_metadata(path)
saved = self.metadata_store.get(str(path))
stat = path.stat()
return {
"filename": path.name,
"path": str(path).replace("\\", "/"),
"size": stat.st_size,
"mtime": stat.st_mtime,
"ext": ext,
"embedded": embedded,
"favorite": saved.get("favorite", False),
"tags": saved.get("tags", []),
"memo": saved.get("memo", ""),
"groups": saved.get("groups", []),
}
def _read_png_metadata(self, path: Path) -> dict:
"""PNGのtEXtチャンクからメタデータを抽出"""
result = {}
try:
with open(path, "rb") as f:
sig = f.read(8)
if sig != b"\x89PNG\r\n\x1a\n":
return result
while True:
chunk_len_bytes = f.read(4)
if len(chunk_len_bytes) < 4:
break
chunk_len = struct.unpack(">I", chunk_len_bytes)[0]
chunk_type = f.read(4).decode("ascii", errors="ignore")
if chunk_len > _PNG_CHUNK_MAX:
f.seek(chunk_len + 4, 1)
if chunk_type == "IEND":
break
continue
chunk_data = f.read(chunk_len)
f.read(4) # CRC
if chunk_type in ("tEXt", "iTXt"):
try:
if chunk_type == "tEXt":
null_idx = chunk_data.index(b"\x00")
key = chunk_data[:null_idx].decode("latin-1")
value = chunk_data[null_idx + 1:].decode("latin-1")
else:
null_idx = chunk_data.index(b"\x00")
key = chunk_data[:null_idx].decode("utf-8")
rest = chunk_data[null_idx + 1:]
rest = rest[2:]
null_idx2 = rest.index(b"\x00")
rest = rest[null_idx2 + 1:]
null_idx3 = rest.index(b"\x00")
value = rest[null_idx3 + 1:].decode("utf-8", errors="replace")
result[key] = value
except (ValueError, UnicodeDecodeError):
pass
if chunk_type == "IEND":
break
except Exception as e:
logger.debug("PNG metadata read error: %s", e)
return result
def _read_jpeg_metadata(self, path: Path) -> dict:
"""JPEGのEXIF/commentからメタデータを抽出(簡易)"""
result = {}
try:
file_size = path.stat().st_size
read_size = min(file_size, _JPEG_READ_MAX)
with open(path, "rb") as f:
data = f.read(read_size)
idx = 0
while idx < len(data) - 3:
if data[idx] == 0xFF:
marker = data[idx + 1]
if marker == 0xFE: # COM
length = struct.unpack(">H", data[idx + 2:idx + 4])[0]
end = idx + 2 + length
comment = data[idx + 4:end].decode("utf-8", errors="replace")
result["Comment"] = comment
break
idx += 2
else:
idx += 1
except Exception as e:
logger.debug("JPEG metadata read error: %s", e)
return result
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""PNG埋め込みメタデータからComfyUIワークフローを抽出"""
path = Path(image_path).resolve()
if path.suffix.lower() != ".png":
return None
if not self._check_path_allowed(path):
return None
embedded = self._read_png_metadata(path)
workflow_str = embedded.get("workflow") or embedded.get("Workflow")
if workflow_str:
try:
return json.loads(workflow_str)
except json.JSONDecodeError:
pass
return None
# ──────────────────────────────────────────────────────────────
# メタデータ保存
# ──────────────────────────────────────────────────────────────
def save_image_meta(self, image_path: str, data: dict) -> bool:
return self.metadata_store.save(image_path, data)
def toggle_favorite(self, image_path: str) -> bool:
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 list_groups(self) -> list[dict]:
return self.metadata_store.list_groups()
def create_group(self, name: str) -> bool:
return self.metadata_store.create_group(name)
def rename_group(self, old_name: str, new_name: str) -> bool:
return self.metadata_store.rename_group(old_name, new_name)
def delete_group(self, name: str) -> bool:
return self.metadata_store.delete_group(name)
def add_to_group(self, image_path: str, group_name: str) -> bool:
meta = self.metadata_store.get(image_path)
groups = meta.get("groups", [])
if group_name not in groups:
groups.append(group_name)
return self.metadata_store.save(image_path, {"groups": groups})
return True
def remove_from_group(self, image_path: str, group_name: str) -> bool:
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)
# ──────────────────────────────────────────────────────────────
# 画像配信
# ──────────────────────────────────────────────────────────────
def serve_image(self, image_path: str):
"""画像のPathオブジェクトを返す(ルートで使用)"""
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_image: path not allowed: %s", p)
return None
return p
+2 -1
View File
@@ -112,6 +112,7 @@ class WorkflowService:
"summary": meta.get("summary", ""),
"modelTypesOverride": override_types,
"favorite": bool(meta.get("favorite", False)),
"badges": meta.get("badges", []),
},
"mtime": stat.st_mtime,
"thumbnail": thumbnail,
@@ -132,7 +133,7 @@ class WorkflowService:
"""Save metadata (tags, memo, summary, etc.) for a workflow."""
metadata = self._load_metadata()
entry = metadata.get(filename, {})
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite"):
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges"):
if key in updates:
entry[key] = updates[key]
entry["updatedAt"] = self._now_iso()
+2 -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
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
@@ -70,5 +70,6 @@ class WorkflowStudio:
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
gallery_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "comfyui-workflow-studio"
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
version = "0.2.7"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
[project.urls]
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
[tool.comfy]
PublisherId = "ketle-man"
DisplayName = "Workflow Studio"
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/icon.svg"
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
+594
View File
@@ -0,0 +1,594 @@
/* ============================================================
Gallery Tab Styles
============================================================ */
/* ── 3カラムレイアウト ──────────────────────────────────────── */
.wfm-gallery-layout {
display: flex;
height: calc(100vh - var(--wfm-header-height, 96px));
overflow: hidden;
gap: 0;
}
/* ── 左: フォルダツリーパネル ──────────────────────────────── */
.wfm-gallery-tree-panel {
width: 250px;
min-width: 250px;
max-width: 250px;
border-right: 1px solid var(--wfm-border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wfm-bg-secondary, #1a1a2e);
}
.wfm-gallery-tree-header {
display: flex;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
}
.wfm-gallery-tree-title {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wfm-gallery-tree {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
}
.wfm-gallery-tree-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
cursor: pointer;
font-size: 12px;
color: var(--wfm-text-primary);
-webkit-user-select: none;
user-select: none;
transition: background 0.1s;
}
.wfm-gallery-tree-item:hover {
background: var(--wfm-bg-hover, rgba(255,255,255,0.06));
}
.wfm-gallery-tree-item.selected {
background: var(--wfm-accent, #4f8ef7);
color: #fff;
}
.wfm-gallery-tree-item.selected .wfm-gallery-tree-count {
background: rgba(255,255,255,0.25);
}
.wfm-gallery-tree-arrow {
font-size: 10px;
width: 14px;
flex-shrink: 0;
cursor: pointer;
color: var(--wfm-text-secondary);
transition: transform 0.15s;
}
.wfm-gallery-tree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.wfm-gallery-tree-count {
font-size: 10px;
background: var(--wfm-accent-muted, rgba(79,142,247,0.2));
color: var(--wfm-accent, #4f8ef7);
border-radius: 8px;
padding: 0 5px;
line-height: 1.4;
flex-shrink: 0;
}
.wfm-gallery-tree-children {
/* 子ノードコンテナ */
}
/* ── 中: 画像一覧パネル ────────────────────────────────────── */
.wfm-gallery-list-panel {
flex: 1;
overflow-y: auto;
min-width: 0;
border-right: 1px solid var(--wfm-border);
background: var(--wfm-bg-primary, #16213e);
}
.wfm-gallery-grid {
padding: 8px;
}
/* サムネイルビュー */
.wfm-gallery-view-thumb {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
align-items: start;
}
.wfm-gallery-thumb-card {
position: relative;
background: var(--wfm-bg-secondary, #1a1a2e);
border: 1px solid var(--wfm-border);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.wfm-gallery-thumb-card:hover {
border-color: var(--wfm-accent, #4f8ef7);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.wfm-gallery-thumb-card.selected {
border-color: var(--wfm-accent, #4f8ef7);
box-shadow: 0 0 0 2px var(--wfm-accent, #4f8ef7);
}
.wfm-gallery-thumb-card.multi-selected {
border-color: #f59e0b;
box-shadow: 0 0 0 2px #f59e0b;
}
.wfm-gallery-thumb-img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
background: var(--wfm-bg-hover, #111);
}
.wfm-gallery-thumb-placeholder {
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--wfm-bg-hover, #222);
color: var(--wfm-text-secondary);
font-size: 24px;
}
/* お気に入りボタン(サムネイルカード右上) */
.wfm-gallery-thumb-fav-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.5);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 13px;
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-fav-btn {
opacity: 1;
}
.wfm-gallery-thumb-fav-btn.active {
color: #fbbf24;
opacity: 1;
}
.wfm-gallery-thumb-fav-btn:hover {
background: rgba(0,0,0,0.75);
color: #fbbf24;
}
/* テーブルビューのお気に入りボタン */
.wfm-gallery-table-fav-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: rgba(255,255,255,0.3);
padding: 2px 4px;
transition: color 0.15s;
}
.wfm-gallery-table-fav-btn:hover,
.wfm-gallery-table-fav-btn.active {
color: #fbbf24;
}
.wfm-gallery-thumb-tags {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px 6px;
}
/* テーブルビュー */
.wfm-gallery-view-table {
display: block;
}
.wfm-gallery-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.wfm-gallery-table thead th {
position: sticky;
top: 0;
background: var(--wfm-bg-secondary, #1a1a2e);
color: var(--wfm-text-secondary);
font-weight: 600;
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid var(--wfm-border);
z-index: 1;
}
.wfm-gallery-table tbody tr {
cursor: pointer;
transition: background 0.1s;
}
.wfm-gallery-table tbody tr:hover {
background: var(--wfm-bg-hover, rgba(255,255,255,0.05));
}
.wfm-gallery-table tbody tr.selected {
background: rgba(79,142,247,0.15);
}
.wfm-gallery-table tbody tr.multi-selected {
background: rgba(245,158,11,0.15);
}
.wfm-gallery-table td {
padding: 4px 8px;
border-bottom: 1px solid var(--wfm-border);
color: var(--wfm-text-primary);
vertical-align: middle;
}
.wfm-gallery-table-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 3px;
display: block;
}
.wfm-gallery-table-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── タグバッジ共通 ────────────────────────────────────────── */
.wfm-gallery-tag-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(79,142,247,0.15);
color: var(--wfm-accent, #4f8ef7);
border: 1px solid rgba(79,142,247,0.3);
border-radius: 10px;
padding: 1px 7px;
font-size: 11px;
white-space: nowrap;
}
.wfm-gallery-tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 12px;
line-height: 1;
opacity: 0.7;
margin-left: 1px;
}
.wfm-gallery-tag-remove:hover {
opacity: 1;
}
/* ── 右: 詳細パネル ────────────────────────────────────────── */
.wfm-gallery-detail-panel {
width: 280px;
min-width: 280px;
max-width: 280px;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wfm-bg-secondary, #1a1a2e);
}
.wfm-gallery-detail-header {
padding: 6px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
}
.wfm-gallery-detail-filename {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* プレビュー */
.wfm-gallery-detail-preview {
width: 100%;
aspect-ratio: 1 / 1;
max-height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--wfm-bg-primary, #16213e);
border-bottom: 1px solid var(--wfm-border);
overflow: hidden;
flex-shrink: 0;
}
.wfm-gallery-detail-img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
cursor: pointer;
}
/* タブコンテンツ */
.wfm-gallery-detail-tab-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.wfm-gallery-section-title {
font-size: 13px;
font-weight: 600;
color: var(--wfm-text-primary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.wfm-gallery-info-section {
margin-bottom: 4px;
padding: 6px 0;
}
.wfm-gallery-info-row {
font-size: 12px;
color: var(--wfm-text-primary);
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-gallery-tags-display {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.wfm-gallery-memo {
width: 100%;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
color: var(--wfm-text-primary);
font-size: 12px;
padding: 6px 8px;
resize: vertical;
box-sizing: border-box;
font-family: inherit;
}
.wfm-gallery-memo:focus {
outline: none;
border-color: var(--wfm-accent, #4f8ef7);
}
/* ── Metadataタブ(Workflow全幅表示) ─────────────────────── */
.wfm-gallery-meta-tab {
display: flex;
flex-direction: column;
padding: 0 !important;
overflow: hidden;
height: 100%;
}
.wfm-gallery-meta-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 8px;
}
.wfm-gallery-meta-status {
font-size: 11px;
color: var(--wfm-text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-gallery-workflow-json {
flex: 1;
margin: 0;
padding: 10px;
font-family: "Consolas", "Monaco", monospace;
font-size: 11px;
line-height: 1.5;
color: var(--wfm-text-primary);
background: var(--wfm-bg-secondary, #1a1a2e);
overflow: auto;
white-space: pre;
word-break: normal;
border: none;
}
/* グループ */
.wfm-gallery-current-groups {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
font-size: 12px;
color: var(--wfm-text-secondary);
}
.wfm-gallery-groups-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.wfm-gallery-group-row {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
font-size: 12px;
}
.wfm-gallery-group-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--wfm-text-primary);
}
/* ── 一括操作バー ──────────────────────────────────────────── */
.wfm-gallery-bulk-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: rgba(245,158,11,0.12);
border-bottom: 1px solid rgba(245,158,11,0.35);
flex-shrink: 0;
flex-wrap: wrap;
}
.wfm-gallery-bulk-count {
font-size: 12px;
font-weight: 600;
color: #f59e0b;
white-space: nowrap;
margin-right: 4px;
}
/* ── ライトボックス ─────────────────────────────────────────── */
.wfm-gallery-lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.wfm-gallery-lightbox-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
max-width: 90vw;
max-height: 90vh;
}
.wfm-gallery-lightbox-img {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
cursor: default;
}
.wfm-gallery-lightbox-footer {
margin-top: 8px;
color: rgba(255,255,255,0.7);
font-size: 13px;
text-align: center;
}
.wfm-gallery-lightbox-close {
position: absolute;
top: -12px;
right: -12px;
background: rgba(0,0,0,0.7);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.wfm-gallery-lightbox-close:hover {
background: rgba(255,255,255,0.2);
}
+66 -25
View File
@@ -250,12 +250,15 @@ body {
.wfm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 161px);
grid-template-columns: repeat(auto-fill, 178px);
grid-auto-rows: max-content;
gap: 10px;
padding: 10px;
flex: 1;
overflow-y: auto;
align-content: start;
align-items: start;
justify-items: start;
}
/* ============================================
@@ -263,8 +266,8 @@ body {
============================================ */
.wfm-card {
width: 161px;
min-width: 161px;
width: 178px;
min-width: 178px;
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
@@ -280,8 +283,8 @@ body {
}
.wfm-card-thumb {
width: 161px;
height: 162px;
width: 178px;
height: 200px;
background: var(--wfm-bg-tertiary);
display: flex;
align-items: center;
@@ -732,7 +735,29 @@ body {
.wfm-settings-container {
padding: 24px;
max-width: 700px;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
}
.wfm-settings-layout {
/* layout wrapper — Theme floats right inside this */
}
.wfm-settings-left-col {
overflow: hidden;
}
.wfm-settings-right-col {
float: right;
width: 50%;
position: sticky;
top: 0;
max-height: calc(100vh - var(--wfm-header-height, 96px) - 48px);
overflow-y: auto;
margin-left: 24px;
margin-bottom: 16px;
box-sizing: border-box;
}
/* ============================================
@@ -827,26 +852,38 @@ body {
============================================ */
.wfm-view-card {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important;
gap: 6px !important;
grid-template-columns: repeat(auto-fill, 220px) !important;
grid-auto-rows: max-content !important;
gap: 8px !important;
justify-items: stretch !important;
align-items: start !important;
}
.wfm-view-card .wfm-card {
display: flex;
flex-direction: column;
min-height: 0;
flex-direction: row;
align-items: center;
width: 220px !important;
min-width: 220px !important;
min-height: 56px;
}
.wfm-view-card .wfm-card-body {
flex: 1;
min-width: 0;
padding: 8px;
padding: 8px 10px;
}
.wfm-view-card .wfm-fav-btn {
flex-shrink: 0;
margin-right: 6px;
}
.wfm-view-card .wfm-card-title {
margin-bottom: 4px;
white-space: normal;
word-break: break-all;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
@@ -878,6 +915,13 @@ body {
.wfm-view-table {
display: block !important;
overflow-y: auto;
justify-items: stretch !important;
}
.wfm-view-table .wfm-card,
.wfm-view-table .wfm-model-card {
width: auto !important;
min-width: 0 !important;
}
.wfm-table {
@@ -1007,6 +1051,7 @@ body {
border-bottom: 1px solid var(--wfm-border);
background: var(--wfm-surface);
flex-shrink: 0;
min-height: 37px;
}
.wfm-side-panel-title {
@@ -1018,18 +1063,13 @@ body {
text-overflow: ellipsis;
}
.wfm-side-panel-close {
background: none;
border: none;
.wfm-side-panel-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--wfm-text-secondary);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.wfm-side-panel-close:hover {
color: var(--wfm-text);
font-size: 13px;
}
/* Side Panel Tabs */
@@ -2885,6 +2925,7 @@ body {
#wfm-nodes-grid.wfm-view-card {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px;
justify-items: stretch;
}
/* Node card: package color left border, auto width */
+17 -1
View File
@@ -90,7 +90,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -188,6 +188,14 @@ function applyI18nToHtml() {
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
});
// Gallery tab
const gallerySideTabBtns = document.querySelectorAll(".wfm-gallery-detail-tab-btn");
gallerySideTabBtns.forEach(btn => {
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
});
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
@@ -273,6 +281,12 @@ function applyI18nToHtml() {
"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-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-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
@@ -335,6 +349,7 @@ import { initGenerateTab } from "./generate-tab.js";
import { initPromptTab } from "./prompt-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme } from "./settings-tab.js";
import { initModelsTab } from "./models-tab.js";
import { initGalleryTab } from "./gallery-tab.js";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
@@ -349,6 +364,7 @@ document.addEventListener("DOMContentLoaded", () => {
initModelsTab();
initGenerateTab();
initPromptTab();
initGalleryTab();
console.log("Workflow Studio: App initialized");
});
File diff suppressed because it is too large Load Diff
+72
View File
@@ -20,6 +20,7 @@ const LANGUAGES = {
// -- Tabs --
tabWorkflow: "Workflow",
tabGenerate: "GenerateUI",
tabGallery: "Gallery",
tabSettings: "Settings",
// -- Workflow toolbar --
@@ -255,6 +256,7 @@ const LANGUAGES = {
modelsCopiedPath: "File path copied",
modelsNoPreview: "No preview",
modelsTagsHint: "(comma separated)",
modelsAllDirs: "All Folders",
modelsAllGroups: "All Groups",
modelsCurrentGroups: "Current Groups",
modelsNoGroup: "Not assigned to any group",
@@ -288,6 +290,17 @@ const LANGUAGES = {
modelsSideInfo: "Info",
modelsSideGroup: "Group",
modelsSideCivitai: "CivitAI",
gallerySideMetadata: "Metadata",
// -- Gallery --
galleryAllTags: "All Tags",
galleryFolders: "Folders",
gallerySelectFolder: "Select a folder to browse images.",
galleryNoImages: "No images found.",
gallerySearchPlaceholder: "Search images...",
galleryOutputDir: "Gallery Output Folder",
galleryOutputDirLabel: "Output Folder Path",
galleryOutputDirHint: "Set the ComfyUI output folder to browse in the Gallery tab. Leave blank to use ComfyUI's default output directory.",
// -- Help & Support --
tabHelp: "Help",
@@ -343,6 +356,17 @@ const LANGUAGES = {
helpModels7: "Group management: create, rename, delete groups and assign/remove models",
helpModels8: "GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "User-defined badges: assign free-label badges with custom colors shared with the Workflow tab",
helpGalleryTitle: "Gallery Tab",
helpGallery1: "Browse images in the ComfyUI output folder — set the folder path in Settings > Gallery Output Folder",
helpGallery2: "3-column layout: folder tree (left) | image grid (center) | detail panel (right)",
helpGallery3: "Thumbnail and Table view modes; double-click any image to open fullscreen lightbox",
helpGallery4: "Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name",
helpGallery5: "Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All",
helpGallery6: "Detail panel — Info tab: filename, size, date, tags (add/remove), memo with save",
helpGallery7: "Detail panel — Metadata tab: embedded ComfyUI workflow JSON viewer with one-click clipboard copy",
helpGallery8: "Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image",
helpGallery9: "Supports PNG (tEXt/iTXt metadata), JPEG, WebP, GIF; PNG workflow extraction reads ComfyUI-embedded JSON",
helpGallery10: "Folder tree: click a folder to load its images; expand subfolders with ▶ arrow",
helpSidepanelTitle: "Workflow Studio Library (ComfyUI Side Panel)",
helpSidepanel1: "Open from the Workflow Studio Library button in the ComfyUI toolbar",
helpSidepanel2: "Workflows tab: All / ★ Favorites / Groups sub-tabs; ★ shown for favorites in All view",
@@ -486,6 +510,7 @@ const LANGUAGES = {
// -- Tabs --
tabWorkflow: "ワークフロー",
tabGenerate: "生成UI",
tabGallery: "ギャラリー",
tabSettings: "設定",
// -- Workflow toolbar --
@@ -721,6 +746,7 @@ const LANGUAGES = {
modelsCopiedPath: "ファイルパスをコピーしました",
modelsNoPreview: "プレビューなし",
modelsTagsHint: "(カンマ区切り)",
modelsAllDirs: "すべてのフォルダ",
modelsAllGroups: "すべてのグループ",
modelsCurrentGroups: "所属グループ",
modelsNoGroup: "グループに未所属",
@@ -754,6 +780,17 @@ const LANGUAGES = {
modelsSideInfo: "基本情報",
modelsSideGroup: "グループ",
modelsSideCivitai: "CivitAI",
gallerySideMetadata: "メタデータ",
// -- Gallery --
galleryAllTags: "すべてのタグ",
galleryFolders: "フォルダ",
gallerySelectFolder: "フォルダを選択して画像を表示します。",
galleryNoImages: "画像が見つかりません。",
gallerySearchPlaceholder: "画像を検索...",
galleryOutputDir: "ギャラリー Outputフォルダ",
galleryOutputDirLabel: "Outputフォルダのパス",
galleryOutputDirHint: "ギャラリータブで参照するComfyUIのoutputフォルダを設定します。空欄の場合はComfyUIのデフォルトoutputディレクトリを使用します。",
// -- Help & Support --
tabHelp: "ヘルプ",
@@ -809,6 +846,17 @@ const LANGUAGES = {
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
helpModels8: "GenUI Modelボタン:選択モデルをGenerateUIの現在のワークフローの該当ノードに直接適用(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "ユーザー定義バッジ:自由ラベルのバッジをモデルに付与(Workflowタブと色パレットを共有)",
helpGalleryTitle: "Galleryタブ",
helpGallery1: "ComfyUIの出力フォルダ内画像を閲覧 — Settings > Gallery Output Folder でフォルダパスを設定可能",
helpGallery2: "3カラムレイアウト: フォルダツリー (左) | 画像グリッド (中央) | 詳細パネル (右)",
helpGallery3: "サムネイル・テーブル表示モード切替;ダブルクリックで全画面ライトボックス表示",
helpGallery4: "ツールバーフィルタ:お気に入り(★)、テキスト検索、タグフィルタ、グループフィルタ、日付・名前ソート",
helpGallery5: "Ctrl+クリックで複数選択 — 一括操作:グループへ追加、一括お気に入り、一括お気に入り解除",
helpGallery6: "詳細パネル — Infoタブ:ファイル名、サイズ、日付、タグ(追加/削除)、メモ(保存機能付き)",
helpGallery7: "詳細パネル — Metadataタブ:埋め込みComfyUIワークフローJSONビューアとワンクリッククリップボードコピー",
helpGallery8: "詳細パネル — Groupsタブ:画像ごとのグループ作成、割り当て、解除、名前変更、削除",
helpGallery9: "PNG (tEXt/iTXt メタデータ), JPEG, WebP, GIF対応; PNGはComfyUIの埋め込みJSONを読み込んでワークフロー抽出可能",
helpGallery10: "フォルダツリー:フォルダをクリックして画像を読み込む;▶矢印でサブフォルダを展開",
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
helpSidepanel2: "Workflowsタブ:All / ★ Favorites / Groupsサブタブ、Allビューでお気に入りに★表示",
@@ -952,6 +1000,7 @@ const LANGUAGES = {
// -- Tabs --
tabWorkflow: "工作流",
tabGenerate: "生成UI",
tabGallery: "图库",
tabSettings: "设置",
// -- Workflow toolbar --
@@ -1183,6 +1232,7 @@ const LANGUAGES = {
modelsCopiedPath: "文件路径已复制",
modelsNoPreview: "无预览",
modelsTagsHint: "(逗号分隔)",
modelsAllDirs: "所有文件夹",
modelsAllGroups: "所有分组",
modelsCurrentGroups: "当前分组",
modelsNoGroup: "未分配到任何分组",
@@ -1216,6 +1266,17 @@ const LANGUAGES = {
modelsSideInfo: "基本信息",
modelsSideGroup: "分组",
modelsSideCivitai: "CivitAI",
gallerySideMetadata: "元数据",
// -- Gallery --
galleryAllTags: "所有标签",
galleryFolders: "文件夹",
gallerySelectFolder: "选择文件夹以浏览图片。",
galleryNoImages: "未找到图片。",
gallerySearchPlaceholder: "搜索图片...",
galleryOutputDir: "图库输出文件夹",
galleryOutputDirLabel: "输出文件夹路径",
galleryOutputDirHint: "设置图库标签页浏览的ComfyUI输出文件夹。留空则使用ComfyUI默认输出目录。",
// -- Help & Support --
tabHelp: "帮助",
@@ -1271,6 +1332,17 @@ const LANGUAGES = {
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
helpModels8: "GenUI Model按钮:将选中模型直接应用到GenerateUI当前工作流的对应节点(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "用户自定义徽章:为模型添加自由标签徽章(颜色调色板与Workflow标签共享)",
helpGalleryTitle: "图库标签页 (Gallery)",
helpGallery1: "浏览 ComfyUI 输出文件夹中的图像 — 可以在 设置 > 图库输出文件夹 中设置文件夹路径",
helpGallery2: "三列布局:文件夹树(左) | 图像网格(中) | 详情面板(右)",
helpGallery3: "缩略图和表格视图模式;双击任何图像可在全屏灯箱中打开",
helpGallery4: "工具栏过滤器:收藏 (★)、文本搜索、标签过滤、分组过滤、按日期/名称排序",
helpGallery5: "使用 Ctrl+点击 多选 — 批量操作:添加到分组、全部收藏、取消全部收藏",
helpGallery6: "详情面板 — 信息(Info)标签:文件名、大小、日期、标签(添加/移除)、包含保存功能的备注",
helpGallery7: "详情面板 — 元数据(Metadata)标签:ComfyUI 嵌入的内置工作流 JSON 查看器及一键复制功能",
helpGallery8: "详情面板 — 分组(Groups)标签:对每个图像进行组的创建、分配、移除、重命名和删除",
helpGallery9: "支持 PNG(tEXt/iTXt 元数据)、JPEG、WebP、GIF;对于 PNG 会读取 ComfyUI 内置的 JSON 提取工作流",
helpGallery10: "文件夹树:点击文件夹加载其包含的图像;使用 ▶ 箭头展开子文件夹",
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
helpSidepanel2: "Workflows标签:All / ★ Favorites / Groups子标签,All视图中收藏项显示★",
+41 -45
View File
@@ -27,6 +27,7 @@ const state = {
searchText: "",
tagFilter: "",
badgeFilter: "",
dirFilter: "",
groupFilter: "",
showFavoritesOnly: false,
viewMode: localStorage.getItem("wfm_models_view") || "thumb",
@@ -36,7 +37,6 @@ const state = {
currentPage: 0,
};
const MODELS_PER_PAGE = 24;
const FETCH_MAP = {
checkpoint: () => comfyUI.fetchCheckpoints(),
@@ -257,6 +257,13 @@ function filterModels() {
});
}
if (state.dirFilter) {
models = models.filter((m) => {
const { dir } = parseModelPath(m);
return dir === state.dirFilter;
});
}
if (state.groupFilter) {
const members = state.modelGroups[state.groupFilter] || [];
models = models.filter((m) => members.includes(m));
@@ -428,6 +435,20 @@ function renderGroupFilter() {
select.value = state.groupFilter;
}
function renderDirFilter() {
const select = document.getElementById("wfm-models-dir-filter");
if (!select) return;
const dirs = [...new Set(
getCurrentModels()
.map((m) => parseModelPath(m).dir)
.filter((d) => d !== "")
)].sort();
select.innerHTML =
`<option value="">${t("modelsAllDirs")}</option>` +
dirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("");
select.value = state.dirFilter;
}
async function fetchModelGroups() {
try {
const res = await fetch("/api/wfm/models/groups");
@@ -466,23 +487,18 @@ function renderModelGrid() {
return;
}
// Pagination
const start = state.currentPage * MODELS_PER_PAGE;
const page = filtered.slice(start, start + MODELS_PER_PAGE);
const totalPages = Math.ceil(filtered.length / MODELS_PER_PAGE);
if (state.viewMode === "table") {
renderTableView(grid, page, totalPages);
renderTableView(grid, filtered);
} else if (state.viewMode === "card") {
renderCardView(grid, page, totalPages);
renderCardView(grid, filtered);
} else {
renderThumbView(grid, page, totalPages);
renderThumbView(grid, filtered);
}
}
// ── Thumbnail View (same as Workflow tab) ─────────────────
function renderThumbView(grid, models, totalPages) {
function renderThumbView(grid, models) {
grid.innerHTML = "";
models.forEach((modelName) => {
const meta = state.modelMetadata[modelName] || {};
@@ -524,13 +540,11 @@ function renderThumbView(grid, models, totalPages) {
});
grid.appendChild(card);
});
updatePagination(totalPages);
}
// ── Card View (no thumbnail, compact) ─────────────────────
function renderCardView(grid, models, totalPages) {
function renderCardView(grid, models) {
grid.innerHTML = "";
models.forEach((modelName) => {
const meta = state.modelMetadata[modelName] || {};
@@ -550,8 +564,6 @@ function renderCardView(grid, models, totalPages) {
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(name)}</div>
<div class="wfm-card-meta">
${dir ? `<span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}
<span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>
${userBadges} ${tagsHtml}
</div>
</div>
@@ -568,13 +580,11 @@ function renderCardView(grid, models, totalPages) {
});
grid.appendChild(card);
});
updatePagination(totalPages);
}
// ── Table View ────────────────────────────────────────────
function renderTableView(grid, models, totalPages) {
function renderTableView(grid, models) {
const rows = models
.map((modelName) => {
const meta = state.modelMetadata[modelName] || {};
@@ -621,25 +631,6 @@ function renderTableView(grid, models, totalPages) {
toggleFavorite(mn);
});
});
updatePagination(totalPages);
}
function updatePagination(totalPages) {
const container = document.getElementById("wfm-models-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${state.currentPage + 1} / ${totalPages}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderModelGrid(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderModelGrid(); }
});
}
// ── Favorite toggle ───────────────────────────────────────
@@ -755,6 +746,7 @@ function openDetailModal(modelName) {
await saveModelMetadata(modelName, { tags, memo, badges });
showToast(t("modelsSaved"), "success");
renderTagFilter();
renderDirFilter();
renderModelGrid();
});
@@ -805,9 +797,6 @@ function openDetailModal(modelName) {
function showSidePanel(modelName) {
state.selectedModel = modelName;
const panel = document.getElementById("wfm-models-side-panel");
if (!panel) return;
panel.style.display = "flex";
// Highlight selected
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
@@ -830,9 +819,9 @@ function showSidePanel(modelName) {
}
function closeSidePanel() {
const panel = document.getElementById("wfm-models-side-panel");
if (panel) panel.style.display = "none";
state.selectedModel = null;
const titleEl = document.getElementById("wfm-models-panel-title");
if (titleEl) titleEl.textContent = "";
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
el.classList.remove("wfm-card-selected");
});
@@ -1209,6 +1198,7 @@ async function loadModelsForCurrentType() {
if (state.loaded[type] && state.modelsByType[type].length > 0) {
renderTagFilter();
renderDirFilter();
renderModelGrid();
return;
}
@@ -1224,6 +1214,7 @@ async function loadModelsForCurrentType() {
state.loaded[type] = true;
renderTagFilter();
renderDirFilter();
renderModelGrid();
} catch (err) {
console.error("Failed to load models:", err);
@@ -1343,6 +1334,7 @@ export function initModelsTab() {
state.searchText = "";
state.tagFilter = "";
state.badgeFilter = "";
state.dirFilter = "";
state.currentPage = 0;
state.selectedModel = null;
closeSidePanel();
@@ -1374,6 +1366,13 @@ export function initModelsTab() {
renderModelGrid();
});
// Dir filter
document.getElementById("wfm-models-dir-filter")?.addEventListener("change", (e) => {
state.dirFilter = e.target.value;
state.currentPage = 0;
renderModelGrid();
});
// Group filter
document.getElementById("wfm-models-group-filter")?.addEventListener("change", (e) => {
state.groupFilter = e.target.value;
@@ -1424,9 +1423,6 @@ export function initModelsTab() {
batchFetchCivitai();
});
// Side panel close
document.getElementById("wfm-models-panel-close")?.addEventListener("click", closeSidePanel);
// Side panel tab switching
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
+100 -90
View File
@@ -8,7 +8,6 @@ import { t } from "./i18n.js";
// ── State ─────────────────────────────────────────────────
const NODES_PER_PAGE = 50;
const state = {
allNodes: [],
@@ -257,28 +256,16 @@ function renderNodeGrid() {
if (filtered.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
clearPagination();
return;
}
if (state.viewMode === "table") {
renderNodeTableView(grid, filtered);
clearPagination();
return;
}
// Card view with pagination
const totalPages = Math.ceil(filtered.length / NODES_PER_PAGE);
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
if (state.currentPage < 0) state.currentPage = 0;
const start = state.currentPage * NODES_PER_PAGE;
const pageItems = filtered.slice(start, start + NODES_PER_PAGE);
grid.innerHTML = "";
pageItems.forEach(node => grid.appendChild(createNodeCard(node)));
renderPagination(filtered.length, totalPages);
filtered.forEach(node => grid.appendChild(createNodeCard(node)));
}
function createNodeCard(node) {
@@ -313,34 +300,6 @@ function createNodeCard(node) {
return card;
}
function renderPagination(totalItems, totalPages) {
const container = document.getElementById("wfm-nodes-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
const start = state.currentPage * NODES_PER_PAGE + 1;
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${start}-${end} / ${totalItems}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
});
}
function clearPagination() {
const container = document.getElementById("wfm-nodes-pagination");
if (container) container.innerHTML = "";
}
function scrollGridToTop() {
document.getElementById("wfm-nodes-grid")?.scrollTo(0, 0);
}
function renderNodeTableView(grid, filtered) {
grid.innerHTML = "";
@@ -398,9 +357,6 @@ async function toggleFavorite(nodeName, btnEl) {
function showNodeSidePanel(node) {
state.selectedNode = node;
const panel = document.getElementById("wfm-nodes-side-panel");
if (!panel) return;
panel.style.display = "flex";
// Highlight selected card
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
@@ -544,42 +500,61 @@ function renderSideGroups(node) {
const el = document.getElementById("wfm-nodes-side-group");
if (!el) return;
const groupNames = Object.keys(state.nodeGroups).sort();
const memberOf = groupNames.filter(g => (state.nodeGroups[g] || []).includes(node.name));
const allGroups = Object.keys(state.nodeGroups).sort();
const memberOf = allGroups.filter(g => (state.nodeGroups[g] || []).includes(node.name));
const available = allGroups.filter(g => !memberOf.includes(g));
let html = `<h4 style="margin:0 0 8px;">${t("nodesGroups")}</h4>`;
el.innerHTML = `
<div style="padding:0 4px;">
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
${memberOf.length === 0
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
: memberOf.map(g =>
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">&times;</button>
</div>`
).join("")}
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${available.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
<div style="display:flex;gap:4px;">
<input type="text" id="wfm-nodes-new-group-input" class="wfm-input"
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
</div>
</div>
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-nodes-manage-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${allGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-nodes-rename-group-btn" class="wfm-btn wfm-btn-sm"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">&#9998;</button>
<button id="wfm-nodes-delete-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">&times;</button>
</div>
</div>
</div>
`;
// Current groups
if (memberOf.length) {
html += memberOf.map(g =>
`<div class="wfm-node-group-item">
<span>${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}">${t("nodesRemoveGroup")}</button>
</div>`
).join("");
} else {
html += `<p style="color:var(--wfm-text-secondary);font-size:12px;">No groups assigned</p>`;
}
// Assign to group
const available = groupNames.filter(g => !memberOf.includes(g));
html += `<div style="margin-top:12px;display:flex;gap:4px;">
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;">
<option value="">${t("nodesAssignGroup")}</option>
${available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+</button>
</div>`;
// Create new group
html += `<div style="margin-top:8px;display:flex;gap:4px;">
<input type="text" id="wfm-nodes-new-group-input" class="wfm-input" style="flex:1;" placeholder="${t("nodesGroupName")}">
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm">${t("nodesCreateGroup")}</button>
</div>`;
el.innerHTML = html;
// Event listeners
// グループから除外
el.querySelectorAll(".wfm-node-remove-group").forEach(btn => {
btn.addEventListener("click", async () => {
const group = btn.dataset.group;
@@ -591,8 +566,9 @@ function renderSideGroups(node) {
});
});
document.getElementById("wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
const sel = document.getElementById("wfm-nodes-assign-group-select");
// グループに追加
el.querySelector("#wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
const sel = el.querySelector("#wfm-nodes-assign-group-select");
const group = sel?.value;
if (!group) return;
if (!state.nodeGroups[group]) state.nodeGroups[group] = [];
@@ -604,25 +580,61 @@ function renderSideGroups(node) {
}
});
document.getElementById("wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
const input = document.getElementById("wfm-nodes-new-group-input");
// グループ作成(作成後に現在のノードに追加)
el.querySelector("#wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
const input = el.querySelector("#wfm-nodes-new-group-input");
const name = input?.value?.trim();
if (!name) return;
if (!state.nodeGroups[name]) state.nodeGroups[name] = [];
if (!state.nodeGroups[name].includes(node.name)) {
state.nodeGroups[name].push(node.name);
if (state.nodeGroups[name]) {
showToast("Group already exists", "warning");
return;
}
state.nodeGroups[name] = [node.name];
await saveNodeGroups(state.nodeGroups);
input.value = "";
renderSideGroups(node);
renderFilters();
});
// グループ名変更
el.querySelector("#wfm-nodes-rename-group-btn")?.addEventListener("click", async () => {
const sel = el.querySelector("#wfm-nodes-manage-group-select");
const oldName = sel?.value;
if (!oldName) return;
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
if (state.nodeGroups[newName] !== undefined) {
showToast("Group name already exists", "warning");
return;
}
state.nodeGroups[newName] = state.nodeGroups[oldName];
delete state.nodeGroups[oldName];
await saveNodeGroups(state.nodeGroups);
if (state.groupFilter === oldName) state.groupFilter = newName;
renderSideGroups(node);
renderFilters();
showToast(`Renamed to "${newName}"`, "success");
});
// グループ削除
el.querySelector("#wfm-nodes-delete-group-btn")?.addEventListener("click", async () => {
const sel = el.querySelector("#wfm-nodes-manage-group-select");
const name = sel?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
delete state.nodeGroups[name];
await saveNodeGroups(state.nodeGroups);
if (state.groupFilter === name) state.groupFilter = "";
renderSideGroups(node);
renderFilters();
showToast(`Group "${name}" deleted`, "success");
});
}
function closeNodeSidePanel() {
const panel = document.getElementById("wfm-nodes-side-panel");
if (panel) panel.style.display = "none";
state.selectedNode = null;
const titleEl = document.getElementById("wfm-nodes-panel-title");
if (titleEl) titleEl.textContent = "";
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
el.classList.remove("wfm-card-selected");
});
@@ -1082,8 +1094,6 @@ export function initNodesTab() {
loadNodesData();
});
// Side panel close
document.getElementById("wfm-nodes-panel-close")?.addEventListener("click", closeNodeSidePanel);
// Side panel tab switching
document.querySelectorAll(".wfm-nodes-side-tab-btn").forEach(btn => {
+103 -19
View File
@@ -346,6 +346,12 @@ export async function initSettingsTab() {
workflowsDirInfo = await res.json();
} catch {}
// Load gallery output dir info
let outputDirInfo = { current: "", default: "", saved: "" };
try {
const res = await fetch("/api/wfm/settings/output-dir");
outputDirInfo = await res.json();
} catch {}
const uiLang = getLang();
const summaryLang = getSummaryLang();
@@ -353,26 +359,10 @@ export async function initSettingsTab() {
container.innerHTML = `
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
<!-- Language Settings -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
<div class="wfm-form-group">
<label>${t("langLabel")}</label>
<select class="wfm-select" id="wfm-settings-ui-lang">
${buildLangOptions(getLanguageOptions(), uiLang)}
</select>
</div>
<div class="wfm-form-group">
<label>${t("summaryLangLabel")}</label>
<select class="wfm-select" id="wfm-settings-summary-lang">
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
</select>
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
⚠ ${t("summaryLangNote")}
</small>
</div>
</details>
<div class="wfm-settings-layout">
<!-- Right column: Theme (floated right) -->
<div class="wfm-settings-right-col">
<!-- Theme -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("themeLabel")}</summary>
@@ -471,6 +461,30 @@ export async function initSettingsTab() {
</div>
</details>
</div><!-- /wfm-settings-right-col -->
<!-- Left column: all settings (Language + rest), flows left of floated Theme -->
<div class="wfm-settings-left-col">
<!-- Language Settings -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
<div class="wfm-form-group">
<label>${t("langLabel")}</label>
<select class="wfm-select" id="wfm-settings-ui-lang">
${buildLangOptions(getLanguageOptions(), uiLang)}
</select>
</div>
<div class="wfm-form-group">
<label>${t("summaryLangLabel")}</label>
<select class="wfm-select" id="wfm-settings-summary-lang">
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
</select>
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
⚠ ${t("summaryLangNote")}
</small>
</div>
</details>
<!-- Workflow Data Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
@@ -492,6 +506,27 @@ export async function initSettingsTab() {
</div>
</details>
<!-- Gallery Output Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
<div class="wfm-form-group">
<label>${t("galleryOutputDirLabel")}</label>
<div style="display:flex;gap:8px;">
<input type="text" class="wfm-input" id="wfm-settings-output-dir"
value="${serverSettings.gallery_output_dir || ""}"
placeholder="${outputDirInfo.default}">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-output-dir-apply">${t("workflowsDirApply")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-output-dir-reset">${t("workflowsDirDefault")}</button>
</div>
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("galleryOutputDirHint")}
</small>
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;">
${t("workflowsDirCurrent")}: <code id="wfm-settings-output-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${outputDirInfo.current}</code>
</div>
</div>
</details>
<!-- ComfyUI Settings -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
@@ -573,6 +608,11 @@ export async function initSettingsTab() {
<!-- Save Button -->
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
</div><!-- /wfm-settings-left-col -->
<div style="clear:both;"></div>
</div><!-- /wfm-settings-layout -->
`;
// --- Theme change handler ---
@@ -828,6 +868,50 @@ export async function initSettingsTab() {
}
});
// --- Gallery output dir handlers ---
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
const dirInput = document.getElementById("wfm-settings-output-dir");
const currentEl = document.getElementById("wfm-settings-output-dir-current");
const newDir = dirInput.value.trim();
try {
const res = await fetch("/api/wfm/settings/output-dir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gallery_output_dir: newDir }),
});
const data = await res.json();
if (data.error) {
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
} else {
if (currentEl) currentEl.textContent = data.current;
showToast(t("workflowsDirChanged"), "success");
// gallery-tab に通知して再ロード
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
}
} catch (err) {
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
}
});
document.getElementById("wfm-settings-output-dir-reset")?.addEventListener("click", async () => {
const dirInput = document.getElementById("wfm-settings-output-dir");
const currentEl = document.getElementById("wfm-settings-output-dir-current");
dirInput.value = "";
try {
const res = await fetch("/api/wfm/settings/output-dir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gallery_output_dir: "" }),
});
const data = await res.json();
if (currentEl) currentEl.textContent = data.current;
showToast(t("workflowsDirChanged"), "success");
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
} catch (err) {
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
}
});
// --- Ollama model list loader ---
async function loadOllamaModels() {
const select = document.getElementById("wfm-settings-ollama-model");
+153 -140
View File
@@ -25,7 +25,6 @@ const state = {
currentPage: 0,
};
const WF_PER_PAGE = 24;
function getBadgePalette() {
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
@@ -245,12 +244,17 @@ function renderModelFilters() {
const container = document.getElementById("wfm-model-filters");
if (!container) return;
const badges = getAllBadges();
const palette = getBadgePalette();
const badges = Object.keys(palette).sort();
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>
${badges.map((b) => `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}">${escapeHtml(b)}</button>`).join("")}
${badges.map((b) => {
const color = palette[b] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
return `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}"${style}>${escapeHtml(b)}</button>`;
}).join("")}
`;
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
@@ -314,27 +318,16 @@ function renderGrid() {
if (filtered.length === 0) {
grid.innerHTML =
'<p class="wfm-placeholder">No workflows found</p>';
updateWfPagination(0);
return;
}
if (state.viewMode === "table") {
renderTableView(grid, filtered);
updateWfPagination(0);
return;
}
// Thumbnail / Card view with pagination
const totalPages = Math.ceil(filtered.length / WF_PER_PAGE);
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
if (state.currentPage < 0) state.currentPage = 0;
const start = state.currentPage * WF_PER_PAGE;
const pageItems = filtered.slice(start, start + WF_PER_PAGE);
updateWfPagination(totalPages);
grid.innerHTML = "";
pageItems.forEach((wf) => {
filtered.forEach((wf) => {
const card = document.createElement("div");
card.className = "wfm-card";
card.dataset.filename = wf.filename;
@@ -384,22 +377,6 @@ function renderGrid() {
}
}
function updateWfPagination(totalPages) {
const container = document.getElementById("wfm-workflow-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${state.currentPage + 1} / ${totalPages}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
});
}
function renderTableView(grid, filtered) {
const table = document.createElement("table");
@@ -486,7 +463,6 @@ async function showSidePanel(wf, cardEl) {
titleEl.textContent = wf.filename.replace(/\.json$/, "");
contentEl.textContent = t("loading");
panel.style.display = "flex";
state.selectedWf = wf;
@@ -506,7 +482,7 @@ async function showSidePanel(wf, cardEl) {
sidePanelThumbUpdate(wf);
// Update group tab
sidePanelGroupUpdate();
renderSideGroup(wf);
const badgeEl = document.getElementById("wfm-json-format-badge");
try {
@@ -530,17 +506,17 @@ async function showSidePanel(wf, cardEl) {
}
function closeSidePanel() {
const panel = document.getElementById("wfm-side-panel");
if (panel) panel.style.display = "none";
document.querySelectorAll(".wfm-card, .wfm-table-row").forEach((c) =>
c.classList.remove("wfm-card-selected")
);
state.selectedWf = null;
const titleEl = document.getElementById("wfm-side-panel-title");
if (titleEl) titleEl.textContent = "";
const listLoadBtn = document.getElementById("wfm-list-load-btn");
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
sidePanelGroupUpdate();
renderSideGroup(null);
}
// ============================================
@@ -575,49 +551,156 @@ function sidePanelThumbUpdate(wf) {
// Group Tab in Side Panel
// ============================================
function sidePanelGroupUpdate() {
const nameEl = document.getElementById("wfm-grp-prop-name");
const groupEl = document.getElementById("wfm-grp-prop-group");
const assignBtn = document.getElementById("wfm-assign-group-btn");
const removeBtn = document.getElementById("wfm-remove-group-btn");
function renderSideGroup(wf) {
const el = document.getElementById("wfm-side-tab-group");
if (!el) return;
if (!state.selectedWf) {
if (nameEl) nameEl.textContent = t("noSelection");
if (groupEl) groupEl.textContent = "";
if (assignBtn) assignBtn.disabled = true;
if (removeBtn) removeBtn.disabled = true;
const allGroups = groups.groupNames();
if (!wf) {
el.innerHTML = `<p style="padding:12px;color:var(--wfm-text-secondary);font-size:12px;">${t("noSelection")}</p>`;
_refreshGroupFilter();
return;
}
const filename = state.selectedWf.filename;
if (nameEl) nameEl.textContent = filename.replace(/\.json$/, "");
const grps = groups.groupsOf(filename);
if (groupEl) groupEl.textContent = grps.length > 0 ? `${t("groupsLabel")}: ${grps.join(", ")}` : `${t("groupsLabel")}: ${t("groupsNone")}`;
if (assignBtn) assignBtn.disabled = false;
const filename = wf.filename;
const memberOf = groups.groupsOf(filename);
const available = allGroups.filter(g => !memberOf.includes(g));
const selGroup = document.getElementById("wfm-group-select")?.value;
if (removeBtn) removeBtn.disabled = !selGroup || !grps.includes(selGroup);
el.innerHTML = `
<div style="padding:8px;">
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
${memberOf.length === 0
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
: memberOf.map(g =>
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-wf-grp-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">&times;</button>
</div>`
).join("")}
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-grp-assign-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${available.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-grp-assign-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
<div style="display:flex;gap:4px;">
<input type="text" id="wfm-grp-new-input" class="wfm-input"
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
<button id="wfm-grp-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
</div>
</div>
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-grp-manage-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${allGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-grp-rename-btn" class="wfm-btn wfm-btn-sm"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">&#9998;</button>
<button id="wfm-grp-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">&times;</button>
</div>
</div>
</div>
`;
// グループから除外
el.querySelectorAll(".wfm-wf-grp-remove").forEach(btn => {
btn.addEventListener("click", () => {
groups.remove(filename, btn.dataset.group);
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Removed from "${btn.dataset.group}"`, "success");
});
});
// グループに追加
el.querySelector("#wfm-grp-assign-btn")?.addEventListener("click", () => {
const g = el.querySelector("#wfm-grp-assign-sel")?.value;
if (!g) return;
groups.assign(filename, g);
renderSideGroup(wf);
showToast(`Added to "${g}"`, "success");
});
// グループ作成(作成後に現在のWFに追加)
el.querySelector("#wfm-grp-create-btn")?.addEventListener("click", () => {
const input = el.querySelector("#wfm-grp-new-input");
const name = input?.value.trim();
if (!name) return;
if (!groups.createGroup(name)) {
showToast(`"${name}" already exists`, "warning");
return;
}
groups.assign(filename, name);
input.value = "";
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" created`, "success");
});
// グループ名変更
el.querySelector("#wfm-grp-rename-btn")?.addEventListener("click", () => {
const oldName = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!oldName) return;
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
if (!groups.renameGroup(oldName, newName)) {
showToast("Rename failed", "error");
return;
}
if (state.groupFilter === oldName) state.groupFilter = newName;
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Renamed to "${newName}"`, "success");
});
// グループ削除
el.querySelector("#wfm-grp-delete-btn")?.addEventListener("click", () => {
const name = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
groups.deleteGroup(name);
if (state.groupFilter === name) {
state.groupFilter = "";
const filterSel = document.getElementById("wfm-group-filter");
if (filterSel) filterSel.value = "";
}
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" deleted`, "success");
});
_refreshGroupFilter();
}
function refreshGroupSelects() {
const sideSel = document.getElementById("wfm-group-select");
/** ツールバーのグループフィルタセレクトのみ更新 */
function _refreshGroupFilter() {
const filterSel = document.getElementById("wfm-group-filter");
if (!filterSel) return;
const prevVal = filterSel.value;
const grpNames = groups.groupNames();
[filterSel, sideSel].forEach((sel, i) => {
if (!sel) return;
const prevVal = sel.value;
sel.innerHTML = i === 0
? `<option value="">${t("allGroups")}</option>`
: `<option value="">${t("selectGroup")}</option>`;
grpNames.forEach((g) => {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
sel.appendChild(opt);
});
if (grpNames.includes(prevVal)) sel.value = prevVal;
filterSel.innerHTML = `<option value="">${t("allGroups")}</option>`;
grpNames.forEach(g => {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
filterSel.appendChild(opt);
});
if (grpNames.includes(prevVal)) filterSel.value = prevVal;
}
// ============================================
@@ -1090,9 +1173,6 @@ export function initWorkflowTab() {
});
}
// Side panel: close button
document.getElementById("wfm-side-panel-close")?.addEventListener("click", closeSidePanel);
// Side panel: tab switching
document.querySelectorAll(".wfm-side-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -1126,74 +1206,7 @@ export function initWorkflowTab() {
// Group management
groups.load();
refreshGroupSelects();
// Add group
document.getElementById("wfm-group-add-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-group-name-input");
const name = input?.value.trim();
if (!name) return;
if (!groups.createGroup(name)) {
showToast(`"${name}" already exists`, "error");
return;
}
input.value = "";
refreshGroupSelects();
showToast(`Group "${name}" created`, "success");
});
// Rename group
document.getElementById("wfm-group-rename-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const oldName = sel?.value;
if (!oldName) return;
const newName = prompt("New group name:", oldName);
if (!newName || newName === oldName) return;
if (!groups.renameGroup(oldName, newName)) {
showToast("Rename failed", "error");
return;
}
refreshGroupSelects();
sidePanelGroupUpdate();
showToast(`Group renamed to "${newName}"`, "success");
});
// Delete group
document.getElementById("wfm-group-delete-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const name = sel?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
groups.deleteGroup(name);
refreshGroupSelects();
sidePanelGroupUpdate();
showToast(`Group "${name}" deleted`, "success");
});
// Assign to group
document.getElementById("wfm-assign-group-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const groupName = sel?.value;
if (!groupName || !state.selectedWf) return;
groups.assign(state.selectedWf.filename, groupName);
sidePanelGroupUpdate();
showToast(`Added to "${groupName}"`, "success");
});
// Remove from group
document.getElementById("wfm-remove-group-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const groupName = sel?.value;
if (!groupName || !state.selectedWf) return;
groups.remove(state.selectedWf.filename, groupName);
sidePanelGroupUpdate();
showToast(`Removed from "${groupName}"`, "success");
});
// Update remove button state when group select changes
document.getElementById("wfm-group-select")?.addEventListener("change", () => {
sidePanelGroupUpdate();
});
_refreshGroupFilter();
// Toolbar: group filter dropdown
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
+139 -38
View File
@@ -6,6 +6,7 @@
<title>Workflow Studio</title>
<link rel="icon" type="image/svg+xml" href="/wfm_static/favicon.svg">
<link rel="stylesheet" href="/wfm_static/css/main.css">
<link rel="stylesheet" href="/wfm_static/css/gallery-tab.css">
</head>
<body>
<!-- Header -->
@@ -19,6 +20,7 @@
<button class="wfm-tab" data-tab="models">Models</button>
<button class="wfm-tab" data-tab="generate">GenerateUI</button>
<button class="wfm-tab" data-tab="prompt">Prompt</button>
<button class="wfm-tab" data-tab="gallery">Gallery</button>
<button class="wfm-tab" data-tab="settings">Settings</button>
<button class="wfm-tab" data-tab="help">Help</button>
</nav>
@@ -59,7 +61,6 @@
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="View settings">&#9881;</button>
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
</div>
<div id="wfm-workflow-pagination" class="wfm-pagination-inline"></div>
</div>
</div>
<div class="wfm-workflow-area">
@@ -67,10 +68,9 @@
<p class="wfm-placeholder">Loading workflows...</p>
</div>
<!-- Side Panel -->
<div id="wfm-side-panel" class="wfm-side-panel" style="display:none;">
<div id="wfm-side-panel" class="wfm-side-panel">
<div class="wfm-side-panel-header">
<span id="wfm-side-panel-title" class="wfm-side-panel-title"></span>
<button id="wfm-side-panel-close" class="wfm-side-panel-close">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-side-tab-btn active" data-side-tab="thumb">Thumbnail</button>
@@ -98,35 +98,8 @@
<pre id="wfm-json-content" class="wfm-json-content"></pre>
</div>
<!-- Group Tab -->
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;">
<div class="wfm-group-panel">
<div class="wfm-group-section">
<div class="wfm-group-section-title">Properties</div>
<div id="wfm-grp-prop-name" class="wfm-group-prop">No selection</div>
<div id="wfm-grp-prop-group" class="wfm-group-prop"></div>
</div>
<hr class="wfm-group-divider">
<div class="wfm-group-section">
<div class="wfm-group-section-title">Group Management</div>
<div style="display:flex;gap:4px;margin-bottom:6px;">
<input type="text" id="wfm-group-name-input" class="wfm-input" placeholder="Group name" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
<button id="wfm-group-add-btn" class="wfm-btn wfm-btn-sm" title="Create group">Add</button>
</div>
<div style="display:flex;gap:4px;margin-bottom:6px;">
<select id="wfm-group-select" class="wfm-select" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
<option value="">Select group</option>
</select>
<button id="wfm-group-rename-btn" class="wfm-btn wfm-btn-sm" title="Rename group">&#9998;</button>
<button id="wfm-group-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete group" style="padding:4px 8px;font-size:12px;">&times;</button>
</div>
</div>
<hr class="wfm-group-divider">
<div class="wfm-group-section">
<div class="wfm-group-section-title">Workflow Actions</div>
<button id="wfm-assign-group-btn" class="wfm-btn wfm-btn-sm" style="width:100%;margin-bottom:4px;" disabled>Add to Group</button>
<button id="wfm-remove-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="width:100%;margin-bottom:4px;font-size:12px;" disabled>Remove from Group</button>
</div>
</div>
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;overflow-y:auto;">
<!-- JS動的生成: renderSideGroup() -->
</div>
</div>
</div>
@@ -156,7 +129,6 @@
</div>
<span id="wfm-nodes-count" class="wfm-nodes-count"></span>
<button id="wfm-nodes-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<div id="wfm-nodes-pagination" class="wfm-pagination-inline"></div>
</div>
</div>
@@ -166,10 +138,9 @@
<p class="wfm-placeholder" id="wfm-nodes-placeholder">Click to load nodes...</p>
</div>
<!-- Side Panel -->
<div id="wfm-nodes-side-panel" class="wfm-side-panel" style="display:none;">
<div id="wfm-nodes-side-panel" class="wfm-side-panel">
<div class="wfm-side-panel-header">
<span id="wfm-nodes-panel-title" class="wfm-side-panel-title"></span>
<button id="wfm-nodes-panel-close" class="wfm-side-panel-close">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-nodes-side-tab-btn active" data-side-tab="details">Details</button>
@@ -219,6 +190,7 @@
<button class="wfm-filter-btn" id="wfm-models-fav-btn" title="Favorites">&#9733;</button>
<input type="text" id="wfm-models-search" class="wfm-search-input" placeholder="Search models...">
<select id="wfm-models-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
<select id="wfm-models-dir-filter" class="wfm-select wfm-toolbar-select"><option value="">All Folders</option></select>
<select id="wfm-models-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-models-view="thumb" title="Thumbnail">&#8862;</button>
@@ -230,7 +202,6 @@
<button id="wfm-models-civitai-batch-btn" class="wfm-btn wfm-btn-sm" title="Fetch CivitAI info for all models">CivitAI</button>
<span id="wfm-models-civitai-progress" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge settings">&#9881; Badge</button>
<div id="wfm-models-pagination" class="wfm-pagination-inline"></div>
</div>
</div>
@@ -240,10 +211,9 @@
<p class="wfm-placeholder" id="wfm-models-placeholder">Click Refresh to load models...</p>
</div>
<!-- Side Panel -->
<div id="wfm-models-side-panel" class="wfm-side-panel" style="display:none;">
<div id="wfm-models-side-panel" class="wfm-side-panel">
<div class="wfm-side-panel-header">
<span id="wfm-models-panel-title" class="wfm-side-panel-title"></span>
<button id="wfm-models-panel-close" class="wfm-side-panel-close">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-models-side-tab-btn active" data-side-tab="info">Info</button>
@@ -576,6 +546,22 @@
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-gallery-title">Gallery Tab</h3>
<ul>
<li id="wfm-help-gallery-1">Browse images in the ComfyUI output folder — set the folder path in Settings &gt; Gallery Output Folder</li>
<li id="wfm-help-gallery-2">3-column layout: folder tree (left) | image grid (center) | detail panel (right)</li>
<li id="wfm-help-gallery-3">Thumbnail and Table view modes; double-click any image to open fullscreen lightbox</li>
<li id="wfm-help-gallery-4">Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name</li>
<li id="wfm-help-gallery-5">Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All</li>
<li id="wfm-help-gallery-6">Detail panel — Info tab: filename, size, date, tags (add/remove), memo with save</li>
<li id="wfm-help-gallery-7">Detail panel — Metadata tab: embedded ComfyUI workflow JSON viewer with one-click clipboard copy</li>
<li id="wfm-help-gallery-8">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image</li>
<li id="wfm-help-gallery-9">Supports PNG (tEXt/iTXt metadata), JPEG, WebP, GIF; PNG workflow extraction reads ComfyUI-embedded JSON</li>
<li id="wfm-help-gallery-10">Folder tree: click a folder to load its images; expand subfolders with ▶ arrow</li>
</ul>
</div>
<div class="wfm-help-card">
<h3>Models Tab</h3>
<ul>
@@ -660,6 +646,121 @@
</div>
</div>
</section>
<!-- Gallery Tab -->
<section id="wfm-tab-gallery" class="wfm-tab-content">
<!-- Toolbar -->
<div class="wfm-toolbar">
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
<button id="wfm-gallery-fav-btn" class="wfm-filter-btn" title="Favorites">&#9733;</button>
<input type="text" id="wfm-gallery-search" class="wfm-search-input" placeholder="Search images...">
<select id="wfm-gallery-tag-filter" class="wfm-select wfm-toolbar-select">
<option value="">All Tags</option>
</select>
<select id="wfm-gallery-group-filter" class="wfm-select wfm-toolbar-select">
<option value="">All Groups</option>
</select>
<select id="wfm-gallery-sort" class="wfm-select wfm-toolbar-select">
<option value="date_desc">Date (Newest)</option>
<option value="date_asc">Date (Oldest)</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
</select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-gallery-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-gallery-view="table" title="Table">&#8801;</button>
</div>
<span id="wfm-gallery-count" class="wfm-nodes-count"></span>
<button id="wfm-gallery-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
</div>
<!-- 複数選択時の一括操作バー -->
<div id="wfm-gallery-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
<span id="wfm-gallery-bulk-count" class="wfm-gallery-bulk-count"></span>
<button id="wfm-gallery-bulk-deselect" class="wfm-btn wfm-btn-sm">Deselect All</button>
<select id="wfm-gallery-bulk-group-select" class="wfm-select" style="font-size:12px;padding:3px 6px;">
<option value="">Add to Group...</option>
</select>
<button id="wfm-gallery-bulk-group-add" class="wfm-btn wfm-btn-sm wfm-btn-primary">Add</button>
<button id="wfm-gallery-bulk-fav" class="wfm-btn wfm-btn-sm">★ Favorite All</button>
<button id="wfm-gallery-bulk-unfav" class="wfm-btn wfm-btn-sm">☆ Unfavorite All</button>
</div>
</div>
<!-- 3カラムレイアウト -->
<div class="wfm-gallery-layout">
<!-- 左: フォルダツリー -->
<div id="wfm-gallery-tree-panel" class="wfm-gallery-tree-panel">
<div class="wfm-gallery-tree-header">
<span class="wfm-gallery-tree-title">Folders</span>
</div>
<div id="wfm-gallery-tree" class="wfm-gallery-tree">
<p class="wfm-placeholder" id="wfm-gallery-tree-placeholder">Loading...</p>
</div>
</div>
<!-- 中: 画像一覧 -->
<div id="wfm-gallery-list-panel" class="wfm-gallery-list-panel">
<div id="wfm-gallery-grid" class="wfm-gallery-grid wfm-gallery-view-thumb">
<p class="wfm-placeholder" id="wfm-gallery-list-placeholder">Select a folder to browse images.</p>
</div>
</div>
<!-- 右: 詳細情報サイドパネル -->
<div id="wfm-gallery-detail-panel" class="wfm-gallery-detail-panel">
<div class="wfm-gallery-detail-header">
<span id="wfm-gallery-detail-filename" class="wfm-gallery-detail-filename"></span>
</div>
<!-- サムネイルプレビュー -->
<div id="wfm-gallery-detail-preview" class="wfm-gallery-detail-preview">
<span class="wfm-placeholder">No selection</span>
</div>
<!-- タブナビ -->
<div class="wfm-side-tab-nav">
<button class="wfm-gallery-detail-tab-btn active" data-detail-tab="info">Info</button>
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="meta">Metadata</button>
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="group">Groups</button>
</div>
<!-- Info タブ -->
<div id="wfm-gallery-detail-info" class="wfm-gallery-detail-tab-content active">
<div class="wfm-gallery-info-section">
<div class="wfm-gallery-info-row" id="wfm-gallery-info-name"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-size"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-date"></div>
</div>
<hr class="wfm-group-divider">
<!-- タグ -->
<div class="wfm-gallery-info-section">
<div class="wfm-gallery-section-title">Tags</div>
<div id="wfm-gallery-tags-display" class="wfm-gallery-tags-display"></div>
<div style="display:flex;gap:4px;margin-top:4px;">
<input type="text" id="wfm-gallery-tag-input" class="wfm-input" placeholder="Add tag..." style="flex:1;min-width:0;font-size:12px;padding:4px 6px;">
<button id="wfm-gallery-tag-add-btn" class="wfm-btn wfm-btn-sm">Add</button>
</div>
</div>
<hr class="wfm-group-divider">
<!-- メモ -->
<div class="wfm-gallery-info-section">
<div class="wfm-gallery-section-title">Memo</div>
<textarea id="wfm-gallery-memo" class="wfm-gallery-memo" placeholder="Write a memo..." rows="3"></textarea>
<button id="wfm-gallery-memo-save-btn" class="wfm-btn wfm-btn-sm" style="margin-top:4px;width:100%;">Save Memo</button>
</div>
</div>
<!-- Metadata タブ -->
<div id="wfm-gallery-detail-meta" class="wfm-gallery-detail-tab-content wfm-gallery-meta-tab" style="display:none;">
<div class="wfm-gallery-meta-toolbar">
<span id="wfm-gallery-meta-status" class="wfm-gallery-meta-status"></span>
<button id="wfm-gallery-copy-workflow-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled title="Copy embedded workflow JSON">Copy Workflow</button>
</div>
<pre id="wfm-gallery-workflow-json" class="wfm-gallery-workflow-json">No workflow found in this image.</pre>
</div>
<!-- Group タブ -->
<div id="wfm-gallery-detail-group" class="wfm-gallery-detail-tab-content" style="display:none;overflow-y:auto;">
<!-- JS動的生成: renderDetailGroup() -->
</div>
</div>
</div>
</section>
</main>
<!-- Workflow Detail Modal -->
+818
View File
@@ -0,0 +1,818 @@
/**
* Node Library Sidebar Panel
*
* Fixed sidebar panel injected into ComfyUI DOM.
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
* Items are draggable onto the canvas or click-to-place.
*/
console.log("[WFM] node_sets_menu.js loading...");
import { app } from "../../scripts/app.js";
console.log("[WFM] node_sets_menu.js: app imported");
export const NODE_SETS_TOOLTIP = "Node Library – Browse & drag nodes onto canvas";
// ============================================
// State
// ============================================
const state = {
visible: false,
activeTab: "favorites", // favorites | sets | groups
favorites: [], // [{name, display_name}]
nodeSets: [],
groups: {}, // {groupName: [nodeName, ...]}
metadata: {},
searchText: "",
loaded: false,
};
// ============================================
// API
// ============================================
const fetchNodeSets = async () => {
try {
const res = await fetch("/api/wfm/node-sets");
return res.ok ? await res.json() : [];
} catch { return []; }
};
const fetchMetadata = async () => {
try {
const res = await fetch("/api/wfm/nodes/metadata");
return res.ok ? await res.json() : {};
} catch { return {}; }
};
const fetchGroups = async () => {
try {
const res = await fetch("/api/wfm/nodes/groups");
return res.ok ? await res.json() : {};
} catch { return {}; }
};
const loadData = async () => {
const [metadata, nodeSets, groups] = await Promise.all([
fetchMetadata(), fetchNodeSets(), fetchGroups(),
]);
state.metadata = metadata;
state.nodeSets = nodeSets;
state.groups = groups;
// Extract favorites from metadata
state.favorites = [];
for (const [name, meta] of Object.entries(metadata)) {
if (name === "_groups") continue;
if (meta.favorite) {
state.favorites.push({ name, display_name: name });
}
}
state.loaded = true;
};
// ============================================
// Canvas placement helpers
// ============================================
const getCanvasDropPos = (e) => {
const canvas = app.canvas;
if (!canvas) return [100, 100];
const rect = canvas.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
return [x, y];
};
const placeSingleNode = (classType, pos) => {
const graph = app.graph;
if (!graph) return;
const node = LiteGraph.createNode(classType);
if (!node) {
showToast(`Unknown node: ${classType}`, "error");
return;
}
node.pos = pos || [100, 100];
graph.add(node);
app.canvas.setDirty(true, true);
showToast(`Placed: ${node.title || classType}`, "success");
};
const placeNodeSet = (nodeSet, pos) => {
const graph = app.graph;
const canvas = app.canvas;
if (!graph || !canvas) return;
const nodes = nodeSet.nodes || [];
if (nodes.length === 0) return;
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
const positions = nodes.map(n => n.rel_pos || [0, 0]);
const minX = Math.min(...positions.map(p => p[0]));
const minY = Math.min(...positions.map(p => p[1]));
const createdNodes = [];
for (const nodeDef of nodes) {
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
const node = LiteGraph.createNode(nodeDef.class_type);
if (!node) { createdNodes.push(null); continue; }
const rp = nodeDef.rel_pos || [0, 0];
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
if (nodeDef.title) node.title = nodeDef.title;
if (nodeDef.widget_values && node.widgets) {
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
const w = node.widgets.find(ww => ww.name === k);
if (w) w.value = v;
}
}
graph.add(node);
createdNodes.push(node);
}
for (const link of (nodeSet.links || [])) {
const from = createdNodes[link.from_node];
const to = createdNodes[link.to_node];
if (from && to) {
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
}
}
canvas.setDirty(true, true);
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
};
// ============================================
// Drag & Drop onto canvas
// ============================================
let dropHandlerInstalled = false;
const installCanvasDropHandler = () => {
if (dropHandlerInstalled) return;
dropHandlerInstalled = true;
const canvasEl = document.getElementById("graph-canvas");
if (!canvasEl) return;
canvasEl.addEventListener("dragover", (e) => {
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
});
canvasEl.addEventListener("drop", (e) => {
const raw = e.dataTransfer.getData("application/x-wfm-node");
if (!raw) return;
e.preventDefault();
const data = JSON.parse(raw);
const pos = getCanvasDropPos(e);
if (data.type === "single") {
placeSingleNode(data.classType, pos);
} else if (data.type === "set") {
const set = state.nodeSets.find(s => s.id === data.setId);
if (set) placeNodeSet(set, pos);
}
});
};
// ============================================
// Sidebar Panel DOM
// ============================================
let panelEl = null;
const PANEL_ID = "wfm-node-library-panel";
const createPanel = () => {
if (panelEl) return panelEl;
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="wfm-nlp-header">
<span class="wfm-nlp-title">Node Library</span>
<button class="wfm-nlp-refresh" title="Refresh">&#8635;</button>
<button class="wfm-nlp-close" title="Close">&times;</button>
</div>
<div class="wfm-nlp-tabs">
<button class="wfm-nlp-tab active" data-tab="favorites">&#9733; Favorites</button>
<button class="wfm-nlp-tab" data-tab="sets">&#9776; Sets</button>
<button class="wfm-nlp-tab" data-tab="groups">&#128193; Groups</button>
</div>
<div class="wfm-nlp-search">
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
</div>
<div class="wfm-nlp-content"></div>
`;
document.body.appendChild(panel);
panelEl = panel;
// Tab switching
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
btn.addEventListener("click", () => {
state.activeTab = btn.dataset.tab;
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderContent();
});
});
// Close
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
// Search
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
state.searchText = e.target.value.toLowerCase().trim();
renderContent();
});
// Refresh
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
await loadData();
renderContent();
showToast("Node Library refreshed", "success");
});
injectStyles();
return panel;
};
// ============================================
// Render content
// ============================================
const renderContent = () => {
const content = panelEl?.querySelector(".wfm-nlp-content");
if (!content) return;
if (!state.loaded) {
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
return;
}
switch (state.activeTab) {
case "favorites": renderFavorites(content); break;
case "sets": renderSets(content); break;
case "groups": renderGroups(content); break;
}
};
const renderFavorites = (container) => {
let items = state.favorites;
if (state.searchText) {
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
}
if (items.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
</div>`;
return;
}
container.innerHTML = "";
for (const node of items) {
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
el.addEventListener("dblclick", () => {
placeSingleNode(node.name);
});
container.appendChild(el);
}
};
const renderSets = (container) => {
let items = state.nodeSets;
if (state.searchText) {
items = items.filter(s =>
s.name.toLowerCase().includes(state.searchText) ||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
);
}
if (items.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
</div>`;
return;
}
container.innerHTML = "";
for (const set of items) {
const count = (set.nodes || []).length;
const el = createDraggableItem(
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
"set",
{ setId: set.id },
set.description
);
el.addEventListener("dblclick", () => {
placeNodeSet(set);
});
container.appendChild(el);
}
};
const renderGroups = (container) => {
const groupNames = Object.keys(state.groups).sort();
if (groupNames.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
</div>`;
return;
}
let filtered = groupNames;
if (state.searchText) {
filtered = groupNames.filter(g =>
g.toLowerCase().includes(state.searchText) ||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
);
}
container.innerHTML = "";
for (const groupName of filtered) {
const nodes = state.groups[groupName] || [];
const section = document.createElement("div");
section.className = "wfm-nlp-group-section";
const header = document.createElement("div");
header.className = "wfm-nlp-group-header";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.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";
for (const nodeName of nodes) {
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
list.appendChild(el);
}
section.appendChild(list);
container.appendChild(section);
}
};
// ============================================
// Draggable item factory
// ============================================
const createDraggableItem = (label, type, data, subtitle) => {
const el = document.createElement("div");
el.className = "wfm-nlp-item";
el.draggable = true;
el.innerHTML = `
<div class="wfm-nlp-item-label">${label}</div>
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
`;
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
el.classList.add("dragging");
});
el.addEventListener("dragend", () => {
el.classList.remove("dragging");
});
return el;
};
// ============================================
// Toggle & exports
// ============================================
export const togglePanel = async () => {
const panel = createPanel();
state.visible = !state.visible;
panel.style.display = state.visible ? "flex" : "none";
if (state.visible && !state.loaded) {
await loadData();
renderContent();
}
installCanvasDropHandler();
};
export const getNodeSetsIcon = () => `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<path d="M17.5 14v7M14 17.5h7"/>
</svg>
`;
// ============================================
// Toast
// ============================================
const showToast = (message, type = "info") => {
const existing = document.getElementById("wfm-ns-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "wfm-ns-toast";
const colors = {
success: { bg: "rgba(46,213,115,0.95)" },
error: { bg: "rgba(255,71,87,0.95)" },
info: { bg: "rgba(74,158,255,0.95)" },
};
Object.assign(toast.style, {
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
});
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
};
const esc = (s) => s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") : "";
// ============================================
// Save selected nodes as Node Set (context menu)
// ============================================
const saveSelectedAsNodeSet = async () => {
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
if (selectedNodes.length === 0) {
showToast("Select at least one node first", "error");
return;
}
// Prompt for name
const name = prompt("Node Set name:");
if (!name || !name.trim()) return;
// Build node list with relative positions
// Use the top-left of the bounding box as origin
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
const nodeIdToIndex = new Map();
const nodes = [];
selectedNodes.forEach((node, i) => {
nodeIdToIndex.set(node.id, i);
nodes.push({
class_type: node.type || node.comfyClass || "",
title: node.title || "",
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
});
});
// Extract links between selected nodes
const links = [];
const graph = app.graph;
for (const node of selectedNodes) {
if (!node.inputs) continue;
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
const input = node.inputs[slotIdx];
if (input.link == null) continue;
// Resolve link data
let linkData = null;
if (graph.links instanceof Map) {
linkData = graph.links.get(input.link);
} else if (graph.links) {
linkData = graph.links[input.link];
}
if (!linkData) continue;
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
// or object with .origin_id, .origin_slot, etc.
const originId = linkData.origin_id ?? linkData[1];
const originSlot = linkData.origin_slot ?? linkData[2];
const targetSlot = linkData.target_slot ?? linkData[4];
// Only include if both source and target are in the selection
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
links.push({
from_node: nodeIdToIndex.get(originId),
from_slot: originSlot,
to_node: nodeIdToIndex.get(node.id),
to_slot: targetSlot,
});
}
}
}
// Save via API
try {
const res = await fetch("/api/wfm/node-sets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
});
const result = await res.json();
if (result.status === "ok") {
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
// Refresh sidebar if open
if (state.loaded) {
await loadData();
renderContent();
}
} else {
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
}
} catch (err) {
showToast("Failed to save: " + err.message, "error");
}
};
// Register context menu item on all nodes
app.registerExtension({
name: "WorkflowStudio.NodeSetContextMenu",
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(_, options) {
origGetExtraMenuOptions?.apply(this, arguments);
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
// Add separator + menu item
options.push(null);
options.push({
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
callback: saveSelectedAsNodeSet,
});
};
},
});
// ============================================
// Styles
// ============================================
const injectStyles = () => {
if (document.getElementById("wfm-nlp-styles")) return;
const style = document.createElement("style");
style.id = "wfm-nlp-styles";
style.textContent = `
#${PANEL_ID} {
position: fixed;
right: 0;
top: 0;
width: 280px;
height: 100vh;
background: var(--comfy-menu-bg, #1e1e1e);
border-left: 1px solid var(--border-color, #4e4e4e);
z-index: 9999;
display: none;
flex-direction: column;
font-family: sans-serif;
font-size: 13px;
color: var(--input-text, #ddd);
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
}
.wfm-nlp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-title {
font-weight: 600;
font-size: 14px;
flex: 1;
}
.wfm-nlp-close {
background: none;
border: none;
color: var(--input-text, #ddd);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
opacity: 0.6;
}
.wfm-nlp-close:hover { opacity: 1; }
.wfm-nlp-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-tab {
flex: 1;
padding: 8px 4px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--input-text, #ddd);
font-size: 11px;
cursor: pointer;
opacity: 0.6;
transition: all 0.15s;
white-space: nowrap;
}
.wfm-nlp-tab:hover { opacity: 0.9; }
.wfm-nlp-tab.active {
opacity: 1;
border-bottom-color: var(--p-button-background, #4a9eff);
}
.wfm-nlp-search {
padding: 8px;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-search-input {
width: 100%;
padding: 6px 8px;
background: var(--comfy-input-bg, #2a2a2a);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
color: var(--input-text, #ddd);
font-size: 12px;
outline: none;
box-sizing: border-box;
}
.wfm-nlp-search-input:focus {
border-color: var(--p-button-background, #4a9eff);
}
.wfm-nlp-content {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.wfm-nlp-empty {
padding: 24px 16px;
text-align: center;
color: var(--descrip-text, #999);
font-size: 12px;
line-height: 1.6;
}
.wfm-nlp-item {
padding: 7px 12px;
cursor: grab;
border-bottom: 1px solid var(--border-color, #3a3a3a);
transition: background 0.12s;
user-select: none;
}
.wfm-nlp-item:hover {
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-item.dragging {
opacity: 0.5;
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-item-label {
font-size: 12px;
line-height: 1.3;
}
.wfm-nlp-item-sub {
font-size: 10px;
color: var(--descrip-text, #888);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wfm-nlp-badge {
display: inline-block;
font-size: 10px;
padding: 1px 5px;
background: var(--comfy-input-bg, #444);
border-radius: 3px;
color: var(--descrip-text, #aaa);
margin-left: 4px;
vertical-align: middle;
}
.wfm-nlp-group-section {
border-bottom: 1px solid var(--border-color, #3a3a3a);
}
.wfm-nlp-group-header {
padding: 8px 12px;
cursor: pointer;
font-weight: 500;
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.12s;
}
.wfm-nlp-group-header:hover {
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-group-header::before {
content: "\\25BC";
font-size: 8px;
margin-right: 6px;
transition: transform 0.15s;
}
.wfm-nlp-group-header.collapsed::before {
transform: rotate(-90deg);
}
.wfm-nlp-group-list {
padding-left: 8px;
}
.wfm-nlp-group-list .wfm-nlp-item {
padding: 5px 12px;
font-size: 11px;
}
.wfm-nlp-refresh {
background: none;
border: none;
color: var(--input-text, #ddd);
font-size: 16px;
cursor: pointer;
opacity: 0.7;
}
.wfm-nlp-refresh:hover { opacity: 1; }
`;
document.head.appendChild(style);
};
// ============================================
// Context Menu — Save selected nodes as Node Set
// ============================================
const saveSelectedAsNodeSet = async () => {
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
if (selectedNodes.length === 0) {
showToast("Select at least one node first", "error");
return;
}
const name = prompt("Node Set name:");
if (!name || !name.trim()) return;
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
const nodeIdToIndex = new Map();
const nodes = [];
selectedNodes.forEach((node, i) => {
nodeIdToIndex.set(node.id, i);
nodes.push({
class_type: node.type || node.comfyClass || "",
title: node.title || "",
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
});
});
const links = [];
const graph = app.graph;
for (const node of selectedNodes) {
if (!node.inputs) continue;
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
const input = node.inputs[slotIdx];
if (input.link == null) continue;
let linkData = null;
if (graph.links instanceof Map) {
linkData = graph.links.get(input.link);
} else if (graph.links) {
linkData = graph.links[input.link];
}
if (!linkData) continue;
const originId = linkData.origin_id ?? linkData[1];
const originSlot = linkData.origin_slot ?? linkData[2];
const targetSlot = linkData.target_slot ?? linkData[4];
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
links.push({
from_node: nodeIdToIndex.get(originId),
from_slot: originSlot,
to_node: nodeIdToIndex.get(node.id),
to_slot: targetSlot,
});
}
}
}
try {
const res = await fetch("/api/wfm/node-sets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
});
const result = await res.json();
if (result.status === "ok") {
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
if (state.loaded) { await loadData(); renderContent(); }
} else {
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
}
} catch (err) {
showToast("Failed to save: " + err.message, "error");
}
};
export { saveSelectedAsNodeSet };
+565
View File
@@ -0,0 +1,565 @@
console.log("[WFM] top_menu_extension.js loading...");
import { app } from "../../scripts/app.js";
console.log("[WFM] app imported:", !!app);
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
console.log("[WFM] node_sets_menu imports loaded");
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
const WFM_PATH = "/wfm";
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
const MAX_ATTACH_ATTEMPTS = 120;
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
const openWorkflowStudio = (event) => {
const url = `${window.location.origin}${WFM_PATH}`;
if (event.shiftKey) {
window.open(url, "_blank", NEW_WINDOW_FEATURES);
return;
}
window.open(url, "_blank");
};
// ============================================
// Canvas Snapshot - Export workflow as PNG thumbnail
// ============================================
const getWorkflowFilename = () => {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
return `wf_${stamp}.json`;
};
// ---- PNG tEXt chunk embedding helpers ----
const n2b = (n) => new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
const joinArrayBuffer = (...bufs) => {
const result = new Uint8Array(bufs.reduce((total, buf) => total + buf.byteLength, 0));
bufs.reduce((offset, buf) => {
result.set(buf, offset);
return offset + buf.byteLength;
}, 0);
return result;
};
let _crcTable = null;
const crc32 = (data) => {
if (!_crcTable) {
_crcTable = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
_crcTable[n] = c;
}
}
let crc = 0 ^ -1;
for (let i = 0; i < data.byteLength; i++) {
crc = (crc >>> 8) ^ _crcTable[(crc ^ data[i]) & 0xff];
}
return (crc ^ -1) >>> 0;
};
const embedWorkflowInPng = async (blob, workflowJson) => {
const buffer = await blob.arrayBuffer();
const typedArr = new Uint8Array(buffer);
const view = new DataView(buffer);
// Create tEXt chunk: "tEXtworkflow\0{json}"
const data = new TextEncoder().encode(`tEXtworkflow\0${workflowJson}`);
const chunk = joinArrayBuffer(n2b(data.byteLength - 4), data, n2b(crc32(data)));
// Insert after IHDR chunk (8-byte signature + IHDR length field(4) + IHDR type(4) + IHDR data + CRC(4))
const ihdrDataLen = view.getUint32(8);
const sz = ihdrDataLen + 20; // 8(sig) + 4(len) + 4(type) + ihdrDataLen + 4(crc)
const result = joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
return new Blob([result], { type: "image/png" });
};
// ---- Canvas capture ----
const captureCanvasSnapshot = () => {
return new Promise((resolve, reject) => {
try {
const graph = app.graph;
const canvas = app.canvas;
if (!graph || !canvas || !graph._nodes?.length) {
reject(new Error("No workflow loaded"));
return;
}
// Serialize workflow data before changing canvas state
const workflowJson = JSON.stringify(graph.serialize());
// Calculate bounds of all nodes
const bounds = graph._nodes.reduce(
(p, n) => {
if (n.pos[0] < p[0]) p[0] = n.pos[0];
if (n.pos[1] < p[1]) p[1] = n.pos[1];
const b = n.getBounding();
const r = n.pos[0] + b[2];
const bot = n.pos[1] + b[3];
if (r > p[2]) p[2] = r;
if (bot > p[3]) p[3] = bot;
return p;
},
[99999, 99999, -99999, -99999]
);
// Add padding
const padding = 80;
bounds[0] -= padding;
bounds[1] -= padding;
bounds[2] += padding;
bounds[3] += padding;
// Save current canvas state
const savedState = {
scale: canvas.ds.scale,
width: canvas.canvas.width,
height: canvas.canvas.height,
offset: [...canvas.ds.offset],
transform: canvas.canvas.getContext("2d").getTransform(),
};
// Set canvas to render full workflow
const dpr = window.devicePixelRatio || 1;
canvas.ds.scale = 1;
canvas.canvas.width = (bounds[2] - bounds[0]) * dpr;
canvas.canvas.height = (bounds[3] - bounds[1]) * dpr;
canvas.ds.offset = [-bounds[0], -bounds[1]];
canvas.canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
// Render
canvas.draw(true, true);
// Capture to blob
canvas.canvas.toBlob(async (rawBlob) => {
// Restore canvas state
canvas.ds.scale = savedState.scale;
canvas.canvas.width = savedState.width;
canvas.canvas.height = savedState.height;
canvas.ds.offset = savedState.offset;
canvas.canvas.getContext("2d").setTransform(savedState.transform);
canvas.draw(true, true);
if (!rawBlob) {
reject(new Error("Failed to generate canvas image"));
return;
}
try {
// Embed workflow JSON into PNG tEXt chunk
const blob = await embedWorkflowInPng(rawBlob, workflowJson);
resolve(blob);
} catch (embedErr) {
console.warn("Workflow Studio: failed to embed workflow metadata, saving without it:", embedErr);
resolve(rawBlob);
}
}, "image/png");
} catch (err) {
reject(err);
}
});
};
const saveCanvasToWorkflowStudio = async () => {
const filename = getWorkflowFilename();
try {
showNotification("Capturing workflow image...", "info");
const blob = await captureCanvasSnapshot();
const fd = new FormData();
fd.append("filename", filename);
fd.append("image", blob, "canvas.png");
const res = await fetch("/api/wfm/workflows/save-canvas-image", {
method: "POST",
body: fd,
});
const data = await res.json();
if (data.error) {
throw new Error(data.error);
}
showNotification(`Workflow saved: ${filename.replace(/\.json$/, "")}`, "success");
} catch (err) {
showNotification("Error: " + err.message, "error");
}
};
const showNotification = (message, type = "info") => {
// Try using ComfyUI's built-in toast if available
try {
if (app.ui?.dialog) {
// Fallback: use a simple temporary notification
}
} catch (e) { /* ignore */ }
// Simple toast notification
const existing = document.getElementById("wfm-snapshot-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "wfm-snapshot-toast";
const colors = {
success: { bg: "rgba(46,213,115,0.95)", color: "#fff" },
error: { bg: "rgba(255,71,87,0.95)", color: "#fff" },
info: { bg: "rgba(74,158,255,0.95)", color: "#fff" },
};
const c = colors[type] || colors.info;
Object.assign(toast.style, {
position: "fixed",
bottom: "24px",
left: "50%",
transform: "translateX(-50%)",
padding: "10px 24px",
background: c.bg,
color: c.color,
borderRadius: "8px",
fontSize: "14px",
fontWeight: "500",
zIndex: "99999",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
transition: "opacity 0.3s ease",
whiteSpace: "nowrap",
});
toast.textContent = message;
document.body.appendChild(toast);
if (type !== "info") {
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}, 3000);
} else {
// Info messages will be replaced by success/error
setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
};
// ============================================
// Version Detection & UI
// ============================================
const getComfyUIFrontendVersion = async () => {
try {
if (window["__COMFYUI_FRONTEND_VERSION__"]) {
return window["__COMFYUI_FRONTEND_VERSION__"];
}
} catch (error) {
console.warn("Workflow Studio: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
}
try {
const response = await fetch("/system_stats");
const data = await response.json();
if (data?.system?.comfyui_frontend_version) {
return data.system.comfyui_frontend_version;
}
if (data?.system?.required_frontend_version) {
return data.system.required_frontend_version;
}
} catch (error) {
console.warn("Workflow Studio: unable to fetch system_stats:", error);
}
return "0.0.0";
};
const parseVersion = (versionStr) => {
if (!versionStr || typeof versionStr !== "string") {
return [0, 0, 0];
}
const cleanVersion = versionStr.replace(/^[vV]/, "").split("-")[0];
const parts = cleanVersion.split(".").map((part) => parseInt(part, 10) || 0);
while (parts.length < 3) {
parts.push(0);
}
return parts;
};
const compareVersions = (version1, version2) => {
const v1 = typeof version1 === "string" ? parseVersion(version1) : version1;
const v2 = typeof version2 === "string" ? parseVersion(version2) : version2;
for (let i = 0; i < 3; i++) {
if (v1[i] > v2[i]) return 1;
if (v1[i] < v2[i]) return -1;
}
return 0;
};
const supportsActionBarButtons = async () => {
const version = await getComfyUIFrontendVersion();
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
};
const getWfmIcon = () => {
return `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5 L7 17 L12 7 L17 17 L21 5" stroke-width="2.3"/>
<path d="M5 20 Q9 17 12 20 Q15 23 19 20" stroke-width="1.8"/>
</svg>
`;
};
const getSnapshotIcon = () => {
return `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect x="3" y="5" width="18" height="14" rx="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M8 5 L9 3 L15 3 L16 5"/>
</svg>
`;
};
// ============================================
// Button Creation
// ============================================
const createTopMenuButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "wfm",
tooltip: BUTTON_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
});
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
button.element.title = BUTTON_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getWfmIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", openWorkflowStudio);
return button;
};
const createSnapshotButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "snapshot",
tooltip: SNAPSHOT_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse",
});
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
button.element.title = SNAPSHOT_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getSnapshotIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
return button;
};
const createNodeSetsButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "node-sets",
tooltip: NODE_SETS_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
});
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
button.element.title = NODE_SETS_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getNodeSetsIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", (e) => {
e.stopPropagation();
togglePanel();
});
return button;
};
const attachTopMenuButton = async (attempt = 0) => {
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
return;
}
// Try settingsGroup first (legacy ComfyUI)
const settingsGroup = app.menu?.settingsGroup;
// Fallback: find the legacy topbar container used by newer ComfyUI
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
if (attempt >= MAX_ATTACH_ATTEMPTS) {
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
return;
}
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
return;
}
const wfmButton = await createTopMenuButton();
const snapshotButton = await createSnapshotButton();
const nodeSetsButton = await createNodeSetsButton();
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
if (settingsGroup?.element?.parentElement) {
settingsGroup.element.before(buttonGroup.element);
} else if (legacyContainer) {
legacyContainer.prepend(buttonGroup.element);
}
};
app.registerExtension({
name: "WorkflowStudio.TopMenu",
actionBarButtons: [
{
icon: "icon-[mdi--file-document-multiple] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openWorkflowStudio,
},
{
icon: "icon-[mdi--camera] size-4",
tooltip: SNAPSHOT_TOOLTIP,
onClick: saveCanvasToWorkflowStudio,
},
{
icon: "icon-[mdi--view-grid-plus] size-4",
tooltip: NODE_SETS_TOOLTIP,
onClick: () => togglePanel(),
},
],
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
origGetExtraMenuOptions?.apply(this, arguments);
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
options.push(null);
options.push({
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
callback: saveSelectedAsNodeSet,
});
};
},
async setup() {
const injectStyles = () => {
const styleId = "wfm-top-menu-button-styles";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
background-color: var(--primary-hover-bg) !important;
}
`;
document.head.appendChild(style);
};
injectStyles();
const replaceButtonIcon = () => {
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
wfmButtons.forEach((button) => {
button.classList.add("wfm-top-menu-button");
button.innerHTML = getWfmIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
button.style.backgroundColor = "var(--primary-bg)";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
snapButtons.forEach((button) => {
button.classList.add("wfm-snapshot-button");
button.innerHTML = getSnapshotIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
nsButtons.forEach((button) => {
button.classList.add("wfm-node-sets-btn");
button.innerHTML = getNodeSetsIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
if (wfmButtons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
}
};
requestAnimationFrame(replaceButtonIcon);
},
});