Compare commits

...
24 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 4.6 a48d9c1437 v0.3.8: Metadata tab - data9 workflow support and unclassified prompt text type
- Extended extractPromptsFromNodeSet to resolve link sources from any STRING-output
  node (ComfySwitchNode, PrimitiveStringMultiline, etc.) without type restriction
- Added fallback for SamplerCustomAdvanced (no positive/negative inputs): return
  textMap contents as texts[] instead of null
- Extended step4 PrimitiveStringMultiline and step7 final fallback to search
  subgraph nodes via collectAllNodes
- Added "text" prompt type (no POS/NEG badge) for cases where polarity cannot be
  determined; buildPromptItem and handleFile updated accordingly
- Added metaPromptText i18n key (EN/JA/ZH)
- Updated README (v0.3.8, feature description) and DEVLOG

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:09:12 +09:00
34 changed files with 6999 additions and 284 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Publish to Comfy Registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+831
View File
@@ -1,5 +1,836 @@
# DEVLOG - ComfyUI-Workflow-Studio
## 2026-05-16: v0.3.8 Metadata タブ: data9 対応 / プロンプト判別不能テキスト
### 概要
data9 の新規ワークフロー(Flux.2 Dev fp8、Flux.2 Klein 4B Distilled、Ernie Image、
Qwen-Image-Edit 2511、WAN2.2 14B Animate)の Metadata タブ表示に対応。
あわせて、ポジティブ / ネガティブの判別ができないプロンプトを **Text(バッジなし)** として
区別して表示する仕様に変更した。
### 問題と原因
| ファイル | 問題 |
|---|---|
| image_flux2_fp8.json | `SamplerCustomAdvanced` に positive/negative 入力ポートがなく、リンク解決でプロンプトが空になる |
| image_ernie_image.json | `PrimitiveStringMultiline` がサブグラフ内にあり、ステップ4(トップのみ対象)で見つからない |
| image_qwen_image_edit_2511.json | `TextEncodeQwenImageEditPlus` → `FluxKontextMultiReferenceLatentMethod` → KSampler という中間ノード経由で textMap にプロンプトが登録されない |
| video_wan2_2_14B_animate.json | トップレベルの CLIPTextEncode とサブグラフ内の KSampler がクロスレベル接続され、単一レベル解析では取得できない |
### 変更内容
#### `static/js/metadata-tab.js`
**`extractPromptsFromNodeSet`**
- CLIPTextEncode のテキスト入力リンクのソースノードのタイプ制限を撤廃
- 旧: `isPromptStylerNode` または `WFS_PromptText` のみ
- 新: PromptStyler 以外は任意ノードの `widgets_values[originSlot]`(`ComfySwitchNode`、`PrimitiveStringMultiline` 等を包含)
- テキスト入力名に `"prompt"` を追加(`TextEncodeQwenImageEditPlus` 対応)
- `foundSampler = true` かつ pos / neg が両方空の場合(`SamplerCustomAdvanced` 等)、
textMap のテキストを `{ positives: [], negatives: [], texts: [...] }` として返すよう変更
(旧: `return null`)
**`extractPromptsLiteGraph`**
- ステップ4 `PrimitiveStringMultiline` — `nodes`(トップのみ)→ `collectAllNodes(wf)`(サブグラフ含む)に変更
- 戻り値: `{ positives: primTexts, negatives: [] }` → `{ positives: [], negatives: [], texts: primTexts }`
- ステップ7 最終フォールバック — `nodes`(トップのみ)→ `collectAllNodes(wf)`(サブグラフ含む)に変更
- 戻り値: `{ positives: all, negatives: all }` → `{ positives: [], negatives: [], texts: all }`
**`extractPromptsAPI`**
- フォールバック時の戻り値: `{ positives: all, negatives: all }` → `{ positives: [], negatives: [], texts: all }`
**UI**
- `buildPromptItem`: `type === "text"` のときバッジ(POS/NEG)を表示しない
- `buildPromptItem`: fullLabel に `t("metaPromptText")` を使用
- `handleFile`: `(meta.texts ?? []).map(p => ({ type: "text", text: p }))` を allPrompts に追加
#### `static/js/i18n.js`
- `metaPromptText` を EN / JA / ZH に追加("Text" / "テキスト" / "文本")
### 対応済みワークフロー(data/data9)
| ファイル | 適用ステップ | プロンプト結果 |
|---|---|---|
| image_flux2_fp8.json | step5(サブグラフ + SamplerCustomAdvanced fallback) | texts(バッジなし) |
| image_flux2_klein_image_edit_4b_distilled.json | step5(サブグラフ CLIPTextEncode + KSampler) | texts(バッジなし) |
| image_ernie_image.json | step4(PrimitiveStringMultiline in subgraph) | texts(バッジなし) |
| image_qwen_image_edit_2511.json | step5(TextEncodeQwenImageEditPlus fallback) | texts(バッジなし) |
| video_wan2_2_14B_animate.json | step7(全ノード fallback) | texts(バッジなし) |
---
## 2026-05-15: v0.3.7 Metadata タブ: Flux2 / Qwen / Z-Image 対応
### 概要
ComfyUI 公式テンプレート(Flux.2 Dev/Klein、Qwen-Image-Edit、Z-Image)で使われる
**サブグラフ(subgraph)形式**ワークフローの Metadata タブ読み込みに対応。
- `definitions.subgraphs` 内の `UNETLoader` / `CLIPLoader` / `VAELoader` / `LoraLoaderModelOnly` からモデルを抽出
- `MarkdownNote` ノードの `**section** → - [model](url)` パターンからモデルを補完(フォールバック)
- サブグラフ内の `CLIPTextEncode + KSampler` リンクを辿ってプロンプトを取得
- `PrimitiveStringMultiline` ノード(flux2-klein など)からプロンプトを取得
### 変更内容
#### `static/js/metadata-tab.js`
- **`extractPromptsFromNodeSet(nodes, links)`** 新規追加
CLIPTextEncode + KSampler によるプロンプト抽出ロジックを汎用化。トップレベル・サブグラフの両方で再利用
- **`extractMarkdownNoteModels(wf)`** 新規追加
`MarkdownNote` の `**section**` → `- [name](url)` を正規表現で解析し、text_encoders / diffusion_models / vae / loras を抽出
- **`extractCheckpoints` / `extractVAEs`** を `collectAllNodes()` 使用に修正
サブグラフ内のノードも走査対象に
- **`extractPromptsLiteGraph`** を 7 段階フォールバック構成に再設計
1. ImageMetadataPromptLoader → 2. WFS_PromptText → 3. トップレベルCLIPTextEncode+KSampler →
4. PrimitiveStringMultiline → **5. サブグラフCLIPTextEncode+KSampler(新規)** →
6. PromptStyler → 7. 全CLIPTextEncodeテキスト
- **`fromWorkflow`** に MarkdownNote フォールバック補完を追加
### 対応済みワークフロー(data/data8)
| ファイル | モデル抽出 | プロンプト抽出 |
|---|---|---|
| image_flux2_fp8.json | UNETLoader + CLIPLoader + VAELoader + LoraLoader (subgraph) | CLIPTextEncode (subgraph) |
| image_flux2_klein_text_to_image.json | UNETLoader + CLIPLoader + VAELoader (subgraph) | PrimitiveStringMultiline |
| image_qwen_image_edit.json | UNETLoader + CLIPLoader + VAELoader + LoraLoader (subgraph) | なし(画像入力のみ) |
| image_qwen_image_edit_2511.json | 同上 | なし |
| image_qwen_image_layered.json | UNETLoader + CLIPLoader + VAELoader (subgraph) | PrimitiveStringMultiline |
| image_z_image.json | UNETLoader + CLIPLoader + VAELoader (subgraph) | CLIPTextEncode (subgraph) |
| image_z_image_turbo.json | 同上 | CLIPTextEncode (subgraph) |
#### `static/js/i18n.js` + `templates/index.html`
- `metaFormatTodo` を「Flux.2 / Qwen-Image / Z-Image サブグラフ形式に対応済み」に更新(EN/JA/ZH)
- `helpMetadata5` の対応フォーマット一覧を更新し Flux.2 / Qwen-Image / Z-Image を明記(EN/JA/ZH)
- index.html フォールバックテキストも同内容に更新
---
## 2026-05-15: v0.3.6 Metadata タブ追加
### 概要
- PNG / WebP / JSON からモデル・LoRA・プロンプト情報を抽出・表示する Metadata タブを追加
- タブ位置は Prompt タブの右隣(Prompt → Metadata → Gallery)
- プロンプト抽出ロジックを改善し SDXL Prompt Styler を使用したワークフローにも対応
- 抽出プロンプトを GenerateUI タブ・Prompt タブのプリセットへワンクリックで転記するボタンを追加
- 左ペインに対応フォーマット説明と今後の対応予定ノートを追加
- ヘルプタブに Metadata タブの説明カードを追加(EN/JA/ZH i18n 対応)
### 変更内容
#### `static/js/metadata-tab.js` — 新規作成
**メタデータ抽出ロジック(`model-and-prompt-from-metadata` の `workflow_utils.js` ベース)**
- `sanitizeJSON(text)` — JSON に含まれる `NaN` / `Infinity` を `null` に置換
- `readWebPEXIFChunk(file)` — WebP RIFF チャンクから EXIF を読み取る
- `extractWorkflowFromEXIF(exifBytes)` — EXIF バイト列から `workflow:` / `prompt:` キーを検索してワークフロー JSON を返す
- `readAllPNGTextChunks(file)` — PNG tEXt / iTXt チャンクをすべて読み取って `{ keyword: text }` マップを返す(IEND 以降のチャンクも読む)
- `extractCheckpoints / extractVAEs / extractDiffusionModels / extractTextEncoders / extractLoRAs` — LiteGraph 形式・API 形式の両方に対応したモデル抽出関数群
- `extractPrompts(wf)` — 形式を判定して `extractPromptsLiteGraph` / `extractPromptsAPI` に振り分け
- **`resolveLinkedText(wf, srcId, slot)`** — API 形式でのリンク参照 `[nodeId, slot]` を解決。`PromptStyler` 系ノードは `text_positive` / `text_negative` を slot に応じて返す
- `extractPromptsAPI(wf)` — CLIPTextEncode の text 入力がリンク参照(配列)の場合に `resolveLinkedText` で解決するよう修正
- `extractPromptsLiteGraph(wf)` — **`linkSlot` マップを追加**(リンクの origin_slot を追跡); CLIPTextEncode の inputs にリンクがある場合に `PromptStyler` / `WFS_PromptText` の `widgets_values[originSlot]` を解決; フォールバック時にも `PromptStyler` ノードを直接スキャン
- SD WebUI / SD Forge / Fooocus の `parameters` チャンク解析(`parseSDAParameters`, `parseFooocusMetadata`)
- `extractAllMetadata(file)` — PNG / WebP / JSON を統合して `{ source, checkpoints, vaes, diffusionModels, textEncoders, loras, positives, negatives }` を返す
**UI**
- `buildModelItem(label)` — モデル名アイテム(テキストオーバーフロー省略)
- `buildLoRAItem(lora)` — LoRA 名 + `strength_model/strength_clip` バッジ付きアイテム
- `buildPromptItem(label, type, full, ...)` — POS/NEG バッジ付きクリッカブルアイテム。クリックで下部テキストエリアに全文を表示し選択ハイライト
- `renderSection(sectionEl, listEl, items, buildFn)` — アイテムが空の場合は `.wfm-meta-section-empty` クラスを付与してリストを非表示
- `initMetadataTab()` — タブ初期化
- ドロップゾーン / クリックでファイル選択 / drag-over スタイル切り替え
- 画像ファイルはプレビューとして表示、JSON はドロップラベルを維持
- ファイル処理後に各セクションをレンダリング。最初の positive プロンプトを自動選択
- セクションタイトル・フォーマットノート・ヘルプカードの i18n 適用
- **プロンプトアクションボタン**:
- Copy — `navigator.clipboard.writeText` でプレビュー内容をコピー
- GenUI:P / GenUI:N — `#wfm-prompt-pos-text` / `#wfm-prompt-neg-text` に値をセット
- Prompt:P / Prompt:N — `#wfm-preset-pos` / `#wfm-preset-neg` に値をセット
- 成功時は緑のフラッシュアニメーション(`wfm-meta-btn-flash`)
#### `templates/index.html`
- タブナビに `<button class="wfm-tab" data-tab="metadata">Metadata</button>` を Prompt と Gallery の間に追加
- `#wfm-tab-metadata` セクションを追加(Prompt セクションの直後、Settings セクションの前):
- `.wfm-metadata-layout`(3列グリッド: 220px | 1fr | 1fr)
- Col1: ドロップゾーン(`#wfm-meta-drop`)+プレビュー画像+ファイル情報(`#wfm-meta-file-info`)+対応フォーマットノート(`#wfm-meta-format-note`)
- Col2: Checkpoint / VAE / Diffusion Model / Text Encoder の各セクション
- Col3: LoRA セクション+Prompt セクション(リスト+全文テキストエリア+アクションボタン行)
- Help タブ: Prompt Tab カードと Settings Tab カードの間に Metadata Tab カードを追加(`wfm-help-metadata-title/desc/1〜5`)
#### `static/js/app.js`
- `import { initMetadataTab } from "./metadata-tab.js"` を追加
- `tabMap` に `metadata: "tabMetadata"` を追加
- `DOMContentLoaded` 内に `initMetadataTab()` を追加(`initPromptTab()` の直後)
#### `static/js/i18n.js`
EN / JA / ZH の3言語に以下を追加:
- `tabMetadata` — タブラベル
- `metaSectionCkpt / Vae / Diff / Te / Lora / Prompt` — 各セクションタイトル
- `metaDropMain / metaDropSub` — ドロップゾーンラベル
- `metaParsing / metaFileTooLarge / metaNoMetadata / metaParseError` — ステータスメッセージ
- `metaPromptPositive / metaPromptNegative / metaSelectPrompt` — プロンプトエリアラベル
- `metaFormatNoteTitle / metaFmtComfyui / metaFmtSdwebui / metaFmtFooocus / metaFormatTodo` — フォーマットノート
- `helpMetadataTitle / helpMetadataDesc / helpMetadata1〜5` — ヘルプカード
#### `static/css/main.css`
以下のスタイルを末尾に追加:
- `#wfm-tab-metadata` / `.wfm-metadata-layout` — 3列グリッドレイアウト
- `.wfm-metadata-col1/col2/col3` — 各列の flex レイアウト・ボーダー・スクロール
- `.wfm-metadata-drop-zone` (+ `.drag-over` / `:hover`) — ドロップゾーンのボーダー・背景切り替え
- `.wfm-meta-drop-label / main / sub` — ドロップラベル
- `.wfm-meta-preview-img` — プレビュー画像(object-fit: contain)
- `.wfm-meta-file-info` — ファイル情報テキスト
- `.wfm-meta-section` (+ `.wfm-meta-section-empty`) — セクションコンテナ(空時はリストを非表示・タイトルを半透明)
- `.wfm-meta-section-title` — セクション見出し(uppercase + letter-spacing)
- `.wfm-meta-list` — スクロール可能アイテムリスト(`max-height: 140px`)
- `.wfm-meta-item` / `.wfm-meta-item-clickable` (+ `:hover` / `.selected`) — アイテム行
- `.wfm-meta-item-name / badge` — アイテム名・バッジ
- `.wfm-meta-badge-pos / neg` — POS(緑)/ NEG(赤)バッジ
- `.wfm-meta-prompt-section / full-wrap / full-label / full` — プロンプト全文エリア(`min-height: 80px`)
- `.wfm-meta-prompt-actions` — ボタン行(flex wrap)
- `.wfm-meta-btn-flash` — 成功時フラッシュアニメーション
- `.wfm-meta-format-note / note-title / format-list / format-todo` — フォーマットノートスタイル
---
## 2026-05-09: v0.3.5 GenerateUI — Feeder サブタブ・ワークフロー解析精度向上
### 概要
- comfyui-image-feeder カスタムノード対応の Feeder サブタブを GenerateUI タブに追加
- `analyzeWorkflow` の精度向上(SDXL 多段 CONDITIONING チェーン BFS 展開、新ノード種対応、KSamplerAdvanced seed 修正)
- Help タブに Feeder サブタブの説明カードを追加(EN/JA/ZH i18n 対応)
### 変更内容
#### `static/js/feeder-tab.js` — 新規作成
- `_s = { dir, images, selected (Set), presets, running }` モジュール状態
- `_feederNodes()` — currentWorkflow から `class_type === "ImageFeeder"` のノードを列挙
- `refreshFeederNodeList()` — ノードセレクターを再描画(ワークフロー読み込み時に呼ばれる)
- `_loadFromNode(nodeId)` — ノード inputs を左ペインの各フィールドに反映
- `_applyToNode(nodeId)` — フィールド値を currentWorkflow のノード inputs に書き戻す
- `_applyToWorkflow()` — Apply ボタン用(`_applyToNode` 呼び出し+toast)
- フォルダツリー: `_loadTree()` / `_renderTree()` / `_makeTreeRow()` / `_selectDir()` / `_highlightTreeRow()` — `/image_loop/tree` API からツリーを描画
- 画像グリッド: `_loadImages()` / `_renderGrid()` / `_makeCard()` / `_refreshGridCbs()` / `_selectAll()` / `_deselectAll()` — `/image_loop/images` API からサムネイルカードを描画、チェックボックスで選択管理
- プレビュー: `_showPreview()` — カードクリックで右ペインに拡大プレビューと `/image_loop/image_info` の情報を表示
- プリセット: `_loadPresets()` / `_renderPresets()` / `_savePreset()` / `_applyPreset()` / `_deletePreset()` — `/image_feeder/presets` API を使用
- ステータスバー: `_updateStatus()` — 現在フォルダと選択枚数/総枚数を表示
- Run ループ: `_startRun()` / `_stopRun()` / `_setRunUI()`
- Run 前に WebSocket を接続し `image_loop_node_sync` メッセージを専用ハンドラで捕捉
- 各反復で `_applyToNode` → `comfyUI.generate()` → 結果画像を右ペインに表示
- `comfyUI.generate` には右ペインの seed mode/value を使用(KSampler のシードを正しく制御)
- After gen モード (`wfm-feeder-control-after`):
- `loop`: `_lastSync.next_index` で index を更新し無限継続(`has_next=false` でも折り返して継続)
- `increment`: index を更新し `has_next=false` で自動停止
- `fixed`: index を変えず同じ画像で繰り返し
- 生成ごとに右ペインの seed 表示を更新
- `initFeederTab()` — 全コントロールのイベントリスナーを登録し `_loadTree()` / `_loadPresets()` を並列実行
#### `static/js/generate-tab.js`
- `import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js"` を追加
- `loadWorkflowIntoEditor()` に `refreshFeederNodeList()` 呼び出しを追加
- `moveRawJsonToTab()`: `tabKey === "feeder"` の場合は Raw JSON ウィジェットを非表示(feeder タブには rawjson-col がないため)
- `initGenerateTab()` に `await initFeederTab()` を追加
#### `templates/index.html`
- サブタブナビに `<button data-subtab="feeder">Feeder</button>` を追加
- `wfm-gen-subtab-feeder` コンテンツ div を追加:
- 左ペイン (`.wfm-feeder-settings`): ノードセレクター・Apply・全パラメータフィールド・プリセット管理・Run セクション(After gen セレクト + Run/Stop ボタン)
- 中央ペイン (`.wfm-feeder-library`): ステータスバー + ライブラリ本体(フォルダツリー・画像グリッド・プレビューパネル)
- Help タブ: GenerateUI Tab カードの説明を "4-tab layout" に更新
- Help タブ: Feeder サブタブ説明カードを追加(`wfm-help-feeder-title` / `wfm-help-feeder-desc` / `wfm-help-feeder-1`〜`9` の id 付き)
#### `static/css/main.css`
Feeder 専用スタイルを末尾に追加:
- `.wfm-feeder-layout` — flex 横並び全体レイアウト
- `.wfm-feeder-settings` — 左ペイン(幅 220px、縦スクロール)
- `.wfm-feeder-pane-header` / `.wfm-feeder-field-row` / `.wfm-feeder-label` / `.wfm-feeder-input` — フォームレイアウト
- `.wfm-feeder-library` / `.wfm-feeder-status-bar` / `.wfm-feeder-library-body` — 中央ペイン構造
- `.wfm-feeder-tree-pane` / `.wfm-feeder-tree-scroll` / `.wfm-feeder-tree-row` (+ `:hover` / `.active`) / `.wfm-feeder-tree-msg` — フォルダツリー
- `.wfm-feeder-grid-pane` / `.wfm-feeder-grid` / `.wfm-feeder-grid-msg` — 画像グリッドコンテナ
- `.wfm-feeder-card` (+ `:hover` / `.selected`) / `.wfm-feeder-card-cb` / `.wfm-feeder-card-img` / `.wfm-feeder-card-name` — 画像カード
- `.wfm-feeder-preview-pane` / `.wfm-feeder-preview-img` / `.wfm-feeder-preview-name` / `.wfm-feeder-preview-info` — プレビューパネル
#### `static/js/comfyui-workflow.js`
`analyzeWorkflow` を3パス構成に再構成:
- **Pass 1**: KSampler / KSamplerAdvanced を検出し `sampler_nodes` に追加
- `seedKey`: `"seed" in inputs ? "seed" : "noise_seed"` で KSamplerAdvanced の `noise_seed` に対応
- LoraLoader 検出を追加
- **Pass 1b**: BFS 展開(最大5イテレーション)で CONDITIONING チェーンの positive/negative 帰属を伝播
- `COND_PASSTHROUGH` セット: ConditioningCombine / ConditioningConcat / ConditioningAverage / ConditioningZeroOut / ConditioningSetTimestepRange / ControlNetApply / ControlNetApplyAdvanced / IPAdapterApply / IPAdapterApplyFaceID / StyleModelApply
- リンク接続入力を持つノードも帰属を伝播(CLIPTextEncodeSDXL の text_g / text_l が他ノードにリンクされている場合)
- **Pass 2**: 新ノード種の検出
- `CLIPTextEncodeSDXL` / `CLIPTextEncodeSDXLRefiner`: `text_g` / `text_l` が文字列値の場合のみ prompt_nodes に追加
- `TextEncodeQwenImageEditPlus`: `inputs.prompt` を prompt_nodes に追加
- `SDXLPromptStyler` / `SDXLPromptStylerAdvanced`: `text_positive` / `text_negative` をそれぞれ role 付きで追加
- `CheckpointLoader` (WAS): `ct === "Checkpoint Loader"`(スペースあり)を含む判定に修正
- `PrimitiveStringMultiline` / `PrimitiveString`: positive/negative ref がある場合のみ prompt_nodes に追加
- `Power Lora Loader (rgthree)`: `lora_\d+` キーをスキャンして lora_nodes に追加
#### `static/js/comfyui-editor.js`
- KSampler パラメータエディタの hidden input に `data-seed-key` 属性を追加
- Apply 時に `dataset.seedKey`(`"seed"` または `"noise_seed"`)を参照して正しいキーに書き込む
#### `static/js/i18n.js`
- EN / JA / ZH: `helpGen3` を "4-tab layout" / "4タブ構成" / "4标签布局" に更新
- EN / JA / ZH: `helpFeederTitle` / `helpFeederDesc` / `helpFeeder1`〜`9` を追加
#### `static/js/app.js`
- `helpIdMap` に `wfm-help-feeder-title` / `wfm-help-feeder-desc` / `wfm-help-feeder-1`〜`9` を追加
---
## 2026-04-29: v0.3.4 GenerateUI — Checkpoint Batch 刷新・Settings 横並びレイアウト・ヘルプ i18n 修正
### 概要
- Checkpoint Batch: フォルダツリー型ドロップダウン選択に刷新(テキスト入力の Include/Exclude フォルダ指定を廃止)
- Checkpoint Batch: 第二階層以下のサブフォルダにも対応
- Checkpoint Batch: 一時停止 / 再開(Pause/Resume)ボタンを追加
- GenerateUI Settings タブ: KSampler と Latent Image を横並び(各50%幅)に変更
- Help タブ: 最新機能に合わせて説明を更新し、i18n 対応漏れ(Gallery 5〜11、Gen 11)を修正
### 変更内容
#### `templates/index.html`
- Checkpoint Batch パネルの Include/Exclude テキスト入力を廃止し、チェックボックス付きドロップダウンに置き換え
- フォルダヘッダー行: チェックボックス + ▶ 展開矢印 + フォルダ名 + ファイル数
- ファイル行: インデント + チェックボックス + ファイル名(フルパスを tooltip 表示)
- パネル上部: Filter 検索入力 + All / None ボタン
- バッチ進行エリアのプログレスバー下に Pause ボタンを追加(実行中のみ有効)
- ヘルプ: `wfm-help-gen-6`(横並びレイアウト)・`wfm-help-gen-11`(新バッチ機能)の初期テキストを更新
#### `static/js/generate-tab.js`
- `_parseFolderList()` / `_getModelFolder()` / `_filterCheckpoints()` を削除
- `_ckptState = { mode: "all"|"some"|"none", selected: Set<modelPath> }` で選択状態を管理
- `"all"`: 全選択(selected は空)、`"some"`: 部分選択、`"none"`: 全解除
- `_buildFolderTree(models)` — モデルパスをフォルダ → モデルリストの Map に変換(`lastIndexOf("/")` で第二階層以下にも対応)
- `_getFolderCheckState(folderModels)` — `"checked"` / `"indeterminate"` / `"unchecked"` を返す
- `_toggleSingleModel()` / `_toggleFolderModels()` — 単体・フォルダ一括のトグルロジック(all/none/some の状態遷移を管理)
- `_rebuildCkptList()` — フォルダグループ形式の DOM を動的生成(▶ 展開・折りたたみ、`checkbox.indeterminate` 対応)
- `_getSelectedCheckpoints()` — mode に応じた選択リストを返す
- `_ckptBatch.paused` フラグと `_waitIfPaused()` で一時停止・再開を実装(Promise 待機 + resolve で解除)
- `_setPauseBtnState(paused)` — ボタンのテキスト・スタイルを Pause ↔ Resume で切り替え
- `initCheckpointBatch()` に Pause/Resume ボタンのハンドラを追加
- `_runBatchGenerate()`: ループ先頭で `await _waitIfPaused()`、一時停止時にステータステキストを "Paused" 表示、finally ブロックで状態クリーンアップ
- Interrupt(Stop)ボタン: `_ckptBatch.paused = false` と `_resumeResolve()` を呼んで一時停止待機を即解除
#### `static/js/comfyui-editor.js`
- `renderSettingsTab()`: 外枠を `flex-direction: row` に変更し KSampler(左)と Latent Image(右)を横並び(各 `flex:1; min-width:0`)、間に縦の境界線(`border-right: 1px solid var(--wfm-border)`)
#### `static/css/main.css`
- `.wfm-ckpt-dropdown-wrap` / `.wfm-ckpt-dropdown-trigger` / `.wfm-ckpt-dropdown-arrow` / `.wfm-ckpt-dropdown-panel` — ドロップダウン UI スタイル
- `.wfm-ckpt-folder-group` / `.wfm-ckpt-folder-header` / `.wfm-ckpt-folder-toggle` / `.wfm-ckpt-folder-name` / `.wfm-ckpt-folder-count` / `.wfm-ckpt-folder-files` — フォルダツリー UI スタイル
- `.wfm-ckpt-item--indented` — ファイル行のインデント(`padding-left: 24px`)
#### `static/js/i18n.js`
- EN / JA / ZH: `helpGen6` を横並びレイアウトの説明に更新
- EN / JA / ZH: `helpGen11` を追加(フォルダツリードロップダウン、Pause/Resume、Stop の説明)
- EN / JA / ZH: `helpGallery5`〜`helpGallery10` を現在の HTML 構成に合わせて修正(旧版から5項目ずれていたのを解消)
- EN / JA / ZH: `helpGallery11` を追加(PNG/JPEG/WebP/GIF 対応・メタデータ抽出の説明)
#### `static/js/app.js`
- `helpIdMap` に `"wfm-help-gen-11": "helpGen11"`、`"wfm-help-gallery-11": "helpGallery11"` を追加
---
## 2026-04-28: v0.3.3 Promptタブ — ワイルドカード支援パネル・Impact Packシンボリックリンク連携・サブディレクトリ対応・GenerateUI修正
### 概要
- Promptタブのレイアウトを3カラム構成に変更
- 中央ペイン(Col2): Presets / Preset Manager をタブ切り替え化
- 右ペイン(Col3): ワイルドカード支援パネルを新設
- ワイルドカードファイル管理(txt/yaml)の CRUD API を追加
- Settingsタブに Wildcard Integration セクションを追加(Impact Pack ジャンクション/シンボリックリンク連携)
- ワイルドカードフォルダのサブディレクトリファイルに対応(再帰スキャン・ディレクトリグループ表示・`__folder/name__` 形式)
- GenerateUI: `ImpactWildcardEncode` ノードの実行エラーを修正
### 変更内容
#### `py/config.py`
- `COMFYUI_ROOT = _COMFYUI_ROOT` を追加(外部モジュールから参照可能に)
- `WILDCARD_DIR = DATA_DIR / "wildcard"` を追加
#### `py/services/wildcard_service.py` — 新規作成
- `WildcardService(wildcard_dir)` クラス
- `list_wildcards()` — txt/yaml/yml ファイル一覧を返す
- `get_content(filename)` — ファイル内容を取得
- `save_file(filename, content)` — ファイルを保存(新規/上書き)
- `delete_file(filename)` — ファイルを削除
- `_safe_path(filename)` — `..` / スラッシュ / 拡張子チェックによるパストラバーサル防止
- Impact Pack 連携メソッド
- `find_impact_pack_wildcards(comfyui_root)` — `custom_nodes/` 以下で "impact-pack" ディレクトリを検索
- `get_link_status(comfyui_root)` — インストール状況・リンク状態・パスを返す dict
- `create_link(comfyui_root)` — 既存 WFS ファイルを Impact Pack dir へ移行後、ジャンクション/シンボリックリンクを作成
- `remove_link()` — ジャンクション/シンボリックリンクを削除して通常ディレクトリに復元
- プラットフォームヘルパー
- `_is_junction(path)` — Windows の `GetFileAttributesW` + `FILE_ATTRIBUTE_REPARSE_POINT` でジャンクション検出(`os.path.islink()` では検出不可)
- `_create_junction_or_symlink(link, target)` — Windows は `mklink /J`、他 OS は `os.symlink`
- `_remove_junction_or_symlink(path)` — Windows は `rmdir`(ジャンクションを削除、中身に影響なし)、他は `os.unlink`
- コンストラクタ: `_is_junction()` で事前確認し、ジャンクション/シンボリックリンクの場合は mkdir をスキップ
#### `py/routes/wildcard_routes.py` — 新規作成
- `GET /api/wfm/wildcards` — ファイル一覧
- `GET /api/wfm/wildcards/content?filename=` — ファイル内容取得
- `POST /api/wfm/wildcards/save` — ファイル保存
- `POST /api/wfm/wildcards/delete` — ファイル削除
- `GET /api/wfm/wildcards/link-status` — Impact Pack リンク状態取得
- `POST /api/wfm/wildcards/create-link` — ジャンクション/シンボリックリンク作成(移行ファイルリストを返す)
- `POST /api/wfm/wildcards/remove-link` — リンク解除
#### `py/wfm.py`
- `wildcard_routes.setup_routes(app)` を追加
#### `templates/index.html`
- Promptタブ Col2 をタブ切り替え構造(`.wfm-prompt-center-tabnav` + `.wfm-prompt-center-pane`)に変更
- Promptタブ Col3 にワイルドカード支援パネル(`.wfm-wc-panel`)を新設
- ツールバー: `{|}`, `|`, `__`, `:`, `;`, `$`, `<lora:>`, `[]`, `{n$|}`, ファイルピッカーボタン
- プロンプトテキストエリア(`.wfm-wc-prompt-ta`)
- ファイル管理セクション(ファイル一覧 + インラインエディタ)
- Helpタブ: `wfm-help-prompt-8`、`wfm-help-prompt-9`、`wfm-help-settings-10` を追加
#### `static/js/prompt-tab.js`
- ワイルドカード API ヘルパー: `wcFetchFiles()`、`wcFetchContent()`、`wcSaveFile()`、`wcDeleteFile()`
- `wcInsertAtCursor(textarea, open, close)` — 選択テキストをラップ、または `open+close` を挿入してカーソルを `open` の末尾に移動
- `wcRenderFileList()`、`wcOpenEditor()`、`wcCloseEditor()`、`wcRefreshFiles()`、`wcUpdateFilePicker()`
- `initPromptTab()` に追加:
- 中央ペインのタブ切り替えハンドラ
- ワイルドカードツールバーボタン(`data-wc-open` / `data-wc-close` 属性で制御)
- Loraボタン: `<lora::1:LBW=;>` を挿入してカーソル位置調整
- `{n$|}` ボタン: `prompt()` で n を入力し `{n$|}` を挿入
- ファイルピッカー(ポップアップ型、クリックで `__filename__` 挿入)
- ファイルマネージャのイベントハンドラ(新規作成・保存・削除)
#### `static/js/settings-tab.js`
- Data Management セクションの直後に **Wildcard Integration** `<details>` セクションを追加
- セクション展開時に `GET /api/wfm/wildcards/link-status` を呼び出して動的 UI をレンダリング
- Impact Pack 未インストール: インストール誘導メッセージ + GitHub リンク
- インストール済み・リンクなし: Impact Pack パス表示 + 「リンクを作成」ボタン(移行ファイル名を表示)
- リンク済み: リンク先パス表示 + 「リンクを解除」ボタン
#### `static/js/app.js`
- `wfm-help-prompt-8`、`wfm-help-prompt-9`、`wfm-help-settings-10` の ID マッピングを追加
#### `static/js/i18n.js`
- `helpPrompt8`、`helpPrompt9`、`helpSettings10` を EN/JA/ZH に追加
#### `static/css/main.css`
- `.wfm-prompt-center-tabnav`、`.wfm-prompt-center-tab`、`.wfm-prompt-center-pane` — 中央ペインタブ UI
- `.wfm-wc-panel`、`.wfm-wc-toolbar`、`.wfm-wc-btn`、`.wfm-wc-prompt-ta` — ワイルドカードパネル
- `.wfm-wc-file-picker-wrap`、`.wfm-wc-file-picker`、`.wfm-wc-picker-item` — ファイルピッカー
- `.wfm-wc-files-section`、`.wfm-wc-file-list`、`.wfm-wc-file-item`、`.wfm-wc-editor` — ファイルマネージャ
### ワイルドカードサブディレクトリ対応
#### `py/services/wildcard_service.py`
- `list_wildcards()` — `iterdir()` → `rglob("*")` に変更して再帰スキャン。レスポンスに `dir`(サブディレクトリパス、ルートは空文字)と `wc_name`(拡張子なし相対パス、例: `color/animals`)を追加
- `_safe_path()` — `/` 区切りの相対パスを受け付けるよう変更。各パスコンポーネントを個別に検証してパストラバーサルをブロック
- `save_file()` — `path.parent.mkdir(parents=True, exist_ok=True)` を追加してサブディレクトリを自動作成。レスポンスに `dir`、`wc_name` を追加
#### `static/js/prompt-tab.js`
- `wcRenderFileList()` — ファイルをディレクトリごとにグループ化して表示(ルートが先、サブディレクトリはヘッダ付き)。サブディレクトリ内ファイルは `wfm-wc-file-item--sub` クラスでインデント。挿入テキストを `__f.name__` → `__f.wc_name__`(例: `__color/animals__`)に変更
- `wcUpdateFilePicker()` — 同様にディレクトリグループ表示。挿入テキストを `wc_name` ベースに変更
- Editor Save ハンドラ — `name` のバリデーションを `folder/name` 形式に対応。`\` を `/` に正規化して各コンポーネントを個別検証。`filename` を `name.ext`(例: `color/animals.txt`)として構築
#### `templates/index.html`
- エディタ名入力の placeholder を `"name or folder/name"` に更新
#### `static/css/main.css`
- `.wfm-wc-dir-header` — ファイル一覧のディレクトリ区切りヘッダ
- `.wfm-wc-file-item--sub` — サブディレクトリファイルの左インデント
- `.wfm-wc-picker-dir` — ファイルピッカーのディレクトリ区切りラベル
- `.wfm-wc-picker-item--sub` — ファイルピッカー内サブディレクトリファイルのインデント
- Safari 向け `-webkit-user-select` を追加
### GenerateUI: ImpactWildcardEncode バリデーションエラー修正
#### `static/js/comfyui-workflow.js`
- `convertUiToApi()` — COMBO 型ウィジェット値を `/object_info` の選択肢リストと照合し、値が存在しない場合は先頭の有効な選択肢にフォールバック。Impact Pack の `ImpactWildcardEncode` ノードが `"Select Wildcard 🟢 Full Cache"` 等の動的プレースホルダー値を持つ場合でも `"Prompt outputs failed validation"` エラーが発生しなくなる
- `analyzeWorkflow()` — `ImpactWildcardEncode` / `ImpactWildcardProcessor` を `prompt_nodes` として認識(KSampler の positive/negative 接続からロールを判定)。テキストフィールドキーを `text` ではなく `wildcard_text` として記録
#### `static/js/comfyui-editor.js`
- `renderPromptTab()` — select の option に `data-text-key` 属性を追加(`CLIPTextEncode` = `"text"`、`ImpactWildcardEncode` = `"wildcard_text"`)
- Apply ハンドラ — `analysis.prompt_nodes` から `textKey` を取得して `inputs[textKey]` に書き込むよう変更(従来は `inputs.text` 固定)
- `syncToWorkflow()` — 選択中の option の `data-text-key` を読んで正しいフィールドに書き込むよう変更。生成前の同期(`_coreGenerate` 直前の `comfyEditor.syncToWorkflow()` 経由)でも正しく反映される
---
## 2026-04-28: v0.3.2 生成UIタブ — Checkpoint Batch・Seed UIレイアウト修正
### 概要
- 生成UIタブの右パネルに **Checkpoint Batch** 機能を追加
- Seed行のレイアウトを2段構成に変更(seed値とモード選択が見切れる問題を解消)
### 変更内容
#### `templates/index.html` — UI要素追加・修正
**Checkpoint Batch パネル追加:**
- 右パネル(Seed行の下・結果表示の上)に `wfm-ckpt-batch-panel` を追加
- チェックボックス ON/OFF でバッチ設定欄を展開/折りたたみ
- Include Folders テキスト入力 — カンマ区切りでサブフォルダ名を指定。空欄でcheckpointフォルダ内の全モデルを対象
- Exclude Folders テキスト入力 — カンマ区切りで除外するサブフォルダ名を指定
- 対象モデル数プレビュー表示 (`wfm-ckpt-batch-info`)
- バッチ進捗エリア — 現在のモデル名、インデックス/総数、アンバー色プログレスバー (`wfm-ckpt-batch-bar`)
**Seed レイアウト修正:**
- 横並び1行(`flex-direction: row`)→ 縦2行(`flex-direction: column`)に変更
- 1行目: `Seed:` ラベル + 数値入力(`flex:1` で横幅いっぱい)
- 2行目: モード選択セレクト(`width:100%`)
**ヘルプタブ:**
- GenerateUI タブ説明に gen-11(Checkpoint Batch の説明)を追加
- gen-8(Seed control)に2段レイアウト化の補足を追加
#### `static/js/generate-tab.js` — バッチロジック追加・生成処理リファクタリング
**新規追加:**
- `_ckptBatch` — `{ aborted: false }` バッチ中断フラグ
- `_parseFolderList(str)` — カンマ区切り文字列をトリム・小文字化して配列に変換
- `_getModelFolder(modelPath)` — パスの最初の `/` より前を抽出してフォルダ名を取得。`\\` を `/` に正規化、ルート直下は `""` を返す
- `_filterCheckpoints(checkpoints, includeStr, excludeStr)` — include/exclude フィルタを適用したチェックポイントリストを返す。大文字小文字を区別しない
- `_updateBatchInfo()` — チェックポイントリストのフィルタ済み件数を `wfm-ckpt-batch-info` に即時反映
- `initCheckpointBatch()` — チェックボックス・include/exclude 入力のイベントリスナーを登録
- `_coreGenerate(silent)` — 1回の生成コア処理(`silent=true` のとき完了トーストを省略)。エラー時はスローする
- `_runBatchGenerate()` — チェックポイントリストをループし `_coreGenerate` を順番に呼び出す。各反復でワークフロー内の全checkpointノードの `ckpt_name` を書き換える。エラーは件数カウントして継続。完了後に結果サマリをトースト表示
**変更:**
- `handleGenerate()` — バッチが有効な場合は `_runBatchGenerate()` を、無効の場合は `_coreGenerate(false)` を呼び出すよう分岐。ボタン管理(disabled / Stop 表示)を共通 try/finally で処理
- interrupt ボタンのハンドラ — `_ckptBatch.aborted = true` を設定してから `comfyUI.interrupt()` を呼び出し、単発生成とバッチの両方を停止できるように
- Refresh Models ボタンのハンドラ — モデルリスト再取得後に `_updateBatchInfo()` を呼び出してバッチ件数を更新
- `initGenerateTab()` — 末尾で `initCheckpointBatch()` を呼び出し。初期接続成功時に `_updateBatchInfo()` を呼び出し
---
## 2026-04-27: v0.3.1 ギャラリー拡張(フォルダ/ファイル操作・ワークフロー保存)
### 概要
- ギャラリータブにフォルダ作成・削除機能を追加
- ギャラリータブにファイル削除・移動機能を追加(単体・複数選択対応)
- 操作後もフォルダツリーの展開状態を維持するよう改善
- 生成UIタブで生成した画像のワークフローをギャラリーメタデータに自動保存
- MetadataタブのワークフローJSON表示をComfyUI生成画像全般(`prompt`キー)に対応
### 変更内容
#### `py/services/gallery_metadata.py` — メタデータ操作追加
- `delete(image_path)` 追加 — 画像削除時にメタデータエントリを除去
- `rename_path(old_path, new_path)` 追加 — ファイル移動後のパスキー付け替え
- `allowed` セットに `"workflow"` を追加 — 生成UIからのワークフロー保存を受け付けるように
#### `py/services/gallery_service.py` — ファイル・フォルダ操作追加
- `create_folder(parent_path, name)` — 無効文字チェック付きでサブフォルダを作成。作成後にフォルダキャッシュを無効化
- `delete_folder(folder_path)` — `shutil.rmtree` で再帰削除。ルートフォルダは保護
- `delete_images(paths)` — 複数画像ファイルを削除。各ファイルのメタデータ削除・キャッシュ無効化も実行
- `move_images(paths, dest_folder)` — 複数画像を別フォルダへ移動。ファイル名衝突時は連番サフィックス付与。メタデータのパスキーを `rename_path` で引き継ぎ
- `extract_workflow_from_metadata` 修正 — PNG埋め込みの参照キーを `workflow` → `workflow / prompt` の順に拡張。どちらも存在しない場合は `gallery_metadata.json` の `workflow` フィールドへフォールバック
#### `py/routes/gallery_routes.py` — エンドポイント追加
- `POST /wfm/gallery/folder` — フォルダ作成
- `DELETE /wfm/gallery/folder` — フォルダ削除
- `POST /wfm/gallery/images/delete` — 画像削除(単体・複数)
- `POST /wfm/gallery/images/move` — 画像移動(単体・複数)
#### `static/js/gallery-tab.js` — フロントエンド拡張
**API定数追加:**
- `folderCreate`, `folderDelete`, `imagesDelete`, `imagesMove`
**state追加:**
- `folderTree` — ツリー全体データを保持(移動先フォルダ一覧の生成に使用)
**フォルダツリー展開状態の保持:**
- `_getExpandedPaths()` — 再構築前に展開済みフォルダのパスを `Set` で収集
- `_restoreTreeState(expandedPaths, selectedPath)` — 再構築後に展開状態・選択ハイライトを復元。階層の浅い順に展開することで子ノードのDOM存在を保証
- `renderTreeNode` — 各アイテムに `data-path` 属性を付与(復元の識別子)
- `loadFolderTree` — 初回ロード判定(`isFirstLoad`)を追加。2回目以降は `_restoreTreeState` を呼び出して展開状態を維持
**フォルダ操作:**
- `createFolder()` — `prompt()` でフォルダ名入力後、API呼び出し・ツリー再読み込み
- `deleteFolder()` — 確認ダイアログ後、再帰削除・currentFolder リセット・ツリー再読み込み
- フォルダラベルクリック時に Delete Folder ボタンの `disabled` 状態を更新(ルート選択時は削除不可)
**ファイル削除・移動:**
- `performDeleteImages(paths)` — 削除後に `state.images` / `state.selectedImages` を即時更新。詳細パネルの選択中画像が削除された場合はパネルをリセット
- `performMoveImages(paths, dest)` — 移動後に同様に state を即時更新
- `flattenFolderTree(node)` — ツリーデータをフラットリストに変換(移動先選択に使用)
- `openMoveModal(paths)` — 移動先フォルダ選択モーダルを動的生成。現在フォルダを除く全フォルダをドロップダウン表示
- 詳細パネルの Move To... / Delete ボタンを画像選択時に有効化
#### `templates/index.html` — UI要素追加
- フォルダツリーヘッダーに「+ New」「Del」ボタン(`wfm-gallery-tree-actions`)
- 詳細パネル Info タブに「Move To...」「Delete」ボタン(`wfm-gallery-file-actions`)
- 一括バーに「Move To...」「Delete」ボタン
- ヘルプ項目を10項目から11項目に更新
#### `static/css/gallery-tab.css` — スタイル追加
- `.wfm-gallery-tree-header` — `justify-content: space-between` でタイトルとボタンを両端配置
- `.wfm-gallery-tree-actions` — フォルダ操作ボタンのフレックスコンテナ
- `.wfm-gallery-file-actions` — 詳細パネルのファイル操作ボタン
- `.wfm-gallery-move-modal` / `.wfm-gallery-move-modal-title` / `.wfm-gallery-move-dest-sel` / `.wfm-gallery-move-modal-footer` — 移動先選択モーダル
#### `static/js/generate-tab.js` — ワークフロー自動保存
- `_outputDir` モジュール変数 — outputパスをキャッシュ
- `_fetchOutputDir()` — `/api/wfm/settings/output-dir` からoutputパスを取得
- `saveGeneratedImagesMeta(images, workflow)` — 生成完了後、各画像のフルパスを組み立てて `/wfm/gallery/image/meta` にワークフローを保存。`type !== "output"` の一時ファイルはスキップ
- `initGenerateTab` — 初期化時に `_fetchOutputDir()` を実行。`wfm-output-dir-changed` イベントで設定変更時にパスを同期
- `handleGenerate` — 生成成功後に `saveGeneratedImagesMeta` を非同期呼び出し
### 技術的な判断
- **ツリー展開状態の復元**: `innerHTML = ""` による完全再構築を維持しつつ、展開パスを `Set` で保存して復元する方式を採用。差分DOMパッチより実装が単純で、フォルダ追加・削除後のツリー構造変化にも対応できる
- **PNG `prompt` キーの参照**: ComfyUIのSaveImageノードはデフォルトでPNGに `prompt` キー(API形式)のみを埋め込む。`workflow` キー(UI形式)が埋め込まれるのは特定の操作時のみ。`prompt` を参照することで既存の全ComfyUI生成画像に対応
- **フォールバック優先順位**: PNG埋め込み(`workflow` > `prompt`)> `gallery_metadata.json` の順とし、ComfyUI本体が埋め込んだデータを最優先に
---
## 2026-04-18: v0.3.0 モデルグループ表示改善(全タイプ横断・サイドパネル修正)
### 概要
- モデルタブのグループフィルタードロップダウンを全タイプ横断表示に改善
- ComfyUIサイドパネルのModel Groupsビューを v0.2.9 のタイプ別形式に対応させバグ修正
### 変更内容
#### `static/js/models-tab.js` — グループフィルター全タイプ横断表示
- `state.allModelGroups` 追加 — 全タイプのグループを `{ type: { groupName: [models] } }` 形式で保持
- `fetchAllModelGroups()` 追加 — `type` パラメータなしで全タイプのグループをAPIから一括取得
- `renderGroupFilter()` 改善 — ドロップダウンオプションを `[Checkpoint] GroupName` / `[LoRA] GroupName` 形式で全タイプ分表示。値は `type::groupName` としてエンコード
- グループフィルター `change` ハンドラ更新 — 選択したグループが現在と異なるタイプの場合、モデルタイプタブを自動切り替えしてモデルを再ロード
- `saveModelGroups()` — 保存時に `state.allModelGroups[type]` も更新
- `loadModelsForCurrentType()` — ロード時に `state.allModelGroups[type]` を同期
- `loadMetadataAndModels()` — 初回ロード時に `fetchAllModelGroups()` で全タイプのグループを一括取得
#### `web/comfyui/node_sets_menu.js` — サイドパネルのModel Groupsビュー修正
- `renderModelGroups()` を新タイプ別形式 `{ type: { groupName: [...] } }` に対応
- 旧: `groups[groupName]` が配列を期待 → `TypeError: .filter is not a function` でクラッシュ
- 新: `flatGroups = [{modelType, groupName, members}]` にフラット展開してから描画
- グループヘッダーに `[Checkpoint]` / `[LoRA]` 等のタイプラベルを追加(`wfm-nlp-model-type-badge`クラス)
- `typeOf[name] || modelType` — モデルリスト未取得時のフォールバックをグループのモデルタイプに変更
---
## 2026-04-18: v0.2.9 モデル有効/無効・複数選択・一括削除
### 概要
- モデルファイルの拡張子リネーム(`.disabled`サフィックス)でComfyUIへの表示を制御
- モデル種別ごとのグループスコープ(checkpointグループはcheckpointタブにのみ表示)
- 複数選択モードと一括グループ操作・ファイル削除機能を追加
### 変更内容
#### `py/services/models_service.py` — 有効/無効・削除ロジック追加
- `_DISABLED_SUFFIX = ".disabled"` 定数追加
- `_SIDECAR_EXTENSIONS` — 削除対象の付属ファイル拡張子リスト(`.preview.png` 〜 `.webp` + `.json`, `.civitai.info`, `.info`)
- `find_model_file(model_type, model_name)` — 有効・無効両状態を検索、`(Path, is_enabled)` を返す
- `enable_model(model_type, model_name)` — `.disabled` → 元の拡張子にリネーム
- `disable_model(model_type, model_name)` — 元の拡張子 → `.disabled` にリネーム(`path.name` ベースでサブディレクトリ二重化バグを回避)
- `scan_disabled_models(model_type)` — `rglob("*.disabled")` でスキャン、正規化名リストを返す
- `get_model_groups(model_type=None)` — `model_type` パラメータ追加。旧フラット形式 `{ "groupName": [...] }` を `_groups_legacy` に退避し、`{ "_groups": { "checkpoint": {...} } }` 形式へ自動マイグレーション
- `save_model_groups(groups, model_type)` — `model_type` パラメータ追加、種別ごとに保存
- `delete_model(model_type, model_name)` — モデルファイル(有効・無効どちらも対応)を削除。`path.name` から `.disabled` を除去してstemを正しく計算し、全サイドカーファイルを削除。メタデータエントリも削除
#### `py/routes/models_routes.py` — エンドポイント追加・変更
**新エンドポイント:**
- `GET /api/wfm/models/disabled?type=` — 指定タイプの無効モデル名リストを返す
- `POST /api/wfm/models/toggle` — `{ model_type, model_name, enabled }` でモデル1件の有効/無効を切り替え
- `POST /api/wfm/models/group-toggle` — `{ model_type, group_name, enabled }` でグループ内全モデルを一括切り替え
- `POST /api/wfm/models/delete` — `{ model_type, model_names: [] }` で複数モデルを一括削除
**変更:**
- `GET /api/wfm/models/groups` — `?type=` クエリパラメータ追加
- `POST /api/wfm/models/groups` — ボディを `{ "model_type": ..., "groups": {...} }` 形式に変更
#### `static/js/comfyui-client.js` — Hypernetworkバグ修正
- `_fetchModelList` で新ComfyUI APIフォーマット `["COMBO", {"values":[...]}]` に対応
- `Array.isArray(first)` チェックを追加、文字列 `"COMBO"` をスプレッドしてしまう問題を解消
#### `static/js/models-tab.js` — フロントエンド全体
**state追加:**
- `disabledModels: {}` — タイプ別の無効モデルSet
- `statusFilter: "all"` — "all" / "enabled" / "disabled"
- `selectMode: false`, `selectedModels: new Set()` — 複数選択状態
**新関数:**
- `isModelDisabled()`, `fetchDisabledModels()`, `toggleModelEnable()`, `toggleGroupEnable()` — 有効/無効操作
- `toggleSelectMode()` — 選択モードのON/OFF切り替え、ボタンテキスト更新
- `toggleModelSelection(modelName)` — DOM直接更新(再レンダリングなし)
- `clearSelection()` — 選択リセット
- `renderBulkActionBar()` — グループ選択・追加/削除・新規作成・削除ボタンを描画
- `bulkAddToGroup()`, `bulkRemoveFromGroup()` — 一括グループ操作
- `bulkDeleteModels()` — `confirm()` で確認後 `/api/wfm/models/delete` を呼び出し、ローカルstateを即時更新
**各ビュー:**
- ThumbView / CardView: 無効オーバーレイ・⏸ボタン・selectMode時チェックボックスオーバーレイ追加
- TableView: チェックボックス列・`wfm-table-td-check` 追加
- `renderSideGroup()`: "全て有効化" / "全て無効化" ボタン追加
**タイプ切り替え:**
- `groupFilter`, `statusFilter`, `selectMode`, `selectedModels` をリセット
#### `templates/index.html` — UI要素追加
- `<select id="wfm-models-status-filter">` — All / Enabled / Disabled フィルター
- `<button id="wfm-models-select-btn">` — 選択モード切り替え
- `<div id="wfm-models-bulk-bar">` — 一括操作バー(初期非表示)
- `<li id="wfm-help-models-11">` — ヘルプ項目追加
#### `static/css/main.css` — スタイル追加
- `.wfm-model-disabled` / `.wfm-disabled-overlay` — 無効状態の見た目
- `.wfm-toggle-btn` / `.wfm-toggle-btn.wfm-toggle-disabled` — ⏸ボタン
- `.wfm-badge-disabled` — 無効バッジ
- `.wfm-select-check` / `.wfm-select-check.checked` — チェックボックス
- `.wfm-card-checked` — 選択済みカードのアウトライン
- `.wfm-bulk-bar` / `.wfm-bulk-count` / `.wfm-bulk-sep` / `.wfm-table-td-check` — 一括操作バー
#### `static/js/i18n.js` — 新キー追加(3言語)
- 有効/無効操作: `modelEnable`, `modelDisable`, `modelDisabled`, `modelStatusAll`, `modelStatusEnabled`, `modelStatusDisabled`, `modelGroupEnableAll`, `modelGroupDisableAll`, `modelToggleError`, `modelStatusWarning`
- 複数選択: `modelSelectMode`, `modelSelectExit`, `modelSelected`, `modelBulkAddGroup`, `modelBulkRemoveGroup`, `modelBulkCreateAdd`, `modelBulkAddDone`, `modelBulkRemoveDone`
- ファイル削除: `modelBulkDelete`, `modelBulkDeleteConfirm`, `modelBulkDeleteDone`, `modelBulkDeleteError`
- ヘルプ: `helpModels11`(複数選択・一括削除の説明)、`helpModels3` 更新(ステータスフィルター追記)
### 技術的な判断
- **拡張子リネーム方式**: ComfyUIはスキャン時に認識拡張子のみ列挙するため、`.disabled`サフィックスを付けるだけでモデルを隠せる。ファイル移動や専用DBを使わず最もシンプルな実装
- **DOM直接更新(選択モード)**: 選択トグルのたびに`renderModelGrid()`を呼ぶと大量再レンダリングが起きる。`querySelectorAll("[data-model-name]")`で対象要素を絞り`classList.toggle()`のみ実行
- **stemバグの根本原因**: `path.stem`は最後のサフィックスのみ除去する。`model.safetensors.disabled`の`stem`は`model.safetensors`になるため、`model.safetensors.png`を探してしまう。`.disabled`を先に除去してから`Path.stem`を計算する必要がある
---
## 2026-04-14: v0.2.8 データ保存先変更・エクスポート/インポート機能追加
### 概要
- プラグインデータの保存先を `custom_nodes/ComfyUI-Workflow-Studio/data/` から `ComfyUI/user/default/Workflow-Studio/` に変更
- `user/default/` が存在しない環境(Portable版・Dockerなど)では従来の `data/` にフォールバック
- 設定タブに「データ管理」セクションを追加(エクスポート/インポート機能)
### 変更内容
#### `py/config.py` — DATA_DIR 保存先変更
**`DATA_DIR` の解決ロジックを変更:**
- 変更前: `DATA_DIR = PLUGIN_DIR / "data"` (固定)
- 変更後: `user/default/` が存在すれば `DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"`、存在しなければ `PLUGIN_DIR / "data"` にフォールバック
- `_COMFYUI_ROOT` の定義位置を `DATA_DIR` の前に移動(`_COMFYUI_WORKFLOWS` と共用)
#### `py/routes/settings_routes.py` — エクスポート/インポートAPI追加
**新エンドポイント:**
- `GET /api/wfm/settings/export` — `settings.json`, `metadata.json`, `node_metadata.json`, `node_sets.json`, `prompts.json`, `model_metadata.json`, `gallery_metadata.json` の7ファイルを1つのJSONバンドルとして返す(`Content-Disposition: attachment` でDL)
- `POST /api/wfm/settings/import` — JSONバンドルを受け取り、含まれるファイルを個別に書き戻す。存在しないキーはスキップ
**新定数:**
- `_DATA_FILES` — エクスポート/インポート対象ファイル名のリスト
#### `static/js/settings-tab.js` — データ管理UIセクション追加
**Eagle連携セクションの直下に `<!-- Data Management -->` セクションを追加:**
- エクスポートボタン: クリックで `wfm-data-export.json` をダウンロード
- インポートボタン: `<label>` でファイル選択ダイアログを開く(`.json` 限定)
- ステータス表示: 成功・失敗をインラインで表示(成功: green / 失敗: red)
- 同一ファイルの再インポートができるよう、インポート後にファイル入力をリセット
#### `static/js/i18n.js` — 新キー追加(3言語)
**追加キー(英語・日本語・中国語):**
- `dataManagement`, `dataManagementHint`, `exportData`, `importData`
- `exportSuccess`, `exportError`, `importSuccess`, `importError`
#### `templates/index.html` / `static/js/app.js` / `static/js/i18n.js` — ヘルプタブ更新
- `wfm-help-settings-8` をデータ管理の説明に変更
- `wfm-help-settings-9`(アコーディオン説明)を追加(HTML・app.jsマッピング・i18n 3言語)
### 移行手順(既存ユーザー向け)
1. 設定タブ →「データ管理」→「エクスポート」で `wfm-data-export.json` を保存
2. ComfyUIを再起動(新パスで空の状態で起動)
3. 設定タブ →「データ管理」→「インポート」でエクスポートしたファイルを選択
4. ComfyUIを再起動して反映
または手動で `custom_nodes/ComfyUI-Workflow-Studio/data/` の内容を `ComfyUI/user/default/Workflow-Studio/` にコピーしてから再起動。
---
## 2026-04-06: v0.2.6 UI改善・バッジ保存修正・フォルダフィルター追加
### 概要
+114 -17
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.7-green)
![Version](https://img.shields.io/badge/version-0.3.8-green)
## Screenshots
@@ -13,17 +13,21 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|:---:|:---:|
| ![Workflow](docs/screenshot_workflow.png) | ![GenerateUI](docs/screenshot_generate.png) |
| Prompt Tab | Settings Tab |
| Prompt Tab | Metadata Tab |
|:---:|:---:|
| ![Prompt](docs/screenshot_prompt.png) | ![Settings](docs/screenshot_settings.png) |
| ![Prompt](docs/screenshot_prompt.png) | ![Metadata](docs/screenshot_metadata.png) |
| Settings Tab | Help & Support Tab |
|:---:|:---:|
| ![Settings](docs/screenshot_settings.png) | ![Help](docs/screenshot_help.png) |
| Nodes Tab | Models Tab |
|:---:|:---:|
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| Gallery Tab | Help & Support Tab |
| Gallery Tab | Nodes Tab |
|:---:|:---:|
| ![Gallery](docs/screenshot_gallery.png) | ![Help](docs/screenshot_help.png) |
| ![Gallery](docs/screenshot_gallery.png) | ![Nodes](docs/screenshot_nodes.png) |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|:---:|:---:|
@@ -50,19 +54,33 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Embedded workflow metadata** — workflow JSON is embedded in the PNG (tEXt chunk), compatible with ComfyUI's drag-and-drop import
- **Auto-import** — the captured workflow is automatically imported and appears in the Workflow tab
### GenerateUI Tab (v0.2.4)
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
### GenerateUI Tab (v0.3.5)
- **4-tab layout** — Input / Model / Settings / Feeder tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
- **Settings tab** — KSampler (top) and LatentImage (bottom) in the left column; Raw JSON on the right
- **Settings tab** — KSampler and Latent Image side by side at 50% width each; Raw JSON on the right
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
- **Seed control** — randomize, lock, or manually set seeds
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
- **Checkpoint Batch** — enable via checkbox in the right panel to sequentially generate with every checkpoint model; select checkpoints by folder from the dropdown (check a folder to select all its files, expand with ▶ for individual file selection, supports any subfolder depth); Filter input to search; All / None buttons for quick selection; **Pause/Resume** suspends processing between models; Stop aborts after the current generation; amber progress bar tracks per-model progress
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and more
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
### Feeder subtab (v0.3.5)
Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder)** custom node.
- **ImageFeeder node control** — select the target node from a dropdown auto-populated from the loaded workflow; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection) and Apply to the workflow
- **Image library** — 3-pane layout: folder tree (left) browsing `user/default/image-loop-data/`, image grid with checkbox selection (center), preview panel with resolution and file size (right)
- **Selection management** — check individual images; All / None buttons for the current folder; selected files are reflected in `selected_files` on Apply
- **Presets** — save the current directory + selection as a named preset; load or delete presets (server-side persistence via `image-feeder-presets.json`)
- **Continuous Run loop** — Run / Stop buttons below the presets; **After gen** mode controls index behavior after each generation:
- **Loop** — advance index and wrap back to 0 when all images are exhausted (runs indefinitely)
- **Increment** — advance index and auto-stop when all images are consumed
- **Fixed** — always use the same index
- **Index sync** — after each generation the node returns `next_index` via WebSocket (`image_loop_node_sync`); the Index field updates automatically
- **Seed** — Run loop uses the right-pane seed setting (Random / Fixed / Increment / Decrement); the node's own Seed field only affects random sort order
### Prompt Tab
- **3-column layout** — AI Assistant, Presets editor, and Preset Manager displayed side-by-side
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
- **Image attachment** — attach reference images for vision-capable models
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
@@ -71,6 +89,18 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
- **Clipboard copy** — copy positive/negative prompts individually (PP Copy / NP Copy)
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
- **Wildcard input toolbar** — one-click buttons to insert `{|}`, `{n$|}`, `__|__`, `<lora::1:LBW=;>` and other wildcard syntax; wraps selected text when applicable
- **Wildcard file manager** — create, view, and edit `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; click a filename in the file picker to insert `__filename__` at cursor
### Metadata Tab (v0.3.8)
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
- **File drop** — drop a ComfyUI-generated PNG / WebP or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2)
- **LoRA extraction** — lists all LoRA models with `strength_model / strength_clip` values
- **Prompt extraction** — lists prompts with POS / NEG badges when positive/negative can be determined; when distinction is not possible (e.g. `SamplerCustomAdvanced`, intermediate nodes, cross-level connections), prompts are shown without a badge as plain **Text**; click any entry to view the full text below
- **Prompt actions** — Copy to clipboard, **GenUI:P/N** (set GenerateUI positive/negative prompt), **Prompt:P/N** (set Prompt tab preset positive/negative)
- **Format support** — ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 subgraph workflows), SD WebUI, SD Forge, Fooocus
- **Format note** — supported formats and covered model types are always shown in the left column
### Settings Tab
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
@@ -82,18 +112,24 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **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
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling)
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
- **Language** — English / Japanese / Chinese
### Gallery Tab (v0.2.7)
### Gallery Tab (v0.3.1)
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch tag/group/favorite operations
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To... and Delete from the multi-select bar
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations (group, favorite, move, delete)
- **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
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
- **Output folder configurable** — set the scanned output folder from Settings tab
- **Performance** — with folder-level mtime cache (30s TTL) for fast incremental refresh
- **Performance** — folder-level mtime cache (30s TTL) for fast incremental refresh; tree expansion state preserved across folder operations
### Nodes Tab (v0.1.7)
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
@@ -113,9 +149,11 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
- **Group management** — create, rename, delete groups and assign/remove models
- **Group management** — create, rename, delete groups and assign/remove models; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab)
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
- **Enable / Disable models** — hide models from ComfyUI by renaming the file extension (`.disabled` suffix); toggle per card (⏸ button), per group (Enable All / Disable All), or filter by status (All / Enabled / Disabled)
- **Multi-select & bulk operations** — enter selection mode to check multiple models; bulk action bar supports add/remove from groups and permanent file deletion (model file + preview images + sidecar files such as `.json` / `.info`)
### Workflow Studio Library (ComfyUI Side Panel) (v0.2.5)
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
@@ -207,6 +245,63 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### v0.3.8
- **Metadata Tab: extended subgraph support** — added extraction for Flux.2 Dev fp8 (`SamplerCustomAdvanced`), Flux.2 Klein 4B Distilled (image edit), Ernie Image, Qwen-Image-Edit 2511 (`TextEncodeQwenImageEditPlus`), and WAN2.2 14B Animate (top-level CLIPTextEncode + subgraph KSampler)
- **Prompt type: Text (no badge)** — when positive/negative polarity cannot be determined (indirect sampler connections, cross-level links, `PrimitiveStringMultiline` etc.), prompts are listed without a POS/NEG badge; action buttons still work normally
- **Prompt extraction improvements** — `extractPromptsFromNodeSet` now resolves link sources from any STRING-output node (e.g. `ComfySwitchNode`, `PrimitiveStringMultiline`) without type restriction; `PrimitiveStringMultiline` search and final text-encoder fallback both extended to include subgraph nodes via `collectAllNodes`
- **i18n** — added `metaPromptText` key (EN: "Text" / JA: "テキスト" / ZH: "文本")
### v0.3.7
- **Metadata Tab: subgraph workflow support** — Flux.2 (Dev fp8 / Klein), Qwen-Image-Edit (/ 2511 / Layered), and Z-Image (Base / Turbo) official templates now correctly extract models and prompts; these use ComfyUI's `definitions.subgraphs` format with `UNETLoader`, `CLIPLoader`, and `VAELoader` inside the subgraph
- **Prompt extraction improvements** — 7-stage fallback chain: ImageMetadataPromptLoader → WFS_PromptText → top-level CLIPTextEncode+KSampler → `PrimitiveStringMultiline` (flux2-klein) → subgraph CLIPTextEncode+KSampler → PromptStyler → all CLIPTextEncode text
- **MarkdownNote model fallback** — `extractMarkdownNoteModels()` parses `**section** → - [model](url)` patterns from MarkdownNote nodes as a supplemental model source
- **Format note updated** — "Flux.2 / Qwen-Image / Z-Image subgraph workflows supported" (EN/JA/ZH)
### v0.3.6
- **Metadata Tab** — new tab (between Prompt and Gallery) that extracts model and prompt information from ComfyUI-generated PNG/WebP images and workflow JSON files; 3-column layout: drop zone with image preview (left), Checkpoint/VAE/Diffusion Model/Text Encoder lists (center), LoRA list with strength values + Prompt list with full-text viewer (right)
- **Prompt action buttons** — Copy, GenUI:P/N (set GenerateUI prompts), Prompt:P/N (set Prompt tab preset prompts) directly from the extracted prompt text
- **SDXL Prompt Styler support** — improved prompt extraction resolves `PromptStyler → CLIPTextEncode` link chains in both LiteGraph and API workflow formats
- **Format support** — ComfyUI (PNG/WebP/JSON), SD WebUI, SD Forge, Fooocus
### v0.3.5
- **Feeder subtab** — new subtab in GenerateUI for controlling [comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder) nodes; left pane: node selector + all ImageFeeder parameters + presets + Run/Stop controls; center pane: folder tree, image grid with checkbox selection, and preview panel
- **Run loop with After gen modes** — Loop (wrap and continue), Increment (advance and auto-stop), Fixed (constant index); index updates automatically via WebSocket `image_loop_node_sync` after each generation; seed comes from the right-pane seed setting
- **Workflow analysis improvements** — `analyzeWorkflow` now handles multi-hop CONDITIONING chains via BFS (up to 5 iterations), CLIPTextEncodeSDXL / CLIPTextEncodeSDXLRefiner, SDXLPromptStyler / SDXLPromptStylerAdvanced, KSamplerAdvanced (`noise_seed`), TextEncodeQwenImageEditPlus, PrimitiveStringMultiline, Power Lora Loader (rgthree), and `Checkpoint Loader` (WAS) with space in the class name
### v0.3.4
- **Wildcard support panel** — new right column in the Prompt tab with a one-click toolbar for inserting wildcard syntax (`{|}`, `{n$|}`, `__|__`, `:`, `;`, `$`, `<lora::1:LBW=;>`, `[]`, single/multi pick) and wrapping selected text; dedicated prompt textarea and wildcard file picker (click to insert `__filename__`)
- **Wildcard file manager** — create, view, edit, and delete `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; editor opens inline with filename input and save/cancel controls
- **Preset/Preset Manager tab switch** — the center pane of the Prompt tab is now a two-tab panel (Presets / Preset Manager) to reclaim horizontal space for the new wildcard panel
- **Wildcard Integration setting** — new accordion section in Settings tab; auto-detects ComfyUI-Impact-Pack; when installed, provides a button to create a directory junction (Windows) or symlink (other OS) from the WFS wildcard directory to Impact Pack's `wildcards/` directory; existing WFS files are migrated automatically; a Remove Link button reverses the operation
### v0.3.2
- **Checkpoint Batch** — new panel in the GenerateUI right column (below Seed, above results); enable via checkbox to auto-generate once per checkpoint model; filter included/excluded subfolders by comma-separated names (empty = all checkpoints); real-time preview of matched model count; amber progress bar shows current model name and index; Stop button aborts the loop after the current generation completes; failed models are counted and reported in the summary toast
- **Seed layout fix** — seed value input and mode selector (Random / Fixed / Increment / Decrement) now stacked vertically so neither is clipped in narrow panels
### v0.3.1
- **Folder operations** — create subfolders and delete folders (with all contents) directly from the folder tree header buttons
- **File operations** — move or delete individual images from the detail panel; bulk move and bulk delete from the multi-select bar
- **Tree state preserved** — folder expansion state is maintained after create/delete/move/refresh operations
- **Workflow viewer improved** — Metadata tab now reads both `prompt` (API format, the default for most ComfyUI-generated images) and `workflow` (UI format) keys from PNG metadata, covering virtually all ComfyUI output
- **Workflow auto-save** — after each generation in the Generate UI tab, the current workflow is automatically saved to gallery metadata for the generated images; shown in the Metadata tab even if PNG embedding is absent
### v0.3.0
- **Cross-type group filter** — the group filter dropdown in the Models tab now shows groups from all model types simultaneously, each prefixed with a type label (`[Checkpoint]`, `[LoRA]`, etc.); selecting a group automatically switches to the corresponding model type
- **Sidebar group display fixed** — the ComfyUI sidebar "Model Groups" view now correctly renders per-type groups (introduced in v0.2.9) with a type label on each group header; fixes a crash where `.filter is not a function` was thrown on the new nested group structure
### v0.2.9
- **Model Enable/Disable** — hide models from ComfyUI by renaming the file extension to `.disabled`; toggle per model (⏸ button on each card/row), per group (Enable All / Disable All buttons in the Group panel), or filter the grid by status (All / Enabled only / Disabled only). Changes take effect after ComfyUI refresh.
- **Per-type model groups** — groups are now scoped to each model type; checkpoint groups only appear in the Checkpoint tab, LoRA groups only in the LoRA tab, etc. Existing flat groups are automatically migrated.
- **Multi-select mode** — click the Select button in the toolbar to enter selection mode; check individual models across Thumbnail / Card / Table views
- **Bulk group operations** — with models selected, add or remove them from any group in one click, or create a new group and add them simultaneously
- **Bulk file delete** — permanently delete selected model files along with all associated preview images and sidecar files (`.json`, `.civitai.info`, `.info`); requires confirmation before deleting
- **Bug fix** — models in subdirectories could not be disabled due to a path-doubling error; fixed by computing the rename target from `path.name` instead of `model_name`
- **Bug fix** — Hypernetwork tab showed `C`, `O`, `M`, `B` as fake model items; caused by the new ComfyUI API format returning `"COMBO"` as the first element; fixed in `comfyui-client.js`
### v0.2.8
- **Data storage moved** — plugin data (settings, metadata, prompts, etc.) is now stored in `ComfyUI/user/default/Workflow-Studio/` instead of `custom_nodes/ComfyUI-Workflow-Studio/data/`; falls back to the old `data/` directory if `user/default/` does not exist
- **Data Management section** — new Settings tab section to export all plugin data to a single JSON file and import it back; useful for migration, backup, and reinstallation
### v0.2.7
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
@@ -330,7 +425,7 @@ ComfyUI-Workflow-Studio/
│ ├── settings_service.py # Settings persistence
│ └── png_extractor.py # PNG metadata extraction
├── templates/
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Metadata/Gallery/Nodes/Models/Settings/Help)
├── static/
│ ├── favicon.svg # Browser tab icon (W+S Wave)
│ ├── css/main.css # Styles
@@ -338,7 +433,9 @@ ComfyUI-Workflow-Studio/
│ ├── app.js # App initialization & routing
│ ├── workflow-tab.js # Workflow browser
│ ├── generate-tab.js # Generation UI
│ ├── feeder-tab.js # Feeder subtab (ImageFeeder node control + image library)
│ ├── prompt-tab.js # AI assistant & presets
│ ├── metadata-tab.js # Metadata extraction & display (PNG/WebP/JSON)
│ ├── settings-tab.js # Settings panel
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
│ ├── nodes-tab.js # Node browser & node sets
@@ -350,7 +447,7 @@ ComfyUI-Workflow-Studio/
├── web/comfyui/
│ ├── top_menu_extension.js # ComfyUI menu bar integration
│ └── node_sets_menu.js # Workflow Studio Library side panel
└── data/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
```
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+14 -4
View File
@@ -10,13 +10,22 @@ PLUGIN_DIR = Path(__file__).resolve().parent.parent
# Directory paths
TEMPLATES_DIR = PLUGIN_DIR / "templates"
STATIC_DIR = PLUGIN_DIR / "static"
DATA_DIR = PLUGIN_DIR / "data"
# Default workflows directory: ComfyUI's user/default/workflows
# PLUGIN_DIR is custom_nodes/ComfyUI-Workflow-Studio
# ComfyUI root is custom_nodes/../../ = two levels up
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
_COMFYUI_WORKFLOWS = _COMFYUI_ROOT / "user" / "default" / "workflows"
COMFYUI_ROOT = _COMFYUI_ROOT # exposed for external use
# Data directory: prefer ComfyUI's user/default/Workflow-Studio/, fallback to plugin data/
_COMFYUI_USER_DEFAULT = _COMFYUI_ROOT / "user" / "default"
if _COMFYUI_USER_DEFAULT.is_dir():
DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"
logger.info("Workflow Studio: Using user data dir: %s", DATA_DIR)
else:
DATA_DIR = PLUGIN_DIR / "data"
logger.info("Workflow Studio: user/default not found, using plugin data dir: %s", DATA_DIR)
# Default workflows directory: ComfyUI's user/default/workflows
_COMFYUI_WORKFLOWS = _COMFYUI_USER_DEFAULT / "workflows"
# Use ComfyUI workflows dir if it exists, otherwise fallback to plugin data dir
if _COMFYUI_WORKFLOWS.is_dir():
@@ -59,3 +68,4 @@ NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
PROMPTS_FILE = DATA_DIR / "prompts.json"
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
WILDCARD_DIR = DATA_DIR / "wildcard"
+63
View File
@@ -30,6 +30,11 @@ def setup_routes(app: web.Application):
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)
# フォルダ・ファイル操作
app.router.add_post("/wfm/gallery/folder", create_folder_route)
app.router.add_delete("/wfm/gallery/folder", delete_folder_route)
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
app.router.add_post("/wfm/gallery/images/move", move_images_route)
# ──────────────────────────────────────────────────────────────
@@ -217,3 +222,61 @@ async def list_group_images(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
images = _service.list_images_in_group(name)
return web.json_response({"images": images})
# ──────────────────────────────────────────────────────────────
# フォルダ・ファイル操作
# ──────────────────────────────────────────────────────────────
async def create_folder_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/folder - 新規フォルダ作成"""
try:
body = await request.json()
parent = body.get("parent", "")
name = body.get("name", "").strip()
if not parent or not name:
return web.json_response({"ok": False, "error": "parent and name required"}, status=400)
result = _service.create_folder(parent, name)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_folder_route(request: web.Request) -> web.Response:
"""DELETE /wfm/gallery/folder - フォルダ削除"""
try:
body = await request.json()
folder = body.get("path", "")
if not folder:
return web.json_response({"ok": False, "error": "path required"}, status=400)
result = _service.delete_folder(folder)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/delete - 画像削除(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
if not paths:
return web.json_response({"deleted": [], "errors": ["paths required"]}, status=400)
result = _service.delete_images(paths)
return web.json_response(result)
except Exception as e:
return web.json_response({"deleted": [], "errors": [str(e)]}, status=500)
async def move_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/move - 画像移動(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
dest = body.get("dest", "")
if not paths or not dest:
return web.json_response({"moved": [], "errors": ["paths and dest required"]}, status=400)
result = _service.move_images(paths, dest)
return web.json_response(result)
except Exception as e:
return web.json_response({"moved": [], "errors": [str(e)]}, status=500)
+148 -5
View File
@@ -27,6 +27,10 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
app.router.add_get("/api/wfm/models/disabled", handle_get_disabled)
app.router.add_post("/api/wfm/models/toggle", handle_toggle_model)
app.router.add_post("/api/wfm/models/group-toggle", handle_toggle_group)
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
# ── Model Metadata ─────────────────────────────────────────
@@ -61,9 +65,10 @@ async def handle_save_metadata(request: web.Request) -> web.Response:
async def handle_get_groups(request: web.Request) -> web.Response:
"""GET /api/wfm/models/groups"""
"""GET /api/wfm/models/groups?type=checkpoint"""
model_type = request.query.get("type", "")
try:
result = await asyncio.to_thread(_service.get_model_groups)
result = await asyncio.to_thread(_service.get_model_groups, model_type or None)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model groups: %s", e)
@@ -71,10 +76,17 @@ async def handle_get_groups(request: web.Request) -> web.Response:
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/models/groups"""
"""POST /api/wfm/models/groups
Body: { "model_type": "checkpoint", "groups": { "groupName": [...] } }
"""
try:
groups = await request.json()
result = await asyncio.to_thread(_service.save_model_groups, groups)
body = await request.json()
model_type = body.get("model_type", "")
groups = body.get("groups", {})
if not model_type:
return web.json_response({"error": "model_type required"}, status=400)
result = await asyncio.to_thread(_service.save_model_groups, groups, model_type)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving model groups: %s", e)
@@ -386,3 +398,134 @@ async def handle_get_filepath(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error resolving model filepath: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Model Enable / Disable ────────────────────────────────
async def handle_get_disabled(request: web.Request) -> web.Response:
"""GET /api/wfm/models/disabled?type=checkpoint
Returns list of disabled model names (normalized, without .disabled suffix).
"""
model_type = request.query.get("type", "")
if not model_type:
return web.json_response({"error": "type required"}, status=400)
try:
result = await asyncio.to_thread(_service.scan_disabled_models, model_type)
return web.json_response(result)
except Exception as e:
logger.error("Error scanning disabled models: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_model(request: web.Request) -> web.Response:
"""POST /api/wfm/models/toggle
Body: { "model_type": "checkpoint", "model_name": "v1-5.safetensors", "enabled": false }
Renames the model file to add/remove .disabled suffix.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_name = body.get("model_name", "")
enabled = body.get("enabled", True)
if not model_type or not model_name:
return web.json_response({"error": "model_type and model_name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
return web.json_response({"status": "ok", "enabled": enabled})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except OSError as e:
logger.error("OS error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
except Exception as e:
logger.error("Error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_group(request: web.Request) -> web.Response:
"""POST /api/wfm/models/group-toggle
Body: { "model_type": "checkpoint", "group_name": "MyGroup", "enabled": true }
Enables or disables all models belonging to the specified group.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
group_name = body.get("group_name", "")
enabled = body.get("enabled", True)
if not model_type or not group_name:
return web.json_response({"error": "model_type and group_name required"}, status=400)
groups = await asyncio.to_thread(_service.get_model_groups, model_type)
members = groups.get(group_name, [])
ok_list = []
error_list = []
for model_name in members:
if ".." in model_name:
continue
try:
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
ok_list.append(model_name)
except Exception as e:
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({
"status": "ok",
"enabled": enabled,
"ok": ok_list,
"errors": error_list,
})
except Exception as e:
logger.error("Error toggling group: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_models(request: web.Request) -> web.Response:
"""POST /api/wfm/models/delete
Body: { "model_type": "checkpoint", "model_names": ["a.safetensors", "b.safetensors"] }
Deletes each model file and associated preview/sidecar files, then removes metadata.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_names = body.get("model_names", [])
if not model_type or not isinstance(model_names, list) or not model_names:
return web.json_response({"error": "model_type and model_names[] required"}, status=400)
ok_list = []
error_list = []
for model_name in model_names:
if ".." in str(model_name):
error_list.append({"model": model_name, "error": "Invalid model name"})
continue
try:
result = await asyncio.to_thread(_service.delete_model, model_type, model_name)
ok_list.append({"model": model_name, "deleted": result["deleted"]})
logger.info("Deleted model: %s (%s files)", model_name, len(result["deleted"]))
except FileNotFoundError as e:
error_list.append({"model": model_name, "error": str(e)})
except Exception as e:
logger.error("Error deleting model %s: %s", model_name, e)
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({"status": "ok", "ok": ok_list, "errors": error_list})
except Exception as e:
logger.error("Error in delete_models: %s", e)
return web.json_response({"error": str(e)}, status=500)
+84 -1
View File
@@ -1,18 +1,30 @@
"""Settings API routes."""
import asyncio
import json
import logging
from pathlib import Path
from aiohttp import web
from ..services.settings_service import SettingsService
from ..config import DEFAULT_WORKFLOWS_DIR
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
logger = logging.getLogger(__name__)
_service = SettingsService()
# Data files included in export/import
_DATA_FILES = [
"settings.json",
"metadata.json",
"node_metadata.json",
"node_sets.json",
"prompts.json",
"model_metadata.json",
"gallery_metadata.json",
]
def setup_routes(app: web.Application):
"""Register settings API routes."""
@@ -22,6 +34,8 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
app.router.add_get("/api/wfm/settings/export", handle_export)
app.router.add_post("/api/wfm/settings/import", handle_import)
async def handle_get(request: web.Request) -> web.Response:
@@ -151,3 +165,72 @@ async def handle_set_output_dir(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error setting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _load_data_file(filename: str):
"""Load a single data file, return empty dict/list if missing."""
path = DATA_DIR / filename
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _build_export_bundle() -> dict:
"""Collect all data files into a single export bundle."""
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
for filename in _DATA_FILES:
data = _load_data_file(filename)
if data is not None:
bundle[filename] = data
return bundle
async def handle_export(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
try:
bundle = await asyncio.to_thread(_build_export_bundle)
body = json.dumps(bundle, ensure_ascii=False, indent=2)
return web.Response(
body=body.encode("utf-8"),
content_type="application/json",
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
)
except Exception as e:
logger.error("Error exporting data: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _apply_import_bundle(bundle: dict) -> dict:
"""Write imported bundle back to individual data files. Returns summary."""
summary = {"imported": [], "skipped": []}
DATA_DIR.mkdir(parents=True, exist_ok=True)
for filename in _DATA_FILES:
if filename not in bundle:
summary["skipped"].append(filename)
continue
try:
path = DATA_DIR / filename
with open(path, "w", encoding="utf-8") as f:
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
summary["imported"].append(filename)
except Exception as e:
logger.error("Import failed for %s: %s", filename, e)
summary["skipped"].append(filename)
return summary
async def handle_import(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
try:
bundle = await request.json()
if not isinstance(bundle, dict):
return web.json_response({"error": "Invalid bundle format"}, status=400)
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
return web.json_response({"status": "ok", **summary})
except Exception as e:
logger.error("Error importing data: %s", e)
return web.json_response({"error": str(e)}, status=500)
+98
View File
@@ -0,0 +1,98 @@
"""Wildcard file management API routes."""
import asyncio
import logging
from aiohttp import web
from ..config import DATA_DIR, COMFYUI_ROOT
from ..services.wildcard_service import WildcardService
logger = logging.getLogger(__name__)
_service = WildcardService(DATA_DIR / "wildcard")
def setup_routes(app: web.Application):
app.router.add_get("/api/wfm/wildcards", handle_list)
app.router.add_get("/api/wfm/wildcards/content", handle_get_content)
app.router.add_post("/api/wfm/wildcards/save", handle_save)
app.router.add_post("/api/wfm/wildcards/delete", handle_delete)
app.router.add_get("/api/wfm/wildcards/link-status", handle_link_status)
app.router.add_post("/api/wfm/wildcards/create-link", handle_create_link)
app.router.add_post("/api/wfm/wildcards/remove-link", handle_remove_link)
async def handle_list(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.list_wildcards)
return web.json_response(result)
except Exception as e:
logger.error("Error listing wildcards: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_content(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
try:
content = await asyncio.to_thread(_service.get_content, filename)
return web.json_response({"content": content})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except Exception as e:
logger.error("Error getting wildcard content: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
content = body.get("content", "")
result = await asyncio.to_thread(_service.save_file, filename, content)
return web.json_response({"status": "ok", "file": result})
except Exception as e:
logger.error("Error saving wildcard: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
await asyncio.to_thread(_service.delete_file, filename)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error("Error deleting wildcard: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_link_status(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.get_link_status, COMFYUI_ROOT)
return web.json_response(result)
except Exception as e:
logger.error("Error getting wildcard link status: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_create_link(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.create_link, COMFYUI_ROOT)
return web.json_response({"status": "ok", **result})
except RuntimeError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error creating wildcard link: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_remove_link(request: web.Request) -> web.Response:
try:
await asyncio.to_thread(_service.remove_link)
return web.json_response({"status": "ok"})
except RuntimeError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error removing wildcard link: %s", e)
return web.json_response({"error": str(e)}, status=500)
+18 -1
View File
@@ -64,7 +64,7 @@ class GalleryMetadataStore:
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups"}
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
@@ -123,3 +123,20 @@ class GalleryMetadataStore:
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
def delete(self, image_path: str) -> bool:
"""画像のメタデータエントリを削除する"""
key = self._normalize_path(image_path)
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
def rename_path(self, old_path: str, new_path: str) -> bool:
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
old_key = self._normalize_path(old_path)
new_key = self._normalize_path(new_path)
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
+126 -11
View File
@@ -350,20 +350,35 @@ class GalleryService:
return result
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""PNG埋め込みメタデータからComfyUIワークフローを抽出"""
"""ワークフローを抽出する。
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
"""
path = Path(image_path).resolve()
if path.suffix.lower() != ".png":
return None
if not self._check_path_allowed(path):
return None
embedded = self._read_png_metadata(path)
workflow_str = embedded.get("workflow") or embedded.get("Workflow")
if workflow_str:
try:
return json.loads(workflow_str)
except json.JSONDecodeError:
pass
return None
if path.suffix.lower() == ".png":
embedded = self._read_png_metadata(path)
# 1. workflow キー (ComfyUI UI形式)
for key in ("workflow", "Workflow"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 2. prompt キー (ComfyUI API形式 — 大多数の生成画像)
for key in ("prompt", "Prompt"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 3. gallery_metadata.json に保存されたworkflow
saved = self.metadata_store.get(str(path))
return saved.get("workflow") or None
# ──────────────────────────────────────────────────────────────
# メタデータ保存
@@ -414,6 +429,106 @@ class GalleryService:
# 画像配信
# ──────────────────────────────────────────────────────────────
def create_folder(self, parent_path: str, name: str) -> dict:
"""選択フォルダ内に新しいサブフォルダを作成する"""
parent = Path(parent_path).resolve()
if not parent.is_dir():
return {"ok": False, "error": "Parent directory not found"}
if not self._check_path_allowed(parent):
return {"ok": False, "error": "Access denied"}
name = name.strip()
invalid_chars = set(r'\/:*?"<>|')
if not name or any(c in invalid_chars for c in name):
return {"ok": False, "error": "Invalid folder name"}
new_folder = parent / name
if new_folder.exists():
return {"ok": False, "error": "Folder already exists"}
try:
new_folder.mkdir()
self._folder_cache.invalidate(parent)
return {"ok": True, "path": str(new_folder).replace("\\", "/")}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_folder(self, folder_path: str) -> dict:
"""フォルダを再帰的に削除する"""
import shutil
folder = Path(folder_path).resolve()
if not folder.is_dir():
return {"ok": False, "error": "Directory not found"}
if not self._check_path_allowed(folder):
return {"ok": False, "error": "Access denied"}
if self._allowed_root and folder == self._allowed_root:
return {"ok": False, "error": "Cannot delete root folder"}
try:
parent = folder.parent
shutil.rmtree(folder)
self._folder_cache.invalidate(parent)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_images(self, paths: list) -> dict:
"""画像ファイルを削除する(複数対応)"""
deleted = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
try:
p.unlink()
self.metadata_store.delete(str(p))
self._folder_cache.invalidate(p.parent)
deleted.append(str(p).replace("\\", "/"))
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"deleted": deleted, "errors": errors}
def move_images(self, paths: list, dest_folder: str) -> dict:
"""画像ファイルを別フォルダへ移動する(複数対応)"""
dest = Path(dest_folder).resolve()
if not dest.is_dir():
return {"ok": False, "error": "Destination folder not found", "moved": [], "errors": []}
if not self._check_path_allowed(dest):
return {"ok": False, "error": "Access denied", "moved": [], "errors": []}
moved = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
dest_path = dest / p.name
counter = 1
while dest_path.exists():
dest_path = dest / f"{p.stem}_{counter}{p.suffix}"
counter += 1
try:
p.rename(dest_path)
old_str = str(p).replace("\\", "/")
new_str = str(dest_path).replace("\\", "/")
self.metadata_store.rename_path(old_str, new_str)
self._folder_cache.invalidate(p.parent)
self._folder_cache.invalidate(dest)
moved.append({"from": old_str, "to": new_str, "filename": dest_path.name})
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"moved": moved, "errors": errors}
def serve_image(self, image_path: str):
"""画像のPathオブジェクトを返す(ルートで使用)"""
p = Path(image_path).resolve()
+130 -4
View File
@@ -13,6 +13,15 @@ logger = logging.getLogger(__name__)
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp"]
# All sidecar extensions to delete when a model is deleted
_SIDECAR_EXTENSIONS = [
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp",
".json", ".civitai.info", ".info",
]
_DISABLED_SUFFIX = ".disabled"
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
@@ -100,16 +109,88 @@ class ModelsService:
self._save_metadata(data)
return entry
def get_model_groups(self):
def get_model_groups(self, model_type=None):
data = self._load_metadata()
return data.get("_groups", {})
raw = data.get("_groups", {})
def save_model_groups(self, groups):
# Migrate old flat format { "groupName": [...] } → per-type { "checkpoint": {...} }
if raw and not any(k in MODEL_TYPE_FOLDER_KEYS for k in raw):
logger.info("Migrating groups to per-type format (old data moved to _groups_legacy)")
data["_groups_legacy"] = raw
data["_groups"] = {}
self._save_metadata(data)
raw = {}
if model_type:
return raw.get(model_type, {})
return raw
def save_model_groups(self, groups, model_type):
data = self._load_metadata()
data["_groups"] = groups
if not isinstance(data.get("_groups"), dict):
data["_groups"] = {}
data["_groups"][model_type] = groups
self._save_metadata(data)
return groups
def find_model_file(self, model_type, model_name):
"""Find a model file, checking both enabled and disabled states.
Returns (Path, is_enabled) or (None, None) if not found.
"""
dirs = _get_model_dirs(model_type)
for d in dirs:
enabled = d / model_name
if enabled.is_file():
return enabled, True
disabled = d / (model_name + _DISABLED_SUFFIX)
if disabled.is_file():
return disabled, False
return None, None
def enable_model(self, model_type, model_name):
"""Rename model.safetensors.disabled → model.safetensors."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if is_enabled:
return
target = path.parent / path.name[: -len(_DISABLED_SUFFIX)]
path.rename(target)
logger.info("Enabled model: %s", model_name)
def disable_model(self, model_type, model_name):
"""Rename model.safetensors → model.safetensors.disabled."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if not is_enabled:
return
target = path.parent / (path.name + _DISABLED_SUFFIX)
path.rename(target)
logger.info("Disabled model: %s", model_name)
def scan_disabled_models(self, model_type):
"""Scan directories for .disabled files.
Returns list of normalized model names (without .disabled suffix).
"""
dirs = _get_model_dirs(model_type)
disabled = []
for d in dirs:
if not d.is_dir():
continue
for f in d.rglob("*"):
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
try:
rel = str(f.relative_to(d))
if rel.endswith(_DISABLED_SUFFIX):
rel = rel[: -len(_DISABLED_SUFFIX)]
disabled.append(rel.replace("\\", "/"))
except ValueError:
pass
return disabled
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
@@ -148,3 +229,48 @@ class ModelsService:
logger.debug("Preview: model file not found in any dir: %s", model_name)
return None
def delete_model(self, model_type, model_name):
"""Delete a model file and all associated preview/sidecar files.
Also removes the model's metadata entry.
Returns dict with deleted file paths.
"""
if ".." in model_name:
raise ValueError(f"Invalid model name: {model_name}")
path, _ = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
deleted = []
parent = path.parent
# Compute stem from the original (non-disabled) filename.
# path.name for a disabled file is e.g. "model.safetensors.disabled"
# so we strip the .disabled suffix first, then take Path.stem.
orig_name = path.name
if orig_name.endswith(_DISABLED_SUFFIX):
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
stem = Path(orig_name).stem # "model.safetensors" -> "model"
# Delete the model file itself
path.unlink()
deleted.append(str(path))
logger.info("Deleted model file: %s", path)
# Delete all sidecar files (previews, metadata, info)
for ext in _SIDECAR_EXTENSIONS:
sidecar = parent / (stem + ext)
if sidecar.is_file():
sidecar.unlink()
deleted.append(str(sidecar))
logger.info("Deleted sidecar: %s", sidecar)
# Remove metadata entry
data = self._load_metadata()
if model_name in data:
del data[model_name]
self._save_metadata(data)
return {"deleted": deleted}
+205
View File
@@ -0,0 +1,205 @@
"""Wildcard file management service."""
import logging
import os
import platform
import shutil
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
ALLOWED_EXTS = {"txt", "yaml", "yml"}
class WildcardService:
def __init__(self, wildcard_dir: Path):
self.wildcard_dir = wildcard_dir
# Only mkdir if it is a real directory (not a junction/symlink)
if not self.wildcard_dir.exists() and not self._is_junction(self.wildcard_dir):
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# File CRUD
# ------------------------------------------------------------------
def list_wildcards(self) -> list[dict]:
"""Return sorted list of wildcard files recursively (follows junction/symlink)."""
if not self.wildcard_dir.exists():
return []
files = []
for path in sorted(self.wildcard_dir.rglob("*")):
if path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS:
rel = path.relative_to(self.wildcard_dir)
dir_posix = rel.parent.as_posix()
if dir_posix == ".":
dir_posix = ""
files.append({
"name": path.stem,
"filename": rel.as_posix(),
"ext": path.suffix.lstrip(".").lower(),
"size": path.stat().st_size,
"dir": dir_posix,
"wc_name": rel.with_suffix("").as_posix(),
})
return files
def get_content(self, filename: str) -> str:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if not path.is_file():
raise FileNotFoundError(f"File not found: {filename}")
return path.read_text(encoding="utf-8")
def save_file(self, filename: str, content: str) -> dict:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
rel = path.relative_to(self.wildcard_dir)
dir_posix = rel.parent.as_posix()
if dir_posix == ".":
dir_posix = ""
return {
"name": path.stem,
"filename": rel.as_posix(),
"ext": path.suffix.lstrip(".").lower(),
"size": path.stat().st_size,
"dir": dir_posix,
"wc_name": rel.with_suffix("").as_posix(),
}
def delete_file(self, filename: str) -> None:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if path.is_file():
path.unlink()
def _safe_path(self, filename: str) -> Path | None:
if not filename or not filename.strip():
return None
# Normalize to forward slashes and strip leading/trailing slashes
filename = filename.replace("\\", "/").strip("/")
# Validate each path component — block traversal and empty parts
parts = filename.split("/")
for p in parts:
if not p or p == ".." or p == ".":
return None
ext = Path(parts[-1]).suffix.lstrip(".").lower()
if ext not in ALLOWED_EXTS:
return None
return self.wildcard_dir.joinpath(*parts)
# ------------------------------------------------------------------
# Impact Pack integration
# ------------------------------------------------------------------
@staticmethod
def find_impact_pack_wildcards(comfyui_root: Path) -> Path | None:
"""Return Impact Pack wildcards directory path, or None if not installed."""
custom_nodes = comfyui_root / "custom_nodes"
if not custom_nodes.is_dir():
return None
for d in custom_nodes.iterdir():
if d.is_dir() and "impact-pack" in d.name.lower():
wildcards = d / "wildcards"
if wildcards.is_dir():
return wildcards
return None
def get_link_status(self, comfyui_root: Path) -> dict:
"""Return current Impact Pack integration status."""
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
is_linked = self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()
resolved_target = None
if is_linked:
try:
resolved_target = str(self.wildcard_dir.resolve())
except Exception:
pass
return {
"impact_pack_installed": impact_dir is not None,
"impact_pack_wildcards_dir": str(impact_dir) if impact_dir else None,
"wfs_wildcard_dir": str(self.wildcard_dir),
"is_linked": is_linked,
"link_target": resolved_target,
}
def create_link(self, comfyui_root: Path) -> dict:
"""Replace WFS wildcard dir with a junction/symlink → Impact Pack wildcards."""
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
if impact_dir is None:
raise RuntimeError("ComfyUI-Impact-Pack is not installed.")
if self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink():
raise RuntimeError("Link already exists.")
# Migrate existing WFS files to Impact Pack dir
migrated: list[str] = []
if self.wildcard_dir.is_dir():
for f in self.wildcard_dir.iterdir():
if f.is_file():
dest = impact_dir / f.name
if not dest.exists():
shutil.copy2(str(f), str(dest))
migrated.append(f.name)
shutil.rmtree(str(self.wildcard_dir))
# Create junction (Windows) or symlink (others)
self._create_junction_or_symlink(self.wildcard_dir, impact_dir)
logger.info("Wildcard link created: %s -> %s", self.wildcard_dir, impact_dir)
return {"migrated_files": migrated}
def remove_link(self) -> None:
"""Remove junction/symlink and restore WFS wildcard dir as a plain directory."""
if not (self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()):
raise RuntimeError("No link to remove.")
self._remove_junction_or_symlink(self.wildcard_dir)
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
logger.info("Wildcard link removed, restored plain dir: %s", self.wildcard_dir)
# ------------------------------------------------------------------
# Platform helpers
# ------------------------------------------------------------------
@staticmethod
def _is_junction(path: Path) -> bool:
"""Return True if path is a Windows directory junction."""
if platform.system() != "Windows":
return False
try:
import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x0400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
return attrs != 0xFFFFFFFF and bool(attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception:
return False
@staticmethod
def _create_junction_or_symlink(link: Path, target: Path) -> None:
if platform.system() == "Windows":
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"mklink /J failed: {result.stderr.strip()}")
else:
os.symlink(target, link, target_is_directory=True)
@staticmethod
def _remove_junction_or_symlink(path: Path) -> None:
if platform.system() == "Windows":
result = subprocess.run(
["cmd", "/c", "rmdir", str(path)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"rmdir failed: {result.stderr.strip()}")
else:
os.unlink(str(path))
+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, gallery_routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
@@ -71,5 +71,6 @@ class WorkflowStudio:
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
gallery_routes.setup_routes(app)
wildcard_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+3 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-workflow-studio"
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
version = "0.2.7"
version = "0.3.7"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
@@ -10,7 +10,7 @@ dependencies = ["jinja2"]
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
[tool.comfy]
PublisherId = "ketle-man"
PublisherId = "statsu"
DisplayName = "Workflow Studio"
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/icon.svg"
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/docs/thumb.png"
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
+55
View File
@@ -27,10 +27,12 @@
.wfm-gallery-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 4px;
}
.wfm-gallery-tree-title {
@@ -39,6 +41,13 @@
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1;
}
.wfm-gallery-tree-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
@@ -592,3 +601,49 @@
.wfm-gallery-lightbox-close:hover {
background: rgba(255,255,255,0.2);
}
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
.wfm-gallery-file-actions {
display: flex;
gap: 6px;
padding-top: 4px;
}
.wfm-gallery-file-actions .wfm-btn {
flex: 1;
}
/* ── 移動先選択モーダル ──────────────────────────────────────── */
.wfm-gallery-move-modal {
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 8px;
padding: 20px;
min-width: 320px;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 12px;
}
.wfm-gallery-move-modal-title {
font-size: 14px;
font-weight: 600;
color: var(--wfm-text-primary);
}
.wfm-gallery-move-dest-sel {
width: 100%;
padding: 6px 8px;
font-size: 13px;
}
.wfm-gallery-move-modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
+1124
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -90,7 +90,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -265,14 +265,24 @@ function applyI18nToHtml() {
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
"wfm-help-gen-11": "helpGen11",
"wfm-help-feeder-title": "helpFeederTitle",
"wfm-help-feeder-desc": "helpFeederDesc",
"wfm-help-feeder-1": "helpFeeder1", "wfm-help-feeder-2": "helpFeeder2",
"wfm-help-feeder-3": "helpFeeder3", "wfm-help-feeder-4": "helpFeeder4",
"wfm-help-feeder-5": "helpFeeder5", "wfm-help-feeder-6": "helpFeeder6",
"wfm-help-feeder-7": "helpFeeder7", "wfm-help-feeder-8": "helpFeeder8",
"wfm-help-feeder-9": "helpFeeder9",
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
"wfm-help-prompt-7": "helpPrompt7",
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
"wfm-help-prompt-9": "helpPrompt9",
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
@@ -280,13 +290,15 @@ function applyI18nToHtml() {
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
"wfm-help-models-9": "helpModels9",
"wfm-help-models-9": "helpModels9", "wfm-help-models-10": "helpModels10",
"wfm-help-models-11": "helpModels11",
"wfm-help-gallery-title": "helpGalleryTitle",
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
"wfm-help-gallery-11": "helpGallery11",
"wfm-help-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
@@ -350,6 +362,7 @@ 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";
import { initMetadataTab } from "./metadata-tab.js";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
@@ -364,6 +377,7 @@ document.addEventListener("DOMContentLoaded", () => {
initModelsTab();
initGenerateTab();
initPromptTab();
initMetadataTab();
initGalleryTab();
console.log("Workflow Studio: App initialized");
+7 -1
View File
@@ -88,7 +88,13 @@ export const comfyUI = {
try {
const info = await this.fetchObjectInfo(cls);
if (info?.[cls]?.input?.required?.[inputKey]) {
return info[cls].input.required[inputKey][0] || [];
const inputDef = info[cls].input.required[inputKey];
const first = inputDef[0];
if (Array.isArray(first)) return first;
// Newer ComfyUI format: ["COMBO", { "values": [...] }]
if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) {
return inputDef[1].values;
}
}
} catch {}
}
+23 -13
View File
@@ -77,7 +77,7 @@ export const comfyEditor = {
<label>Positive Prompt</label>
<div style="display:flex;gap:8px;margin-bottom:6px;">
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1">
${positiveNodes.map((n) => `<option value="${n.id}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-apply">Apply</button>
@@ -88,7 +88,7 @@ export const comfyEditor = {
<label>Negative Prompt</label>
<div style="display:flex;gap:8px;margin-bottom:6px;">
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1">
${negativeNodes.map((n) => `<option value="${n.id}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-apply">Apply</button>
@@ -101,7 +101,9 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-prompt-pos-target")?.value;
const text = document.getElementById("wfm-prompt-pos-text")?.value;
if (nodeId && comfyUI.currentWorkflow?.[nodeId]) {
comfyUI.currentWorkflow[nodeId].inputs.text = text;
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
const textKey = promptNode?.textKey || "text";
comfyUI.currentWorkflow[nodeId].inputs[textKey] = text;
_syncRawJson();
}
});
@@ -110,7 +112,9 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-prompt-neg-target")?.value;
const text = document.getElementById("wfm-prompt-neg-text")?.value;
if (nodeId && comfyUI.currentWorkflow?.[nodeId]) {
comfyUI.currentWorkflow[nodeId].inputs.text = text;
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
const textKey = promptNode?.textKey || "text";
comfyUI.currentWorkflow[nodeId].inputs[textKey] = text;
_syncRawJson();
}
});
@@ -209,11 +213,11 @@ export const comfyEditor = {
const latent = analysis.latent_nodes?.[0];
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:0;">
<div style="padding-bottom:14px;border-bottom:1px solid var(--wfm-border);margin-bottom:14px;">
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
<div style="flex:1;min-width:0;padding-right:14px;border-right:1px solid var(--wfm-border);">
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
${sampler ? `
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}">
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}" data-seed-key="${sampler.seedKey || "seed"}">
<div class="wfm-form-group">
<label>Seed</label>
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? -1}">
@@ -245,7 +249,7 @@ export const comfyEditor = {
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply">Apply</button>
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
</div>
<div>
<div style="flex:1;min-width:0;padding-left:14px;">
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">Latent Image</h3>
${latent ? `
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
@@ -271,7 +275,9 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
inputs.seed = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
// KSamplerAdvanced uses noise_seed; fall back to seed
const seedKey = document.getElementById("wfm-settings-sampler-id")?.dataset?.seedKey || "seed";
inputs[seedKey] = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
inputs.steps = parseInt(document.getElementById("wfm-settings-steps")?.value) || 20;
inputs.cfg = parseFloat(document.getElementById("wfm-settings-cfg")?.value) || 7;
inputs.sampler_name = document.getElementById("wfm-settings-sampler-name")?.value;
@@ -402,16 +408,20 @@ export const comfyEditor = {
syncToWorkflow() {
// Sync prompt texts before generation
const posTarget = document.getElementById("wfm-prompt-pos-target")?.value;
const posSelect = document.getElementById("wfm-prompt-pos-target");
const posTarget = posSelect?.value;
const posText = document.getElementById("wfm-prompt-pos-text")?.value;
if (posTarget && comfyUI.currentWorkflow?.[posTarget]) {
comfyUI.currentWorkflow[posTarget].inputs.text = posText;
const posTextKey = posSelect.selectedOptions[0]?.dataset?.textKey || "text";
comfyUI.currentWorkflow[posTarget].inputs[posTextKey] = posText;
}
const negTarget = document.getElementById("wfm-prompt-neg-target")?.value;
const negSelect = document.getElementById("wfm-prompt-neg-target");
const negTarget = negSelect?.value;
const negText = document.getElementById("wfm-prompt-neg-text")?.value;
if (negTarget && comfyUI.currentWorkflow?.[negTarget]) {
comfyUI.currentWorkflow[negTarget].inputs.text = negText;
const negTextKey = negSelect.selectedOptions[0]?.dataset?.textKey || "text";
comfyUI.currentWorkflow[negTarget].inputs[negTextKey] = negText;
}
},
};
+183 -10
View File
@@ -343,6 +343,24 @@ export const comfyWorkflow = {
wIdx++;
}
}
// For COMBO inputs, validate the value against the available choices.
// Dynamic COMBO options (e.g. Impact Pack's "Select to add Wildcard") may
// differ between the saved workflow and the current ComfyUI instance
// (e.g. "Select Wildcard 🟢 Full Cache" vs "Select Wildcard"), causing
// "Prompt outputs failed validation". Fall back to first choice when mismatch.
if (expectedType === "COMBO") {
const allInputDefs = {
...(objectInfo[node.type]?.input?.required || {}),
...(objectInfo[node.type]?.input?.optional || {}),
};
const spec = allInputDefs[name];
if (spec) {
const choices = Array.isArray(spec[0]) ? spec[0] : null;
if (choices && choices.length > 0 && !choices.includes(val)) {
val = choices[0];
}
}
}
inputs[name] = val;
}
} else {
@@ -446,7 +464,7 @@ export const comfyWorkflow = {
if (!workflow) return result;
// Build sampler connections for prompt classification
// === Pass 1: Collect sampler refs + all_nodes ===
const samplerPositiveRef = {};
const samplerNegativeRef = {};
@@ -462,40 +480,174 @@ export const comfyWorkflow = {
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
// KSamplerAdvanced uses noise_seed instead of seed
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
result.sampler_nodes.push({
id, type: ct, title,
seed: inputs.seed, steps: inputs.steps, cfg: inputs.cfg,
seed: inputs.seed ?? inputs.noise_seed,
seedKey,
steps: inputs.steps, cfg: inputs.cfg,
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
denoise: inputs.denoise,
});
}
}
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
// Nodes that pass CONDITIONING through without changing positive/negative role
const COND_PASSTHROUGH = new Set([
"ConditioningCombine", "ConditioningConcat", "ConditioningAverage",
"ConditioningZeroOut", "ConditioningSetTimestepRange",
"ControlNetApply", "ControlNetApplyAdvanced",
"IPAdapterApply", "IPAdapterApplyFaceID",
"StyleModelApply",
]);
for (let iter = 0; iter < 5; iter++) {
for (const [id, node] of Object.entries(workflow)) {
if (!node?.class_type) continue;
const ct = node.class_type;
const inputs = node.inputs || {};
const isPos = !!samplerPositiveRef[id];
const isNeg = !!samplerNegativeRef[id];
if (!isPos && !isNeg) continue;
if (COND_PASSTHROUGH.has(ct)) {
for (const v of Object.values(inputs)) {
if (Array.isArray(v)) {
if (isPos) samplerPositiveRef[v[0]] = true;
if (isNeg) samplerNegativeRef[v[0]] = true;
}
}
}
// Propagate role through text encoder linked inputs (CLIPTextEncode → upstream text source)
if (ct === "CLIPTextEncode") {
const tv = inputs.text;
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
for (const key of ["text_g", "text_l"]) {
const tv = inputs[key];
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
const tv = inputs.wildcard_text;
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
}
// === Pass 2: Main analysis ===
for (const [id, node] of Object.entries(workflow)) {
if (!node?.class_type) continue;
const ct = node.class_type;
const inputs = node.inputs || {};
const title = node._meta?.title || ct;
if (ct.includes("CheckpointLoader")) {
const isPos = !!samplerPositiveRef[id];
const isNeg = !!samplerNegativeRef[id];
const getRole = () => {
if (isPos && !isNeg) return "positive";
if (isNeg && !isPos) return "negative";
if (/pos|正/i.test(title)) return "positive";
if (/neg|負/i.test(title)) return "negative";
return "unknown";
};
// --- Checkpoint nodes ---
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
result.checkpoint_nodes.push({
id, type: ct, title,
ckpt_name: inputs.ckpt_name,
});
}
// --- Prompt nodes ---
if (ct === "CLIPTextEncode") {
let role = "unknown";
if (samplerPositiveRef[id]) role = "positive";
else if (samplerNegativeRef[id]) role = "negative";
else if (/pos|正/i.test(title)) role = "positive";
else if (/neg|負/i.test(title)) role = "negative";
// Only add when text is a direct string (if linked, upstream node will be detected)
const textVal = inputs.text;
if (typeof textVal === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "text",
});
}
}
// SDXL dual-text encoder
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
const textG = inputs.text_g;
const textL = inputs.text_l;
// Add only if at least one text field is a direct string
if (typeof textG === "string" || typeof textL === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: typeof textG === "string" ? textG : (typeof textL === "string" ? textL : ""),
textKey: typeof textG === "string" ? "text_g" : "text_l",
});
}
}
// Qwen-based image+text encoder
if (ct === "TextEncodeQwenImageEditPlus") {
result.prompt_nodes.push({
id, type: ct, title, role,
text: inputs.text,
id, type: ct, title, role: getRole(),
text: inputs.prompt || "",
textKey: "prompt",
});
}
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
if (inputs.text_positive !== undefined) {
result.prompt_nodes.push({
id, type: ct, title: `${title} [positive]`, role: "positive",
text: inputs.text_positive || "", textKey: "text_positive",
});
}
if (inputs.text_negative !== undefined) {
result.prompt_nodes.push({
id, type: ct, title: `${title} [negative]`, role: "negative",
text: inputs.text_negative || "", textKey: "text_negative",
});
}
}
// ImpactWildcardEncode / ImpactWildcardProcessor — treat wildcard_text as prompt
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
const textVal = inputs.wildcard_text;
if (typeof textVal === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "wildcard_text",
});
}
}
// PrimitiveStringMultiline / PrimitiveString — when feeding a prompt node
if ((ct === "PrimitiveStringMultiline" || ct === "PrimitiveString") && (isPos || isNeg)) {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: inputs.value || "",
textKey: "value",
});
}
// --- Latent nodes ---
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
result.latent_nodes.push({
id, type: ct, title,
@@ -504,6 +656,7 @@ export const comfyWorkflow = {
});
}
// --- LoRA nodes ---
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
result.lora_nodes.push({
id, type: ct, title,
@@ -513,14 +666,31 @@ export const comfyWorkflow = {
});
}
// Power Lora Loader (rgthree) — dynamic lora_N inputs in API format
if (ct === "Power Lora Loader (rgthree)") {
for (const [k, v] of Object.entries(inputs)) {
if (/^lora_\d+$/.test(k) && typeof v === "string") {
result.lora_nodes.push({
id, type: ct, title,
lora_name: v,
strength_model: inputs[k.replace("lora_", "strength_")] ?? 1,
strength_clip: inputs[k.replace("lora_", "strength_clip_")] ?? 1,
});
}
}
}
// --- VAE nodes ---
if (ct === "VAELoader") {
result.vae_nodes.push({ id, type: ct, title, vae_name: inputs.vae_name });
}
// --- Save/preview nodes ---
if (ct === "SaveImage" || ct === "PreviewImage") {
result.save_nodes.push({ id, type: ct, title });
}
// --- Load image nodes ---
if (ct === "LoadImage" || ct === "LoadImageMask"
|| (ct.toLowerCase().includes("load") && ct.toLowerCase().includes("image") && inputs.image !== undefined)) {
result.load_image_nodes.push({
@@ -528,6 +698,7 @@ export const comfyWorkflow = {
});
}
// --- Text encoder (CLIP) nodes ---
if (ct === "DualCLIPLoader" || ct === "CLIPLoader") {
result.text_encoder_nodes.push({
id, type: ct, title,
@@ -536,12 +707,14 @@ export const comfyWorkflow = {
});
}
// --- Diffusion model nodes ---
if (ct === "UNETLoader" || ct === "UnetLoaderGGUF") {
result.diffusion_model_nodes.push({
id, type: ct, title, unet_name: inputs.unet_name,
});
}
// --- ControlNet loader nodes ---
if (ct.includes("ControlNetLoader")) {
result.controlnet_nodes.push({
id, type: ct, title,
+510
View File
@@ -0,0 +1,510 @@
/**
* Feeder Tab - ImageFeeder node control with embedded image library
*/
import { comfyUI } from "./comfyui-client.js";
import { showToast } from "./app.js";
// ── Module State ──────────────────────────────────────────────────
const _s = {
dir: "", // current folder relative path
images: [], // filenames in current folder
selected: new Set(),// selected filenames (across all interactions)
presets: {}, // {presetName: {directory, selected_files}}
running: false, // feeder run loop active
};
// ── Node helpers ──────────────────────────────────────────────────
function _feederNodes() {
const wf = comfyUI.currentWorkflow;
if (!wf) return [];
return Object.entries(wf)
.filter(([, n]) => n?.class_type === "ImageFeeder")
.map(([id, n]) => ({ id, title: n._meta?.title || `ImageFeeder` }));
}
export function refreshFeederNodeList() {
const sel = document.getElementById("wfm-feeder-node-sel");
if (!sel) return;
const nodes = _feederNodes();
if (nodes.length === 0) {
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
return;
}
const prevVal = sel.value;
sel.innerHTML = nodes.map(n =>
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
).join("");
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
_loadFromNode(sel.value || nodes[0].id);
}
function _loadFromNode(nodeId) {
const wf = comfyUI.currentWorkflow;
if (!wf?.[nodeId]) return;
const inp = wf[nodeId].inputs || {};
_setVal("wfm-feeder-dir-input", inp.directory || "");
_setVal("wfm-feeder-sort", inp.sort_mode || "ascending");
_setVal("wfm-feeder-index", inp.index ?? 0);
_setVal("wfm-feeder-start", inp.start_index ?? 0);
_setVal("wfm-feeder-end", inp.end_index ?? 0);
_setVal("wfm-feeder-batch", inp.batch_size ?? 1);
_setVal("wfm-feeder-seed", inp.seed ?? 0);
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
// Restore selected_files
_s.selected.clear();
try {
const arr = JSON.parse(inp.selected_files || "[]");
if (Array.isArray(arr)) arr.forEach(f => _s.selected.add(f));
} catch {}
const dir = inp.directory || "";
if (_s.dir !== dir) {
_s.dir = dir;
_loadImages(dir);
_highlightTreeRow(dir);
} else {
_refreshGridCbs();
}
_updateStatus();
}
function _applyToWorkflow() {
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
const wf = comfyUI.currentWorkflow;
if (!nodeId || !wf?.[nodeId]) {
showToast("No ImageFeeder node selected", "error");
return;
}
_applyToNode(nodeId);
showToast("Applied to workflow", "success");
}
// ── Run Loop ──────────────────────────────────────────────────────
function _setRunUI(running) {
const runBtn = document.getElementById("wfm-feeder-run-btn");
const stopBtn = document.getElementById("wfm-feeder-stop-btn");
if (runBtn) runBtn.disabled = running;
if (stopBtn) stopBtn.disabled = !running;
}
function _applyToNode(nodeId) {
const wf = comfyUI.currentWorkflow;
const inp = wf[nodeId].inputs;
inp.directory = _s.dir;
inp.sort_mode = document.getElementById("wfm-feeder-sort")?.value || "ascending";
inp.index = parseInt(document.getElementById("wfm-feeder-index")?.value) || 0;
inp.start_index = parseInt(document.getElementById("wfm-feeder-start")?.value) || 0;
inp.end_index = parseInt(document.getElementById("wfm-feeder-end")?.value) || 0;
inp.batch_size = parseInt(document.getElementById("wfm-feeder-batch")?.value) || 1;
inp.seed = parseInt(document.getElementById("wfm-feeder-seed")?.value) || 0;
inp.use_selection = document.getElementById("wfm-feeder-use-sel")?.checked ?? true;
inp.selected_files = JSON.stringify([..._s.selected]);
}
async function _startRun() {
if (_s.running) return;
if (!comfyUI.currentWorkflow) { showToast("No workflow loaded", "error"); return; }
if (!comfyUI.connected) { showToast("Not connected to ComfyUI", "error"); return; }
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
showToast("No ImageFeeder node selected", "error");
return;
}
// WebSocket を先に接続してノード sync メッセージを受け取れるようにする
const wsOk = await comfyUI.connectWebSocket();
if (!wsOk) { showToast("WebSocket connection failed", "error"); return; }
_s.running = true;
_setRunUI(true);
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
// image_loop_node_sync メッセージを捕捉するリスナー
let _lastSync = null;
const _syncHandler = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "image_loop_node_sync" && String(msg.data?.node_id) === String(nodeId)) {
_lastSync = msg.data;
}
} catch {}
};
comfyUI.socket.addEventListener("message", _syncHandler);
let count = 0;
try {
while (_s.running) {
_lastSync = null;
_applyToNode(nodeId);
const wf = comfyUI.currentWorkflow;
const inp = wf[nodeId].inputs;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = `#${++count} 0%`;
// 右ペインのseed設定を使用(KSamplerに適用される)
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const { images, seed } = await comfyUI.generate(
{ ...wf },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `#${count} ${(pct * 100).toFixed(0)}%`;
},
}
);
// 右ペインのseed表示を更新(通常の生成と同じ挙動)
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0 && resultImg) {
const blob = await comfyUI.getImageBlob(images[0]);
resultImg.src = URL.createObjectURL(blob);
resultImg.style.display = "block";
}
if (!_s.running) break;
// control after generate: ノードから返された next_index を反映
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
if (control === "fixed") {
// index を変えない
} else if (_lastSync) {
const indexEl = document.getElementById("wfm-feeder-index");
if (indexEl) indexEl.value = _lastSync.next_index;
if (control === "increment" && !_lastSync.has_next) {
_s.running = false;
showToast(`Feeder complete (${count} generated)`, "success");
break;
}
// loop: has_next=false でも次は index=0 からなので続行
}
}
} catch (err) {
if (_s.running) showToast("Feeder run error: " + err.message, "error");
} finally {
comfyUI.socket?.removeEventListener("message", _syncHandler);
_s.running = false;
_setRunUI(false);
if (progressText) progressText.textContent = `Done (${count} generated)`;
}
}
async function _stopRun() {
_s.running = false;
try { await comfyUI.interrupt(); } catch {}
_setRunUI(false);
showToast("Feeder stopped", "info");
}
// ── Folder Tree ───────────────────────────────────────────────────
async function _loadTree() {
const container = document.getElementById("wfm-feeder-tree");
if (!container) return;
container.innerHTML = `<div class="wfm-feeder-tree-msg">Loading...</div>`;
try {
const res = await fetch("/image_loop/tree");
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_renderTree(data.tree || [], container);
} catch (err) {
container.innerHTML = `<div class="wfm-feeder-tree-msg">Cannot reach image-loop API</div>`;
console.warn("[Feeder] tree load failed:", err);
}
}
function _renderTree(items, container, depth = 0) {
if (depth === 0) {
container.innerHTML = "";
container.appendChild(_makeTreeRow("", "🏠 (root)", 0));
}
for (const item of items) {
const icon = item.children?.length > 0 ? "📂" : "📁";
container.appendChild(_makeTreeRow(item.path, `${icon} ${item.name}`, depth + 1));
if (item.children?.length > 0) _renderTree(item.children, container, depth + 1);
}
}
function _makeTreeRow(path, label, depth) {
const row = document.createElement("div");
row.className = "wfm-feeder-tree-row" + (_s.dir === path ? " active" : "");
row.style.paddingLeft = `${8 + depth * 12}px`;
row.dataset.path = path;
row.textContent = label;
row.title = path || "(root)";
row.addEventListener("click", () => _selectDir(path));
return row;
}
function _selectDir(path) {
if (_s.dir !== path) {
_s.selected.clear();
_s.dir = path;
_setVal("wfm-feeder-dir-input", path);
}
_highlightTreeRow(path);
_loadImages(path);
_updateStatus();
}
function _highlightTreeRow(path) {
document.querySelectorAll(".wfm-feeder-tree-row").forEach(el => {
el.classList.toggle("active", el.dataset.path === path);
});
}
// ── Image Grid ────────────────────────────────────────────────────
async function _loadImages(dir) {
const grid = document.getElementById("wfm-feeder-grid");
if (!grid) return;
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
try {
const url = "/image_loop/images" + (dir ? `?dir=${encodeURIComponent(dir)}` : "");
const res = await fetch(url);
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_s.images = data.images || [];
_renderGrid();
} catch (err) {
_s.images = [];
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
}
_updateStatus();
}
function _renderGrid() {
const grid = document.getElementById("wfm-feeder-grid");
if (!grid) return;
if (_s.images.length === 0) {
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images found</div>`;
return;
}
grid.innerHTML = "";
for (const fname of _s.images) grid.appendChild(_makeCard(fname));
}
function _makeCard(fname) {
const relPath = _s.dir ? `${_s.dir}/${fname}` : fname;
const checked = _s.selected.has(fname);
const card = document.createElement("div");
card.className = "wfm-feeder-card" + (checked ? " selected" : "");
card.dataset.name = fname;
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "wfm-feeder-card-cb";
cb.checked = checked;
cb.addEventListener("change", e => {
e.stopPropagation();
if (cb.checked) _s.selected.add(fname);
else _s.selected.delete(fname);
card.classList.toggle("selected", cb.checked);
_updateStatus();
});
const img = document.createElement("img");
img.className = "wfm-feeder-card-img";
img.loading = "lazy";
img.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
const name = document.createElement("div");
name.className = "wfm-feeder-card-name";
name.textContent = fname;
name.title = fname;
card.append(cb, img, name);
card.addEventListener("click", e => {
if (e.target === cb) return;
_showPreview(relPath, fname);
});
return card;
}
function _refreshGridCbs() {
document.querySelectorAll(".wfm-feeder-card").forEach(card => {
const sel = _s.selected.has(card.dataset.name);
card.classList.toggle("selected", sel);
const cb = card.querySelector("input[type=checkbox]");
if (cb) cb.checked = sel;
});
}
function _selectAll() {
_s.images.forEach(f => _s.selected.add(f));
_refreshGridCbs();
_updateStatus();
}
function _deselectAll() {
_s.images.forEach(f => _s.selected.delete(f));
_refreshGridCbs();
_updateStatus();
}
// ── Status Bar ───────────────────────────────────────────────────
function _updateStatus() {
const el = document.getElementById("wfm-feeder-status");
if (!el) return;
const inCurrent = _s.images.filter(f => _s.selected.has(f)).length;
el.textContent = `📂 ${_s.dir || "(root)"} | ${inCurrent} / ${_s.images.length} selected`;
}
// ── Preview Panel ────────────────────────────────────────────────
async function _showPreview(relPath, fname) {
const imgEl = document.getElementById("wfm-feeder-preview-img");
const nameEl = document.getElementById("wfm-feeder-preview-name");
const infoEl = document.getElementById("wfm-feeder-preview-info");
if (imgEl) {
imgEl.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
imgEl.style.display = "block";
}
if (nameEl) nameEl.textContent = fname;
if (infoEl) infoEl.textContent = "Loading...";
try {
const res = await fetch(`/image_loop/image_info?path=${encodeURIComponent(relPath)}`);
if (res.ok && infoEl) {
const d = await res.json();
const kb = d.size_bytes ? (d.size_bytes / 1024).toFixed(1) + " KB" : "";
infoEl.textContent = `${d.width ?? "?"}×${d.height ?? "?"} px\n${kb}`;
}
} catch { if (infoEl) infoEl.textContent = ""; }
}
// ── Presets ──────────────────────────────────────────────────────
async function _loadPresets() {
try {
const res = await fetch("/image_feeder/presets");
if (!res.ok) return;
_s.presets = await res.json();
} catch { _s.presets = {}; }
_renderPresets();
}
function _renderPresets() {
const sel = document.getElementById("wfm-feeder-preset-sel");
if (!sel) return;
const names = Object.keys(_s.presets).sort();
sel.innerHTML = `<option value="">-- Select preset --</option>` +
names.map(n => `<option value="${n}">${n}</option>`).join("");
}
async function _savePreset() {
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
if (!name) { showToast("Enter a preset name", "error"); return; }
const sel = _s.images.filter(f => _s.selected.has(f));
try {
const res = await fetch("/image_feeder/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
});
if (res.ok) {
showToast(`Preset saved: ${name}`, "success");
const nameInput = document.getElementById("wfm-feeder-preset-name");
if (nameInput) nameInput.value = "";
await _loadPresets();
const selEl = document.getElementById("wfm-feeder-preset-sel");
if (selEl) selEl.value = name;
} else { showToast("Save failed", "error"); }
} catch (err) { showToast("Save error: " + err.message, "error"); }
}
async function _applyPreset() {
const selEl = document.getElementById("wfm-feeder-preset-sel");
const name = selEl?.value;
if (!name || !_s.presets[name]) { showToast("Select a preset first", "error"); return; }
const preset = _s.presets[name];
const dir = preset.directory || "";
_s.selected.clear();
(preset.selected_files || []).forEach(f => _s.selected.add(f));
_setVal("wfm-feeder-dir-input", dir);
if (_s.dir !== dir) {
_s.dir = dir;
_highlightTreeRow(dir);
await _loadImages(dir);
}
_refreshGridCbs();
_updateStatus();
showToast(`Preset loaded: ${name}`, "success");
}
async function _deletePreset() {
const name = document.getElementById("wfm-feeder-preset-sel")?.value;
if (!name) return;
try {
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
showToast(`Preset deleted: ${name}`, "info");
await _loadPresets();
} catch (err) { showToast("Delete error", "error"); }
}
// ── Helpers ───────────────────────────────────────────────────────
function _setVal(id, val) {
const el = document.getElementById(id);
if (el) el.value = val ?? "";
}
function _setCheck(id, val) {
const el = document.getElementById(id);
if (el) el.checked = !!val;
}
// ── Init ─────────────────────────────────────────────────────────
export async function initFeederTab() {
document.getElementById("wfm-feeder-node-sel")?.addEventListener("change", e => {
_loadFromNode(e.target.value);
});
document.getElementById("wfm-feeder-apply-btn")?.addEventListener("click", _applyToWorkflow);
document.getElementById("wfm-feeder-dir-input")?.addEventListener("change", e => {
const val = e.target.value.trim();
if (_s.dir !== val) {
_s.dir = val;
_highlightTreeRow(val);
_loadImages(val);
}
});
document.getElementById("wfm-feeder-reload-tree")?.addEventListener("click", _loadTree);
document.getElementById("wfm-feeder-sel-all")?.addEventListener("click", _selectAll);
document.getElementById("wfm-feeder-desel-all")?.addEventListener("click", _deselectAll);
document.getElementById("wfm-feeder-preset-save")?.addEventListener("click", _savePreset);
document.getElementById("wfm-feeder-preset-load")?.addEventListener("click", _applyPreset);
document.getElementById("wfm-feeder-preset-del")?.addEventListener("click", _deletePreset);
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
await Promise.all([_loadTree(), _loadPresets()]);
}
+270
View File
@@ -22,6 +22,10 @@ const API = {
groupDelete: (name) => `/wfm/gallery/groups/${encodeURIComponent(name)}`,
groupAdd: (name) => `/wfm/gallery/groups/${encodeURIComponent(name)}/add`,
groupRemove: (name) => `/wfm/gallery/groups/${encodeURIComponent(name)}/remove`,
folderCreate: `/wfm/gallery/folder`,
folderDelete: `/wfm/gallery/folder`,
imagesDelete: `/wfm/gallery/images/delete`,
imagesMove: `/wfm/gallery/images/move`,
};
// ── 状態 ─────────────────────────────────────────────────────
@@ -40,6 +44,7 @@ const state = {
groups: [],
embeddedWorkflow: null, // 選択画像のworkflow JSON
selectedImages: new Set(), // 複数選択中のパス Set
folderTree: null, // フォルダツリー全体(移動先選択に使用)
};
// ── ヘルパー ──────────────────────────────────────────────────
@@ -62,9 +67,49 @@ function formatDate(mtime) {
// ── フォルダツリー ────────────────────────────────────────────
function _getExpandedPaths() {
const expanded = new Set();
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
const arrow = item.querySelector(".wfm-gallery-tree-arrow");
if (arrow && arrow.dataset.expanded === "true") {
expanded.add(item.dataset.path);
}
});
return expanded;
}
function _restoreTreeState(expandedPaths, selectedPath) {
// 親→子の順で展開するため、階層の浅い順にソート
const sorted = [...expandedPaths].sort(
(a, b) => a.split("/").length - b.split("/").length
);
for (const path of sorted) {
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
if (item.dataset.path !== path) return;
const arrow = item.querySelector(".wfm-gallery-tree-arrow");
if (arrow && arrow.dataset.expanded !== "true" && arrow.style.visibility !== "hidden") {
arrow.click();
}
});
}
// 選択状態を復元(labelクリックは画像リロードを伴うのでハイライトのみ)
if (selectedPath) {
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
if (item.dataset.path === selectedPath) {
document.querySelectorAll(".wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
item.classList.add("selected");
}
});
}
}
async function loadFolderTree() {
if (!state.outputRoot) return;
// 再構築前に展開状態を保存
const expandedPaths = _getExpandedPaths();
const isFirstLoad = expandedPaths.size === 0 && !state.currentFolder;
const tree = document.getElementById("wfm-gallery-tree");
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
@@ -74,8 +119,18 @@ async function loadFolderTree() {
tree.innerHTML = `<p class="wfm-placeholder">${data.error}</p>`;
return;
}
state.folderTree = data;
tree.innerHTML = "";
renderTreeNode(data, tree, 0, true);
if (isFirstLoad) {
// 初回のみ root を自動選択
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
if (firstLabel) firstLabel.click();
} else {
// 展開状態と選択ハイライトを復元
_restoreTreeState(expandedPaths, state.currentFolder);
}
} catch (e) {
tree.innerHTML = `<p class="wfm-placeholder">Error: ${e.message}</p>`;
}
@@ -85,6 +140,7 @@ function renderTreeNode(node, container, depth, isRoot) {
const item = document.createElement("div");
item.className = "wfm-gallery-tree-item";
item.style.paddingLeft = `${depth * 12}px`;
item.dataset.path = node.abs_path;
const hasChildren = node.children && node.children.length > 0;
const absPath = node.abs_path;
@@ -121,6 +177,11 @@ function renderTreeNode(node, container, depth, isRoot) {
state.selectedImages.clear();
updateBulkBar();
loadImages();
// Delete Folder ボタン: rootは削除不可
const delBtn = document.getElementById("wfm-gallery-folder-delete-btn");
if (delBtn) {
delBtn.disabled = (absPath === state.outputRoot.replace(/\\/g, "/") || absPath === state.outputRoot);
}
});
// 矢印クリックで子ノード展開/折りたたみ
@@ -419,6 +480,12 @@ function updateTagFilter(images) {
// ── 詳細パネル ────────────────────────────────────────────────
async function loadImageDetail(img) {
// ファイル操作ボタンを有効化
const moveBtn = document.getElementById("wfm-gallery-img-move-btn");
const delBtn = document.getElementById("wfm-gallery-img-delete-btn");
if (moveBtn) moveBtn.disabled = false;
if (delBtn) delBtn.disabled = false;
// プレビュー
const preview = document.getElementById("wfm-gallery-detail-preview");
preview.innerHTML = `<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${img.filename}" title="Double-click to enlarge">`;
@@ -767,6 +834,181 @@ function escapeHtml(str) {
.replace(/"/g, "&quot;");
}
// ── フォルダ作成・削除 ────────────────────────────────────────
async function createFolder() {
if (!state.currentFolder) {
showToast("Please select a parent folder first", "error");
return;
}
const name = prompt("New folder name:");
if (!name || !name.trim()) return;
try {
const res = await fetch(API.folderCreate, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent: state.currentFolder, name: name.trim() }),
});
const data = await res.json();
if (!data.ok) {
showToast(`Error: ${data.error}`, "error");
return;
}
showToast(`Folder "${name.trim()}" created`, "success");
await loadFolderTree();
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
async function deleteFolder() {
if (!state.currentFolder) return;
if (!confirm(`Delete folder "${state.currentFolder.split("/").pop()}" and all its contents?`)) return;
try {
const res = await fetch(API.folderDelete, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.currentFolder }),
});
const data = await res.json();
if (!data.ok) {
showToast(`Error: ${data.error}`, "error");
return;
}
showToast("Folder deleted", "success");
state.currentFolder = "";
state.images = [];
document.getElementById("wfm-gallery-grid").innerHTML = `<p class="wfm-placeholder">Select a folder to browse images.</p>`;
await loadFolderTree();
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
// ── ファイル削除・移動 ────────────────────────────────────────
async function performDeleteImages(paths) {
try {
const res = await fetch(API.imagesDelete, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths }),
});
const data = await res.json();
if (data.deleted && data.deleted.length > 0) {
showToast(`Deleted ${data.deleted.length} image(s)`, "success");
// 削除された画像をstateから除去
const deletedSet = new Set(data.deleted);
state.images = state.images.filter(img => !deletedSet.has(img.path));
state.selectedImages = new Set([...state.selectedImages].filter(p => !deletedSet.has(p)));
if (state.selectedImage && deletedSet.has(state.selectedImage.path)) {
state.selectedImage = null;
document.getElementById("wfm-gallery-detail-preview").innerHTML = `<span class="wfm-placeholder">No selection</span>`;
document.getElementById("wfm-gallery-detail-filename").textContent = "";
const moveBtn = document.getElementById("wfm-gallery-img-move-btn");
const delBtn = document.getElementById("wfm-gallery-img-delete-btn");
if (moveBtn) moveBtn.disabled = true;
if (delBtn) delBtn.disabled = true;
}
updateBulkBar();
renderImages();
// フォルダツリーのカウントを更新
loadFolderTree();
}
if (data.errors && data.errors.length > 0) {
showToast(`Errors: ${data.errors.join(", ")}`, "error");
}
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
async function performMoveImages(paths, destFolder) {
try {
const res = await fetch(API.imagesMove, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths, dest: destFolder }),
});
const data = await res.json();
if (data.moved && data.moved.length > 0) {
showToast(`Moved ${data.moved.length} image(s)`, "success");
const movedSet = new Set(data.moved.map(m => m.from));
state.images = state.images.filter(img => !movedSet.has(img.path));
state.selectedImages = new Set([...state.selectedImages].filter(p => !movedSet.has(p)));
if (state.selectedImage && movedSet.has(state.selectedImage.path)) {
state.selectedImage = null;
document.getElementById("wfm-gallery-detail-preview").innerHTML = `<span class="wfm-placeholder">No selection</span>`;
document.getElementById("wfm-gallery-detail-filename").textContent = "";
const moveBtn = document.getElementById("wfm-gallery-img-move-btn");
const delBtn = document.getElementById("wfm-gallery-img-delete-btn");
if (moveBtn) moveBtn.disabled = true;
if (delBtn) delBtn.disabled = true;
}
updateBulkBar();
renderImages();
loadFolderTree();
}
if (data.errors && data.errors.length > 0) {
showToast(`Errors: ${data.errors.join(", ")}`, "error");
}
if (data.error) {
showToast(`Error: ${data.error}`, "error");
}
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
// ── フォルダツリーを平坦なリストに変換 ──────────────────────────
function flattenFolderTree(node, result = []) {
if (!node) return result;
result.push({ name: node.name, abs_path: node.abs_path });
if (node.children) {
node.children.forEach(child => flattenFolderTree(child, result));
}
return result;
}
// ── 移動先選択モーダル ────────────────────────────────────────
function openMoveModal(paths) {
const allFolders = flattenFolderTree(state.folderTree);
const destinations = allFolders.filter(f => f.abs_path !== state.currentFolder);
if (destinations.length === 0) {
showToast("No other folders available. Create a subfolder first.", "error");
return;
}
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox";
overlay.innerHTML = `
<div class="wfm-gallery-move-modal">
<div class="wfm-gallery-move-modal-title">Move ${paths.length} image(s) to folder:</div>
<select id="wfm-gallery-move-dest-sel" class="wfm-select wfm-gallery-move-dest-sel">
${destinations.map(f => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.name)}</option>`).join("")}
</select>
<div class="wfm-gallery-move-modal-footer">
<button id="wfm-gallery-move-confirm" class="wfm-btn wfm-btn-primary">Move</button>
<button id="wfm-gallery-move-cancel" class="wfm-btn">Cancel</button>
</div>
</div>
`;
overlay.querySelector("#wfm-gallery-move-cancel").addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector("#wfm-gallery-move-confirm").addEventListener("click", async () => {
const dest = overlay.querySelector("#wfm-gallery-move-dest-sel").value;
if (!dest) return;
overlay.remove();
await performMoveImages(paths, dest);
});
document.body.appendChild(overlay);
}
// ── 一括操作 ─────────────────────────────────────────────────
async function bulkAddToGroup(groupName) {
@@ -993,6 +1235,34 @@ function bindEvents() {
bulkSetFavorite(false);
});
document.getElementById("wfm-gallery-bulk-move")?.addEventListener("click", () => {
if (state.selectedImages.size === 0) return;
openMoveModal([...state.selectedImages]);
});
document.getElementById("wfm-gallery-bulk-delete")?.addEventListener("click", () => {
if (state.selectedImages.size === 0) return;
const count = state.selectedImages.size;
if (!confirm(`Delete ${count} selected image(s)? This cannot be undone.`)) return;
performDeleteImages([...state.selectedImages]);
});
// フォルダ作成・削除
document.getElementById("wfm-gallery-folder-create-btn")?.addEventListener("click", createFolder);
document.getElementById("wfm-gallery-folder-delete-btn")?.addEventListener("click", deleteFolder);
// 詳細パネル: 単体ファイル操作
document.getElementById("wfm-gallery-img-move-btn")?.addEventListener("click", () => {
if (!state.selectedImage) return;
openMoveModal([state.selectedImage.path]);
});
document.getElementById("wfm-gallery-img-delete-btn")?.addEventListener("click", () => {
if (!state.selectedImage) return;
if (!confirm(`Delete "${state.selectedImage.filename}"? This cannot be undone.`)) return;
performDeleteImages([state.selectedImage.path]);
});
// 詳細タブ切り替え
document.querySelectorAll(".wfm-gallery-detail-tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
+534 -72
View File
@@ -8,6 +8,7 @@ import { comfyWorkflow } from "./comfyui-workflow.js";
import { comfyEditor } from "./comfyui-editor.js";
import { t } from "./i18n.js";
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
// ============================================
// Eagle Auto-Save
@@ -50,6 +51,42 @@ async function saveToEagle(imageUrl, name, tags = []) {
}
}
// ============================================
// Gallery Metadata - ワークフロー保存
// ============================================
let _outputDir = "";
async function _fetchOutputDir() {
try {
const res = await fetch("/api/wfm/settings/output-dir");
if (res.ok) {
const data = await res.json();
_outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
}
} catch {}
}
async function saveGeneratedImagesMeta(images, workflow) {
if (!_outputDir) await _fetchOutputDir();
if (!_outputDir) return;
for (const img of images) {
if (img.type !== "output") continue;
const parts = [_outputDir];
if (img.subfolder) parts.push(img.subfolder);
parts.push(img.filename);
const path = parts.join("/");
try {
await fetch("/wfm/gallery/image/meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, workflow }),
});
} catch {}
}
}
// ============================================
// Status & Connection
// ============================================
@@ -109,12 +146,462 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
const genBtn = document.getElementById("wfm-gen-generate-btn");
if (genBtn) genBtn.disabled = !comfyUI.connected;
refreshFeederNodeList();
showToast(`Workflow loaded: ${filename || ""}`, "success");
return true;
}
// ============================================
// Generation
// Checkpoint Batch
// ============================================
const _ckptBatch = { aborted: false, paused: false, _resumeResolve: null };
async function _waitIfPaused() {
if (!_ckptBatch.paused) return;
await new Promise((resolve) => { _ckptBatch._resumeResolve = resolve; });
}
function _setPauseBtnState(paused) {
const btn = document.getElementById("wfm-ckpt-batch-pause-btn");
if (!btn) return;
btn.textContent = paused ? "Resume" : "Pause";
btn.style.background = paused ? "var(--wfm-success, #22c55e)" : "";
btn.style.color = paused ? "#fff" : "";
}
// mode: "all" = 全選択, "some" = 一部選択, "none" = 全解除
const _ckptState = { mode: "all", selected: new Set() };
function _getSelectedCheckpoints() {
const all = comfyEditor.models.checkpoints || [];
if (_ckptState.mode === "all") return all;
if (_ckptState.mode === "none") return [];
return all.filter((m) => _ckptState.selected.has(m));
}
function _updateDropdownLabel() {
const labelEl = document.getElementById("wfm-ckpt-dropdown-label");
if (!labelEl) return;
const total = (comfyEditor.models.checkpoints || []).length;
if (_ckptState.mode === "all") {
labelEl.textContent = total > 0 ? `All (${total})` : "All checkpoints";
} else if (_ckptState.mode === "none") {
labelEl.textContent = `0 / ${total} selected`;
} else {
labelEl.textContent = `${_ckptState.selected.size} / ${total} selected`;
}
}
// モデルパスリストをフォルダ → [modelPath, ...] のMapに変換
function _buildFolderTree(models) {
const map = new Map();
for (const m of models) {
const normalized = m.replace(/\\/g, "/");
const lastSlash = normalized.lastIndexOf("/");
const folder = lastSlash === -1 ? "" : normalized.substring(0, lastSlash);
if (!map.has(folder)) map.set(folder, []);
map.get(folder).push(m);
}
return new Map([...map.entries()].sort((a, b) => {
if (a[0] === b[0]) return 0;
if (a[0] === "") return -1;
if (b[0] === "") return 1;
return a[0].localeCompare(b[0]);
}));
}
// フォルダ内の選択状態を返す: "checked" | "indeterminate" | "unchecked"
function _getFolderCheckState(folderModels) {
if (_ckptState.mode === "all") return "checked";
if (_ckptState.mode === "none") return "unchecked";
const selCount = folderModels.filter((m) => _ckptState.selected.has(m)).length;
if (selCount === 0) return "unchecked";
if (selCount === folderModels.length) return "checked";
return "indeterminate";
}
// 単一モデルの選択トグル(_ckptState を更新)
function _toggleSingleModel(m, checked, all) {
if (_ckptState.mode === "all" && !checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
all.forEach((x) => _ckptState.selected.add(x));
} else if (_ckptState.mode === "none" && checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
}
if (checked) _ckptState.selected.add(m);
else _ckptState.selected.delete(m);
if (_ckptState.selected.size === all.length) { _ckptState.mode = "all"; _ckptState.selected.clear(); }
if (_ckptState.mode === "some" && _ckptState.selected.size === 0) _ckptState.mode = "none";
}
// フォルダ単位の一括トグル(_ckptState を更新)
function _toggleFolderModels(folderModels, checked, all) {
if (_ckptState.mode === "all" && !checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
all.forEach((x) => _ckptState.selected.add(x));
} else if (_ckptState.mode === "none" && checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
}
folderModels.forEach((m) => { if (checked) _ckptState.selected.add(m); else _ckptState.selected.delete(m); });
if (_ckptState.selected.size === all.length) { _ckptState.mode = "all"; _ckptState.selected.clear(); }
if (_ckptState.mode === "some" && _ckptState.selected.size === 0) _ckptState.mode = "none";
}
function _rebuildCkptList() {
const listEl = document.getElementById("wfm-ckpt-list");
if (!listEl) return;
const all = comfyEditor.models.checkpoints || [];
const search = document.getElementById("wfm-ckpt-search")?.value.toLowerCase() || "";
const filtered = search ? all.filter((m) => m.toLowerCase().includes(search)) : all;
if (filtered.length === 0) {
listEl.innerHTML = `<div style="padding:8px 10px;font-size:11px;color:var(--wfm-text-secondary);">No checkpoints</div>`;
return;
}
const folderTree = _buildFolderTree(filtered);
listEl.innerHTML = "";
for (const [folder, models] of folderTree) {
const group = document.createElement("div");
group.className = "wfm-ckpt-folder-group open";
// --- フォルダヘッダー ---
const header = document.createElement("div");
header.className = "wfm-ckpt-folder-header";
const folderCb = document.createElement("input");
folderCb.type = "checkbox";
const folderState = _getFolderCheckState(models);
folderCb.checked = folderState === "checked";
folderCb.indeterminate = folderState === "indeterminate";
const toggle = document.createElement("span");
toggle.className = "wfm-ckpt-folder-toggle";
toggle.textContent = "▶";
const nameSpan = document.createElement("span");
nameSpan.className = "wfm-ckpt-folder-name";
nameSpan.textContent = folder || "(root)";
nameSpan.title = folder || "(root)";
const countSpan = document.createElement("span");
countSpan.className = "wfm-ckpt-folder-count";
countSpan.textContent = models.length;
// --- ファイルリスト ---
const filesDiv = document.createElement("div");
filesDiv.className = "wfm-ckpt-folder-files";
for (const m of models) {
const itemLabel = document.createElement("label");
itemLabel.className = "wfm-ckpt-item wfm-ckpt-item--indented";
const fileCb = document.createElement("input");
fileCb.type = "checkbox";
fileCb.value = m;
if (_ckptState.mode === "all") fileCb.checked = true;
else if (_ckptState.mode === "none") fileCb.checked = false;
else fileCb.checked = _ckptState.selected.has(m);
const normalized = m.replace(/\\/g, "/");
const lastSlash = normalized.lastIndexOf("/");
const fileName = lastSlash === -1 ? m : m.substring(lastSlash + 1);
const fileSpan = document.createElement("span");
fileSpan.className = "wfm-ckpt-item-label";
fileSpan.textContent = fileName;
fileSpan.title = m;
fileCb.addEventListener("change", () => {
_toggleSingleModel(m, fileCb.checked, all);
const s = _getFolderCheckState(models);
folderCb.checked = s === "checked";
folderCb.indeterminate = s === "indeterminate";
_updateDropdownLabel();
_updateBatchInfo();
});
itemLabel.appendChild(fileCb);
itemLabel.appendChild(fileSpan);
filesDiv.appendChild(itemLabel);
}
// フォルダCBのchange
folderCb.addEventListener("change", () => {
_toggleFolderModels(models, folderCb.checked, all);
folderCb.indeterminate = false;
filesDiv.querySelectorAll("input[type=checkbox]").forEach((cb) => { cb.checked = folderCb.checked; });
_updateDropdownLabel();
_updateBatchInfo();
});
// フォルダ名 / トグル矢印クリックで展開・折りたたみ
[toggle, nameSpan].forEach((el) => {
el.addEventListener("click", () => group.classList.toggle("open"));
});
header.appendChild(folderCb);
header.appendChild(toggle);
header.appendChild(nameSpan);
header.appendChild(countSpan);
group.appendChild(header);
group.appendChild(filesDiv);
listEl.appendChild(group);
}
}
function _updateBatchInfo() {
const infoEl = document.getElementById("wfm-ckpt-batch-info");
if (!infoEl) return;
const n = _getSelectedCheckpoints().length;
infoEl.textContent = `${n} checkpoint${n !== 1 ? "s" : ""} will be processed`;
}
function initCheckpointBatch() {
const checkbox = document.getElementById("wfm-ckpt-batch-enabled");
const body = document.getElementById("wfm-ckpt-batch-body");
const wrap = document.getElementById("wfm-ckpt-dropdown-wrap");
const btn = document.getElementById("wfm-ckpt-dropdown-btn");
const panel = document.getElementById("wfm-ckpt-dropdown-panel");
checkbox?.addEventListener("change", () => {
if (body) body.style.display = checkbox.checked ? "block" : "none";
if (checkbox.checked) {
_updateDropdownLabel();
_updateBatchInfo();
}
});
// ドロップダウン開閉
btn?.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = panel.style.display !== "none";
if (isOpen) {
panel.style.display = "none";
wrap.classList.remove("open");
} else {
_rebuildCkptList();
panel.style.display = "block";
wrap.classList.add("open");
document.getElementById("wfm-ckpt-search")?.focus();
}
});
// 外側クリックで閉じる
document.addEventListener("click", (e) => {
if (panel && panel.style.display !== "none" && !wrap?.contains(e.target)) {
panel.style.display = "none";
wrap?.classList.remove("open");
}
});
// 検索フィルター
document.getElementById("wfm-ckpt-search")?.addEventListener("input", _rebuildCkptList);
// 全選択
document.getElementById("wfm-ckpt-select-all")?.addEventListener("click", () => {
_ckptState.mode = "all";
_ckptState.selected.clear();
_rebuildCkptList();
_updateDropdownLabel();
_updateBatchInfo();
});
// 全解除
document.getElementById("wfm-ckpt-deselect-all")?.addEventListener("click", () => {
_ckptState.mode = "none";
_ckptState.selected.clear();
_rebuildCkptList();
_updateDropdownLabel();
_updateBatchInfo();
});
// Pause / Resume
document.getElementById("wfm-ckpt-batch-pause-btn")?.addEventListener("click", () => {
if (!_ckptBatch.paused) {
_ckptBatch.paused = true;
_setPauseBtnState(true);
} else {
_ckptBatch.paused = false;
_setPauseBtnState(false);
if (_ckptBatch._resumeResolve) {
_ckptBatch._resumeResolve();
_ckptBatch._resumeResolve = null;
}
}
});
}
// ============================================
// Generation (core — throws on error)
// ============================================
async function _coreGenerate(silent = false) {
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
}
}
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
if (images.length > 0) {
saveGeneratedImagesMeta(images, { ...comfyUI.currentWorkflow }).catch(() => {});
}
if (!silent) showToast("Generation complete", "success");
}
// ============================================
// Batch generation loop
// ============================================
async function _runBatchGenerate() {
const ckptNodes = comfyUI.currentAnalysis?.checkpoint_nodes || [];
if (ckptNodes.length === 0) {
showToast("No checkpoint node found in workflow", "error");
return;
}
const list = _getSelectedCheckpoints();
if (list.length === 0) {
showToast("No checkpoints selected", "error");
return;
}
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
const batchCount = document.getElementById("wfm-ckpt-batch-count");
const batchBar = document.getElementById("wfm-ckpt-batch-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
if (batchProgress) batchProgress.style.display = "block";
const pauseBtn = document.getElementById("wfm-ckpt-batch-pause-btn");
if (pauseBtn) { pauseBtn.style.display = "block"; pauseBtn.disabled = false; }
_setPauseBtnState(false);
let completed = 0;
let failed = 0;
try {
for (let i = 0; i < list.length; i++) {
if (_ckptBatch.aborted) break;
// 一時停止中は次のモデルへ進む前に待機
if (_ckptBatch.paused) {
if (batchCurrentName) batchCurrentName.textContent = "Paused...";
if (progressText) progressText.textContent = "Paused";
}
await _waitIfPaused();
if (_ckptBatch.aborted) break;
const model = list[i];
if (batchCurrentName) batchCurrentName.textContent = model;
if (batchCount) batchCount.textContent = `${i + 1} / ${list.length}`;
if (batchBar) batchBar.style.width = `${((i / list.length) * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `[${i + 1}/${list.length}] Loading...`;
for (const node of ckptNodes) {
if (comfyUI.currentWorkflow?.[node.id]) {
comfyUI.currentWorkflow[node.id].inputs.ckpt_name = model;
}
}
try {
await _coreGenerate(true);
completed++;
} catch (err) {
if (_ckptBatch.aborted) break;
failed++;
showToast(`[${i + 1}/${list.length}] Failed: ${err.message}`, "error");
}
}
} finally {
if (pauseBtn) pauseBtn.disabled = true;
_ckptBatch.paused = false;
if (_ckptBatch._resumeResolve) {
_ckptBatch._resumeResolve();
_ckptBatch._resumeResolve = null;
}
_setPauseBtnState(false);
}
if (batchBar) batchBar.style.width = "100%";
if (batchCurrentName) batchCurrentName.textContent = _ckptBatch.aborted ? "Stopped" : "Done";
if (_ckptBatch.aborted) {
showToast(`Batch stopped — ${completed} completed, ${failed} failed`, "info");
} else {
showToast(
`Batch complete: ${completed}/${list.length}${failed > 0 ? ` (${failed} failed)` : ""}`,
failed > 0 ? "error" : "success"
);
}
}
// ============================================
// Generate entry point
// ============================================
async function handleGenerate() {
@@ -124,84 +611,31 @@ async function handleGenerate() {
}
if (comfyUI.generating) return;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
// Sync prompts to workflow
comfyEditor.syncToWorkflow();
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
genBtn.disabled = true;
if (interruptBtn) interruptBtn.style.display = "inline-block";
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
_ckptBatch.aborted = false;
_ckptBatch.paused = false;
_ckptBatch._resumeResolve = null;
const batchEnabled = document.getElementById("wfm-ckpt-batch-enabled")?.checked;
try {
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
// Update seed display
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
// Display results
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
// Thumbnails for multiple images
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
if (batchEnabled) {
await _runBatchGenerate();
} else {
try {
await _coreGenerate(false);
} catch (err) {
const progressText = document.getElementById("wfm-gen-progress-text");
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
}
}
// Eagle auto-save
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
showToast("Generation complete", "success");
} catch (err) {
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
} finally {
genBtn.disabled = false;
if (interruptBtn) interruptBtn.style.display = "none";
@@ -235,13 +669,22 @@ export async function initGenerateTab() {
comfyEditor.renderAll(comfyUI.currentAnalysis, comfyUI.currentWorkflow);
}
showToast("Model lists refreshed", "success");
_updateDropdownLabel();
_updateBatchInfo();
});
// Generate button
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", handleGenerate);
// Interrupt button
// Interrupt button (stops both single generation and batch loop)
document.getElementById("wfm-gen-interrupt-btn")?.addEventListener("click", async () => {
_ckptBatch.aborted = true;
// 一時停止中でもループを抜けられるよう待機を解除
_ckptBatch.paused = false;
if (_ckptBatch._resumeResolve) {
_ckptBatch._resumeResolve();
_ckptBatch._resumeResolve = null;
}
await comfyUI.interrupt();
showToast("Interrupted", "info");
});
@@ -249,8 +692,13 @@ export async function initGenerateTab() {
// Move shared Raw JSON widget into the active tab's rawjson-col
function moveRawJsonToTab(tabKey) {
const widget = document.getElementById("wfm-gen-rawjson-widget");
if (!widget) return;
if (tabKey === "feeder") {
widget.style.display = "none";
return;
}
const col = document.getElementById(`wfm-gen-rawjson-col-${tabKey}`);
if (widget && col) {
if (col) {
col.appendChild(widget);
widget.style.display = "flex";
}
@@ -303,11 +751,25 @@ export async function initGenerateTab() {
} catch {}
}
// outputDir を事前取得(生成後の保存に使用)
_fetchOutputDir();
window.addEventListener("wfm-output-dir-changed", (e) => {
_outputDir = (e.detail?.path || "").replace(/\\/g, "/").replace(/\/$/, "");
});
// Checkpoint batch UI
initCheckpointBatch();
// Feeder tab
await initFeederTab();
// Auto-connect on init
const connected = await comfyUI.checkConnection();
updateStatus(connected);
if (connected) {
await comfyEditor.loadModelLists();
_updateDropdownLabel();
_updateBatchInfo();
}
// Auto-load default workflow (doesn't require ComfyUI connection)
+270 -30
View File
@@ -273,6 +273,28 @@ const LANGUAGES = {
modelsGroupExists: "Group already exists",
modelsRenamePrompt: "Enter new group name:",
modelsDeleteGroupConfirm: "Delete group \"{name}\"?",
modelEnable: "Enable",
modelDisable: "Disable",
modelDisabled: "Disabled",
modelStatusAll: "All",
modelStatusEnabled: "Enabled only",
modelStatusDisabled: "Disabled only",
modelGroupEnableAll: "Enable All",
modelGroupDisableAll: "Disable All",
modelToggleError: "Failed to toggle model",
modelStatusWarning: "Changes take effect after ComfyUI refresh",
modelSelectMode: "Select",
modelSelectExit: "Exit Select",
modelSelected: "selected",
modelBulkAddGroup: "Add to Group",
modelBulkRemoveGroup: "Remove from Group",
modelBulkCreateAdd: "Create & Add",
modelBulkAddDone: "models added to group",
modelBulkRemoveDone: "models removed from group",
modelBulkDelete: "Delete Files",
modelBulkDeleteConfirm: "Permanently delete {count} model file(s) and their preview images? This cannot be undone.",
modelBulkDeleteDone: "model file(s) deleted",
modelBulkDeleteError: "Failed to delete some models",
civitaiFetchDesc: "Fetch model information from CivitAI using SHA256 hash.",
civitaiFetch: "Fetch from CivitAI",
civitaiHashing: "Calculating hash... (may take a while for large files)",
@@ -318,14 +340,26 @@ const LANGUAGES = {
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
helpGen1: "Connect/disconnect ComfyUI and check connection status",
helpGen2: "Load workflows from Workflow tab (App format .app.json not supported)",
helpGen3: "3-tab layout — Input (Prompt + Image), Model, Settings — each with a Raw JSON column for instant preview and direct editing",
helpGen3: "4-tab layout — Input (Prompt + Image), Model, Settings, Feeder — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing",
helpGen4: "Input tab: Prompt (top) and Image drag-and-drop (bottom) in the left column",
helpGen5: "Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter",
helpGen6: "Settings tab: KSampler (top) and LatentImage (bottom) in the left column",
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow",
helpGen8: "Seed control: random, fixed, increment, decrement",
helpGen9: "Generate, progress display, stop, and view results",
helpGen10: "Eagle integration: auto-save generated images to Eagle",
helpGen11: "Checkpoint Batch: select checkpoints by folder from the dropdown — check a folder to select all its files, or expand with ▶ to check individual files; use Filter to search; Pause/Resume suspends and continues between models; Stop aborts after the current generation",
helpFeederTitle: "GenerateUI Tab — Feeder subtab",
helpFeederDesc: "Requires the comfyui-image-feeder custom node. Controls ImageFeeder nodes and runs continuous generation loops over an image library stored in user/default/image-loop-data/.",
helpFeeder1: "Left pane — Node settings: select the target ImageFeeder node from the dropdown; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection); click Apply to write changes to the loaded workflow",
helpFeeder2: "Left pane — Presets: save the current directory + selection as a named preset; load or delete saved presets",
helpFeeder3: "Left pane — Run: After gen mode determines index behavior after each generation — Loop (wrap back to 0 and continue indefinitely), Increment (advance index and stop when all images are exhausted), Fixed (always use the same index); click Run to start the loop, Stop to interrupt",
helpFeeder4: "Center pane — Folder tree: browse subfolders inside image-loop-data; click a folder to load its images; click ↺ to reload the tree",
helpFeeder5: "Center pane — Image grid: displays thumbnails of all images in the selected folder; check individual images to add them to the selection; All / None buttons select or clear the entire folder; click an image to open the preview panel",
helpFeeder6: "Center pane — Preview panel: shows the clicked image at larger size with filename, resolution, and file size",
helpFeeder7: "Status bar: shows the current folder path and how many images are selected out of the total in the folder",
helpFeeder8: "Seed during Run: uses the right-pane seed setting (Random / Fixed / Increment / Decrement) — the ImageFeeder's own Seed field controls random sort order only",
helpFeeder9: "Index sync: after each generation the node returns the next index via WebSocket; the Index field updates automatically so the display stays in sync",
helpPrompt1: "AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)",
helpPrompt2: "Apply AI responses to GenerateUI prompts",
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
@@ -333,6 +367,8 @@ const LANGUAGES = {
helpPrompt5: "Preset Manager: browse all presets, favorites, and group-based filtering",
helpPrompt6: "Group management: create groups, assign/remove presets, delete groups",
helpPrompt7: "GenUI Set: apply preset prompts directly to GenerateUI",
helpPrompt8: "Wildcard toolbar: one-click buttons to insert wildcard syntax ({|}, {n$|}, __|__, <lora:>, etc.); wraps selected text",
helpPrompt9: "Wildcard file manager: create/edit/delete .txt/.yaml wildcard files; click filename to insert __filename__ at cursor",
helpSettings1: "UI language and summary language",
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
helpSettings3: "Workflow data folder path",
@@ -340,7 +376,9 @@ const LANGUAGES = {
helpSettings5: "Ollama URL, default model, and connection test",
helpSettings6: "Default workflow setting",
helpSettings7: "Eagle integration URL and auto-save toggle",
helpSettings8: "All sections are collapsible accordion panels",
helpSettings8: "Data Management: export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import to restore. After importing, restart ComfyUI.",
helpSettings9: "All sections are collapsible accordion panels",
helpSettings10: "Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (directory junction on Windows / symlink on other OS); existing files migrated automatically",
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
helpNodes2: "Card and Table view modes with pagination",
helpNodes3: "Favorite nodes and add custom tags for organization",
@@ -349,24 +387,27 @@ const LANGUAGES = {
helpNodes6: "Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes",
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching",
helpModels2: "Thumbnail, Card, and Table view modes with pagination",
helpModels3: "Search, tag filter, group filter, and favorites filter",
helpModels3: "Search, tag filter, group filter, favorites filter, and status filter (All / Enabled only / Disabled only)",
helpModels4: "Side panel: Info (file path, tags, memo), Groups management, CivitAI integration",
helpModels5: "CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type",
helpModels6: "Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change",
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",
helpModels10: "Enable/Disable models: toggle visibility in ComfyUI by renaming file extension (.disabled suffix). Disable per model (⏸ button on each card) or per group (Enable All / Disable All in Group panel). Changes take effect after ComfyUI refresh.",
helpModels11: "Multi-select & bulk operations: click Select to enter selection mode, check multiple models, then use the bulk action bar to add/remove from groups or permanently delete files (model file + preview images + sidecar files such as .json/.info).",
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",
helpGallery5: "Folder tree: click to load images, expand with ▶; '+ New' creates a subfolder inside the selected folder, 'Del' deletes it with all contents",
helpGallery6: "Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All, Move To..., Delete",
helpGallery7: "Detail panel — Info tab: filename, size, date, tags (add/remove), memo; 'Move To...' moves the image to another folder, 'Delete' removes it permanently",
helpGallery8: "Detail panel — Metadata tab: displays workflow JSON from PNG embedded data (prompt / workflow keys) or from metadata saved by the Generate UI tab; one-click clipboard copy",
helpGallery9: "Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image",
helpGallery10: "Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the Metadata tab",
helpGallery11: "Supports PNG (tEXt/iTXt), JPEG, WebP, GIF; workflow extraction reads ComfyUI prompt and workflow keys embedded in PNG metadata",
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",
@@ -433,6 +474,14 @@ const LANGUAGES = {
eagleIntegration: "Eagle Integration (Optional)",
eagleUrl: "Eagle URL",
eagleAutoSave: "Auto-save generated images to Eagle",
dataManagement: "Data Management",
dataManagementHint: "Export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import a previously exported file to restore data. After importing, restart ComfyUI to apply changes.",
exportData: "Export",
importData: "Import",
exportSuccess: "Export complete",
exportError: "Export failed",
importSuccess: "Import complete",
importError: "Import failed",
saveSettings: "Save Settings",
settingsSaved: "Settings saved",
ollamaSaved: "Ollama settings saved",
@@ -492,6 +541,37 @@ const LANGUAGES = {
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
// -- Metadata tab --
tabMetadata: "Metadata",
metaSectionCkpt: "Checkpoint",
metaSectionVae: "VAE",
metaSectionDiff: "Diffusion Model",
metaSectionTe: "Text Encoder",
metaSectionLora: "LoRA",
metaSectionPrompt: "Prompt",
metaDropMain: "Drop image / JSON",
metaDropSub: "PNG · WebP · JSON",
metaParsing: "Parsing...",
metaFileTooLarge: "File too large (max 50 MB)",
metaNoMetadata: "No metadata found",
metaParseError: "Failed to parse metadata",
metaPromptPositive: "Positive",
metaPromptNegative: "Negative",
metaPromptText: "Text",
metaSelectPrompt: "Select a prompt above to view full text...",
metaFormatNoteTitle: "Supported formats",
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG (parameters)",
metaFmtFooocus: "Fooocus — PNG (fooocus_scheme)",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image subgraph workflows supported",
helpMetadataTitle: "Metadata Tab",
helpMetadataDesc: "Drop a ComfyUI-generated PNG/WebP or workflow JSON to instantly extract and view the embedded model and prompt information.",
helpMetadata1: "Left column: drop zone (image preview for PNG/WebP) and file info; click or drop to load",
helpMetadata2: "Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow",
helpMetadata3: "Right column: LoRA list (with strength values) and Prompt list (click to view full text below)",
helpMetadata4: "Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative",
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image subgraph workflows), SD WebUI, SD Forge, Fooocus.",
},
ja: {
@@ -763,6 +843,28 @@ const LANGUAGES = {
modelsGroupExists: "同名のグループが既に存在します",
modelsRenamePrompt: "新しいグループ名を入力:",
modelsDeleteGroupConfirm: "グループ「{name}」を削除しますか?",
modelEnable: "有効化",
modelDisable: "無効化",
modelDisabled: "無効",
modelStatusAll: "すべて",
modelStatusEnabled: "有効のみ",
modelStatusDisabled: "無効のみ",
modelGroupEnableAll: "全て有効化",
modelGroupDisableAll: "全て無効化",
modelToggleError: "切り替えに失敗しました",
modelStatusWarning: "ComfyUIをリフレッシュ後に反映されます",
modelSelectMode: "選択",
modelSelectExit: "選択終了",
modelSelected: "件選択中",
modelBulkAddGroup: "グループに追加",
modelBulkRemoveGroup: "グループから削除",
modelBulkCreateAdd: "作成して追加",
modelBulkAddDone: "件をグループに追加しました",
modelBulkRemoveDone: "件をグループから削除しました",
modelBulkDelete: "ファイルを削除",
modelBulkDeleteConfirm: "{count}件のモデルファイルとプレビュー画像を完全に削除しますか?この操作は取り消せません。",
modelBulkDeleteDone: "件のモデルファイルを削除しました",
modelBulkDeleteError: "一部のモデルの削除に失敗しました",
civitaiFetchDesc: "SHA256ハッシュを使用してCivitAIからモデル情報を取得します。",
civitaiFetch: "CivitAIから取得",
civitaiHashing: "ハッシュ計算中...(大きいファイルは時間がかかります)",
@@ -808,14 +910,26 @@ const LANGUAGES = {
helpWf7: "バッジ管理:バッジの追加・名前変更・削除、カスタム色設定(Modelsタブと共有)— ⚙ Badgeボタン",
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
helpGen2: "Workflowタブからワークフローを読み込み(App形式 .app.json は非対応)",
helpGen3: "3タブ構成 — Input(プロンプト+画像)、Model、Settings — 各タブにRaw JSON列を常時表示",
helpGen3: "4タブ構成 — Input(プロンプト+画像)、Model、Settings、Feeder — Input/Model/SettingsタブにはRaw JSON列を常時表示",
helpGen4: "Inputタブ:左列にPrompt(上段)+ Image ドラッグ&ドロップ(下段)",
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA/ControlNet/UNET/TextEncoderのセレクタ(フィルタ検索付き)",
helpGen6: "Settingsタブ:左列にKSampler(上段)+ LatentImage(下段)",
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映",
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
helpGen9: "画像生成、進捗表示、生成停止、結果表示",
helpGen10: "Eagle連携:生成画像をEagleへ自動保存",
helpGen11: "Checkpoint Batch:ドロップダウンのフォルダツリーからチェックポイントを選択 — フォルダにチェックでそのフォルダ内を一括選択、▶で展開して個別選択も可能;Filterで絞り込み;Pause/Resumeでモデル間の処理を一時停止・再開;Stopで現在の生成完了後に中断",
helpFeederTitle: "GenerateUI Tab — Feeder サブタブ",
helpFeederDesc: "comfyui-image-feeder カスタムノードが必要です。ImageFeeder ノードを操作し、user/default/image-loop-data/ に保存された画像ライブラリを使って連続生成ループを実行します。",
helpFeeder1: "左ペイン — ノード設定:ドロップダウンで対象 ImageFeeder ノードを選択;Directory・Sort Mode・Index・Start/End Index・Batch Size・Seed・Use Selection の各パラメータを編集;Apply で読み込み済みワークフローに反映",
helpFeeder2: "左ペイン — プリセット:現在のフォルダ+選択ファイルを名前付きプリセットとして保存;保存済みプリセットの読み込み・削除",
helpFeeder3: "左ペイン — Run:After gen モードで各生成後の index 動作を設定 — Loop(index を 0 に折り返して無限継続)、Increment(index を進め全画像消化で自動停止)、Fixed(常に同じ index を使用);Run ボタンでループ開始、Stop で中断",
helpFeeder4: "中央ペイン — フォルダツリー:image-loop-data 内のサブフォルダを階層表示;クリックで画像を読み込み;↺ ボタンでツリー再読み込み",
helpFeeder5: "中央ペイン — 画像グリッド:選択フォルダ内の画像をサムネイル表示;チェックボックスで選択に追加;All/None ボタンで一括選択・解除;クリックでプレビューパネルに表示",
helpFeeder6: "中央ペイン — プレビューパネル:クリックした画像を拡大表示し、ファイル名・解像度・ファイルサイズを確認",
helpFeeder7: "ステータスバー:現在のフォルダパスと、そのフォルダ内の総枚数に対する選択枚数を表示",
helpFeeder8: "Run 時のシード:右ペインのシード設定(ランダム / 固定 / 増加 / 減少)を使用 — ImageFeeder 自身の Seed フィールドはランダムソートの並び順のみに影響",
helpFeeder9: "Index 同期:生成完了後にノードが次の index を WebSocket で返送;Index フィールドが自動更新されて表示と実行状態が一致",
helpPrompt1: "AIアシスタント(Ollama):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応)",
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
@@ -823,6 +937,8 @@ const LANGUAGES = {
helpPrompt5: "プリセットマネージャー:全プリセット一覧、お気に入り、グループ別フィルタリング",
helpPrompt6: "グループ管理:グループ作成、プリセットの割り当て・解除、グループ削除",
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
helpPrompt8: "ワイルドカードツールバー:{|}・{n$|}・__|__・<lora:>などの構文をワンクリック挿入。選択テキストのラップも対応",
helpPrompt9: "ワイルドカードファイルマネージャー:.txt/.yamlファイルの作成・編集・削除。ファイル名をクリックすると__ファイル名__をカーソル位置に挿入",
helpSettings1: "UI言語と要約言語の設定",
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
@@ -830,7 +946,9 @@ const LANGUAGES = {
helpSettings5: "Ollama URL、デフォルトモデル、接続テスト",
helpSettings6: "デフォルトワークフローの設定・解除",
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
helpSettings8: "各セクションはアコーディオン式で折りたたみ可能",
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど全データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
helpSettings9: "各セクションはアコーディオン式で折りたたみ可能",
helpSettings10: "Wildcard Integration:WFSのワイルドカードディレクトリをComfyUI-Impact-Packのwildcardsディレクトリへリンク(Windowsはジャンクション、他OSはシンボリックリンク)。既存ファイルは自動で移行されます",
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
@@ -839,24 +957,27 @@ const LANGUAGES = {
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embeddingをサブタブで切り替え",
helpModels2: "サムネイル / カード / テーブル表示モード切り替え(ページネーション付き)",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ、ステータスフィルタ(すべて / 有効のみ / 無効のみ)",
helpModels4: "サイドパネル:基本情報(ファイルパス、タグ、メモ)、グループ管理、CivitAI連携",
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得",
helpModels6: "詳細モーダル:プレビュー画像、CivitAI情報(ベースモデル、トリガーワード、タグ)、サムネイル変更",
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
helpModels8: "GenUI Modelボタン:選択モデルをGenerateUIの現在のワークフローの該当ノードに直接適用(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "ユーザー定義バッジ:自由ラベルのバッジをモデルに付与(Workflowタブと色パレットを共有)",
helpModels10: "モデルの有効/無効切り替え:拡張子リネーム(.disabledサフィックス)でComfyUIへの表示を制御。各カードの⏸ボタンで単体操作、グループパネルの「全て有効化 / 全て無効化」でグループ一括操作。変更はComfyUIリフレッシュ後に反映。",
helpModels11: "複数選択と一括操作:「選択」ボタンで選択モードに入り複数モデルにチェックを入れると、一括操作バーからグループへの追加/削除、またはファイルの完全削除(モデルファイル+プレビュー画像+.json/.infoなどのサイドカーファイル)が行えます。",
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: "フォルダツリー:フォルダをクリックして画像を読み込む;▶矢印でサブフォルダを展開",
helpGallery5: "フォルダツリー:クリックで画像を読み込み;▶で展開;「+ New」で選択フォルダ内にサブフォルダを作成;「Del」でそのフォルダとすべての内容を削除",
helpGallery6: "Ctrl+クリックで複数選択 — 一括操作:グループへ追加、一括お気に入り、一括お気に入り解除、移動(Move To...)、削除",
helpGallery7: "詳細パネル — Infoタブ:ファイル名、サイズ、日付、タグ(追加/削除)、メモ;「Move To...」で他フォルダへ移動;「Delete」で完全削除",
helpGallery8: "詳細パネル — Metadataタブ:PNGの埋め込みデータ(prompt / workflow キー)またはGenerateUIタブで保存したメタデータからワークフローJSONを表示;ワンクリックでクリップボードコピー",
helpGallery9: "詳細パネル — Groupsタブ:画像ごとのグループ作成、割り当て、解除、名前変更、削除",
helpGallery10: "ワークフロー自動保存:GenerateUIタブで生成した画像はワークフローが自動でギャラリーメタデータに保存され、Metadataタブで確認可能",
helpGallery11: "PNG (tEXt/iTXt)、JPEG、WebP、GIF対応;PNG形式はComfyUIの埋め込みJSON(prompt / workflow キー)を読み込んでワークフロー抽出が可能",
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
helpSidepanel2: "Workflowsタブ:All / ★ Favorites / Groupsサブタブ、Allビューでお気に入りに★表示",
@@ -923,6 +1044,14 @@ const LANGUAGES = {
eagleIntegration: "Eagle 連携 (オプション)",
eagleUrl: "Eagle URL",
eagleAutoSave: "生成画像をEagleに自動保存",
dataManagement: "データ管理",
dataManagementHint: "設定・メタデータ・プロンプトなど全データをJSONファイルにエクスポート、またはインポートして復元できます。インポート後はComfyUIを再起動してください。",
exportData: "エクスポート",
importData: "インポート",
exportSuccess: "エクスポート完了",
exportError: "エクスポート失敗",
importSuccess: "インポート完了",
importError: "インポート失敗",
saveSettings: "設定を保存",
settingsSaved: "設定を保存しました",
ollamaSaved: "Ollama設定を保存しました",
@@ -982,6 +1111,37 @@ const LANGUAGES = {
themeFontTrainOne: "トレイン One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
// -- Metadata tab --
tabMetadata: "メタデータ",
metaSectionCkpt: "Checkpoint",
metaSectionVae: "VAE",
metaSectionDiff: "Diffusion Model",
metaSectionTe: "Text Encoder",
metaSectionLora: "LoRA",
metaSectionPrompt: "プロンプト",
metaDropMain: "画像 / JSON をドロップ",
metaDropSub: "PNG · WebP · JSON",
metaParsing: "解析中...",
metaFileTooLarge: "ファイルが大きすぎます(最大 50 MB)",
metaNoMetadata: "メタデータが見つかりません",
metaParseError: "メタデータの解析に失敗しました",
metaPromptPositive: "ポジティブ",
metaPromptNegative: "ネガティブ",
metaPromptText: "テキスト",
metaSelectPrompt: "上のプロンプトを選択すると全文を表示します...",
metaFormatNoteTitle: "対応フォーマット",
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters チャンク)",
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme チャンク)",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image サブグラフ形式に対応済み",
helpMetadataTitle: "Metadata タブ",
helpMetadataDesc: "ComfyUI で生成した PNG / WebP または ワークフロー JSON をドロップすると、埋め込まれたモデル・プロンプト情報を即座に抽出・表示します。",
helpMetadata1: "左列: ドロップゾーン(PNG/WebP は画像プレビュー表示)+ファイル情報・対応フォーマット説明",
helpMetadata2: "中列: ワークフローから抽出した Checkpoint・VAE・Diffusion Model・Text Encoder の一覧",
helpMetadata3: "右列: LoRA 一覧(強度値付き)+プロンプト一覧(クリックで下部に全文表示)",
helpMetadata4: "プロンプトアクション — Copy: クリップボードにコピー; GenUI:P/N: GenerateUI のポジティブ/ネガティブプロンプトにセット; Prompt:P/N: Prompt タブ Presets のポジティブ/ネガティブにセット",
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
},
zh: {
@@ -1249,6 +1409,28 @@ const LANGUAGES = {
modelsGroupExists: "同名分组已存在",
modelsRenamePrompt: "输入新分组名称:",
modelsDeleteGroupConfirm: "删除分组「{name}」?",
modelEnable: "启用",
modelDisable: "禁用",
modelDisabled: "已禁用",
modelStatusAll: "全部",
modelStatusEnabled: "仅启用",
modelStatusDisabled: "仅禁用",
modelGroupEnableAll: "全部启用",
modelGroupDisableAll: "全部禁用",
modelToggleError: "切换失败",
modelStatusWarning: "刷新ComfyUI后生效",
modelSelectMode: "选择",
modelSelectExit: "退出选择",
modelSelected: "个已选中",
modelBulkAddGroup: "添加到分组",
modelBulkRemoveGroup: "从分组移除",
modelBulkCreateAdd: "创建并添加",
modelBulkAddDone: "个模型已添加到分组",
modelBulkRemoveDone: "个模型已从分组移除",
modelBulkDelete: "删除文件",
modelBulkDeleteConfirm: "永久删除 {count} 个模型文件及其预览图?此操作无法撤销。",
modelBulkDeleteDone: "个模型文件已删除",
modelBulkDeleteError: "部分模型删除失败",
civitaiFetchDesc: "使用SHA256哈希从CivitAI获取模型信息。",
civitaiFetch: "从CivitAI获取",
civitaiHashing: "计算哈希中...(大文件可能需要较长时间)",
@@ -1294,14 +1476,26 @@ const LANGUAGES = {
helpWf7: "徽章管理:添加、重命名、删除徽章并自定义颜色(与Models标签共享)— ⚙ Badge按钮",
helpGen1: "连接/断开ComfyUI,检查连接状态",
helpGen2: "从工作流标签页加载工作流(不支持App格式.app.json)",
helpGen3: "3标签布局 — Input(提示词+图片)、Model、Settings — 每个标签含Raw JSON列实时预览",
helpGen3: "4标签布局 — Input(提示词+图片)、Model、Settings、Feeder — Input/Model/Settings标签各含Raw JSON列实时预览",
helpGen4: "Input标签:左列为Prompt(上)+ Image拖放(下)",
helpGen5: "Model标签:Checkpoint/VAE/LoRA/ControlNet/UNET/TextEncoder选择器(含筛选)",
helpGen6: "Settings标签:左列为KSampler(上)+ LatentImage(下)",
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流",
helpGen8: "种子管理:随机/固定/递增/递减",
helpGen9: "生成图片、进度显示、停止生成、查看结果",
helpGen10: "Eagle集成:自动保存生成图片到Eagle",
helpGen11: "Checkpoint Batch:从下拉框文件夹树中选择检查点 — 勾选文件夹可一键选中该文件夹内的所有文件,▶展开后也可单独选择;使用Filter筛选;Pause/Resume可在模型之间暂停和继续;Stop在当前生成完成后中止",
helpFeederTitle: "GenerateUI Tab — Feeder 子标签",
helpFeederDesc: "需要 comfyui-image-feeder 自定义节点。用于操控 ImageFeeder 节点,并对存储在 user/default/image-loop-data/ 中的图片库执行连续生成循环。",
helpFeeder1: "左侧面板 — 节点设置:从下拉框选择目标 ImageFeeder 节点;编辑所有节点参数(Directory、Sort Mode、Index、Start/End Index、Batch Size、Seed、Use Selection);点击 Apply 将更改写入当前工作流",
helpFeeder2: "左侧面板 — 预设:将当前目录+选择文件保存为命名预设;加载或删除已保存的预设",
helpFeeder3: "左侧面板 — Run:After gen 模式决定每次生成后的 index 行为 — Loop(回到0并无限循环)、Increment(递增 index,全部图片用完后自动停止)、Fixed(始终使用相同 index);点击 Run 开始循环,Stop 中断",
helpFeeder4: "中央面板 — 文件夹树:浏览 image-loop-data 内的子文件夹;点击文件夹加载图片;点击 ↺ 重新加载目录树",
helpFeeder5: "中央面板 — 图片网格:缩略图显示所选文件夹内的所有图片;勾选图片加入选择集;All/None 按钮一键全选或清除;点击图片打开预览面板",
helpFeeder6: "中央面板 — 预览面板:放大显示点击的图片,并显示文件名、分辨率和文件大小",
helpFeeder7: "状态栏:显示当前文件夹路径,以及该文件夹内已选中图片数占总数的比例",
helpFeeder8: "Run 时的种子:使用右侧面板的种子设置(随机/固定/递增/递减)— ImageFeeder 自身的 Seed 字段仅影响随机排序的顺序",
helpFeeder9: "Index 同步:每次生成完成后节点通过 WebSocket 返回下一个 index;Index 字段自动更新,保持显示与执行状态一致",
helpPrompt1: "AI助手(Ollama):模型选择、聊天咨询提示词、图片附件(视觉模型)",
helpPrompt2: "将AI回复应用到生成UI的提示词",
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
@@ -1309,6 +1503,8 @@ const LANGUAGES = {
helpPrompt5: "预设管理器:浏览所有预设、收藏、按组筛选",
helpPrompt6: "组管理:创建组、分配/移除预设、删除组",
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
helpPrompt8: "通配符工具栏:一键插入通配符语法({|}、{n$|}、__|__、<lora:>等);支持包裹选中文本",
helpPrompt9: "通配符文件管理:创建/编辑/删除.txt/.yaml通配符文件;点击文件名在光标处插入__文件名__",
helpSettings1: "UI语言和摘要语言设置",
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
helpSettings3: "工作流数据文件夹路径更改/重置",
@@ -1316,7 +1512,9 @@ const LANGUAGES = {
helpSettings5: "Ollama URL、默认模型、连接测试",
helpSettings6: "默认工作流设置/取消",
helpSettings7: "Eagle集成URL、自动保存开关",
helpSettings8: "各部分可折叠展开",
helpSettings8: "数据管理:将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
helpSettings9: "各部分可折叠展开",
helpSettings10: "Wildcard Integration:将WFS通配符目录链接到ComfyUI-Impact-Pack的wildcards目录(Windows为目录联结,其他OS为符号链接);现有文件自动迁移",
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
helpNodes2: "卡片和表格视图模式切换(带分页)",
helpNodes3: "收藏节点,添加自定义标签进行整理",
@@ -1325,24 +1523,27 @@ const LANGUAGES = {
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embedding,通过子标签切换",
helpModels2: "缩略图、卡片和表格视图模式切换(带分页)",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选、状态筛选(全部 / 仅启用 / 仅禁用)",
helpModels4: "侧面板:基本信息(文件路径、标签、备注)、分组管理、CivitAI集成",
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取",
helpModels6: "详情弹窗:预览图片、CivitAI信息(基础模型、触发词、标签)、缩略图更改",
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
helpModels8: "GenUI Model按钮:将选中模型直接应用到GenerateUI当前工作流的对应节点(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "用户自定义徽章:为模型添加自由标签徽章(颜色调色板与Workflow标签共享)",
helpModels10: "模型启用/禁用:通过重命名文件扩展名(添加.disabled后缀)控制ComfyUI中的显示。点击每个卡片上的⏸按钮单独操作,或在分组面板中使用「全部启用/全部禁用」进行批量操作。更改在刷新ComfyUI后生效。",
helpModels11: "多选与批量操作:点击「选择」按钮进入选择模式,勾选多个模型后,可通过批量操作栏将其添加/移出分组,或永久删除文件(模型文件+预览图+.json/.info等附属文件)。",
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: "文件夹树:点击文件夹加载其包含的图像;使用 ▶ 箭头展开子文件夹",
helpGallery5: "文件夹树:点击加载图像,▶展开;'+ New'在选定文件夹内创建子文件夹;'Del'删除该文件夹及其所有内容",
helpGallery6: "使用Ctrl+点击多选 — 批量操作:添加到分组、全部收藏、取消全部收藏、移动到...、删除",
helpGallery7: "详情面板 — 信息(Info)标签:文件名、大小、日期、标签(添加/移除)、备注;'移动到...'将图像移至另一文件夹;'删除'永久删除",
helpGallery8: "详情面板 — 元数据(Metadata)标签:显示来自PNG嵌入数据(prompt / workflow键)或Generate UI标签保存的工作流JSON;一键复制到剪贴板",
helpGallery9: "详情面板 — 分组(Groups)标签:对每个图像进行组的创建、分配、移除、重命名和删除",
helpGallery10: "工作流自动保存:在Generate UI标签生成的图像,其工作流会自动保存到图库元数据,并在Metadata标签中显示",
helpGallery11: "支持PNG(tEXt/iTXt)、JPEG、WebP、GIF;PNG格式读取ComfyUI嵌入的JSON(prompt / workflow键)提取工作流",
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
helpSidepanel2: "Workflows标签:All / ★ Favorites / Groups子标签,All视图中收藏项显示★",
@@ -1409,6 +1610,14 @@ const LANGUAGES = {
eagleIntegration: "Eagle 集成 (可选)",
eagleUrl: "Eagle URL",
eagleAutoSave: "自动保存生成图片到Eagle",
dataManagement: "数据管理",
dataManagementHint: "将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入之前导出的文件以恢复数据。导入后请重启ComfyUI以使更改生效。",
exportData: "导出",
importData: "导入",
exportSuccess: "导出完成",
exportError: "导出失败",
importSuccess: "导入完成",
importError: "导入失败",
saveSettings: "保存设置",
settingsSaved: "设置已保存",
ollamaSaved: "Ollama设置已保存",
@@ -1468,6 +1677,37 @@ const LANGUAGES = {
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
// -- Metadata tab --
tabMetadata: "元数据",
metaSectionCkpt: "Checkpoint",
metaSectionVae: "VAE",
metaSectionDiff: "扩散模型",
metaSectionTe: "文本编码器",
metaSectionLora: "LoRA",
metaSectionPrompt: "提示词",
metaDropMain: "拖放图片 / JSON",
metaDropSub: "PNG · WebP · JSON",
metaParsing: "解析中...",
metaFileTooLarge: "文件过大(最大 50 MB)",
metaNoMetadata: "未找到元数据",
metaParseError: "元数据解析失败",
metaPromptPositive: "正向提示",
metaPromptNegative: "负向提示",
metaPromptText: "文本",
metaSelectPrompt: "选择上方提示词以查看全文...",
metaFormatNoteTitle: "支持格式",
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters)",
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme)",
metaFormatTodo: "已支持 Flux.2 / Qwen-Image / Z-Image 子图工作流",
helpMetadataTitle: "Metadata 标签页",
helpMetadataDesc: "拖放 ComfyUI 生成的 PNG/WebP 或工作流 JSON,即可提取并显示其中的模型和提示词信息。",
helpMetadata1: "左列:拖放区(PNG/WebP 显示图片预览)及文件信息与支持格式说明",
helpMetadata2: "中列:从工作流中提取的 Checkpoint、VAE、扩散模型、文本编码器列表",
helpMetadata3: "右列:LoRA 列表(含强度值)和提示词列表(点击在下方显示全文)",
helpMetadata4: "提示词操作 — Copy:复制到剪贴板;GenUI:P/N:设置 GenerateUI 正/负向提示;Prompt:P/N:设置 Prompt 标签页预设正/负向提示",
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image 子图工作流)、SD WebUI、SD Forge、Fooocus。",
},
};
+749
View File
@@ -0,0 +1,749 @@
/**
* Metadata Tab
* Drop a PNG/WebP/JSON file to extract and display model/prompt metadata.
* Parsing logic adapted from model-and-prompt-from-metadata (workflow_utils.js).
*/
import { t } from "./i18n.js";
// ── File size limit ───────────────────────────────────────────
const MAX_FILE_SIZE = 50 * 1024 * 1024;
// ── Sanitize JSON (NaN/Infinity → null) ──────────────────────
function sanitizeJSON(text) {
return text
.replace(/-Infinity\b/g, "null")
.replace(/\bInfinity\b/g, "null")
.replace(/\bNaN\b/g, "null");
}
// ── WebP EXIF ────────────────────────────────────────────────
async function readWebPEXIFChunk(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const ascii = new TextDecoder("latin1");
if (bytes.byteLength < 12) return null;
if (ascii.decode(bytes.slice(0, 4)) !== "RIFF") return null;
if (ascii.decode(bytes.slice(8, 12)) !== "WEBP") return null;
let offset = 12;
while (offset + 8 <= buffer.byteLength) {
const fourcc = ascii.decode(bytes.slice(offset, offset + 4));
const chunkSize = view.getUint32(offset + 4, true);
if (fourcc === "EXIF") return bytes.slice(offset + 8, offset + 8 + chunkSize);
offset += 8 + chunkSize;
if (chunkSize % 2 === 1) offset++;
}
return null;
}
function extractWorkflowFromEXIF(exifBytes) {
const utf8 = new TextDecoder("utf-8", { fatal: false });
const text = utf8.decode(exifBytes);
for (const key of ["workflow:", "prompt:"]) {
const idx = text.indexOf(key + "{");
if (idx < 0) continue;
let jsonStr = text.slice(idx + key.length);
const nullIdx = jsonStr.indexOf("\x00");
if (nullIdx >= 0) jsonStr = jsonStr.slice(0, nullIdx);
try { return JSON.parse(sanitizeJSON(jsonStr)); } catch {
const lb = jsonStr.lastIndexOf("}");
if (lb > 0) { try { return JSON.parse(sanitizeJSON(jsonStr.slice(0, lb + 1))); } catch {} }
}
}
return null;
}
// ── PNG text chunks ───────────────────────────────────────────
function findNull(arr, start = 0) {
for (let i = start; i < arr.length; i++) if (arr[i] === 0) return i;
return -1;
}
function parseTEXtChunk(data, latin1) {
const np = findNull(data);
if (np === -1) return null;
return { keyword: latin1.decode(data.slice(0, np)), text: latin1.decode(data.slice(np + 1)) };
}
function parseITXtChunk(data, latin1, utf8) {
const np = findNull(data);
if (np === -1) return null;
const keyword = latin1.decode(data.slice(0, np));
let pos = np + 3;
pos = findNull(data, pos); if (pos === -1) return null; pos++;
pos = findNull(data, pos); if (pos === -1) return null; pos++;
return { keyword, text: utf8.decode(data.slice(pos)) };
}
async function readAllPNGTextChunks(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return null;
const view = new DataView(buffer);
const latin1 = new TextDecoder("latin1");
const utf8 = new TextDecoder("utf-8");
let offset = 8;
const chunks = {};
while (offset + 12 <= buffer.byteLength) {
const length = view.getUint32(offset);
if (offset + 12 + length > buffer.byteLength) break;
const type = latin1.decode(bytes.slice(offset + 4, offset + 8));
const data = bytes.slice(offset + 8, offset + 8 + length);
if (type === "tEXt") { const c = parseTEXtChunk(data, latin1); if (c) chunks[c.keyword] = c.text; }
else if (type === "iTXt") { const c = parseITXtChunk(data, latin1, utf8); if (c) chunks[c.keyword] = c.text; }
offset += 12 + length;
}
return chunks;
}
// ── Workflow extraction helpers ───────────────────────────────
function collectUnique(arr) {
const seen = new Set(), out = [];
for (const v of arr) { if (v && typeof v === "string" && !seen.has(v)) { seen.add(v); out.push(v); } }
return out;
}
function collectAllNodes(workflow) {
if (!Array.isArray(workflow.nodes)) return [];
const all = [...workflow.nodes];
for (const sg of workflow.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) all.push(...sg.nodes);
return all;
}
const META_NODE_TYPES = new Set(["ImageMetadataCheckpointLoader", "ImageMetadataPromptLoader"]);
const VAE_NONE = "None";
function extractCheckpoints(wf) {
if (!wf || typeof wf !== "object") return [];
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => n.type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n.type)).map(n => n.widgets_values?.[0]));
return collectUnique(Object.values(wf).filter(n => n?.class_type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n?.class_type)).map(n => n.inputs?.ckpt_name));
}
function extractVAEs(wf) {
if (!wf || typeof wf !== "object") return [];
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).flatMap(n => { if (n.type === "VAELoader") return [n.widgets_values?.[0]]; if (META_NODE_TYPES.has(n.type ?? "")) { const v = n.widgets_values?.[1]; return v && v !== VAE_NONE ? [v] : []; } return []; }));
return collectUnique(Object.values(wf).flatMap(n => { if (!n || typeof n !== "object") return []; if (n.class_type === "VAELoader") return [n.inputs?.vae_name]; if (META_NODE_TYPES.has(n.class_type ?? "")) { const v = n.inputs?.vae_name; return v && v !== VAE_NONE ? [v] : []; } return []; }));
}
function extractDiffusionModels(wf) {
if (!wf || typeof wf !== "object") return [];
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => n.type === "UNETLoader").map(n => n.widgets_values?.[0]));
return collectUnique(Object.values(wf).filter(n => n?.class_type === "UNETLoader").map(n => n.inputs?.unet_name));
}
function extractTextEncoders(wf) {
if (!wf || typeof wf !== "object") return [];
const names = [];
if (Array.isArray(wf.nodes)) {
for (const n of collectAllNodes(wf)) {
if (n.type === "CLIPLoader") { if (n.widgets_values?.[0]) names.push(n.widgets_values[0]); }
else if (n.type === "DualCLIPLoader") { [0,1].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
else if (n.type === "TripleCLIPLoader") { [0,1,2].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
}
} else {
for (const n of Object.values(wf)) {
if (!n || typeof n !== "object") continue;
const ct = n.class_type ?? "";
if (ct === "CLIPLoader") { if (n.inputs?.clip_name) names.push(n.inputs.clip_name); }
else if (ct === "DualCLIPLoader") { if (n.inputs?.clip_name1) names.push(n.inputs.clip_name1); if (n.inputs?.clip_name2) names.push(n.inputs.clip_name2); }
else if (ct === "TripleCLIPLoader") { ["clip_name1","clip_name2","clip_name3"].forEach(k => { if (n.inputs?.[k]) names.push(n.inputs[k]); }); }
}
}
return collectUnique(names);
}
function extractLoRAs(wf) {
if (!wf || typeof wf !== "object") return [];
const results = [], seen = new Set();
function add(name, sm, sc) {
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
seen.add(name);
results.push({ name, strength_model: typeof sm === "number" ? sm : 1.0, strength_clip: typeof sc === "number" ? sc : 1.0 });
}
if (Array.isArray(wf.nodes)) {
for (const n of collectAllNodes(wf)) {
const type = n.type ?? "";
if (type === "LoraLoader") add(n.widgets_values?.[0], n.widgets_values?.[1], n.widgets_values?.[2]);
else if (type === "LoraLoaderModelOnly") add(n.widgets_values?.[0], n.widgets_values?.[1], 1.0);
else if (type === "ImageMetadataLoRALoader") { for (let i = 0; i < 3; i++) add(n.widgets_values?.[i*3], n.widgets_values?.[i*3+1], n.widgets_values?.[i*3+2]); }
else if (type === "Lora Loader (LoraManager)") { const list = n.widgets_values?.find(v => Array.isArray(v)); if (list) for (const l of list) { if (l?.active !== false) add(l?.name, l?.strength ?? 1.0, l?.clipStrength ?? l?.strength ?? 1.0); } }
}
} else {
for (const n of Object.values(wf)) {
if (!n || typeof n !== "object") continue;
const ct = n.class_type ?? "";
if (ct === "LoraLoader") add(n.inputs?.lora_name, n.inputs?.strength_model, n.inputs?.strength_clip);
else if (ct === "LoraLoaderModelOnly") add(n.inputs?.lora_name, n.inputs?.strength, 1.0);
}
}
return results;
}
function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("TextEncode") || ct.includes("TextEncoderSD"); }
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
function extractPrompts(wf) {
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
}
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
function extractPromptsFromNodeSet(nodes, links) {
const nodeMap = new Map();
for (const n of nodes) nodeMap.set(n.id, n);
const linkOrigin = new Map(), linkSlot = new Map();
if (Array.isArray(links)) {
for (const lk of links) {
if (Array.isArray(lk)) {
linkOrigin.set(lk[0], lk[1]);
linkSlot.set(lk[0], lk[2] ?? 0);
} else if (lk && typeof lk === "object") {
const id = lk.id ?? lk[0], origin = lk.origin_id ?? lk[1], slot = lk.origin_slot ?? lk[2] ?? 0;
if (id != null && origin != null) { linkOrigin.set(id, origin); linkSlot.set(id, slot); }
}
}
}
const textMap = new Map();
for (const n of nodes) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const text = n.widgets_values?.[0];
if (text && typeof text === "string") {
textMap.set(n.id, text);
} else if (Array.isArray(n.inputs)) {
const textInput = n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt");
if (textInput?.link != null) {
const originId = linkOrigin.get(textInput.link);
const originSlot = linkSlot.get(textInput.link) ?? 0;
const srcNode = originId != null ? nodeMap.get(originId) : null;
if (srcNode) {
const srcType = srcNode.type ?? "";
if (isPromptStylerNode(srcType)) {
const v = srcNode.widgets_values?.[originSlot];
if (v && typeof v === "string") textMap.set(n.id, v);
} else {
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
if (v && typeof v === "string") textMap.set(n.id, v);
}
}
}
}
}
const pos = new Set(), neg = new Set();
let foundSampler = false;
for (const n of nodes) {
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
foundSampler = true;
for (const inp of n.inputs) {
if (!inp || inp.link == null) continue;
const originId = linkOrigin.get(inp.link);
if (originId == null) continue;
const text = textMap.get(originId);
if (!text) continue;
const name = inp.name ?? "";
if (name === "positive" || name.startsWith("positive")) pos.add(text);
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
}
}
if (!foundSampler) return null;
// SamplerCustomAdvanced などで positive/negative 直結がない場合、判別不能テキストとして返す
if (pos.size === 0 && neg.size === 0) {
const allTexts = [...textMap.values()].filter(t => t.trim());
if (allTexts.length > 0) return { positives: [], negatives: [], texts: allTexts };
return null;
}
return { positives: [...pos], negatives: [...neg] };
}
// MarkdownNote の **section** → - [name](url) パターンからモデルを抽出
// flux/qwen/z-image などのサブグラフ形式ワークフロー向け補完用
function extractMarkdownNoteModels(wf) {
const allNodes = [];
if (Array.isArray(wf.nodes)) allNodes.push(...wf.nodes);
for (const sg of wf.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) allNodes.push(...sg.nodes);
const result = { checkpoints: [], vaes: [], diffusionModels: [], textEncoders: [], loras: [] };
const seen = { checkpoints: new Set(), vaes: new Set(), diffusionModels: new Set(), textEncoders: new Set(), loras: new Set() };
function addU(arr, set, name) { if (name && typeof name === "string" && !set.has(name)) { set.add(name); arr.push(name); } }
for (const n of allNodes) {
if (n.type !== "MarkdownNote") continue;
const raw = n.widgets_values;
const text = Array.isArray(raw) ? raw[0] : (typeof raw === "string" ? raw : null);
if (!text) continue;
const sRe = /\*\*([^*\n]+)\*\*/g;
let sm;
while ((sm = sRe.exec(text)) !== null) {
const sec = sm[1].trim().toLowerCase().replace(/\s+/g, "_");
if (!["text_encoders", "diffusion_models", "vae", "checkpoints", "loras"].includes(sec)) continue;
const rest = text.slice(sm.index + sm[0].length);
const end = rest.search(/\n\*\*|\n##/);
const content = end >= 0 ? rest.slice(0, end) : rest;
const lRe = /^- \[([^\]]+)\]/gm;
let lm;
while ((lm = lRe.exec(content)) !== null) {
const name = lm[1].trim();
if (sec === "text_encoders") addU(result.textEncoders, seen.textEncoders, name);
else if (sec === "diffusion_models") addU(result.diffusionModels, seen.diffusionModels, name);
else if (sec === "vae") addU(result.vaes, seen.vaes, name);
else if (sec === "checkpoints") addU(result.checkpoints, seen.checkpoints, name);
else if (sec === "loras") addU(result.loras, seen.loras, name);
}
}
}
const hasAny = result.checkpoints.length || result.vaes.length || result.diffusionModels.length || result.textEncoders.length || result.loras.length;
return hasAny ? result : null;
}
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
function resolveLinkedText(wf, srcId, slot) {
const src = wf[String(srcId)];
if (!src || typeof src !== "object") return null;
const ct = src.class_type ?? "";
// PromptStyler系: slot 0 = text_positive, slot 1 = text_negative
if (isPromptStylerNode(ct)) {
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
return (v && typeof v === "string") ? v : null;
}
// 汎用テキストキー
const keys = slot === 0
? ["text_positive", "text", "text_g", "prompt"]
: ["text_negative", "text_l"];
for (const k of keys) {
const v = src.inputs?.[k];
if (v && typeof v === "string") return v;
}
return null;
}
function extractPromptsAPI(wf) {
const metaNodes = Object.values(wf).filter(n => n?.class_type === "ImageMetadataPromptLoader");
if (metaNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of metaNodes) { if (n.inputs?.positive_text) pos.add(n.inputs.positive_text); if (n.inputs?.negative_text) neg.add(n.inputs.negative_text); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
const textMap = new Map();
for (const [id, n] of Object.entries(wf)) {
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
if (raw && typeof raw === "string") {
textMap.set(id, raw);
} else if (Array.isArray(raw)) {
// リンク参照 [srcNodeId, slot] → 解決
const text = resolveLinkedText(wf, raw[0], raw[1] ?? 0);
if (text) textMap.set(id, text);
}
}
const pos = new Set(), neg = new Set();
let foundSampler = false;
for (const n of Object.values(wf)) {
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
foundSampler = true;
for (const [key, val] of Object.entries(n.inputs ?? {})) {
if (!Array.isArray(val)) continue;
const text = textMap.get(String(val[0]));
if (!text) continue;
if (key === "positive" || key.startsWith("positive")) pos.add(text);
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
}
}
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
return { positives: [...pos], negatives: [...neg] };
}
function extractPromptsLiteGraph(wf) {
const { nodes, links } = wf;
if (!Array.isArray(nodes)) return { positives: [], negatives: [] };
// 1. ImageMetadataPromptLoader (WFS専用)
const metaNodes = nodes.filter(n => n.type === "ImageMetadataPromptLoader");
if (metaNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of metaNodes) { const p = n.widgets_values?.[2], ng = n.widgets_values?.[3]; if (p) pos.add(p); if (ng) neg.add(ng); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
// 2. WFS_PromptText (WFS専用)
const wfsNodes = nodes.filter(n => n.type === "WFS_PromptText");
if (wfsNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of wfsNodes) { const p = n.widgets_values?.[0], ng = n.widgets_values?.[1]; if (p) pos.add(p); if (ng) neg.add(ng); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
// 3. トップレベルの CLIPTextEncode + KSampler
const topResult = extractPromptsFromNodeSet(nodes, links ?? []);
if (topResult) return topResult;
// 4. PrimitiveStringMultiline(flux2-klein / ernie など)- サブグラフ内も探す
const primTexts = [];
for (const n of collectAllNodes(wf)) {
if (n.type !== "PrimitiveStringMultiline") continue;
const t = Array.isArray(n.widgets_values) ? n.widgets_values[0] : n.widgets_values;
if (t && typeof t === "string" && t.trim()) primTexts.push(t.trim());
}
if (primTexts.length > 0) return { positives: [], negatives: [], texts: primTexts };
// 5. サブグラフ内の CLIPTextEncode + KSampler(z-image / qwen / flux など)
for (const sg of wf.definitions?.subgraphs ?? []) {
if (!Array.isArray(sg.nodes)) continue;
const sgResult = extractPromptsFromNodeSet(sg.nodes, sg.links ?? []);
if (sgResult) return sgResult;
}
// 6. PromptStyler フォールバック
const stylerPos = new Set(), stylerNeg = new Set();
for (const n of nodes) {
if (!isPromptStylerNode(n.type ?? "")) continue;
const vals = n.widgets_values ?? [];
for (let i = 0; i < vals.length; i++) {
if (typeof vals[i] !== "string" || !vals[i].trim()) continue;
if (i % 2 === 0) stylerPos.add(vals[i]);
else stylerNeg.add(vals[i]);
}
}
if (stylerPos.size > 0 || stylerNeg.size > 0) return { positives: [...stylerPos], negatives: [...stylerNeg] };
// 7. テキストエンコーダー全テキスト(最終フォールバック)- サブグラフも含む・判別不能
const all = [];
for (const n of collectAllNodes(wf)) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const t = n.widgets_values?.[0];
if (t && typeof t === "string" && t.trim()) all.push(t);
}
return { positives: [], negatives: [], texts: all };
}
// ── SD/Fooocus prompt extraction ──────────────────────────────
function parseSDAParameters(raw) {
const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const stepsMatch = text.match(/\nSteps:\s+\d/);
if (!stepsMatch) return null;
const paramsStart = stepsMatch.index + 1;
const promptSection = text.slice(0, paramsStart - 1);
const paramsLine = text.slice(paramsStart);
const negSep = "\nNegative prompt: ";
const negIdx = promptSection.indexOf(negSep);
let positive = "", negative = "";
if (negIdx !== -1) { positive = promptSection.slice(0, negIdx).trim(); negative = promptSection.slice(negIdx + negSep.length).trim(); }
else positive = promptSection.trim();
const params = {};
const re = /,?\s*([A-Za-z][A-Za-z0-9 ]*):\s*("(?:[^"\\]|\\.)*"|[^,]+)/g;
let m;
while ((m = re.exec(paramsLine)) !== null) params[m[1].trim()] = m[2].trim().replace(/^"|"$/g, "");
return { positive, negative, params };
}
function parseFooocusMetadata(raw) {
let obj; try { obj = JSON.parse(raw); } catch { return null; }
if (!obj?.base_model) return null;
const toArray = v => !v ? [] : Array.isArray(v) ? v.filter(Boolean) : [String(v)];
return { checkpoint: obj.base_model, vae: (obj.vae && obj.vae !== "Default") ? obj.vae : null, positives: toArray(obj.full_prompt ?? obj.prompt), negatives: toArray(obj.full_negative_prompt ?? obj.negative_prompt) };
}
// ── Master extractAllMetadata ─────────────────────────────────
async function extractAllMetadata(file) {
const name = file.name.toLowerCase();
const isJSON = file.type === "application/json" || name.endsWith(".json");
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
function fromWorkflow(wf, source) {
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)
const mdm = extractMarkdownNoteModels(wf);
if (mdm) {
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
if (!base.vaes.length) base.vaes = mdm.vaes;
if (!base.diffusionModels.length) base.diffusionModels = mdm.diffusionModels;
if (!base.textEncoders.length) base.textEncoders = mdm.textEncoders;
if (!base.loras.length) base.loras = mdm.loras;
}
return base;
}
if (isJSON) {
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
return wf ? fromWorkflow(wf, "comfyui") : null;
}
if (isWebP) {
const exif = await readWebPEXIFChunk(file);
if (!exif) return null;
const wf = extractWorkflowFromEXIF(exif);
return wf ? fromWorkflow(wf, "comfyui") : null;
}
// PNG
const chunks = await readAllPNGTextChunks(file);
if (!chunks) return null;
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.fooocus_scheme === "fooocus" && chunks.parameters) {
const f = parseFooocusMetadata(chunks.parameters);
if (!f) return null;
return { source: "fooocus", checkpoints: [f.checkpoint], vaes: f.vae ? [f.vae] : [], diffusionModels: [], textEncoders: [], loras: [], positives: f.positives, negatives: f.negatives };
}
if (chunks.parameters) {
const p = parseSDAParameters(chunks.parameters);
if (!p) return null;
const { positive, negative, params } = p;
const modelName = params["Model"];
if (!modelName) return null;
if (params["Module 2"] != null) {
const textEncoders = [];
for (let i = 2; i <= 9; i++) { const mod = params[`Module ${i}`]; if (!mod) break; textEncoders.push(mod); }
return { source: "sd_forge", checkpoints: [], vaes: params["Module 1"] ? [params["Module 1"]] : [], diffusionModels: [modelName], textEncoders, loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
}
const vaeValue = params["Module 1"] ?? params["VAE"] ?? null;
return { source: "sd", checkpoints: [modelName], vaes: vaeValue ? [vaeValue] : [], diffusionModels: [], textEncoders: [], loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
}
return null;
}
// ── UI helpers ────────────────────────────────────────────────
function escapeHtml(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function buildModelItem(label) {
const el = document.createElement("div");
el.className = "wfm-meta-item";
el.title = label;
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(label)}</span>`;
return el;
}
function buildLoRAItem(lora) {
const el = document.createElement("div");
el.className = "wfm-meta-item";
el.title = lora.name;
const sm = lora.strength_model.toFixed(2);
const sc = lora.strength_clip.toFixed(2);
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(lora.name)}</span><span class="wfm-meta-item-badge">${sm}/${sc}</span>`;
return el;
}
function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
const el = document.createElement("div");
el.className = "wfm-meta-item wfm-meta-item-clickable";
const snippet = label.length > 60 ? label.slice(0, 60) + "…" : label;
const typeBadge = type === "positive" ? `<span class="wfm-meta-badge-pos">POS</span>`
: type === "negative" ? `<span class="wfm-meta-badge-neg">NEG</span>`
: ``;
el.innerHTML = `${typeBadge}<span class="wfm-meta-item-name">${escapeHtml(snippet)}</span>`;
el.addEventListener("click", () => {
listEl.querySelectorAll(".wfm-meta-item-clickable").forEach(e => e.classList.remove("selected"));
el.classList.add("selected");
fullArea.value = full;
fullLabel.textContent = type === "positive" ? t("metaPromptPositive")
: type === "negative" ? t("metaPromptNegative")
: t("metaPromptText") || "Text";
});
return el;
}
function renderSection(sectionEl, listEl, items, buildFn) {
listEl.innerHTML = "";
if (items.length === 0) { sectionEl.classList.add("wfm-meta-section-empty"); return; }
sectionEl.classList.remove("wfm-meta-section-empty");
for (const item of items) listEl.appendChild(buildFn(item));
}
// ── Tab initialization ────────────────────────────────────────
export function initMetadataTab() {
const dropZone = document.getElementById("wfm-meta-drop");
const dropLabel = document.getElementById("wfm-meta-drop-label");
const previewImg = document.getElementById("wfm-meta-preview-img");
const fileInfo = document.getElementById("wfm-meta-file-info");
const fileInput = document.getElementById("wfm-meta-file-input");
const ckptSection = document.getElementById("wfm-meta-ckpt-section");
const vaeSection = document.getElementById("wfm-meta-vae-section");
const diffSection = document.getElementById("wfm-meta-diff-section");
const teSection = document.getElementById("wfm-meta-te-section");
const loraSection = document.getElementById("wfm-meta-lora-section");
const promptSection = document.getElementById("wfm-meta-prompt-section");
const ckptList = document.getElementById("wfm-meta-ckpt-list");
const vaeList = document.getElementById("wfm-meta-vae-list");
const diffList = document.getElementById("wfm-meta-diff-list");
const teList = document.getElementById("wfm-meta-te-list");
const loraList = document.getElementById("wfm-meta-lora-list");
const promptList = document.getElementById("wfm-meta-prompt-list");
const promptFull = document.getElementById("wfm-meta-prompt-full");
const promptFullLabel = document.getElementById("wfm-meta-prompt-full-label");
if (!dropZone) return;
// Apply i18n to section titles
const titleMap = {
"wfm-meta-ckpt-section": "metaSectionCkpt",
"wfm-meta-vae-section": "metaSectionVae",
"wfm-meta-diff-section": "metaSectionDiff",
"wfm-meta-te-section": "metaSectionTe",
"wfm-meta-lora-section": "metaSectionLora",
"wfm-meta-prompt-section": "metaSectionPrompt",
};
for (const [id, key] of Object.entries(titleMap)) {
const title = document.querySelector(`#${id} .wfm-meta-section-title`);
if (title) { const tx = t(key); if (tx && tx !== key) title.textContent = tx; }
}
// Apply i18n to format note
const i18nIds = {
"wfm-meta-format-note-title": "metaFormatNoteTitle",
"wfm-meta-fmt-comfyui": "metaFmtComfyui",
"wfm-meta-fmt-sdwebui": "metaFmtSdwebui",
"wfm-meta-fmt-fooocus": "metaFmtFooocus",
"wfm-meta-format-todo": "metaFormatTodo",
};
for (const [id, key] of Object.entries(i18nIds)) {
const el = document.getElementById(id);
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
}
// Apply i18n to help card
const helpIds = {
"wfm-help-metadata-title": "helpMetadataTitle",
"wfm-help-metadata-desc": "helpMetadataDesc",
"wfm-help-metadata-1": "helpMetadata1",
"wfm-help-metadata-2": "helpMetadata2",
"wfm-help-metadata-3": "helpMetadata3",
"wfm-help-metadata-4": "helpMetadata4",
"wfm-help-metadata-5": "helpMetadata5",
};
for (const [id, key] of Object.entries(helpIds)) {
const el = document.getElementById(id);
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
}
function clearAll() {
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => { if (l) l.innerHTML = ""; });
[ckptSection, vaeSection, diffSection, teSection, loraSection, promptSection].forEach(s => { if (s) s.classList.add("wfm-meta-section-empty"); });
if (promptFull) promptFull.value = "";
if (promptFullLabel) promptFullLabel.textContent = "";
}
async function handleFile(file) {
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
fileInfo.textContent = t("metaFileTooLarge");
fileInfo.style.color = "var(--wfm-warning)";
return;
}
fileInfo.textContent = t("metaParsing");
fileInfo.style.color = "var(--wfm-text-secondary)";
clearAll();
// Show preview for images
const isImage = file.type.startsWith("image/") || file.name.toLowerCase().match(/\.(png|webp|jpg|jpeg)$/);
if (isImage) {
const url = URL.createObjectURL(file);
previewImg.src = url;
previewImg.style.display = "block";
dropLabel.style.display = "none";
previewImg.onload = () => URL.revokeObjectURL(url);
} else {
previewImg.style.display = "none";
dropLabel.style.display = "flex";
}
let meta;
try {
meta = await extractAllMetadata(file);
} catch (err) {
console.error("[MetadataTab]", err);
fileInfo.textContent = t("metaParseError");
fileInfo.style.color = "var(--wfm-danger)";
return;
}
if (!meta) {
fileInfo.textContent = t("metaNoMetadata");
fileInfo.style.color = "var(--wfm-warning)";
return;
}
const sizeKB = (file.size / 1024).toFixed(1);
const sourceLabel = { comfyui: "ComfyUI", sd: "SD WebUI", sd_forge: "SD Forge", fooocus: "Fooocus" }[meta.source] ?? meta.source;
fileInfo.textContent = `${file.name} (${sizeKB} KB · ${sourceLabel})`;
fileInfo.style.color = "var(--wfm-text-secondary)";
renderSection(ckptSection, ckptList, meta.checkpoints, n => buildModelItem(n));
renderSection(vaeSection, vaeList, meta.vaes, n => buildModelItem(n));
renderSection(diffSection, diffList, meta.diffusionModels, n => buildModelItem(n));
renderSection(teSection, teList, meta.textEncoders, n => buildModelItem(n));
renderSection(loraSection, loraList, meta.loras, l => buildLoRAItem(l));
// Prompts
promptList.innerHTML = "";
const allPrompts = [
...meta.positives.map(p => ({ type: "positive", text: p })),
...meta.negatives.map(p => ({ type: "negative", text: p })),
...(meta.texts ?? []).map(p => ({ type: "text", text: p })),
];
if (allPrompts.length > 0) {
promptSection.classList.remove("wfm-meta-section-empty");
for (const { type, text } of allPrompts) {
promptList.appendChild(buildPromptItem(text, type, text, promptFull, promptFullLabel, promptList));
}
// Auto-select first positive
const firstPositive = promptList.querySelector(".wfm-meta-item-clickable");
if (firstPositive) firstPositive.click();
} else {
promptSection.classList.add("wfm-meta-section-empty");
}
}
// ── Prompt action buttons ─────────────────────────────────
function getPromptText() { return promptFull?.value ?? ""; }
function flashBtn(btn) {
btn.classList.add("wfm-meta-btn-flash");
setTimeout(() => btn.classList.remove("wfm-meta-btn-flash"), 600);
}
function setTextareaValue(id, text) {
const el = document.getElementById(id);
if (!el) return false;
el.value = text;
el.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
document.getElementById("wfm-meta-copy-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
navigator.clipboard.writeText(text).then(() => flashBtn(this));
});
document.getElementById("wfm-meta-genui-pos-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-prompt-pos-text", text)) flashBtn(this);
});
document.getElementById("wfm-meta-genui-neg-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-prompt-neg-text", text)) flashBtn(this);
});
document.getElementById("wfm-meta-preset-pos-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-preset-pos", text)) flashBtn(this);
});
document.getElementById("wfm-meta-preset-neg-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-preset-neg", text)) flashBtn(this);
});
// Drag & Drop
dropZone.addEventListener("dragover", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", e => { e.stopPropagation(); dropZone.classList.remove("drag-over"); });
dropZone.addEventListener("drop", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); handleFile(e.dataTransfer.files?.[0]); });
dropZone.addEventListener("click", e => { if (e.target === previewImg) return; fileInput.click(); });
fileInput.addEventListener("change", () => { handleFile(fileInput.files?.[0]); fileInput.value = ""; });
// Prevent scroll passthrough in lists
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => {
if (l) l.addEventListener("wheel", e => e.stopPropagation(), { passive: true });
});
clearAll();
}
+422 -29
View File
@@ -23,12 +23,17 @@ const state = {
},
modelMetadata: {},
modelGroups: {},
allModelGroups: {}, // { type: { groupName: [models] } } — all types
civitaiCache: {},
disabledModels: {}, // { type: Set<modelName> }
selectMode: false,
selectedModels: new Set(),
searchText: "",
tagFilter: "",
badgeFilter: "",
dirFilter: "",
groupFilter: "",
statusFilter: "all", // "all" | "enabled" | "disabled"
showFavoritesOnly: false,
viewMode: localStorage.getItem("wfm_models_view") || "thumb",
activeModelType: "checkpoint",
@@ -200,6 +205,225 @@ function modelBadgesHtml(modelName, clickable = false) {
// ── API ───────────────────────────────────────────────────
// ── Multi-select & Bulk Group Operations ─────────────────
function toggleSelectMode() {
state.selectMode = !state.selectMode;
if (!state.selectMode) state.selectedModels.clear();
const btn = document.getElementById("wfm-models-select-btn");
if (btn) {
btn.classList.toggle("active", state.selectMode);
btn.textContent = state.selectMode ? t("modelSelectExit") : t("modelSelectMode");
}
renderModelGrid();
renderBulkActionBar();
}
function toggleModelSelection(modelName) {
if (state.selectedModels.has(modelName)) {
state.selectedModels.delete(modelName);
} else {
state.selectedModels.add(modelName);
}
// Update DOM directly (avoid full re-render)
document.querySelectorAll("[data-model-name]").forEach((el) => {
if (el.dataset.modelName !== modelName) return;
const checked = state.selectedModels.has(modelName);
el.classList.toggle("wfm-card-checked", checked);
const c = el.querySelector(".wfm-select-check");
if (c) c.classList.toggle("checked", checked);
});
renderBulkActionBar();
}
function clearSelection() {
state.selectedModels.clear();
renderModelGrid();
renderBulkActionBar();
}
function renderBulkActionBar() {
const bar = document.getElementById("wfm-models-bulk-bar");
if (!bar) return;
if (!state.selectMode || state.selectedModels.size === 0) {
bar.style.display = "none";
return;
}
const count = state.selectedModels.size;
const groupNames = Object.keys(state.modelGroups).sort();
const noGroups = groupNames.length === 0;
const groupOptions = noGroups
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: groupNames.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
bar.style.display = "flex";
bar.innerHTML = `
<span class="wfm-bulk-count">${count} ${t("modelSelected")}</span>
<select id="wfm-bulk-group-select" class="wfm-select" style="font-size:12px;">${groupOptions}</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-add-btn"${noGroups ? " disabled" : ""}>${t("modelBulkAddGroup")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-remove-btn"${noGroups ? " disabled" : ""}>${t("modelBulkRemoveGroup")}</button>
<span class="wfm-bulk-sep">|</span>
<input type="text" id="wfm-bulk-new-group-input" class="wfm-search-input" style="width:120px;font-size:12px;" placeholder="${t("modelsGroupName")}">
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-create-add-btn">${t("modelBulkCreateAdd")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-clear-btn" title="Clear selection">&times;</button>
<span class="wfm-bulk-sep" style="margin-left:8px;"></span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-delete-btn">${t("modelBulkDelete")}</button>
`;
document.getElementById("wfm-bulk-add-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkAddToGroup(g);
});
document.getElementById("wfm-bulk-remove-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkRemoveFromGroup(g);
});
document.getElementById("wfm-bulk-create-add-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-bulk-new-group-input");
const name = input?.value.trim();
if (!name) return;
if (state.modelGroups[name]) { showToast(t("modelsGroupExists"), "warning"); return; }
bulkAddToGroup(name);
});
document.getElementById("wfm-bulk-clear-btn")?.addEventListener("click", clearSelection);
document.getElementById("wfm-bulk-delete-btn")?.addEventListener("click", bulkDeleteModels);
}
async function bulkAddToGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) groups[groupName] = [];
const toAdd = [...state.selectedModels].filter((m) => !groups[groupName].includes(m));
groups[groupName] = [...groups[groupName], ...toAdd];
await saveModelGroups(groups);
showToast(`${toAdd.length} ${t("modelBulkAddDone")}`, "success");
renderBulkActionBar();
}
async function bulkRemoveFromGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) return;
const before = groups[groupName].length;
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
const removed = before - groups[groupName].length;
if (groups[groupName].length === 0) delete groups[groupName];
await saveModelGroups(groups);
showToast(`${removed} ${t("modelBulkRemoveDone")}`, "success");
renderModelGrid();
renderBulkActionBar();
}
async function bulkDeleteModels() {
const count = state.selectedModels.size;
if (count === 0) return;
const msg = t("modelBulkDeleteConfirm").replace("{count}", count);
if (!window.confirm(msg)) return;
const model_names = [...state.selectedModels];
try {
const res = await fetch("/api/wfm/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names }),
});
const data = await res.json();
const okCount = (data.ok || []).length;
const errCount = (data.errors || []).length;
if (errCount > 0) {
showToast(`${t("modelBulkDeleteError")}: ${errCount} errors`, "error");
}
if (okCount > 0) {
showToast(`${okCount} ${t("modelBulkDeleteDone")}`, "success");
// Remove deleted models from local state
model_names.forEach((mn) => {
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(mn);
if (idx !== -1) list.splice(idx, 1);
delete state.modelMetadata[mn];
const ds = state.disabledModels[state.activeModelType];
if (ds) ds.delete(mn);
state.selectedModels.delete(mn);
});
state.loaded[state.activeModelType] = false;
renderModelGrid();
renderBulkActionBar();
}
} catch (err) {
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
}
}
// ── Enable / Disable helpers ──────────────────────────────
function isModelDisabled(modelName) {
const s = state.disabledModels[state.activeModelType];
return s ? s.has(modelName) : false;
}
async function fetchDisabledModels(type) {
try {
const res = await fetch(`/api/wfm/models/disabled?type=${encodeURIComponent(type)}`);
return res.ok ? await res.json() : [];
} catch { return []; }
}
async function toggleModelEnable(modelName) {
const nowDisabled = isModelDisabled(modelName);
const newEnabled = nowDisabled;
try {
const res = await fetch("/api/wfm/models/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_type: state.activeModelType,
model_name: modelName,
enabled: newEnabled,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "toggle failed");
}
const s = state.disabledModels[state.activeModelType] || new Set();
if (newEnabled) s.delete(modelName);
else s.add(modelName);
state.disabledModels[state.activeModelType] = s;
showToast(t("modelStatusWarning"), "info");
renderModelGrid();
if (state.selectedModel === modelName) renderSideInfo(modelName);
} catch (err) {
showToast(t("modelToggleError") + ": " + err.message, "error");
}
}
async function toggleGroupEnable(groupName, enable) {
try {
const res = await fetch("/api/wfm/models/group-toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_type: state.activeModelType,
group_name: groupName,
enabled: enable,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "group toggle failed");
}
const data = await res.json();
const members = state.modelGroups[groupName] || [];
const s = state.disabledModels[state.activeModelType] || new Set();
members.forEach((m) => { if (enable) s.delete(m); else s.add(m); });
state.disabledModels[state.activeModelType] = s;
const errCount = data.errors?.length || 0;
if (errCount > 0) showToast(`${errCount} ${t("modelToggleError")}`, "warning");
showToast(t("modelStatusWarning"), "info");
renderModelGrid();
} catch (err) {
showToast(t("modelToggleError") + ": " + err.message, "error");
}
}
async function fetchModelMetadata() {
try {
const res = await fetch("/api/wfm/models/metadata");
@@ -236,6 +460,12 @@ function getCurrentModels() {
function filterModels() {
let models = getCurrentModels();
if (state.statusFilter === "enabled") {
models = models.filter((m) => !isModelDisabled(m));
} else if (state.statusFilter === "disabled") {
models = models.filter((m) => isModelDisabled(m));
}
if (state.showFavoritesOnly) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
@@ -428,11 +658,31 @@ function renderTagFilter() {
function renderGroupFilter() {
const select = document.getElementById("wfm-models-group-filter");
if (!select) return;
const groups = Object.keys(state.modelGroups).sort();
// Collect groups across all types with type labels
const options = [];
const typeOrder = Object.keys(TYPE_LABELS);
typeOrder.forEach((type) => {
const groups = state.allModelGroups[type];
if (!groups) return;
const names = Object.keys(groups).sort();
names.forEach((g) => {
options.push({ type, name: g });
});
});
const currentValue = state.groupFilter
? `${state.activeModelType}::${state.groupFilter}`
: "";
select.innerHTML =
`<option value="">${t("modelsAllGroups")}</option>` +
groups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
select.value = state.groupFilter;
options.map(({ type, name }) => {
const typeLabel = TYPE_LABELS[type] || type;
const value = `${type}::${name}`;
const isActive = value === currentValue;
return `<option value="${escapeHtml(value)}"${isActive ? " selected" : ""}>[${typeLabel}] ${escapeHtml(name)}</option>`;
}).join("");
}
function renderDirFilter() {
@@ -450,6 +700,13 @@ function renderDirFilter() {
}
async function fetchModelGroups() {
try {
const res = await fetch(`/api/wfm/models/groups?type=${encodeURIComponent(state.activeModelType)}`);
return res.ok ? await res.json() : {};
} catch { return {}; }
}
async function fetchAllModelGroups() {
try {
const res = await fetch("/api/wfm/models/groups");
return res.ok ? await res.json() : {};
@@ -458,12 +715,14 @@ async function fetchModelGroups() {
async function saveModelGroups(groups) {
state.modelGroups = groups;
state.allModelGroups[state.activeModelType] = groups;
await fetch("/api/wfm/models/groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(groups),
body: JSON.stringify({ model_type: state.activeModelType, groups }),
});
renderGroupFilter();
renderBulkActionBar();
}
// ── Render: Grid ──────────────────────────────────────────
@@ -503,6 +762,7 @@ function renderThumbView(grid, models) {
models.forEach((modelName) => {
const meta = state.modelMetadata[modelName] || {};
const { name } = parseModelPath(modelName);
const disabled = isModelDisabled(modelName);
const userBadges = modelBadgesHtml(modelName);
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
const favStar = meta.favorite ? "\u2605" : "\u2606";
@@ -510,6 +770,7 @@ function renderThumbView(grid, models) {
const card = document.createElement("div");
card.className = "wfm-card";
if (disabled) card.classList.add("wfm-model-disabled");
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
card.dataset.modelName = modelName;
@@ -517,12 +778,14 @@ function renderThumbView(grid, models) {
<div class="wfm-card-thumb">
<img style="display:none" />
<span class="wfm-card-thumb-placeholder">${t("modelsNoPreview")}</span>
${disabled ? `<span class="wfm-disabled-overlay">${t("modelDisabled")}</span>` : ""}
</div>
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(getStem(name))}</div>
<div class="wfm-card-meta">${userBadges} ${tagsHtml}</div>
</div>
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>`;
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
// Load preview without 404 console spam
const img = card.querySelector(".wfm-card-thumb img");
@@ -533,11 +796,24 @@ function renderThumbView(grid, models) {
e.stopPropagation();
toggleFavorite(modelName);
});
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => {
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
openDetailModal(modelName);
toggleModelEnable(modelName);
});
if (state.selectMode) {
const isChecked = state.selectedModels.has(modelName);
card.classList.toggle("wfm-card-checked", isChecked);
const checkEl = document.createElement("div");
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
card.appendChild(checkEl);
card.addEventListener("click", (e) => {
if (e.target.closest(".wfm-fav-btn, .wfm-toggle-btn")) return;
toggleModelSelection(modelName);
});
} else {
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
}
grid.appendChild(card);
});
}
@@ -550,6 +826,7 @@ function renderCardView(grid, models) {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const disabled = isModelDisabled(modelName);
const userBadges = modelBadgesHtml(modelName);
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
const favStar = meta.favorite ? "\u2605" : "\u2606";
@@ -557,27 +834,42 @@ function renderCardView(grid, models) {
const card = document.createElement("div");
card.className = "wfm-card wfm-model-card";
if (disabled) card.classList.add("wfm-model-disabled");
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
card.dataset.modelName = modelName;
card.innerHTML = `
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(name)}</div>
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(name)}${disabled ? ` <span class="wfm-badge wfm-badge-disabled">${t("modelDisabled")}</span>` : ""}</div>
<div class="wfm-card-meta">
${userBadges} ${tagsHtml}
</div>
</div>
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>`;
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(modelName);
});
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => {
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
openDetailModal(modelName);
toggleModelEnable(modelName);
});
if (state.selectMode) {
const isChecked = state.selectedModels.has(modelName);
card.classList.toggle("wfm-card-checked", isChecked);
const checkEl = document.createElement("div");
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
card.appendChild(checkEl);
card.addEventListener("click", (e) => {
if (e.target.closest(".wfm-fav-btn, .wfm-toggle-btn")) return;
toggleModelSelection(modelName);
});
} else {
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
}
grid.appendChild(card);
});
}
@@ -590,10 +882,18 @@ function renderTableView(grid, models) {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const disabled = isModelDisabled(modelName);
const isChecked = state.selectMode && state.selectedModels.has(modelName);
const favIcon = meta.favorite ? "&#9733;" : "&#9734;";
const tagsStr = (meta.tags || []).join(", ");
const memo = meta.memo || "";
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}" data-model-name="${escapeHtml(modelName)}">
const toggleLabel = disabled ? t("modelEnable") : t("modelDisable");
const toggleIcon = disabled ? "▶" : "⏸";
const checkCell = state.selectMode
? `<td class="wfm-table-td-check"><div class="wfm-select-check${isChecked ? " checked" : ""}"></div></td>`
: "";
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}${disabled ? " wfm-model-disabled" : ""}${isChecked ? " wfm-card-checked" : ""}" data-model-name="${escapeHtml(modelName)}">
${checkCell}
<td class="wfm-models-table-fav" title="Favorite">${favIcon}</td>
<td class="wfm-table-td-thumb"><img class="wfm-table-thumb" style="display:none" /></td>
<td title="${escapeHtml(modelName)}">${escapeHtml(name)}</td>
@@ -601,11 +901,14 @@ function renderTableView(grid, models) {
<td class="wfm-table-td-ext">${escapeHtml(ext)}</td>
<td>${escapeHtml(tagsStr)}</td>
<td class="wfm-table-td-memo" title="${escapeHtml(memo)}">${escapeHtml(memo)}</td>
<td class="wfm-table-td-toggle"><button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${toggleLabel}">${toggleIcon}</button></td>
</tr>`;
})
.join("");
const checkTh = state.selectMode ? `<th style="width:24px;"></th>` : "";
grid.innerHTML = `<table class="wfm-models-table"><thead><tr>
${checkTh}
<th style="width:30px;">&#9733;</th>
<th style="width:40px;"></th>
<th>${t("modelsFileName")}</th>
@@ -613,23 +916,31 @@ function renderTableView(grid, models) {
<th class="wfm-table-th-ext">${t("modelsExt")}</th>
<th>${t("modelsTags")}</th>
<th>${t("modelsMemo")}</th>
<th style="width:50px;"></th>
</tr></thead><tbody>${rows}</tbody></table>`;
grid.querySelectorAll(".wfm-models-table-row").forEach((row) => {
const mn = row.dataset.modelName;
// Load preview image without 404 console spam
const img = row.querySelector(".wfm-table-thumb");
if (img) loadPreviewImage(img, null, mn);
row.addEventListener("click", () => showSidePanel(mn));
row.addEventListener("dblclick", (e) => {
e.stopPropagation();
openDetailModal(mn);
});
if (state.selectMode) {
row.addEventListener("click", (e) => {
if (e.target.closest(".wfm-models-table-fav, .wfm-toggle-btn")) return;
toggleModelSelection(mn);
});
} else {
row.addEventListener("click", () => showSidePanel(mn));
row.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(mn); });
}
row.querySelector(".wfm-models-table-fav").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(mn);
});
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleModelEnable(mn);
});
});
}
@@ -993,6 +1304,10 @@ function renderSideGroup(modelName) {
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-rename-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsRename")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-models-group-delete-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsDelete")}</button>
</div>
<div style="display:flex;gap:4px;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-enable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupEnableAll")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-disable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupDisableAll")}</button>
</div>
</div>
</div>
`;
@@ -1069,6 +1384,22 @@ function renderSideGroup(modelName) {
renderModelGrid();
});
});
// Enable all models in selected group
document.getElementById("wfm-models-group-enable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, true).then(() => renderSideGroup(modelName));
});
// Disable all models in selected group
document.getElementById("wfm-models-group-disable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, false).then(() => renderSideGroup(modelName));
});
}
// ── Side Panel: CivitAI Tab ───────────────────────────────
@@ -1197,8 +1528,12 @@ async function loadModelsForCurrentType() {
const placeholder = document.getElementById("wfm-models-placeholder");
if (state.loaded[type] && state.modelsByType[type].length > 0) {
// Reload groups for this type (groups are per-type)
state.modelGroups = await fetchModelGroups();
state.allModelGroups[type] = state.modelGroups;
renderTagFilter();
renderDirFilter();
renderGroupFilter();
renderModelGrid();
return;
}
@@ -1209,12 +1544,24 @@ async function loadModelsForCurrentType() {
const fetchFn = FETCH_MAP[type];
if (!fetchFn) throw new Error("Unknown model type: " + type);
const models = await fetchFn();
state.modelsByType[type] = models || [];
const [models, disabledList, groups] = await Promise.all([
fetchFn(), fetchDisabledModels(type), fetchModelGroups(),
]);
const disabledSet = new Set(Array.isArray(disabledList) ? disabledList : []);
state.disabledModels[type] = disabledSet;
state.modelGroups = groups;
state.allModelGroups[type] = groups;
// Merge enabled + disabled into one list (dedup)
// Guard: ensure models is an array (ComfyUI may return non-array on edge cases)
const enabledList = Array.isArray(models) ? models : [];
const allModels = [...new Set([...enabledList, ...disabledSet])];
state.modelsByType[type] = allModels;
state.loaded[type] = true;
renderTagFilter();
renderDirFilter();
renderGroupFilter();
renderModelGrid();
} catch (err) {
console.error("Failed to load models:", err);
@@ -1312,13 +1659,13 @@ async function batchFetchCivitai() {
}
async function loadMetadataAndModels() {
const [metadata, groups, civitaiCache] = await Promise.all([
fetchModelMetadata(), fetchModelGroups(), fetchCivitaiCache()
const [metadata, civitaiCache, allGroups] = await Promise.all([
fetchModelMetadata(), fetchCivitaiCache(), fetchAllModelGroups()
]);
state.modelMetadata = metadata;
state.modelGroups = groups;
state.civitaiCache = civitaiCache;
renderGroupFilter();
state.allModelGroups = allGroups;
// groups are loaded per-type inside loadModelsForCurrentType
await loadModelsForCurrentType();
}
@@ -1335,8 +1682,17 @@ export function initModelsTab() {
state.tagFilter = "";
state.badgeFilter = "";
state.dirFilter = "";
state.groupFilter = "";
state.statusFilter = "all";
state.currentPage = 0;
state.selectedModel = null;
const statusFilter = document.getElementById("wfm-models-status-filter");
if (statusFilter) statusFilter.value = "all";
state.selectMode = false;
state.selectedModels.clear();
const selectBtn = document.getElementById("wfm-models-select-btn");
if (selectBtn) { selectBtn.classList.remove("active"); selectBtn.textContent = t("modelSelectMode"); }
renderBulkActionBar();
closeSidePanel();
const searchInput = document.getElementById("wfm-models-search");
@@ -1373,9 +1729,43 @@ export function initModelsTab() {
renderModelGrid();
});
// Group filter
document.getElementById("wfm-models-group-filter")?.addEventListener("change", (e) => {
state.groupFilter = e.target.value;
// Group filter — value is "type::groupName" or "" for all
document.getElementById("wfm-models-group-filter")?.addEventListener("change", async (e) => {
const value = e.target.value;
if (!value) {
state.groupFilter = "";
} else {
const sepIdx = value.indexOf("::");
const type = value.substring(0, sepIdx);
const groupName = value.substring(sepIdx + 2);
// Auto-switch model type if different
if (type !== state.activeModelType) {
document.querySelectorAll(".wfm-models-type-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.modelType === type);
});
state.activeModelType = type;
state.searchText = "";
state.tagFilter = "";
state.badgeFilter = "";
state.dirFilter = "";
state.statusFilter = "all";
state.selectedModel = null;
const statusFilter = document.getElementById("wfm-models-status-filter");
if (statusFilter) statusFilter.value = "all";
const searchInput = document.getElementById("wfm-models-search");
if (searchInput) searchInput.value = "";
closeSidePanel();
await loadModelsForCurrentType();
}
state.groupFilter = groupName;
}
state.currentPage = 0;
renderModelGrid();
});
// Status filter
document.getElementById("wfm-models-status-filter")?.addEventListener("change", (e) => {
state.statusFilter = e.target.value;
state.currentPage = 0;
renderModelGrid();
});
@@ -1418,6 +1808,9 @@ export function initModelsTab() {
loadModelsForCurrentType();
});
// Select mode
document.getElementById("wfm-models-select-btn")?.addEventListener("click", toggleSelectMode);
// CivitAI batch fetch
document.getElementById("wfm-models-civitai-batch-btn")?.addEventListener("click", () => {
batchFetchCivitai();
+380
View File
@@ -593,6 +593,233 @@ function applyToGenerateUI(text) {
showToast(t("appliedToGenerateUI"), "success");
}
// ============================================
// Wildcard API helpers
// ============================================
async function wcFetchFiles() {
try {
const res = await fetch("/api/wfm/wildcards");
return res.ok ? await res.json() : [];
} catch { return []; }
}
async function wcFetchContent(filename) {
try {
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(filename)}`);
const data = await res.json();
return data.content ?? null;
} catch { return null; }
}
async function wcSaveFile(filename, content) {
try {
const res = await fetch("/api/wfm/wildcards/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, content }),
});
const data = await res.json();
return data.status === "ok" ? data.file : null;
} catch { return null; }
}
async function wcDeleteFile(filename) {
try {
await fetch("/api/wfm/wildcards/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
} catch { /* ignore */ }
}
// ============================================
// Wildcard state & helpers
// ============================================
let wcFiles = [];
let wcEditingFilename = null; // null = new file
// Insert text at cursor; if open+close provided wraps selection
function wcInsertAtCursor(textarea, open, close = "") {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
let inserted, cursorStart, cursorEnd;
if (selected && close) {
inserted = open + selected + close;
cursorStart = start + open.length;
cursorEnd = cursorStart + selected.length;
} else if (close) {
inserted = open + close;
cursorStart = start + open.length;
cursorEnd = cursorStart;
} else {
inserted = open;
cursorStart = start + open.length;
cursorEnd = cursorStart;
}
textarea.value =
textarea.value.substring(0, start) +
inserted +
textarea.value.substring(end);
textarea.setSelectionRange(cursorStart, cursorEnd);
textarea.focus();
textarea.dispatchEvent(new Event("input"));
}
// ============================================
// Wildcard file list rendering
// ============================================
function wcRenderFileList() {
const list = document.getElementById("wfm-wc-file-list");
if (!list) return;
list.innerHTML = "";
if (wcFiles.length === 0) {
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">No wildcard files yet.<br><small>Click "+ New" to create one.</small></div>`;
return;
}
// Group by directory; root ("") first, then alphabetical
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
function addFileItem(f, indented) {
const item = document.createElement("div");
item.className = "wfm-wc-file-item" + (indented ? " wfm-wc-file-item--sub" : "");
item.innerHTML = `
<div class="wfm-wc-file-item-info">
<span class="wfm-wc-file-name">${esc(f.name)}</span>
<span class="wfm-wc-file-ext">.${esc(f.ext)}</span>
</div>
<div class="wfm-wc-file-item-actions">
<button class="wfm-pm-action-btn wfm-wc-use-btn" title="Insert __${esc(f.wc_name)}__ at cursor">Use</button>
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">&#9998;</button>
</div>
`;
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
e.stopPropagation();
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const content = await wcFetchContent(f.filename);
wcOpenEditor(f.wc_name, f.ext, content ?? "", f.filename);
});
item.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
list.appendChild(item);
}
for (const dir of dirs) {
if (dir) {
const header = document.createElement("div");
header.className = "wfm-wc-dir-header";
header.textContent = dir + "/";
list.appendChild(header);
}
for (const f of grouped[dir]) {
addFileItem(f, dir !== "");
}
}
}
// ============================================
// Wildcard file editor
// ============================================
function wcOpenEditor(name, ext, content, existingFilename) {
wcEditingFilename = existingFilename || null;
const editor = document.getElementById("wfm-wc-editor");
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
const deleteBtn = document.getElementById("wfm-wc-editor-delete-btn");
if (!editor) return;
nameInput.value = name || "";
extSelect.value = ext || "txt";
contentTA.value = content || "";
deleteBtn.style.display = existingFilename ? "" : "none";
editor.style.display = "";
nameInput.focus();
}
function wcCloseEditor() {
wcEditingFilename = null;
const editor = document.getElementById("wfm-wc-editor");
if (editor) editor.style.display = "none";
}
async function wcRefreshFiles() {
wcFiles = await wcFetchFiles();
wcRenderFileList();
wcUpdateFilePicker();
}
// ============================================
// Wildcard file picker popup
// ============================================
function wcUpdateFilePicker() {
const picker = document.getElementById("wfm-wc-file-picker");
if (!picker) return;
picker.innerHTML = "";
if (wcFiles.length === 0) {
picker.innerHTML = `<div style="padding:8px;font-size:11px;color:var(--wfm-text-secondary);">No wildcard files</div>`;
return;
}
// Group by directory same as file list
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
for (const dir of dirs) {
if (dir) {
const sep = document.createElement("div");
sep.className = "wfm-wc-picker-dir";
sep.textContent = dir + "/";
picker.appendChild(sep);
}
for (const f of grouped[dir]) {
const btn = document.createElement("button");
btn.className = "wfm-wc-picker-item" + (dir ? " wfm-wc-picker-item--sub" : "");
btn.textContent = `__${f.wc_name}__`;
btn.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
picker.style.display = "none";
});
picker.appendChild(btn);
}
}
}
// ============================================
// Initialize
// ============================================
@@ -853,4 +1080,157 @@ export function initPromptTab() {
pmSearchText = e.target.value.trim();
renderPresetManager();
});
// ── Center column tab switching ──────────────────────────
document.querySelectorAll(".wfm-prompt-center-tab").forEach(btn => {
btn.addEventListener("click", () => {
const target = btn.dataset.ptab;
document.querySelectorAll(".wfm-prompt-center-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".wfm-prompt-center-pane").forEach(p => {
p.style.display = p.id === `wfm-ptab-${target}` ? "" : "none";
});
});
});
// ── Wildcard syntax buttons ──────────────────────────────
document.querySelectorAll(".wfm-wc-btn[data-wc-open]").forEach(btn => {
btn.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const open = btn.dataset.wcOpen || "";
const close = btn.dataset.wcClose || "";
wcInsertAtCursor(ta, open, close);
});
});
// LoRA LBW template button
document.getElementById("wfm-wc-lora-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
wcInsertAtCursor(ta, "<lora::1:LBW=;>");
// Move cursor to after "lora:" for the name
const pos = ta.selectionStart - "<lora::1:LBW=;>".length + 6;
ta.setSelectionRange(pos, pos);
ta.focus();
});
// n$${ } button — asks for n then inserts multi-pick template
document.getElementById("wfm-wc-nset-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const n = prompt("Number of items to pick (n):", "2");
if (n === null) return;
const num = parseInt(n, 10);
if (isNaN(num) || num < 1) { showToast("Enter a positive integer", "error"); return; }
const template = "{" + num + "$$|}";
const start = ta.selectionStart;
wcInsertAtCursor(ta, template);
// Move cursor between $$ and |
const inner = start + ("{" + num + "$$").length;
ta.setSelectionRange(inner, inner);
ta.focus();
});
// File picker toggle
const filePickBtn = document.getElementById("wfm-wc-filepick-btn");
const filePicker = document.getElementById("wfm-wc-file-picker");
filePickBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (!filePicker) return;
const isOpen = filePicker.style.display !== "none";
filePicker.style.display = isOpen ? "none" : "";
if (!isOpen) wcUpdateFilePicker();
});
document.addEventListener("click", () => {
if (filePicker) filePicker.style.display = "none";
});
// ── Wildcard prompt actions ──────────────────────────────
document.getElementById("wfm-wc-copy-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
const text = ta?.value || "";
if (!text.trim()) { showToast(t("noTextToCopy"), "error"); return; }
navigator.clipboard.writeText(text).then(() => showToast(t("copiedToClipboard"), "success"));
});
document.getElementById("wfm-wc-to-pos-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-pos");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast("Sent to Positive Prompt", "success");
});
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-neg");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast("Sent to Negative Prompt", "success");
});
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) { ta.value = ""; ta.focus(); }
});
// ── Wildcard file manager ────────────────────────────────
document.getElementById("wfm-wc-new-file-btn")?.addEventListener("click", () => {
wcOpenEditor("", "txt", "", null);
});
document.getElementById("wfm-wc-files-refresh-btn")?.addEventListener("click", () => {
wcRefreshFiles();
});
// Editor: save
document.getElementById("wfm-wc-editor-save-btn")?.addEventListener("click", async () => {
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
// Normalize separators and strip leading/trailing slashes
const rawName = (nameInput?.value || "").trim().replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
const ext = extSelect?.value || "txt";
const content = contentTA?.value || "";
if (!rawName) { showToast("Enter a filename", "error"); return; }
// Validate each path component (allow letters, numbers, -, _, ., space)
const nameParts = rawName.split("/");
if (nameParts.some(p => !p || !/^[\w\-. ]+$/.test(p))) {
showToast("Invalid path. Use folder/name format, each part: letters, numbers, -, _, . only", "error");
return;
}
const name = rawName;
const filename = `${name}.${ext}`;
const saved = await wcSaveFile(filename, content);
if (saved) {
showToast(`Saved: ${filename}`, "success");
wcCloseEditor();
wcRefreshFiles();
} else {
showToast("Failed to save file", "error");
}
});
// Editor: delete
document.getElementById("wfm-wc-editor-delete-btn")?.addEventListener("click", async () => {
if (!wcEditingFilename) return;
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
await wcDeleteFile(wcEditingFilename);
showToast(`Deleted: ${wcEditingFilename}`, "success");
wcCloseEditor();
wcRefreshFiles();
});
// Editor: cancel
document.getElementById("wfm-wc-editor-cancel-btn")?.addEventListener("click", () => {
wcCloseEditor();
});
// Initial wildcard file load
wcRefreshFiles();
}
+150
View File
@@ -606,6 +606,26 @@ export async function initSettingsTab() {
</div>
</details>
<!-- Data Management -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
<p style="font-size:12px;color:var(--wfm-text-secondary);margin-bottom:12px;">${t("dataManagementHint")}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export">${t("exportData")}</button>
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
${t("importData")}
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
</label>
</div>
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
</details>
<!-- Wildcard Integration -->
<details class="wfm-settings-section" id="wfm-wildcard-integration-section">
<summary class="wfm-settings-summary">Wildcard Integration</summary>
<div id="wfm-wildcard-link-status" style="font-size:13px;color:var(--wfm-text-secondary);">Loading...</div>
</details>
<!-- Save Button -->
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
@@ -1036,4 +1056,134 @@ export async function initSettingsTab() {
if (savedUrl) {
comfyUI.updateUrl(savedUrl);
}
// --- Wildcard Integration ---
async function wcLoadLinkStatus() {
const el = document.getElementById("wfm-wildcard-link-status");
if (!el) return;
try {
const res = await fetch("/api/wfm/wildcards/link-status");
const data = await res.json();
wcRenderLinkStatus(el, data);
} catch (e) {
el.innerHTML = `<span style="color:var(--wfm-error,#f44336)">Error: ${e.message}</span>`;
}
}
function wcRenderLinkStatus(el, data) {
if (!data.impact_pack_installed) {
el.innerHTML = `
<p style="margin:0 0 8px;">ComfyUI-Impact-Pack がインストールされていません。</p>
<p style="margin:0;font-size:12px;color:var(--wfm-text-secondary);">
ワイルドカードを Impact Pack と共有するには、先に
<a href="https://github.com/ltdrdata/ComfyUI-Impact-Pack" target="_blank" rel="noopener"
style="color:var(--wfm-accent);">ComfyUI-Impact-Pack</a>
をインストールしてください。
</p>`;
return;
}
if (data.is_linked) {
el.innerHTML = `
<p style="margin:0 0 4px;color:var(--wfm-success,#4caf50);">✓ リンク済み</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
WFS wildcard → ${data.link_target || data.impact_pack_wildcards_dir}
</p>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-wc-remove-link">リンクを解除</button>
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
document.getElementById("wfm-wc-remove-link")?.addEventListener("click", async () => {
const msg = document.getElementById("wfm-wc-link-msg");
try {
const r = await fetch("/api/wfm/wildcards/remove-link", { method: "POST" });
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.statusText);
msg.textContent = "リンクを解除しました。";
msg.style.color = "var(--wfm-success,#4caf50)";
setTimeout(wcLoadLinkStatus, 800);
} catch (e) {
msg.textContent = "エラー: " + e.message;
msg.style.color = "var(--wfm-error,#f44336)";
}
});
} else {
el.innerHTML = `
<p style="margin:0 0 4px;">Impact Pack wildcards ディレクトリ:</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">${data.impact_pack_wildcards_dir}</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
WFS の wildcard ディレクトリを Impact Pack の wildcards ディレクトリへのリンクに置き換えます。
既存の WFS ワイルドカードファイルは Impact Pack ディレクトリへ移行されます。
</p>
<button class="wfm-btn wfm-btn-sm" id="wfm-wc-create-link">リンクを作成</button>
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
document.getElementById("wfm-wc-create-link")?.addEventListener("click", async () => {
const msg = document.getElementById("wfm-wc-link-msg");
try {
const r = await fetch("/api/wfm/wildcards/create-link", { method: "POST" });
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.statusText);
const migrated = d.migrated_files?.length
? ` (移行: ${d.migrated_files.join(", ")})`
: "";
msg.textContent = "リンクを作成しました。" + migrated;
msg.style.color = "var(--wfm-success,#4caf50)";
setTimeout(wcLoadLinkStatus, 800);
} catch (e) {
msg.textContent = "エラー: " + e.message;
msg.style.color = "var(--wfm-error,#f44336)";
}
});
}
}
// Load wildcard link status when section is opened
document.getElementById("wfm-wildcard-integration-section")?.addEventListener("toggle", (e) => {
if (e.target.open) wcLoadLinkStatus();
});
// --- Data Export ---
document.getElementById("wfm-settings-export")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-data-status");
try {
const res = await fetch("/api/wfm/settings/export");
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "wfm-data-export.json";
a.click();
URL.revokeObjectURL(url);
statusEl.textContent = t("exportSuccess");
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("exportError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
});
// --- Data Import ---
document.getElementById("wfm-settings-import-file")?.addEventListener("change", async (e) => {
const statusEl = document.getElementById("wfm-settings-data-status");
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const bundle = JSON.parse(text);
const res = await fetch("/api/wfm/settings/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bundle),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || res.statusText);
const imported = result.imported?.join(", ") || "-";
statusEl.textContent = t("importSuccess") + ": " + imported;
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("importError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
// Reset file input so same file can be re-selected
e.target.value = "";
});
}
+426 -61
View File
@@ -20,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="metadata">Metadata</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>
@@ -192,6 +193,11 @@
<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>
<select id="wfm-models-status-filter" class="wfm-select wfm-toolbar-select">
<option value="all">All</option>
<option value="enabled">Enabled only</option>
<option value="disabled">Disabled only</option>
</select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-models-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-models-view="card" title="Card">&#9776;</button>
@@ -202,7 +208,10 @@
<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>
<button id="wfm-models-select-btn" class="wfm-btn wfm-btn-sm" title="Multi-select models">Select</button>
</div>
<!-- Bulk action bar (shown in select mode) -->
<div id="wfm-models-bulk-bar" class="wfm-bulk-bar" style="display:none;"></div>
</div>
<!-- Grid + Side Panel -->
@@ -256,6 +265,7 @@
<button class="wfm-gen-subtab-btn active" data-subtab="input">Input</button>
<button class="wfm-gen-subtab-btn" data-subtab="model">Model</button>
<button class="wfm-gen-subtab-btn" data-subtab="settings">Settings</button>
<button class="wfm-gen-subtab-btn" data-subtab="feeder">Feeder</button>
</div>
<!-- Tab: Input — left sub-col: Prompt(top)+Image(bottom), right sub-col: Raw JSON -->
@@ -302,6 +312,121 @@
<div class="wfm-gen-rawjson-col" id="wfm-gen-rawjson-col-settings"></div>
</div>
</div>
<!-- Tab: Feeder — full-width: settings(left) + library(center) -->
<div id="wfm-gen-subtab-feeder" class="wfm-gen-subtab-content">
<div class="wfm-feeder-layout">
<!-- Left: Node selector + params + presets -->
<div class="wfm-feeder-settings">
<div class="wfm-feeder-pane-header">ImageFeeder Node</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px;">
<select id="wfm-feeder-node-sel" class="wfm-select" style="flex:1;min-width:0;"></select>
<button id="wfm-feeder-apply-btn" class="wfm-btn wfm-btn-primary" style="white-space:nowrap;">Apply</button>
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">Directory</label>
<input type="text" id="wfm-feeder-dir-input" class="wfm-input wfm-feeder-input" placeholder="e.g. subdir">
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">Sort Mode</label>
<select id="wfm-feeder-sort" class="wfm-select wfm-feeder-input">
<option value="ascending">ascending</option>
<option value="descending">descending</option>
<option value="random">random</option>
</select>
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">Index</label>
<input type="number" id="wfm-feeder-index" class="wfm-input wfm-feeder-input" value="0" min="0">
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">Start Index</label>
<input type="number" id="wfm-feeder-start" class="wfm-input wfm-feeder-input" value="0" min="0">
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">End Index</label>
<input type="number" id="wfm-feeder-end" class="wfm-input wfm-feeder-input" value="0" min="0">
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">Batch Size</label>
<input type="number" id="wfm-feeder-batch" class="wfm-input wfm-feeder-input" value="1" min="1">
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">Seed</label>
<input type="number" id="wfm-feeder-seed" class="wfm-input wfm-feeder-input" value="0">
</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">Use Selection</label>
<input type="checkbox" id="wfm-feeder-use-sel" checked style="width:16px;height:16px;accent-color:var(--wfm-primary);">
</div>
<div class="wfm-feeder-pane-header" style="margin-top:12px;">Presets</div>
<div style="display:flex;gap:6px;margin-bottom:6px;">
<input type="text" id="wfm-feeder-preset-name" class="wfm-input" style="flex:1;min-width:0;" placeholder="Preset name">
<button id="wfm-feeder-preset-save" class="wfm-btn wfm-btn-sm" style="white-space:nowrap;">Save</button>
</div>
<div style="display:flex;gap:6px;">
<select id="wfm-feeder-preset-sel" class="wfm-select" style="flex:1;min-width:0;"></select>
<button id="wfm-feeder-preset-load" class="wfm-btn wfm-btn-sm wfm-btn-primary" style="white-space:nowrap;">Load</button>
<button id="wfm-feeder-preset-del" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="white-space:nowrap;">Del</button>
</div>
<div class="wfm-feeder-pane-header" style="margin-top:12px;">Run</div>
<div class="wfm-feeder-field-row">
<label class="wfm-feeder-label">After gen</label>
<select id="wfm-feeder-control-after" class="wfm-select wfm-feeder-input">
<option value="loop">Loop</option>
<option value="increment">Increment</option>
<option value="fixed">Fixed</option>
</select>
</div>
<div style="display:flex;gap:8px;margin-top:4px;">
<button id="wfm-feeder-run-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">▶ Run</button>
<button id="wfm-feeder-stop-btn" class="wfm-btn wfm-btn-danger" style="flex:1;" disabled>■ Stop</button>
</div>
</div>
<!-- Center: Image Library -->
<div class="wfm-feeder-library">
<!-- Status bar -->
<div id="wfm-feeder-status" class="wfm-feeder-status-bar">Select a folder</div>
<div class="wfm-feeder-library-body">
<!-- Folder tree -->
<div class="wfm-feeder-tree-pane">
<div class="wfm-feeder-pane-header" style="display:flex;align-items:center;justify-content:space-between;">
<span>Folders</span>
<button id="wfm-feeder-reload-tree" class="wfm-btn wfm-btn-sm" title="Reload">↺</button>
</div>
<div id="wfm-feeder-tree" class="wfm-feeder-tree-scroll">
<div class="wfm-feeder-tree-msg">Loading...</div>
</div>
</div>
<!-- Image grid -->
<div class="wfm-feeder-grid-pane">
<div class="wfm-feeder-pane-header" style="display:flex;align-items:center;justify-content:space-between;">
<span>Images</span>
<div style="display:flex;gap:4px;">
<button id="wfm-feeder-sel-all" class="wfm-btn wfm-btn-sm">All</button>
<button id="wfm-feeder-desel-all" class="wfm-btn wfm-btn-sm">None</button>
</div>
</div>
<div id="wfm-feeder-grid" class="wfm-feeder-grid">
<div class="wfm-feeder-grid-msg">Select a folder</div>
</div>
</div>
<!-- Preview -->
<div class="wfm-feeder-preview-pane">
<div class="wfm-feeder-pane-header">Preview</div>
<img id="wfm-feeder-preview-img" class="wfm-feeder-preview-img" style="display:none;" alt="">
<div id="wfm-feeder-preview-name" class="wfm-feeder-preview-name"></div>
<pre id="wfm-feeder-preview-info" class="wfm-feeder-preview-info"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Generation Controls & Results -->
@@ -322,10 +447,12 @@
</div>
<!-- Seed -->
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="width:130px;">
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:auto;">
<div style="display:flex;flex-direction:column;gap:4px;margin-top:8px;">
<div style="display:flex;gap:8px;align-items:center;">
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="flex:1;min-width:0;">
</div>
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:100%;">
<option value="random">Random</option>
<option value="fixed">Fixed</option>
<option value="increment">Increment</option>
@@ -334,6 +461,48 @@
</div>
</div>
<!-- Checkpoint Batch Panel -->
<div class="wfm-gen-panel" id="wfm-ckpt-batch-panel">
<div style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="wfm-ckpt-batch-enabled" style="width:14px;height:14px;cursor:pointer;accent-color:var(--wfm-primary);">
<label for="wfm-ckpt-batch-enabled" style="font-weight:600;font-size:13px;cursor:pointer;">Checkpoint Batch</label>
</div>
<div id="wfm-ckpt-batch-body" style="display:none;margin-top:8px;">
<div style="margin-bottom:6px;">
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:3px;">Checkpoints</div>
<div class="wfm-ckpt-dropdown-wrap" id="wfm-ckpt-dropdown-wrap">
<button type="button" id="wfm-ckpt-dropdown-btn" class="wfm-ckpt-dropdown-trigger">
<span id="wfm-ckpt-dropdown-label">All checkpoints</span>
<span class="wfm-ckpt-dropdown-arrow">&#9660;</span>
</button>
<div id="wfm-ckpt-dropdown-panel" class="wfm-ckpt-dropdown-panel" style="display:none;">
<div style="padding:6px 8px 4px;">
<input type="text" id="wfm-ckpt-search" class="wfm-input" style="height:28px;padding:4px 8px;font-size:11px;" placeholder="Filter...">
</div>
<div style="display:flex;gap:4px;padding:0 8px 4px;">
<button type="button" id="wfm-ckpt-select-all" class="wfm-btn wfm-btn-sm" style="flex:1;font-size:10px;">All</button>
<button type="button" id="wfm-ckpt-deselect-all" class="wfm-btn wfm-btn-sm" style="flex:1;font-size:10px;">None</button>
</div>
<div id="wfm-ckpt-list" class="wfm-ckpt-dropdown-list"></div>
</div>
</div>
</div>
<div id="wfm-ckpt-batch-info" style="font-size:11px;color:var(--wfm-text-secondary);"></div>
<div id="wfm-ckpt-batch-progress" style="margin-top:8px;display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:11px;">
<span id="wfm-ckpt-batch-current-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:75%;color:var(--wfm-text-primary);"></span>
<span id="wfm-ckpt-batch-count" style="color:var(--wfm-text-secondary);white-space:nowrap;margin-left:8px;"></span>
</div>
<div class="wfm-gen-progress-track">
<div id="wfm-ckpt-batch-bar" class="wfm-gen-progress-bar" style="background:var(--wfm-warning,#f59e0b);"></div>
</div>
<div style="margin-top:6px;">
<button id="wfm-ckpt-batch-pause-btn" class="wfm-btn wfm-btn-sm" style="width:100%;">Pause</button>
</div>
</div>
</div>
</div>
<!-- Result Display -->
<div class="wfm-gen-result">
<img id="wfm-gen-result-img" style="display:none;max-width:100%;border-radius:6px;">
@@ -396,61 +565,209 @@
</div>
</div>
<!-- Col 2: Preset Editor -->
<!-- Col 2: Presets + Preset Manager (tabbed) -->
<div class="wfm-prompt-split-col">
<div class="wfm-prompt-split-header">Presets</div>
<div class="wfm-preset-panel">
<div style="display:flex;gap:6px;margin-bottom:10px;align-items:center;">
<select id="wfm-preset-select" class="wfm-select" style="flex:1;">
<option value="">New Preset (none selected)</option>
</select>
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">GenUI Set</button>
<!-- Center column tab navigation -->
<div class="wfm-prompt-center-tabnav">
<button class="wfm-prompt-center-tab active" data-ptab="presets">Presets</button>
<button class="wfm-prompt-center-tab" data-ptab="manager">Preset Manager</button>
</div>
<!-- Presets pane -->
<div id="wfm-ptab-presets" class="wfm-prompt-center-pane">
<div class="wfm-preset-panel">
<div style="display:flex;gap:6px;margin-bottom:10px;align-items:center;">
<select id="wfm-preset-select" class="wfm-select" style="flex:1;">
<option value="">New Preset (none selected)</option>
</select>
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">GenUI Set</button>
</div>
<div class="wfm-form-group">
<label>Preset Name</label>
<input type="text" id="wfm-preset-name" class="wfm-input" placeholder="Enter preset name...">
</div>
<div class="wfm-form-group">
<label>Category</label>
<input type="text" id="wfm-preset-category" class="wfm-input" placeholder="e.g. Portrait, Landscape...">
</div>
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<label>Positive Prompt</label>
<textarea id="wfm-preset-pos" class="wfm-textarea" style="flex:1;" placeholder="Positive prompt..."></textarea>
</div>
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<label>Negative Prompt</label>
<textarea id="wfm-preset-neg" class="wfm-textarea" style="flex:1;" placeholder="Negative prompt..."></textarea>
</div>
<div style="display:flex; gap:4px;">
<button id="wfm-preset-copy-pos-btn" class="wfm-btn" style="flex:2;" title="Copy Positive Prompt">PP Copy</button>
<button id="wfm-preset-copy-neg-btn" class="wfm-btn" style="flex:2;" title="Copy Negative Prompt">NP Copy</button>
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Save</button>
</div>
<div style="display:flex; gap:4px; margin-top:4px;">
<select id="wfm-preset-group-select" class="wfm-select" style="flex:1;">
<option value="">Select group...</option>
</select>
<button id="wfm-preset-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
<button id="wfm-preset-new-group-btn" class="wfm-btn wfm-btn-sm">+ Group</button>
<button id="wfm-preset-del-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected group">Del Group</button>
</div>
</div>
<div class="wfm-form-group">
<label>Preset Name</label>
<input type="text" id="wfm-preset-name" class="wfm-input" placeholder="Enter preset name...">
</div>
<div class="wfm-form-group">
<label>Category</label>
<input type="text" id="wfm-preset-category" class="wfm-input" placeholder="e.g. Portrait, Landscape...">
</div>
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<label>Positive Prompt</label>
<textarea id="wfm-preset-pos" class="wfm-textarea" style="flex:1;" placeholder="Positive prompt..."></textarea>
</div>
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<label>Negative Prompt</label>
<textarea id="wfm-preset-neg" class="wfm-textarea" style="flex:1;" placeholder="Negative prompt..."></textarea>
</div>
<div style="display:flex; gap:4px;">
<button id="wfm-preset-copy-pos-btn" class="wfm-btn" style="flex:2;" title="Copy Positive Prompt">PP Copy</button>
<button id="wfm-preset-copy-neg-btn" class="wfm-btn" style="flex:2;" title="Copy Negative Prompt">NP Copy</button>
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Save</button>
</div>
<div style="display:flex; gap:4px; margin-top:4px;">
<select id="wfm-preset-group-select" class="wfm-select" style="flex:1;">
<option value="">Select group...</option>
</select>
<button id="wfm-preset-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
<button id="wfm-preset-new-group-btn" class="wfm-btn wfm-btn-sm">+ Group</button>
<button id="wfm-preset-del-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected group">Del Group</button>
</div>
<!-- Preset Manager pane -->
<div id="wfm-ptab-manager" class="wfm-prompt-center-pane" style="display:none;">
<div class="wfm-preset-manager">
<div class="wfm-pm-tabs">
<button class="wfm-pm-tab active" data-pmtab="all">All</button>
<button class="wfm-pm-tab" data-pmtab="favorites">&#9733;</button>
<button class="wfm-pm-tab" data-pmtab="groups">Groups</button>
</div>
<div class="wfm-pm-search">
<input type="text" id="wfm-pm-search-input" class="wfm-input" placeholder="Search presets...">
</div>
<div id="wfm-pm-list" class="wfm-pm-list"></div>
</div>
</div>
</div>
<!-- Col 3: Preset Manager (list, favorites, groups) -->
<!-- Col 3: Wildcard support -->
<div class="wfm-prompt-split-col">
<div class="wfm-prompt-split-header">Preset Manager</div>
<div class="wfm-preset-manager">
<div class="wfm-pm-tabs">
<button class="wfm-pm-tab active" data-pmtab="all">All</button>
<button class="wfm-pm-tab" data-pmtab="favorites">&#9733;</button>
<button class="wfm-pm-tab" data-pmtab="groups">Groups</button>
<div class="wfm-prompt-split-header">Wildcard</div>
<div class="wfm-wc-panel">
<!-- Input assistance section -->
<div class="wfm-wc-input-section">
<!-- Wildcard syntax buttons -->
<div class="wfm-wc-toolbar">
<button class="wfm-wc-btn" data-wc-open="{" data-wc-close="}" title="Random pick set e.g. {cat|dog}">{ }</button>
<button class="wfm-wc-btn" data-wc-open="|" title="Separator">|</button>
<button class="wfm-wc-btn" data-wc-open="__" data-wc-close="__" title="Wildcard file reference e.g. __animals__">__</button>
<button class="wfm-wc-btn" data-wc-open=":" title="Colon">:</button>
<button class="wfm-wc-btn" data-wc-open=";" title="Semicolon">;</button>
<button class="wfm-wc-btn" data-wc-open="$$" title="Multi-pick e.g. {2$$cat|dog|bird}">$$</button>
<button class="wfm-wc-btn" data-wc-open="[" data-wc-close="]" title="Bracket set e.g. [cat|dog]">[ ]</button>
<button class="wfm-wc-btn" id="wfm-wc-lora-btn" title="LoRA with LBW e.g. &lt;lora:name:1:LBW=...;...&gt;">&lt;lora&gt;</button>
<button class="wfm-wc-btn" id="wfm-wc-nset-btn" title="Multi-pick set e.g. {2$$cat|dog|bird}">n$${ }</button>
<div class="wfm-wc-file-picker-wrap">
<button class="wfm-wc-btn" id="wfm-wc-filepick-btn" title="Insert wildcard file reference">&#128194; File</button>
<div id="wfm-wc-file-picker" class="wfm-wc-file-picker" style="display:none;"></div>
</div>
</div>
<!-- Prompt textarea -->
<textarea id="wfm-wc-prompt" class="wfm-textarea wfm-wc-prompt-ta" rows="4" placeholder="Compose wildcard prompt here...&#10;e.g. a photo of {cat|dog}, __colors__ background"></textarea>
<!-- Action buttons -->
<div class="wfm-wc-prompt-actions">
<button id="wfm-wc-copy-btn" class="wfm-btn wfm-btn-sm">Copy</button>
<button id="wfm-wc-to-pos-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" title="Send to Preset Positive Prompt">&#8594; Pos</button>
<button id="wfm-wc-to-neg-btn" class="wfm-btn wfm-btn-sm" title="Send to Preset Negative Prompt">&#8594; Neg</button>
<button id="wfm-wc-clear-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Clear</button>
</div>
</div>
<div class="wfm-pm-search">
<input type="text" id="wfm-pm-search-input" class="wfm-input" placeholder="Search presets...">
<!-- Wildcard file manager section -->
<div class="wfm-wc-files-section">
<div class="wfm-wc-files-header">
<span class="wfm-wc-files-title">Wildcard Files</span>
<div style="display:flex;gap:4px;">
<button id="wfm-wc-new-file-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New</button>
<button id="wfm-wc-files-refresh-btn" class="wfm-btn wfm-btn-sm" title="Reload file list">&#8635;</button>
</div>
</div>
<!-- File list -->
<div id="wfm-wc-file-list" class="wfm-wc-file-list">
<p class="wfm-placeholder" style="padding:12px;font-size:12px;">Loading...</p>
</div>
<!-- Editor (shown when a file is selected or New is clicked) -->
<div id="wfm-wc-editor" class="wfm-wc-editor" style="display:none;">
<div class="wfm-wc-editor-header">
<input type="text" id="wfm-wc-editor-name" class="wfm-input" placeholder="name or folder/name" style="flex:1;min-width:0;">
<select id="wfm-wc-editor-ext" class="wfm-select" style="width:70px;">
<option value="txt">txt</option>
<option value="yaml">yaml</option>
</select>
</div>
<div class="wfm-wc-editor-hint">
txt: one item per line &nbsp;|&nbsp; yaml: key: [item1, item2, ...]
</div>
<textarea id="wfm-wc-editor-content" class="wfm-textarea wfm-wc-editor-ta" rows="8" placeholder="one item per line (txt)&#10;or YAML format (yaml)"></textarea>
<div class="wfm-wc-editor-actions">
<button id="wfm-wc-editor-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">Save</button>
<button id="wfm-wc-editor-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="display:none;">Delete</button>
<button id="wfm-wc-editor-cancel-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Metadata Tab -->
<section id="wfm-tab-metadata" class="wfm-tab-content">
<div class="wfm-metadata-layout">
<!-- Col1: Drop zone + preview -->
<div class="wfm-metadata-col1">
<div class="wfm-metadata-drop-zone" id="wfm-meta-drop">
<div class="wfm-meta-drop-label" id="wfm-meta-drop-label">
<div class="wfm-meta-drop-main">Drop image / JSON</div>
<div class="wfm-meta-drop-sub">PNG · WebP · JSON</div>
</div>
<img id="wfm-meta-preview-img" class="wfm-meta-preview-img" style="display:none;" alt="preview">
</div>
<div class="wfm-meta-file-info" id="wfm-meta-file-info"></div>
<div class="wfm-meta-format-note" id="wfm-meta-format-note">
<div class="wfm-meta-format-note-title" id="wfm-meta-format-note-title">Supported formats</div>
<ul class="wfm-meta-format-list">
<li id="wfm-meta-fmt-comfyui">ComfyUI — PNG / WebP / JSON</li>
<li id="wfm-meta-fmt-sdwebui">SD WebUI / SD Forge — PNG (parameters)</li>
<li id="wfm-meta-fmt-fooocus">Fooocus — PNG (fooocus_scheme)</li>
</ul>
<div class="wfm-meta-format-todo" id="wfm-meta-format-todo">Flux.2 / Qwen-Image / Z-Image subgraph workflows supported</div>
</div>
<input type="file" id="wfm-meta-file-input" accept="image/png,image/webp,.json,application/json" style="display:none;">
</div>
<!-- Col2: Model info -->
<div class="wfm-metadata-col2">
<div class="wfm-meta-section" id="wfm-meta-ckpt-section">
<div class="wfm-meta-section-title">Checkpoint</div>
<div class="wfm-meta-list" id="wfm-meta-ckpt-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-vae-section">
<div class="wfm-meta-section-title">VAE</div>
<div class="wfm-meta-list" id="wfm-meta-vae-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-diff-section">
<div class="wfm-meta-section-title">Diffusion Model</div>
<div class="wfm-meta-list" id="wfm-meta-diff-list"></div>
</div>
<div class="wfm-meta-section" id="wfm-meta-te-section">
<div class="wfm-meta-section-title">Text Encoder</div>
<div class="wfm-meta-list" id="wfm-meta-te-list"></div>
</div>
</div>
<!-- Col3: LoRA + Prompt -->
<div class="wfm-metadata-col3">
<div class="wfm-meta-section" id="wfm-meta-lora-section">
<div class="wfm-meta-section-title">LoRA</div>
<div class="wfm-meta-list" id="wfm-meta-lora-list"></div>
</div>
<div class="wfm-meta-section wfm-meta-prompt-section" id="wfm-meta-prompt-section">
<div class="wfm-meta-section-title">Prompt</div>
<div class="wfm-meta-list" id="wfm-meta-prompt-list"></div>
<div class="wfm-meta-prompt-full-wrap">
<div class="wfm-meta-prompt-full-label" id="wfm-meta-prompt-full-label"></div>
<textarea class="wfm-meta-prompt-full" id="wfm-meta-prompt-full" readonly placeholder="Select a prompt above to view full text..."></textarea>
<div class="wfm-meta-prompt-actions">
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-copy-btn">Copy</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-genui-pos-btn">GenUI:P</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-genui-neg-btn">GenUI:N</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-preset-pos-btn">Prompt:P</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-meta-preset-neg-btn">Prompt:N</button>
</div>
</div>
<div id="wfm-pm-list" class="wfm-pm-list"></div>
</div>
</div>
</div>
@@ -496,14 +813,31 @@
<ul>
<li id="wfm-help-gen-1">Connect/disconnect ComfyUI and check connection status</li>
<li id="wfm-help-gen-2">Load workflows from Workflow tab (App format .app.json not supported)</li>
<li id="wfm-help-gen-3">3-tab layout — Input (Prompt + Image), Model, Settings — each with a Raw JSON column for instant preview and direct editing</li>
<li id="wfm-help-gen-3">4-tab layout — Input (Prompt + Image), Model, Settings, Feeder — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing</li>
<li id="wfm-help-gen-4">Input tab: Prompt (top) and Image drag-and-drop (bottom) in the left column</li>
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter</li>
<li id="wfm-help-gen-6">Settings tab: KSampler (top) and LatentImage (bottom) in the left column</li>
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow</li>
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement</li>
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement — seed input and mode selector stacked vertically for readability</li>
<li id="wfm-help-gen-9">Generate, progress display, stop, and view results</li>
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle</li>
<li id="wfm-help-gen-11">Checkpoint Batch: select checkpoints by folder from the dropdown — check a folder to select all its files, or expand with ▶ to check individual files; use Filter to search; Pause/Resume suspends and continues between models; Stop aborts after the current generation</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-feeder-title">GenerateUI Tab — Feeder subtab</h3>
<p id="wfm-help-feeder-desc">Requires the comfyui-image-feeder custom node. Controls ImageFeeder nodes and runs continuous generation loops over an image library stored in user/default/image-loop-data/.</p>
<ul>
<li id="wfm-help-feeder-1">Left pane — Node settings: select the target ImageFeeder node from the dropdown; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection); click Apply to write changes to the loaded workflow</li>
<li id="wfm-help-feeder-2">Left pane — Presets: save the current directory + selection as a named preset; load or delete saved presets</li>
<li id="wfm-help-feeder-3">Left pane — Run: After gen mode determines index behavior after each generation — Loop (wrap back to 0 and continue indefinitely), Increment (advance index and stop when all images are exhausted), Fixed (always use the same index); click Run to start the loop, Stop to interrupt</li>
<li id="wfm-help-feeder-4">Center pane — Folder tree: browse subfolders inside image-loop-data; click a folder to load its images; click ↺ to reload the tree</li>
<li id="wfm-help-feeder-5">Center pane — Image grid: displays thumbnails of all images in the selected folder; check individual images to add them to the selection; All / None buttons select or clear the entire folder; click an image to open the preview panel</li>
<li id="wfm-help-feeder-6">Center pane — Preview panel: shows the clicked image at larger size with filename, resolution, and file size</li>
<li id="wfm-help-feeder-7">Status bar: shows the current folder path and how many images are selected out of the total in the folder</li>
<li id="wfm-help-feeder-8">Seed during Run: uses the right-pane seed setting (Random / Fixed / Increment / Decrement) — the ImageFeeder's own Seed field controls random sort order only</li>
<li id="wfm-help-feeder-9">Index sync: after each generation the node returns the next index via WebSocket; the Index field updates automatically so the display stays in sync</li>
</ul>
</div>
@@ -517,6 +851,20 @@
<li id="wfm-help-prompt-5">Preset Manager: browse all presets, favorites, and group-based filtering</li>
<li id="wfm-help-prompt-6">Group management: create groups, assign/remove presets, delete groups</li>
<li id="wfm-help-prompt-7">GenUI Set: apply preset prompts directly to GenerateUI</li>
<li id="wfm-help-prompt-8">Wildcard toolbar: one-click buttons to insert wildcard syntax ({|}, {n$|}, __|__, &lt;lora:&gt;, etc.); wraps selected text</li>
<li id="wfm-help-prompt-9">Wildcard file manager: create/edit/delete .txt/.yaml wildcard files; click filename to insert __filename__ at cursor</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-metadata-title">Metadata Tab</h3>
<p id="wfm-help-metadata-desc">Drop a ComfyUI-generated PNG/WebP or workflow JSON to instantly extract and view the embedded model and prompt information.</p>
<ul>
<li id="wfm-help-metadata-1">Left column: drop zone (image preview for PNG/WebP) and file info; click or drop to load</li>
<li id="wfm-help-metadata-2">Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow</li>
<li id="wfm-help-metadata-3">Right column: LoRA list (with strength values) and Prompt list (click to view full text below)</li>
<li id="wfm-help-metadata-4">Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative</li>
<li id="wfm-help-metadata-5">Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image subgraph workflows), SD WebUI, SD Forge, Fooocus.</li>
</ul>
</div>
@@ -530,7 +878,9 @@
<li id="wfm-help-settings-5">Ollama URL, default model, and connection test</li>
<li id="wfm-help-settings-6">Default workflow setting</li>
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</li>
<li id="wfm-help-settings-8">All sections are collapsible accordion panels</li>
<li id="wfm-help-settings-8">Data Management: export all plugin data to JSON / import to restore</li>
<li id="wfm-help-settings-9">All sections are collapsible accordion panels</li>
<li id="wfm-help-settings-10">Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (junction/symlink); existing files migrated automatically</li>
</ul>
</div>
@@ -553,12 +903,13 @@
<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>
<li id="wfm-help-gallery-5">Folder tree: click to load images, expand with ▶; "+ New" creates a subfolder inside the selected folder, "Del" deletes it with all contents</li>
<li id="wfm-help-gallery-6">Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All, Move To..., Delete</li>
<li id="wfm-help-gallery-7">Detail panel — Info tab: filename, size, date, tags (add/remove), memo; "Move To..." moves the image to another folder, "Delete" removes it permanently</li>
<li id="wfm-help-gallery-8">Detail panel — Metadata tab: displays workflow JSON from PNG embedded data (prompt / workflow keys) or from metadata saved by the Generate UI tab; one-click clipboard copy</li>
<li id="wfm-help-gallery-9">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image</li>
<li id="wfm-help-gallery-10">Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the Metadata tab</li>
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF; workflow extraction reads ComfyUI prompt and workflow keys embedded in PNG metadata</li>
</ul>
</div>
@@ -567,13 +918,15 @@
<ul>
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching</li>
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, and favorites filter</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, favorites filter, and status filter (All / Enabled only / Disabled only)</li>
<li id="wfm-help-models-4">Side panel: Info (file path, tags, memo), Groups management, CivitAI integration</li>
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
<li id="wfm-help-models-6">Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change</li>
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models</li>
<li id="wfm-help-models-8">GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)</li>
<li id="wfm-help-models-9">User-defined badges: assign free-label badges with custom colors shared with the Workflow tab</li>
<li id="wfm-help-models-10">Enable/Disable models: toggle visibility in ComfyUI by renaming file extension (.disabled suffix). Disable per model (⏸ button on each card) or per group (Enable All / Disable All in Group panel). Changes take effect after ComfyUI refresh.</li>
<li id="wfm-help-models-11">Multi-select &amp; bulk operations: click Select to enter selection mode, check multiple models, then use the bulk action bar to add/remove from groups or permanently delete files (model file + preview images + sidecar files such as .json/.info).</li>
</ul>
</div>
@@ -683,6 +1036,8 @@
<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>
<button id="wfm-gallery-bulk-move" class="wfm-btn wfm-btn-sm">Move To...</button>
<button id="wfm-gallery-bulk-delete" class="wfm-btn wfm-btn-sm wfm-btn-danger">Delete</button>
</div>
</div>
@@ -692,6 +1047,10 @@
<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 class="wfm-gallery-tree-actions">
<button id="wfm-gallery-folder-create-btn" class="wfm-btn wfm-btn-sm" title="Create subfolder in selected folder">+ New</button>
<button id="wfm-gallery-folder-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected folder" disabled>Del</button>
</div>
</div>
<div id="wfm-gallery-tree" class="wfm-gallery-tree">
<p class="wfm-placeholder" id="wfm-gallery-tree-placeholder">Loading...</p>
@@ -744,6 +1103,12 @@
<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>
<hr class="wfm-group-divider">
<!-- ファイル操作 -->
<div class="wfm-gallery-info-section wfm-gallery-file-actions">
<button id="wfm-gallery-img-move-btn" class="wfm-btn wfm-btn-sm" disabled>Move To...</button>
<button id="wfm-gallery-img-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" disabled>Delete</button>
</div>
</div>
<!-- Metadata タブ -->
<div id="wfm-gallery-detail-meta" class="wfm-gallery-detail-tab-content wfm-gallery-meta-tab" style="display:none;">
+21 -18
View File
@@ -1324,10 +1324,21 @@ const renderModelFavorites = async (container) => {
const renderModelGroups = async (container) => {
container.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
const groups = state.modelGroups;
const groupNames = Object.keys(groups);
// allGroups is { type: { groupName: [modelName, ...] } }
const allGroups = state.modelGroups;
if (groupNames.length === 0) {
// Flatten into [{ modelType, groupName, members }]
const flatGroups = [];
for (const [modelType, groups] of Object.entries(allGroups)) {
if (typeof groups !== "object" || Array.isArray(groups)) continue;
for (const [groupName, members] of Object.entries(groups)) {
if (Array.isArray(members) && members.length > 0) {
flatGroups.push({ modelType, groupName, members });
}
}
}
if (flatGroups.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">No groups found.</div>`;
return;
}
@@ -1347,18 +1358,18 @@ const renderModelGroups = async (container) => {
container.innerHTML = "";
let hasAny = false;
for (const groupName of groupNames) {
const members = (groups[groupName] || []).filter(name => matchesModelSearch(name));
if (state.searchText && members.length === 0) continue;
if ((groups[groupName] || []).length === 0) continue;
for (const { modelType, groupName, members } of flatGroups) {
const filtered = members.filter(name => matchesModelSearch(name));
if (state.searchText && filtered.length === 0) continue;
hasAny = true;
const section = document.createElement("div");
section.className = "wfm-nlp-group-section";
const typeLabel = MODEL_TYPE_LABELS[modelType] || modelType;
const header = document.createElement("div");
header.className = "wfm-nlp-group-header collapsed";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${members.length}</span>`;
header.innerHTML = `<span class="wfm-nlp-model-type-badge">[${esc(typeLabel)}]</span> <span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${filtered.length}</span>`;
header.addEventListener("click", () => {
const listEl = section.querySelector(".wfm-nlp-group-list");
listEl.style.display = listEl.style.display === "none" ? "block" : "none";
@@ -1369,17 +1380,9 @@ const renderModelGroups = async (container) => {
const listEl = document.createElement("div");
listEl.className = "wfm-nlp-group-list";
listEl.style.display = "none";
for (const name of members) {
const type = typeOf[name] || "checkpoint";
for (const name of filtered) {
const type = typeOf[name] || modelType;
const item = createModelItem(name, type);
const typeLabel = MODEL_TYPE_LABELS[type] || type;
const typeBadgeEl = item.querySelector(".wfm-nlp-item-body");
if (typeBadgeEl) {
const tb = document.createElement("span");
tb.className = "wfm-nlp-model-type-badge";
tb.textContent = typeLabel;
typeBadgeEl.prepend(tb);
}
listEl.appendChild(item);
}
section.appendChild(listEl);