Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823f1ebd54 | ||
|
|
0886ea6e2b | ||
|
|
226fc885d7 | ||
|
|
8d48c2276f |
@@ -1,5 +1,268 @@
|
||||
# DEVLOG - ComfyUI-Workflow-Studio
|
||||
|
||||
## 2026-04-28: v0.3.3 Promptタブ — ワイルドカード支援パネル・Impact Packシンボリックリンク連携
|
||||
|
||||
### 概要
|
||||
|
||||
- Promptタブのレイアウトを3カラム構成に変更
|
||||
- 中央ペイン(Col2): Presets / Preset Manager をタブ切り替え化
|
||||
- 右ペイン(Col3): ワイルドカード支援パネルを新設
|
||||
- ワイルドカードファイル管理(txt/yaml)の CRUD API を追加
|
||||
- Settingsタブに Wildcard Integration セクションを追加(Impact Pack ジャンクション/シンボリックリンク連携)
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `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` — ファイルマネージャ
|
||||
|
||||
---
|
||||
|
||||
## 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 モデル有効/無効・複数選択・一括削除
|
||||
|
||||
### 概要
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -50,19 +50,20 @@ 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)
|
||||
### GenerateUI Tab (v0.3.2)
|
||||
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
|
||||
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
|
||||
- **Settings tab** — KSampler (top) and LatentImage (bottom) in the left column; Raw JSON on the right
|
||||
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow
|
||||
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
|
||||
- **Seed control** — randomize, lock, or manually set seeds
|
||||
- **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; filter by include folders (comma-separated subfolder names, empty = all) and/or exclude folders; Stop button aborts after the current generation completes; amber progress bar tracks per-model progress
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
|
||||
### 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 +72,8 @@ 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
|
||||
|
||||
### Settings Tab
|
||||
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
|
||||
@@ -83,18 +86,23 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **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
|
||||
@@ -210,6 +218,27 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.3
|
||||
- **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.
|
||||
|
||||
@@ -13,6 +13,7 @@ STATIC_DIR = PLUGIN_DIR / "static"
|
||||
|
||||
# ComfyUI root is custom_nodes/../../ = two levels up
|
||||
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
|
||||
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"
|
||||
@@ -67,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""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 (follows junction/symlink)."""
|
||||
if not self.wildcard_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for path in sorted(self.wildcard_dir.iterdir()):
|
||||
if path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS:
|
||||
files.append({
|
||||
"name": path.stem,
|
||||
"filename": path.name,
|
||||
"ext": path.suffix.lstrip(".").lower(),
|
||||
"size": path.stat().st_size,
|
||||
})
|
||||
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.write_text(content, encoding="utf-8")
|
||||
return {
|
||||
"name": path.stem,
|
||||
"filename": path.name,
|
||||
"ext": path.suffix.lstrip(".").lower(),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
|
||||
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 ".." in filename or "/" in filename or "\\" in filename:
|
||||
return None
|
||||
ext = Path(filename).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.wildcard_dir / filename
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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))
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1923,6 +1923,287 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Prompt Tab — Center column tabs
|
||||
============================================ */
|
||||
|
||||
.wfm-prompt-center-tabnav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-prompt-center-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.wfm-prompt-center-tab:hover {
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-prompt-center-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-prompt-center-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Wildcard Panel
|
||||
============================================ */
|
||||
|
||||
.wfm-wc-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Input assistance section */
|
||||
.wfm-wc-input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Wildcard syntax button toolbar */
|
||||
.wfm-wc-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.wfm-wc-btn {
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
color: var(--wfm-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wfm-wc-btn:hover {
|
||||
background: var(--wfm-surface-2, var(--wfm-surface));
|
||||
border-color: var(--wfm-primary);
|
||||
color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-wc-prompt-ta {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-wc-prompt-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* File picker popup */
|
||||
.wfm-wc-file-picker-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wfm-wc-file-picker {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
background: var(--wfm-bg);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
min-width: 160px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.wfm-wc-picker-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-text);
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-wc-picker-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wfm-wc-picker-item:hover {
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
/* File manager section */
|
||||
.wfm-wc-files-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
padding-top: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-wc-files-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-wc-files-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.wfm-wc-file-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item:hover {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
|
||||
.wfm-wc-file-item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-wc-file-name {
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: var(--wfm-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wfm-wc-file-ext {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item:hover .wfm-wc-file-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-wc-use-btn {
|
||||
font-size: 10px !important;
|
||||
color: var(--wfm-primary) !important;
|
||||
}
|
||||
|
||||
/* File editor */
|
||||
.wfm-wc-editor {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 8px;
|
||||
background: var(--wfm-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-header {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-hint {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-ta {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Image Input (I2I) Slots
|
||||
============================================ */
|
||||
|
||||
+3
-2
@@ -268,12 +268,13 @@ function applyI18nToHtml() {
|
||||
"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-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",
|
||||
|
||||
+267
-2
@@ -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,12 +119,17 @@ async function loadFolderTree() {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${data.error}</p>`;
|
||||
return;
|
||||
}
|
||||
state.folderTree = data;
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
// root フォルダを自動選択(フォルダ未選択の場合のみ)
|
||||
if (!state.currentFolder) {
|
||||
|
||||
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>`;
|
||||
@@ -90,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;
|
||||
@@ -126,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);
|
||||
}
|
||||
});
|
||||
|
||||
// 矢印クリックで子ノード展開/折りたたみ
|
||||
@@ -424,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">`;
|
||||
@@ -772,6 +834,181 @@ function escapeHtml(str) {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ── フォルダ作成・削除 ────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
@@ -998,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", () => {
|
||||
|
||||
+256
-71
@@ -50,6 +50,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
|
||||
// ============================================
|
||||
@@ -114,7 +150,199 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Generation
|
||||
// Checkpoint Batch
|
||||
// ============================================
|
||||
|
||||
const _ckptBatch = { aborted: false };
|
||||
|
||||
function _parseFolderList(str) {
|
||||
return str.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
function _getModelFolder(modelPath) {
|
||||
const normalized = modelPath.replace(/\\/g, "/");
|
||||
const idx = normalized.indexOf("/");
|
||||
return idx === -1 ? "" : normalized.substring(0, idx).toLowerCase();
|
||||
}
|
||||
|
||||
function _filterCheckpoints(checkpoints, includeStr, excludeStr) {
|
||||
const inc = _parseFolderList(includeStr);
|
||||
const exc = _parseFolderList(excludeStr);
|
||||
return checkpoints.filter((m) => {
|
||||
const folder = _getModelFolder(m);
|
||||
if (inc.length > 0 && !inc.includes(folder)) return false;
|
||||
if (exc.length > 0 && exc.includes(folder)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function _updateBatchInfo() {
|
||||
const infoEl = document.getElementById("wfm-ckpt-batch-info");
|
||||
if (!infoEl) return;
|
||||
const inc = document.getElementById("wfm-ckpt-batch-include")?.value || "";
|
||||
const exc = document.getElementById("wfm-ckpt-batch-exclude")?.value || "";
|
||||
const n = _filterCheckpoints(comfyEditor.models.checkpoints, inc, exc).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");
|
||||
|
||||
checkbox?.addEventListener("change", () => {
|
||||
if (body) body.style.display = checkbox.checked ? "block" : "none";
|
||||
if (checkbox.checked) _updateBatchInfo();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-ckpt-batch-include")?.addEventListener("input", _updateBatchInfo);
|
||||
document.getElementById("wfm-ckpt-batch-exclude")?.addEventListener("input", _updateBatchInfo);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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 inc = document.getElementById("wfm-ckpt-batch-include")?.value || "";
|
||||
const exc = document.getElementById("wfm-ckpt-batch-exclude")?.value || "";
|
||||
const list = _filterCheckpoints(comfyEditor.models.checkpoints, inc, exc);
|
||||
|
||||
if (list.length === 0) {
|
||||
showToast("No checkpoints match the folder filter", "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";
|
||||
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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 +352,29 @@ 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;
|
||||
|
||||
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 +408,15 @@ export async function initGenerateTab() {
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, comfyUI.currentWorkflow);
|
||||
}
|
||||
showToast("Model lists refreshed", "success");
|
||||
_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;
|
||||
await comfyUI.interrupt();
|
||||
showToast("Interrupted", "info");
|
||||
});
|
||||
@@ -303,11 +478,21 @@ 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();
|
||||
|
||||
// Auto-connect on init
|
||||
const connected = await comfyUI.checkConnection();
|
||||
updateStatus(connected);
|
||||
if (connected) {
|
||||
await comfyEditor.loadModelLists();
|
||||
_updateBatchInfo();
|
||||
}
|
||||
|
||||
// Auto-load default workflow (doesn't require ComfyUI connection)
|
||||
|
||||
@@ -355,6 +355,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",
|
||||
@@ -364,6 +366,7 @@ const LANGUAGES = {
|
||||
helpSettings7: "Eagle integration URL and auto-save toggle",
|
||||
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",
|
||||
@@ -878,6 +881,8 @@ const LANGUAGES = {
|
||||
helpPrompt5: "プリセットマネージャー:全プリセット一覧、お気に入り、グループ別フィルタリング",
|
||||
helpPrompt6: "グループ管理:グループ作成、プリセットの割り当て・解除、グループ削除",
|
||||
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
|
||||
helpPrompt8: "ワイルドカードツールバー:{|}・{n$|}・__|__・<lora:>などの構文をワンクリック挿入。選択テキストのラップも対応",
|
||||
helpPrompt9: "ワイルドカードファイルマネージャー:.txt/.yamlファイルの作成・編集・削除。ファイル名をクリックすると__ファイル名__をカーソル位置に挿入",
|
||||
helpSettings1: "UI言語と要約言語の設定",
|
||||
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
|
||||
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
|
||||
@@ -887,6 +892,7 @@ const LANGUAGES = {
|
||||
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど全データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
|
||||
helpSettings9: "各セクションはアコーディオン式で折りたたみ可能",
|
||||
helpSettings10: "Wildcard Integration:WFSのワイルドカードディレクトリをComfyUI-Impact-Packのwildcardsディレクトリへリンク(Windowsはジャンクション、他OSはシンボリックリンク)。既存ファイルは自動で移行されます",
|
||||
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
|
||||
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
|
||||
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
|
||||
@@ -1397,6 +1403,8 @@ const LANGUAGES = {
|
||||
helpPrompt5: "预设管理器:浏览所有预设、收藏、按组筛选",
|
||||
helpPrompt6: "组管理:创建组、分配/移除预设、删除组",
|
||||
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
|
||||
helpPrompt8: "通配符工具栏:一键插入通配符语法({|}、{n$|}、__|__、<lora:>等);支持包裹选中文本",
|
||||
helpPrompt9: "通配符文件管理:创建/编辑/删除.txt/.yaml通配符文件;点击文件名在光标处插入__文件名__",
|
||||
helpSettings1: "UI语言和摘要语言设置",
|
||||
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
|
||||
helpSettings3: "工作流数据文件夹路径更改/重置",
|
||||
@@ -1406,6 +1414,7 @@ const LANGUAGES = {
|
||||
helpSettings7: "Eagle集成URL、自动保存开关",
|
||||
helpSettings8: "数据管理:将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
|
||||
helpSettings9: "各部分可折叠展开",
|
||||
helpSettings10: "Wildcard Integration:将WFS通配符目录链接到ComfyUI-Impact-Pack的wildcards目录(Windows为目录联结,其他OS为符号链接);现有文件自动迁移",
|
||||
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
|
||||
helpNodes2: "卡片和表格视图模式切换(带分页)",
|
||||
helpNodes3: "收藏节点,添加自定义标签进行整理",
|
||||
|
||||
+67
-8
@@ -23,6 +23,7 @@ const state = {
|
||||
},
|
||||
modelMetadata: {},
|
||||
modelGroups: {},
|
||||
allModelGroups: {}, // { type: { groupName: [models] } } — all types
|
||||
civitaiCache: {},
|
||||
disabledModels: {}, // { type: Set<modelName> }
|
||||
selectMode: false,
|
||||
@@ -657,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() {
|
||||
@@ -685,8 +706,16 @@ async function fetchModelGroups() {
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
async function fetchAllModelGroups() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/groups");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
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" },
|
||||
@@ -1501,6 +1530,7 @@ async function loadModelsForCurrentType() {
|
||||
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();
|
||||
@@ -1520,6 +1550,7 @@ async function loadModelsForCurrentType() {
|
||||
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)
|
||||
@@ -1628,11 +1659,12 @@ async function batchFetchCivitai() {
|
||||
}
|
||||
|
||||
async function loadMetadataAndModels() {
|
||||
const [metadata, civitaiCache] = await Promise.all([
|
||||
fetchModelMetadata(), fetchCivitaiCache()
|
||||
const [metadata, civitaiCache, allGroups] = await Promise.all([
|
||||
fetchModelMetadata(), fetchCivitaiCache(), fetchAllModelGroups()
|
||||
]);
|
||||
state.modelMetadata = metadata;
|
||||
state.civitaiCache = civitaiCache;
|
||||
state.allModelGroups = allGroups;
|
||||
// groups are loaded per-type inside loadModelsForCurrentType
|
||||
await loadModelsForCurrentType();
|
||||
}
|
||||
@@ -1697,9 +1729,36 @@ 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();
|
||||
});
|
||||
|
||||
@@ -593,6 +593,191 @@ 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;
|
||||
}
|
||||
|
||||
for (const f of wcFiles) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item";
|
||||
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.name)}__ at cursor">Use</button>
|
||||
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">✎</button>
|
||||
</div>
|
||||
`;
|
||||
// Insert reference into wildcard prompt
|
||||
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.name}__`);
|
||||
});
|
||||
// Open editor
|
||||
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const content = await wcFetchContent(f.filename);
|
||||
wcOpenEditor(f.name, f.ext, content ?? "", f.filename);
|
||||
});
|
||||
// Click row = same as Use
|
||||
item.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.name}__`);
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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;
|
||||
}
|
||||
for (const f of wcFiles) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-wc-picker-item";
|
||||
btn.textContent = `__${f.name}__`;
|
||||
btn.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.name}__`);
|
||||
picker.style.display = "none";
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialize
|
||||
// ============================================
|
||||
@@ -853,4 +1038,151 @@ 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");
|
||||
|
||||
const name = nameInput?.value.trim();
|
||||
const ext = extSelect?.value || "txt";
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!name) { showToast("Enter a filename", "error"); return; }
|
||||
if (!/^[\w\-. ]+$/.test(name)) { showToast("Filename can only contain letters, numbers, -, _, .", "error"); return; }
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -620,6 +620,12 @@ export async function initSettingsTab() {
|
||||
<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>
|
||||
|
||||
@@ -1051,6 +1057,89 @@ export async function initSettingsTab() {
|
||||
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");
|
||||
|
||||
+182
-57
@@ -330,10 +330,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>
|
||||
@@ -342,6 +344,34 @@
|
||||
</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;">Include Folders (empty = all, comma-separated)</div>
|
||||
<input type="text" id="wfm-ckpt-batch-include" class="wfm-input" placeholder="e.g. sdxl, flux">
|
||||
</div>
|
||||
<div style="margin-bottom:6px;">
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:3px;">Exclude Folders (comma-separated)</div>
|
||||
<input type="text" id="wfm-ckpt-batch-exclude" class="wfm-input" placeholder="e.g. old, test">
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Display -->
|
||||
<div class="wfm-gen-result">
|
||||
<img id="wfm-gen-result-img" style="display:none;max-width:100%;border-radius:6px;">
|
||||
@@ -404,61 +434,139 @@
|
||||
</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">★</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">★</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. <lora:name:1:LBW=...;...>"><lora></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">📂 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... 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">→ Pos</button>
|
||||
<button id="wfm-wc-to-neg-btn" class="wfm-btn wfm-btn-sm" title="Send to Preset Negative Prompt">→ 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">↻</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="filename (no extension)" 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 | 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) 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 id="wfm-pm-list" class="wfm-pm-list"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -509,9 +617,10 @@
|
||||
<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-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: enable via checkbox in the right panel to auto-generate once per checkpoint model; filter by include folders (comma-separated), exclude folders, or leave blank to use all checkpoints; Stop button aborts the loop after the current generation; per-model progress bar shown in amber</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -525,6 +634,8 @@
|
||||
<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$|}, __|__, <lora:>, 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>
|
||||
|
||||
@@ -540,6 +651,7 @@
|
||||
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</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>
|
||||
|
||||
@@ -562,12 +674,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>
|
||||
|
||||
@@ -694,6 +807,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>
|
||||
|
||||
@@ -703,6 +818,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>
|
||||
@@ -755,6 +874,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;">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user