Compare commits
@@ -0,0 +1,22 @@
|
||||
name: Publish to Comfy Registry
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,202 @@
|
||||
# ComfyUI-Workflow-Studio 開発ガイドライン
|
||||
|
||||
## プロジェクト概要
|
||||
ComfyUI用ワークフロー管理プラグイン。aiohttp (ComfyUI PromptServer) 上でSPAを提供する。
|
||||
|
||||
## アーキテクチャ
|
||||
- エントリポイント: `__init__.py` → `py/wfm.py` (WorkflowStudio.add_routes)
|
||||
- バックエンド: `py/routes/` (aiohttp ルート), `py/services/` (ビジネスロジック)
|
||||
- フロントエンド: `web/comfyui/` (ComfyUI拡張), `static/` (SPA), `templates/` (Jinja2)
|
||||
- データ: `data/` (settings.json, metadata.json, node_metadata.json)
|
||||
- i18n: 英語 / 日本語 / 中国語
|
||||
|
||||
---
|
||||
|
||||
## ComfyUI カスタムノード開発ルール
|
||||
|
||||
### NODE_CLASS_MAPPINGS キーは永久に変更不可
|
||||
`NODE_CLASS_MAPPINGS` のキー文字列は、保存済みワークフローとノードを紐付ける公開識別子。
|
||||
一度公開したキーを変更すると、ユーザーの既存ワークフローが壊れる。
|
||||
将来ノードを追加する場合は、キー名を慎重に決定すること。
|
||||
- 名前空間衝突を防ぐため、パック名をプレフィックスに付ける (例: `"WFS_MyNode"`)
|
||||
- リネーム時は旧キーをエイリアスとして残す:
|
||||
```python
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"WFS_NewName": MyNodeClass, # 新名
|
||||
"WFS_OldName": MyNodeClass, # エイリアス(後方互換)
|
||||
}
|
||||
```
|
||||
|
||||
### ワークフローJSON: ウィジェット値は位置インデックスで格納される
|
||||
ワークフローJSONはウィジェットの値を「名前」ではなく「位置インデックス」で保存する。
|
||||
入力の追加・並べ替えは、保存済みワークフローの値を別パラメータにずらす破壊的変更になる。
|
||||
ワークフロー解析・編集コード (`workflow_analyzer.py`, `workflow_service.py`) を変更する際は、
|
||||
このインデックスマッピングを必ず考慮すること。
|
||||
|
||||
**INPUT_TYPES変更時のチェックリスト:**
|
||||
- ウィジェット追加: 末尾に追加が最も安全。中間挿入は全保存済みJSONの更新が必要
|
||||
- ウィジェット削除: 後続のインデックスが全てずれる。全保存済みJSONの`widgets_values`を修正
|
||||
- 並べ替え: `widgets_values`の順序も合わせて更新
|
||||
- `INPUT_TYPES`を変更するコミットは、影響を受けるワークフローJSONも同時にコミットする
|
||||
|
||||
### ワークフローJSON整合性ルール
|
||||
- `last_node_id` は最大ノードID以上であること(低いと重複IDが発生し無言で壊れる)
|
||||
- `last_link_id` は最大リンクID以上であること
|
||||
- リンクは3箇所で整合する必要がある: `links[]`配列、ソースノードの`outputs[slot].links`、ターゲットノードの`inputs[slot].link`
|
||||
- いずれかが不一致だと接続が無言で消える
|
||||
|
||||
### IS_CHANGED によるキャッシュ制御
|
||||
ComfyUIはキャッシュ付きデータフローグラフ実行エンジン。入力が変わらないと判断したノードはスキップされる。
|
||||
- 毎回実行が必要なノード: `IS_CHANGED` で `float("NaN")` を返す
|
||||
- 本番用: 入力に基づく決定論的なハッシュを返す
|
||||
- 注意: `IS_CHANGED` はスロット接続された入力に `None` を受け取ることがある。`None`チェックを必ず入れる
|
||||
|
||||
### VALIDATE_INPUTS の落とし穴
|
||||
スロット接続された入力は検証時に `None` として渡される(実行時には実値が来る)。
|
||||
```python
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, text=None, **kwargs):
|
||||
if text is None:
|
||||
return True # スロット接続 — 実行時に実値が来る
|
||||
if isinstance(text, str) and text.strip() == "":
|
||||
return "Text input cannot be empty"
|
||||
return True
|
||||
```
|
||||
|
||||
### ノードの安全な分離読み込み
|
||||
1つの壊れた依存が全ノードを道連れにしないよう、個別にtry/exceptで読み込む:
|
||||
```python
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
for name, (mod_path, cls_name) in _NODE_MODULES.items():
|
||||
try:
|
||||
mod = importlib.import_module(mod_path, package=__name__)
|
||||
NODE_CLASS_MAPPINGS[name] = getattr(mod, cls_name)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Failed to load '{name}': {e}")
|
||||
```
|
||||
|
||||
### Hidden Inputs (UNIQUE_ID, PROMPT, EXTRA_PNGINFO)
|
||||
```python
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID", # キャッシュの名前空間キーに使用
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO", # PNG出力にメタデータ埋め込み
|
||||
}
|
||||
```
|
||||
`UNIQUE_ID`を使わずモジュールレベルキャッシュを共有すると、同一ノードの複数インスタンスが干渉する。
|
||||
|
||||
### Lazy Inputs & 条件付き実行
|
||||
不要な上流実行を防ぐ:
|
||||
```python
|
||||
# INPUT_TYPESで宣言
|
||||
"optional": {"heavy_data": ("IMAGE", {"lazy": True})}
|
||||
|
||||
# 必要な入力だけリクエスト
|
||||
def check_lazy_status(self, **kwargs):
|
||||
needed = []
|
||||
if self.needs_heavy_data:
|
||||
needed.append("heavy_data")
|
||||
return needed # 空リスト = 上流実行をスキップ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 開発時の注意事項
|
||||
|
||||
### Python変更後は必ずComfyUIを再起動
|
||||
ComfyUIは起動時に `custom_nodes/` を一度だけインポートする。
|
||||
Pythonファイルの変更はComfyUIプロセスの完全再起動まで反映されない。
|
||||
|
||||
### __pycache__ のクリア
|
||||
ディレクトリ間のコピーやブランチ切り替え後、古いバイトコードが残ることがある。
|
||||
動作がおかしい場合は `__pycache__/` を削除してから再起動する。
|
||||
|
||||
### 依存パッケージのインストール先
|
||||
Windows Portable版のComfyUIは `python_embeded/` 内の組み込みPythonを使用する。
|
||||
システムのPythonにインストールしても認識されない。
|
||||
```
|
||||
python_embeded/python.exe -m pip install package_name
|
||||
```
|
||||
torch関連パッケージはCUDA index URLを指定:
|
||||
```
|
||||
python_embeded/python.exe -m pip install torchaudio --index-url https://download.pytorch.org/whl/cu130
|
||||
```
|
||||
インストール後は必ず `torch.cuda.is_available()` で確認する。
|
||||
|
||||
### デプロイ
|
||||
ソースリポジトリと `custom_nodes/` のランタイムディレクトリは分離する。
|
||||
`custom_nodes/` 内で直接編集せず、同期スクリプトでパッケージ全体をデプロイする。
|
||||
部分的な同期は原因不明のエラーを引き起こす。
|
||||
デプロイ後はハッシュ比較でファイル同一性を検証する。
|
||||
|
||||
### prestartup_script.py
|
||||
環境変数の設定は `__init__.py` では遅い。`prestartup_script.py` はノードインポート前に実行される唯一の場所:
|
||||
```python
|
||||
# custom_nodes/ComfyUI-Workflow-Studio/prestartup_script.py
|
||||
import os
|
||||
os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
```
|
||||
|
||||
### 依存パッケージの競合防止
|
||||
- 依存はゆるくピン留め: `transformers>=4.40,<6.0` (厳密な `==` は避ける)
|
||||
- torchは `requirements.txt` にピン留めしない(ComfyUI本体が管理)
|
||||
- バージョン依存のインポートは `try/except` でフォールバック
|
||||
|
||||
### テスト戦略 (ComfyUI不要のテスト)
|
||||
1. `ast.parse()` で構文チェック (0.1秒)
|
||||
2. ビジネスロジックを `execute()` から分離し pytest でテスト (1-2秒)
|
||||
3. ComfyUI統合テストは最終確認のみ (30-60秒)
|
||||
|
||||
### ファイルパスの扱い
|
||||
出力・一時ファイルは `folder_paths.get_output_directory()` / `folder_paths.get_temp_directory()` を使用。
|
||||
相対パスはComfyUIの起動方法によって解決先が変わる。
|
||||
|
||||
### Windows固有の注意
|
||||
- PowerShellのデフォルトエンコーディングはUTF-8を壊す。ファイル書き込みはPython経由で行う
|
||||
- Windows MAX_PATH (260文字) 制限: 浅いパス (`C:\ComfyUI`) にインストールするか、LongPathsEnabled レジストリを有効化
|
||||
- HuggingFaceのシンボリックリンクは開発者モードなしだと無言でファイルが複製される (`HF_HOME`を短いパスに設定)
|
||||
|
||||
### バウンドキャッシュ
|
||||
モジュールレベルのキャッシュは `OrderedDict` + 最大サイズ制限で実装する。
|
||||
無制限キャッシュは長時間セッションでメモリを食い潰す。
|
||||
|
||||
---
|
||||
|
||||
## サイレント障害の診断
|
||||
|
||||
| 症状 | 原因 | 対処 |
|
||||
|------|------|------|
|
||||
| 編集後も動作が変わらない | 古いコード | ComfyUI再起動 + `__pycache__/` 削除 |
|
||||
| 変更したのにノードが実行されない | キャッシュ | `IS_CHANGED` で `float("NaN")` を返す |
|
||||
| ワークフロー読込で値がおかしい | UIマッピングずれ | ウィジェット位置が保存JSONと一致するか確認 |
|
||||
| ノードが赤い "missing" 表示 | タイプ名不一致 | `NODE_CLASS_MAPPINGS` のキーと大文字小文字を確認 |
|
||||
| 接続線が消える | リンクID不整合 | links[], outputs[].links, inputs[].link の3箇所を検証 |
|
||||
| 重複ノード動作 | `last_node_id` が低すぎる | 最大ノードID + 1 に設定 |
|
||||
| CUDAが使えなくなった | CPU版wheelのインストール | `torch.cuda.is_available()` 確認、CUDA index URLで再インストール |
|
||||
| ヘッドレスモードで失敗 | UI前提のコード | `{"ui": {...}, "result": (...)}` で分離 |
|
||||
|
||||
---
|
||||
|
||||
## コミット前チェックリスト
|
||||
1. 全ファイルを `custom_nodes/` に同期(部分同期しない)
|
||||
2. 両方の `__pycache__/` をクリア
|
||||
3. 全 .py ファイルのハッシュ比較で同一性を確認
|
||||
4. `ast.parse()` で全 .py ファイルの構文チェック
|
||||
5. `INPUT_TYPES` 変更時は `widgets_values` も同時更新
|
||||
6. 削除した依存への参照が残っていないか grep で確認
|
||||
7. ComfyUI完全再起動 → ワークフロー読み込み → エンドツーエンド実行確認
|
||||
|
||||
---
|
||||
|
||||
## リリース手順
|
||||
|
||||
コミット・プッシュ・GitHub リリース(`gh release create`)を行う際は、**必ず以下の順序**で実施する。
|
||||
|
||||
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
|
||||
2. `README.md` のバージョンバッジを更新(``)
|
||||
3. `README.md` の Changelog に新バージョンのエントリを追加
|
||||
4. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
5. 上記すべてを同一コミットにまとめてプッシュ
|
||||
6. `gh release create vX.Y.Z` でリリースを作成
|
||||
|
||||
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
|
||||
@@ -1,9 +1,25 @@
|
||||
from .py.wfm import WorkflowStudio
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
# Register routes on import
|
||||
WorkflowStudio.add_routes()
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]
|
||||
# Register custom nodes (isolated loading)
|
||||
_NODE_MODULES = {
|
||||
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText", "Prompt Text (WFS)"),
|
||||
"WFS_GalleryFeeder": (".py.nodes.gallery_feeder_node", "WFS_GalleryFeeder", "Gallery Feeder (WFS)"),
|
||||
}
|
||||
|
||||
for _name, (_mod_path, _cls_name, _display_name) in _NODE_MODULES.items():
|
||||
try:
|
||||
import importlib
|
||||
_mod = importlib.import_module(_mod_path, package=__name__)
|
||||
NODE_CLASS_MAPPINGS[_name] = getattr(_mod, _cls_name)
|
||||
NODE_DISPLAY_NAME_MAPPINGS[_name] = _display_name
|
||||
except Exception as _e:
|
||||
print(f"[WARNING] Workflow Studio: Failed to load '{_name}': {_e}")
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2100 900" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="2100" y2="900" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="50%" stop-color="#16213e"/>
|
||||
<stop offset="100%" stop-color="#1a1a2e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wStroke" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4a9eff"/>
|
||||
<stop offset="100%" stop-color="#7b68ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sWave" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7b68ee"/>
|
||||
<stop offset="100%" stop-color="#4a9eff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textGrad" x1="0" y1="0" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#e0e0ff"/>
|
||||
<stop offset="100%" stop-color="#b0b0d0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="2100" height="900" fill="url(#bg)"/>
|
||||
<!-- Subtle grid -->
|
||||
<line x1="0" y1="225" x2="2100" y2="225" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="450" x2="2100" y2="450" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="675" x2="2100" y2="675" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<!-- Decorative wave lines in background -->
|
||||
<path d="M0 750 Q262 700 525 750 Q787 800 1050 750 Q1312 700 1575 750 Q1837 800 2100 750" stroke="#4a9eff" stroke-opacity="0.08" stroke-width="2" fill="none"/>
|
||||
<path d="M0 780 Q262 730 525 780 Q787 830 1050 780 Q1312 730 1575 780 Q1837 830 2100 780" stroke="#7b68ee" stroke-opacity="0.06" stroke-width="2" fill="none"/>
|
||||
<!-- W+S Logo (left side) -->
|
||||
<g transform="translate(180, 180)">
|
||||
<!-- W -->
|
||||
<path d="M60 80 L120 280 L180 130 L240 280 L300 80" stroke="url(#wStroke)" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- S wave -->
|
||||
<path d="M80 340 Q140 300 200 340 Q260 380 320 340" stroke="url(#sWave)" stroke-width="18" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
<!-- Title text -->
|
||||
<text x="720" y="380" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="120" font-weight="700" fill="url(#textGrad)" letter-spacing="-2">Workflow Studio</text>
|
||||
<!-- Subtitle -->
|
||||
<text x="720" y="470" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="42" fill="#8888aa" letter-spacing="1">Workflow Management & Generation UI for ComfyUI</text>
|
||||
<!-- Feature pills -->
|
||||
<g font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="28" fill="#a0a0c0">
|
||||
<rect x="720" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="820" y="559" text-anchor="middle" fill="#4a9eff">Workflows</text>
|
||||
<rect x="940" y="530" width="200" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1040" y="559" text-anchor="middle" fill="#7b68ee">GenerateUI</text>
|
||||
<rect x="1160" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1260" y="559" text-anchor="middle" fill="#4a9eff">AI Prompts</text>
|
||||
<rect x="1380" y="530" width="180" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1470" y="559" text-anchor="middle" fill="#7b68ee">Themes</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wStroke" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4a9eff"/>
|
||||
<stop offset="100%" stop-color="#7b68ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sWave" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7b68ee"/>
|
||||
<stop offset="100%" stop-color="#4a9eff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="400" height="400" rx="48" fill="url(#bg)"/>
|
||||
<!-- Subtle grid pattern -->
|
||||
<line x1="0" y1="100" x2="400" y2="100" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="200" x2="400" y2="200" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="300" x2="400" y2="300" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="100" y1="0" x2="100" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="200" y1="0" x2="200" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="300" y1="0" x2="300" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<!-- W letterform -->
|
||||
<path d="M80 110 L140 290 L200 150 L260 290 L320 110" stroke="url(#wStroke)" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- S wave -->
|
||||
<path d="M100 330 Q150 295 200 330 Q250 365 300 330" stroke="url(#sWave)" stroke-width="20" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,461 @@
|
||||
/* ============================================
|
||||
Workflow Studio - Theme Definitions
|
||||
============================================
|
||||
|
||||
各テーマは :root の CSS変数を上書きすることで適用される。
|
||||
body の font-family も各テーマで変更する。
|
||||
|
||||
現在のデフォルトテーマ (Deep Ocean Dark) の変数:
|
||||
--wfm-bg: #1a1a2e
|
||||
--wfm-bg-secondary: #16213e
|
||||
--wfm-bg-tertiary: #0f3460
|
||||
--wfm-surface: #1e2a4a
|
||||
--wfm-surface-hover: #253358
|
||||
--wfm-border: #2a3a5c
|
||||
--wfm-text: #e0e0e0
|
||||
--wfm-text-secondary: #a0a0b0
|
||||
--wfm-primary: #4a9eff
|
||||
--wfm-primary-hover: #3a8eef
|
||||
--wfm-danger: #ff4757
|
||||
--wfm-success: #2ed573
|
||||
--wfm-warning: #ffa502
|
||||
--wfm-accent: #7c5cfc
|
||||
--wfm-radius: 8px
|
||||
--wfm-radius-sm: 4px
|
||||
--wfm-shadow: 0 2px 8px rgba(0,0,0,0.3)
|
||||
============================================ */
|
||||
|
||||
|
||||
/* ============================================
|
||||
1. Pop & Vibrant (ポップ調)
|
||||
明るく楽しく、クリエイティビティを刺激するデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="pop-vibrant"] {
|
||||
--wfm-bg: #F8F9FA;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #EEF0F2;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #F0F4FF;
|
||||
--wfm-border: #E0E0E0;
|
||||
--wfm-text: #2D2D2D;
|
||||
--wfm-text-secondary: #666666;
|
||||
--wfm-primary: #FF6B6B;
|
||||
--wfm-primary-hover: #FF5252;
|
||||
--wfm-danger: #FF4757;
|
||||
--wfm-success: #2ED573;
|
||||
--wfm-warning: #FFA502;
|
||||
--wfm-accent: #00D2D3;
|
||||
--wfm-radius: 16px;
|
||||
--wfm-radius-sm: 8px;
|
||||
--wfm-shadow: 0 4px 16px rgba(255, 107, 107, 0.15);
|
||||
/* 追加テーマ変数 */
|
||||
--wfm-tag-sdxl: #FF6B6B;
|
||||
--wfm-tag-sd15: #00D2D3;
|
||||
--wfm-tag-flux: #FFC048;
|
||||
--wfm-tag-default: #A55EEA;
|
||||
}
|
||||
|
||||
[data-theme="pop-vibrant"] body {
|
||||
font-family: 'Zen Maru Gothic', 'Rounded Mplus 1c', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
2. Light Minimalist (クリーン&ライト)
|
||||
現代的でノイズのない、コンテンツ集中型デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="light-minimalist"] {
|
||||
--wfm-bg: #FFFFFF;
|
||||
--wfm-bg-secondary: #F5F5F5;
|
||||
--wfm-bg-tertiary: #FAFAFA;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #F0F4FF;
|
||||
--wfm-border: #EEEEEE;
|
||||
--wfm-text: #333333;
|
||||
--wfm-text-secondary: #888888;
|
||||
--wfm-primary: #4A9EFF;
|
||||
--wfm-primary-hover: #3A8EEF;
|
||||
--wfm-danger: #EF4444;
|
||||
--wfm-success: #22C55E;
|
||||
--wfm-warning: #F59E0B;
|
||||
--wfm-accent: #6366F1;
|
||||
--wfm-radius: 8px;
|
||||
--wfm-radius-sm: 4px;
|
||||
--wfm-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="light-minimalist"] body {
|
||||
font-family: 'Inter', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
3. Cyberpunk / Hacker (サイバーパンク)
|
||||
SF映画のようなハッカー感・ギーク感
|
||||
============================================ */
|
||||
|
||||
[data-theme="cyberpunk"] {
|
||||
--wfm-bg: #000000;
|
||||
--wfm-bg-secondary: #0A0A0A;
|
||||
--wfm-bg-tertiary: #111111;
|
||||
--wfm-surface: #0D0D0D;
|
||||
--wfm-surface-hover: #1A1A1A;
|
||||
--wfm-border: #00FF4130;
|
||||
--wfm-text: #00FF41;
|
||||
--wfm-text-secondary: #00CC33;
|
||||
--wfm-primary: #FF00FF;
|
||||
--wfm-primary-hover: #CC00CC;
|
||||
--wfm-danger: #FF073A;
|
||||
--wfm-success: #00FF41;
|
||||
--wfm-warning: #FFD300;
|
||||
--wfm-accent: #00FFFF;
|
||||
--wfm-radius: 0px;
|
||||
--wfm-radius-sm: 0px;
|
||||
--wfm-shadow: 0 0 12px rgba(0, 255, 65, 0.3);
|
||||
/* ネオン発光ボーダー用 */
|
||||
--wfm-glow-primary: 0 0 8px rgba(255, 0, 255, 0.6);
|
||||
--wfm-glow-accent: 0 0 8px rgba(0, 255, 255, 0.6);
|
||||
/* JSONパネル用 */
|
||||
--wfm-code-text: #00FF41;
|
||||
--wfm-code-bg: #000000;
|
||||
}
|
||||
|
||||
[data-theme="cyberpunk"] body {
|
||||
font-family: 'Space Mono', 'Fira Code', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
4. Glassmorphism (グラスモーフィズム)
|
||||
半透明の洗練された未来的デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="glassmorphism"] {
|
||||
--wfm-bg: #1A0533;
|
||||
--wfm-bg-secondary: rgba(255, 255, 255, 0.08);
|
||||
--wfm-bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||
--wfm-surface: rgba(255, 255, 255, 0.10);
|
||||
--wfm-surface-hover: rgba(255, 255, 255, 0.16);
|
||||
--wfm-border: rgba(255, 255, 255, 0.18);
|
||||
--wfm-text: #F0F0F0;
|
||||
--wfm-text-secondary: #C0C0D0;
|
||||
--wfm-primary: #7B68EE;
|
||||
--wfm-primary-hover: #6A5ACD;
|
||||
--wfm-danger: #FF6B6B;
|
||||
--wfm-success: #69DB7C;
|
||||
--wfm-warning: #FFD43B;
|
||||
--wfm-accent: #DA77F2;
|
||||
--wfm-radius: 12px;
|
||||
--wfm-radius-sm: 8px;
|
||||
--wfm-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
/* Glassmorphism用追加変数 */
|
||||
--wfm-glass-blur: blur(12px);
|
||||
--wfm-glass-bg: rgba(255, 255, 255, 0.10);
|
||||
/* 背景メッシュグラデーション */
|
||||
--wfm-mesh-gradient: linear-gradient(135deg, #1A0533 0%, #0D1B3E 25%, #1A0533 50%, #2D1054 75%, #0D1B3E 100%);
|
||||
}
|
||||
|
||||
[data-theme="glassmorphism"] body {
|
||||
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
5. Neumorphism (ニューモーフィズム)
|
||||
柔らかく立体的な凸凹表現
|
||||
============================================ */
|
||||
|
||||
[data-theme="neumorphism"] {
|
||||
--wfm-bg: #D1D9E6;
|
||||
--wfm-bg-secondary: #D1D9E6;
|
||||
--wfm-bg-tertiary: #C8D0DD;
|
||||
--wfm-surface: #D1D9E6;
|
||||
--wfm-surface-hover: #D8E0ED;
|
||||
--wfm-border: transparent;
|
||||
--wfm-text: #4A5568;
|
||||
--wfm-text-secondary: #718096;
|
||||
--wfm-primary: #6C8EBF;
|
||||
--wfm-primary-hover: #5A7CAD;
|
||||
--wfm-danger: #E57373;
|
||||
--wfm-success: #81C784;
|
||||
--wfm-warning: #FFB74D;
|
||||
--wfm-accent: #7986CB;
|
||||
--wfm-radius: 12px;
|
||||
--wfm-radius-sm: 8px;
|
||||
/* ニューモーフィズム用の光と影 */
|
||||
--wfm-shadow: 6px 6px 12px #B8C0CC, -6px -6px 12px #EAF2FF;
|
||||
--wfm-shadow-inset: inset 4px 4px 8px #B8C0CC, inset -4px -4px 8px #EAF2FF;
|
||||
}
|
||||
|
||||
[data-theme="neumorphism"] body {
|
||||
font-family: 'Nunito', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
6. Retro 8-bit / Pixel (レトロゲーム調)
|
||||
遊び心満載のファミコン風デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="retro-pixel"] {
|
||||
--wfm-bg: #2C2C34;
|
||||
--wfm-bg-secondary: #3A3A44;
|
||||
--wfm-bg-tertiary: #24242C;
|
||||
--wfm-surface: #3A3A44;
|
||||
--wfm-surface-hover: #4A4A54;
|
||||
--wfm-border: #000000;
|
||||
--wfm-text: #F0F0E0;
|
||||
--wfm-text-secondary: #B0B0A0;
|
||||
--wfm-primary: #5B8FD4;
|
||||
--wfm-primary-hover: #4A7EC3;
|
||||
--wfm-danger: #D94848;
|
||||
--wfm-success: #48A848;
|
||||
--wfm-warning: #D4A017;
|
||||
--wfm-accent: #D45BD4;
|
||||
--wfm-radius: 0px;
|
||||
--wfm-radius-sm: 0px;
|
||||
/* 太い黒ボーダー + ベタ塗り影 */
|
||||
--wfm-shadow: 4px 4px 0px #000000;
|
||||
--wfm-border-width: 3px;
|
||||
}
|
||||
|
||||
[data-theme="retro-pixel"] body {
|
||||
font-family: 'DotGothic16', 'Press Start 2P', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
7. Pastel Dream (パステルドリーム)
|
||||
柔らかく優しい、目に優しいデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="pastel-dream"] {
|
||||
--wfm-bg: #F0E6FF;
|
||||
--wfm-bg-secondary: #E8F5E9;
|
||||
--wfm-bg-tertiary: #F3E5F5;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #FFF3E0;
|
||||
--wfm-border: #E1BEE7;
|
||||
--wfm-text: #5D4E75;
|
||||
--wfm-text-secondary: #8E7CA8;
|
||||
--wfm-primary: #B39DDB;
|
||||
--wfm-primary-hover: #9575CD;
|
||||
--wfm-danger: #F48FB1;
|
||||
--wfm-success: #A5D6A7;
|
||||
--wfm-warning: #FFE082;
|
||||
--wfm-accent: #80DEEA;
|
||||
--wfm-radius: 20px;
|
||||
--wfm-radius-sm: 12px;
|
||||
--wfm-shadow: 0 4px 16px rgba(179, 157, 219, 0.2);
|
||||
/* パステルタグカラー */
|
||||
--wfm-tag-sdxl: #FFAB91;
|
||||
--wfm-tag-sd15: #A5D6A7;
|
||||
--wfm-tag-flux: #90CAF9;
|
||||
--wfm-tag-default: #CE93D8;
|
||||
}
|
||||
|
||||
[data-theme="pastel-dream"] body {
|
||||
font-family: 'M PLUS Rounded 1c', 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
8. Brutalism / High Contrast (ブルータリズム)
|
||||
大胆で前衛的なハイコントラストデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="brutalism"] {
|
||||
--wfm-bg: #FFFFFF;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #F0F0F0;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #CCFF00;
|
||||
--wfm-border: #000000;
|
||||
--wfm-text: #000000;
|
||||
--wfm-text-secondary: #333333;
|
||||
--wfm-primary: #CCFF00;
|
||||
--wfm-primary-hover: #BBEE00;
|
||||
--wfm-danger: #FF0000;
|
||||
--wfm-success: #00FF00;
|
||||
--wfm-warning: #FFFF00;
|
||||
--wfm-accent: #CCFF00;
|
||||
--wfm-radius: 0px;
|
||||
--wfm-radius-sm: 0px;
|
||||
/* 極太ボーダー + ベタ影 */
|
||||
--wfm-shadow: 6px 6px 0px #000000;
|
||||
--wfm-border-width: 3px;
|
||||
}
|
||||
|
||||
[data-theme="brutalism"] body {
|
||||
font-family: 'Arial Black', 'Impact', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
9. Earthy / Nature (アースカラー)
|
||||
落ち着いたオーガニックなナチュラルデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="earthy"] {
|
||||
--wfm-bg: #F5E6D3;
|
||||
--wfm-bg-secondary: #EDE0D4;
|
||||
--wfm-bg-tertiary: #E8D5C0;
|
||||
--wfm-surface: #FFF8F0;
|
||||
--wfm-surface-hover: #F5EDE4;
|
||||
--wfm-border: #D4B896;
|
||||
--wfm-text: #3E2723;
|
||||
--wfm-text-secondary: #6D4C41;
|
||||
--wfm-primary: #8D6E63;
|
||||
--wfm-primary-hover: #795548;
|
||||
--wfm-danger: #C0392B;
|
||||
--wfm-success: #6B8E23;
|
||||
--wfm-warning: #D4A017;
|
||||
--wfm-accent: #BF6830;
|
||||
--wfm-radius: 8px;
|
||||
--wfm-radius-sm: 4px;
|
||||
--wfm-shadow: 0 2px 8px rgba(62, 39, 35, 0.12);
|
||||
/* アースカラータグ */
|
||||
--wfm-tag-sdxl: #6B8E23;
|
||||
--wfm-tag-sd15: #BF6830;
|
||||
--wfm-tag-flux: #D4A017;
|
||||
--wfm-tag-default: #8D6E63;
|
||||
}
|
||||
|
||||
[data-theme="earthy"] body {
|
||||
font-family: 'Noto Sans JP', 'Noto Serif JP', Georgia, serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
10. Material UI (マテリアルデザイン風)
|
||||
Googleライクな直感的デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="material"] {
|
||||
--wfm-bg: #FAFAFA;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #F5F5F5;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #E8EAF6;
|
||||
--wfm-border: #E0E0E0;
|
||||
--wfm-text: #212121;
|
||||
--wfm-text-secondary: #757575;
|
||||
--wfm-primary: #3F51B5;
|
||||
--wfm-primary-hover: #303F9F;
|
||||
--wfm-danger: #F44336;
|
||||
--wfm-success: #4CAF50;
|
||||
--wfm-warning: #FF9800;
|
||||
--wfm-accent: #FF4081;
|
||||
--wfm-radius: 4px;
|
||||
--wfm-radius-sm: 2px;
|
||||
/* マテリアルシャドウ (Elevation 2) */
|
||||
--wfm-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
|
||||
[data-theme="material"] body {
|
||||
font-family: 'Roboto', 'Noto Sans JP', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
11. Monotone + One Accent (モノトーン+ワンアクセント)
|
||||
スタイリッシュなプロツール感
|
||||
============================================ */
|
||||
|
||||
[data-theme="monotone-accent"] {
|
||||
--wfm-bg: #F7F7F7;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #EBEBEB;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #F0F0F0;
|
||||
--wfm-border: #D0D0D0;
|
||||
--wfm-text: #1A1A1A;
|
||||
--wfm-text-secondary: #808080;
|
||||
--wfm-primary: #FF6B35;
|
||||
--wfm-primary-hover: #E85D2A;
|
||||
--wfm-danger: #CC0000;
|
||||
--wfm-success: #555555;
|
||||
--wfm-warning: #888888;
|
||||
--wfm-accent: #FF6B35;
|
||||
--wfm-radius: 6px;
|
||||
--wfm-radius-sm: 3px;
|
||||
--wfm-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="monotone-accent"] body {
|
||||
font-family: 'Helvetica Neue', 'Arial', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
12. Corporate Trust (コーポレート・トラスト)
|
||||
信頼感と堅牢性のあるBtoB向けデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="corporate"] {
|
||||
--wfm-bg: #F0F4F8;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #E2E8F0;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #EBF0F6;
|
||||
--wfm-border: #CBD5E1;
|
||||
--wfm-text: #1E293B;
|
||||
--wfm-text-secondary: #64748B;
|
||||
--wfm-primary: #2563EB;
|
||||
--wfm-primary-hover: #1D4ED8;
|
||||
--wfm-danger: #DC2626;
|
||||
--wfm-success: #16A34A;
|
||||
--wfm-warning: #D97706;
|
||||
--wfm-accent: #7C3AED;
|
||||
--wfm-radius: 4px;
|
||||
--wfm-radius-sm: 2px;
|
||||
--wfm-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="corporate"] body {
|
||||
font-family: 'Noto Sans JP', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
テーマ適用方法のメモ
|
||||
============================================
|
||||
|
||||
HTMLの <html> タグに data-theme 属性を設定して切り替える:
|
||||
|
||||
<html data-theme="cyberpunk">
|
||||
<html data-theme="pastel-dream">
|
||||
<html data-theme="brutalism">
|
||||
...
|
||||
|
||||
JavaScriptでの切り替え例:
|
||||
|
||||
document.documentElement.setAttribute('data-theme', 'cyberpunk');
|
||||
|
||||
テーマをリセットしてデフォルトに戻す場合:
|
||||
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
|
||||
============================================
|
||||
|
||||
テーマ一覧:
|
||||
|
||||
| ID | 名前 | 系統 |
|
||||
|-------------------|-------------------------------|----------|
|
||||
| (default) | Deep Ocean Dark | ダーク |
|
||||
| pop-vibrant | Pop & Vibrant | ライト |
|
||||
| light-minimalist | Light Minimalist | ライト |
|
||||
| cyberpunk | Cyberpunk / Hacker | ダーク |
|
||||
| glassmorphism | Glassmorphism | ダーク |
|
||||
| neumorphism | Neumorphism | ライト |
|
||||
| retro-pixel | Retro 8-bit / Pixel | ダーク |
|
||||
| pastel-dream | Pastel Dream | ライト |
|
||||
| brutalism | Brutalism / High Contrast | ライト |
|
||||
| earthy | Earthy / Nature | ライト |
|
||||
| material | Material UI | ライト |
|
||||
| monotone-accent | Monotone + One Accent | ライト |
|
||||
| corporate | Corporate Trust | ライト |
|
||||
|
||||
============================================ */
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -10,13 +10,22 @@ PLUGIN_DIR = Path(__file__).resolve().parent.parent
|
||||
# Directory paths
|
||||
TEMPLATES_DIR = PLUGIN_DIR / "templates"
|
||||
STATIC_DIR = PLUGIN_DIR / "static"
|
||||
DATA_DIR = PLUGIN_DIR / "data"
|
||||
|
||||
# Default workflows directory: ComfyUI's user/default/workflows
|
||||
# PLUGIN_DIR is custom_nodes/ComfyUI-Workflow-Studio
|
||||
# ComfyUI root is custom_nodes/../../ = two levels up
|
||||
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
|
||||
_COMFYUI_WORKFLOWS = _COMFYUI_ROOT / "user" / "default" / "workflows"
|
||||
COMFYUI_ROOT = _COMFYUI_ROOT # exposed for external use
|
||||
|
||||
# Data directory: prefer ComfyUI's user/default/Workflow-Studio/, fallback to plugin data/
|
||||
_COMFYUI_USER_DEFAULT = _COMFYUI_ROOT / "user" / "default"
|
||||
if _COMFYUI_USER_DEFAULT.is_dir():
|
||||
DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"
|
||||
logger.info("Workflow Studio: Using user data dir: %s", DATA_DIR)
|
||||
else:
|
||||
DATA_DIR = PLUGIN_DIR / "data"
|
||||
logger.info("Workflow Studio: user/default not found, using plugin data dir: %s", DATA_DIR)
|
||||
|
||||
# Default workflows directory: ComfyUI's user/default/workflows
|
||||
_COMFYUI_WORKFLOWS = _COMFYUI_USER_DEFAULT / "workflows"
|
||||
|
||||
# Use ComfyUI workflows dir if it exists, otherwise fallback to plugin data dir
|
||||
if _COMFYUI_WORKFLOWS.is_dir():
|
||||
@@ -55,3 +64,13 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Metadata file path
|
||||
METADATA_FILE = DATA_DIR / "metadata.json"
|
||||
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||
NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
|
||||
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
|
||||
PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
TAGGER_SETTINGS_FILE = DATA_DIR / "tagger_settings.json"
|
||||
TAGGER_MODELS_DIR = _COMFYUI_ROOT / "models" / "tagger"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""WFS_GalleryFeeder – Feeds images from a gallery group into a workflow."""
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feeder専用グループ名(gallery_metadata.json 内で予約される)
|
||||
FEEDER_GROUP = "__Feeder__"
|
||||
|
||||
|
||||
class WFS_GalleryFeeder:
|
||||
"""Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"group_name": ("STRING", {"default": FEEDER_GROUP}),
|
||||
"index": ("INT", {"default": 0, "min": 0, "max": 99999}),
|
||||
"sort_mode": (["filename_asc", "filename_desc", "random"],),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "Workflow Studio"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# 毎回実行させる(インデックスが変わるため)
|
||||
return float("NaN")
|
||||
|
||||
def execute(self, group_name: str, index: int, sort_mode: str, seed: int):
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
paths = self._get_sorted_paths(group_name, sort_mode, seed)
|
||||
if not paths:
|
||||
logger.warning("WFS_GalleryFeeder: group '%s' is empty or not found", group_name)
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
img_path = paths[index % len(paths)]
|
||||
try:
|
||||
img = PILImage.open(img_path).convert("RGB")
|
||||
except Exception as e:
|
||||
logger.error("WFS_GalleryFeeder: failed to load '%s': %s", img_path, e)
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
tensor = torch.from_numpy(arr).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
# ── 内部ヘルパー ──────────────────────────────────────────────
|
||||
|
||||
def _get_sorted_paths(self, group_name: str, sort_mode: str, seed: int) -> list[str]:
|
||||
metadata_file = self._find_metadata_file()
|
||||
if not metadata_file.exists():
|
||||
return []
|
||||
try:
|
||||
with open(metadata_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error("WFS_GalleryFeeder: metadata read error: %s", e)
|
||||
return []
|
||||
|
||||
paths = [
|
||||
key for key, meta in data.get("images", {}).items()
|
||||
if group_name in meta.get("groups", []) and Path(key).exists()
|
||||
]
|
||||
|
||||
if sort_mode == "filename_asc":
|
||||
paths.sort(key=lambda p: Path(p).name.lower())
|
||||
elif sort_mode == "filename_desc":
|
||||
paths.sort(key=lambda p: Path(p).name.lower(), reverse=True)
|
||||
elif sort_mode == "random":
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(paths)
|
||||
|
||||
return paths
|
||||
|
||||
@staticmethod
|
||||
def _find_metadata_file() -> Path:
|
||||
# py/nodes/ -> py/ -> plugin root -> ComfyUI/custom_nodes/ -> ComfyUI root
|
||||
plugin_root = Path(__file__).resolve().parent.parent.parent
|
||||
comfyui_root = plugin_root.parent.parent
|
||||
user_default = comfyui_root / "user" / "default"
|
||||
if user_default.is_dir():
|
||||
return user_default / "Workflow-Studio" / "gallery_metadata.json"
|
||||
return plugin_root / "data" / "gallery_metadata.json"
|
||||
@@ -0,0 +1,31 @@
|
||||
"""WFS_PromptText – Prompt preset node with positive/negative STRING outputs."""
|
||||
|
||||
|
||||
class WFS_PromptText:
|
||||
"""Outputs positive and negative prompt strings."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"positive": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"placeholder": "Positive prompt...",
|
||||
}),
|
||||
"negative": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"placeholder": "Negative prompt...",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("positive", "negative")
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "Workflow Studio"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def execute(self, positive, negative):
|
||||
return (positive, negative)
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
@@ -17,6 +18,84 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/eagle/test", handle_test)
|
||||
|
||||
|
||||
def _resolve_local_path(filename, subfolder, type_name):
|
||||
"""filename/subfolder/type を ComfyUI 出力ディレクトリ上の実パスに解決する。
|
||||
ComfyUI と Eagle が同一マシン上で動作している場合のみ有効なパスが返る
|
||||
(別マシンの場合は None を返し、呼び出し側は addFromURL にフォールバックする)。"""
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
base_dir = folder_paths.get_directory_by_type(type_name or "output")
|
||||
if not base_dir:
|
||||
return None
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
|
||||
filename = os.path.basename(filename or "")
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
full_dir = os.path.abspath(os.path.join(base_dir, subfolder or ""))
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(full_dir), os.path.normcase(base_dir))) != os.path.normcase(base_dir):
|
||||
# ディレクトリトラバーサル対策: base_dir 配下以外は拒否
|
||||
return None
|
||||
|
||||
full_path = os.path.join(full_dir, filename)
|
||||
if not os.path.isfile(full_path):
|
||||
return None
|
||||
return full_path
|
||||
|
||||
|
||||
def _resolve_absolute_svg_path(abs_path):
|
||||
"""comfyui-tosvg の Save SVG String ノードなどが返す絶対パスを検証する。
|
||||
ComfyUI管理下(output/input/temp)ディレクトリ配下の実在する .svg ファイルのみ許可する
|
||||
(任意の絶対パスをそのまま信頼すると、外部からこのAPIを叩かれた際に
|
||||
ローカルの任意ファイルを送信させられてしまうため)。"""
|
||||
if not abs_path or not abs_path.lower().endswith(".svg"):
|
||||
return None
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
abs_path = os.path.abspath(abs_path)
|
||||
if not os.path.isfile(abs_path):
|
||||
return None
|
||||
|
||||
for type_name in ("output", "temp", "input"):
|
||||
base_dir = folder_paths.get_directory_by_type(type_name)
|
||||
if not base_dir:
|
||||
continue
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
try:
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(abs_path), os.path.normcase(base_dir))) == os.path.normcase(base_dir):
|
||||
return abs_path
|
||||
except ValueError:
|
||||
# Windowsで異なるドライブレターの場合 commonpath が例外を投げる
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _eagle_add_from_path(eagle_url, file_path, name, tags):
|
||||
"""Proxy request to Eagle's addFromPath API (runs in thread).
|
||||
SVG は ComfyUI の /view がセキュリティ上 application/octet-stream +
|
||||
attachment で返すため、addFromURL 経由だと正しい種別で保存されないことがある。
|
||||
ComfyUI と Eagle が同一マシン上にある場合はローカルパスを直接渡す方が確実。"""
|
||||
eagle_url = eagle_url.rstrip("/")
|
||||
payload = json.dumps({"path": file_path, "name": name, "tags": tags}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{eagle_url}/api/item/addFromPath",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _eagle_add(eagle_url, image_url, name, tags):
|
||||
"""Proxy request to Eagle API (runs in thread)."""
|
||||
eagle_url = eagle_url.rstrip("/")
|
||||
@@ -71,11 +150,33 @@ async def handle_add(request: web.Request) -> web.Response:
|
||||
image_url = body.get("url", "")
|
||||
name = body.get("name", "image.png")
|
||||
tags = body.get("tags", [])
|
||||
filename = body.get("filename", "")
|
||||
subfolder = body.get("subfolder", "")
|
||||
file_type = body.get("type", "output")
|
||||
local_path_hint = body.get("localPath", "")
|
||||
|
||||
if not image_url:
|
||||
if not image_url and not local_path_hint:
|
||||
return web.json_response({"status": "error", "message": "No URL provided"}, status=400)
|
||||
|
||||
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
|
||||
# SVG は /view が application/octet-stream + attachment で返すため、
|
||||
# ローカルファイルが解決できる場合は addFromPath で直接渡す
|
||||
local_path = None
|
||||
if local_path_hint:
|
||||
# comfyui-tosvg の Save SVG String ノードなど、絶対パスが既知の場合
|
||||
local_path = _resolve_absolute_svg_path(local_path_hint)
|
||||
elif (filename or name or "").lower().endswith(".svg"):
|
||||
local_path = _resolve_local_path(filename or name, subfolder, file_type)
|
||||
|
||||
if not local_path and not image_url:
|
||||
return web.json_response(
|
||||
{"status": "error", "message": f"Could not resolve local SVG path: {local_path_hint}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if local_path:
|
||||
result = await asyncio.to_thread(_eagle_add_from_path, eagle_url, local_path, name, tags)
|
||||
else:
|
||||
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
|
||||
return web.json_response(result)
|
||||
except urllib.error.URLError as e:
|
||||
logger.error("Eagle URL error: %s", e)
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import DATA_DIR
|
||||
from ..services.gallery_service import GalleryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = GalleryService(DATA_DIR)
|
||||
|
||||
|
||||
def _init_allowed_root() -> None:
|
||||
"""起動時に保存済みの出力ディレクトリで _allowed_root を初期化する。
|
||||
Gallery タブを開く前に Feeder タブの Gallery モードが serve_image を呼んでも
|
||||
404 にならないようにするために必要。"""
|
||||
try:
|
||||
settings_file = DATA_DIR / "settings.json"
|
||||
if settings_file.exists():
|
||||
with open(settings_file, "r", encoding="utf-8") as f:
|
||||
settings = json.load(f)
|
||||
saved_root = settings.get("gallery_output_dir", "").strip()
|
||||
if saved_root and Path(saved_root).is_dir():
|
||||
_service.update_output_root(saved_root)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to load saved gallery root: %s", e)
|
||||
|
||||
# フォールバック: ComfyUI のデフォルト output フォルダ
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir and Path(output_dir).is_dir():
|
||||
_service.update_output_root(output_dir)
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
|
||||
|
||||
|
||||
_init_allowed_root()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/folders", list_folders)
|
||||
app.router.add_get("/wfm/gallery/images", list_images)
|
||||
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
|
||||
app.router.add_get("/wfm/gallery/image/serve", serve_image)
|
||||
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
|
||||
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
|
||||
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
|
||||
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
|
||||
app.router.add_get("/wfm/gallery/groups", list_groups)
|
||||
app.router.add_post("/wfm/gallery/groups", create_group)
|
||||
app.router.add_put("/wfm/gallery/groups/{name}", rename_group)
|
||||
app.router.add_delete("/wfm/gallery/groups/{name}", delete_group)
|
||||
app.router.add_post("/wfm/gallery/groups/ensure", ensure_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/clear", clear_group_images)
|
||||
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
|
||||
# バルク操作
|
||||
app.router.add_post("/wfm/gallery/bulk/favorite", bulk_favorite)
|
||||
app.router.add_post("/wfm/gallery/bulk/group", bulk_group)
|
||||
# フォルダ・ファイル操作
|
||||
app.router.add_post("/wfm/gallery/folder", create_folder_route)
|
||||
app.router.add_delete("/wfm/gallery/folder", delete_folder_route)
|
||||
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
|
||||
app.router.add_post("/wfm/gallery/images/move", move_images_route)
|
||||
app.router.add_post("/wfm/gallery/images/export-zip", export_images_zip)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_folders(request: web.Request) -> web.Response:
|
||||
root = request.rel_url.query.get("root", "")
|
||||
if not root:
|
||||
return web.json_response({"error": "root parameter required"}, status=400)
|
||||
try:
|
||||
tree = _service.list_folder_tree(root)
|
||||
return web.json_response(tree)
|
||||
except Exception as e:
|
||||
logger.error("list_folders error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像一覧
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_images(request: web.Request) -> web.Response:
|
||||
folder = request.rel_url.query.get("folder", "")
|
||||
search = request.rel_url.query.get("search", "")
|
||||
sort_by = request.rel_url.query.get("sort", "date_desc")
|
||||
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
|
||||
tag_filter = request.rel_url.query.get("tag", "")
|
||||
group_filter = request.rel_url.query.get("group", "")
|
||||
|
||||
if not folder:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
|
||||
try:
|
||||
images = await asyncio.to_thread(
|
||||
_service.list_images,
|
||||
folder,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
favorite_only=favorite_only,
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
)
|
||||
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
|
||||
if not search:
|
||||
_service.start_background_index(folder)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except Exception as e:
|
||||
logger.error("list_images error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_image_meta(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
meta = _service.get_image_metadata(path)
|
||||
return web.json_response(meta)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_image_workflow(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
wf = _service.extract_workflow_from_metadata(path)
|
||||
if wf is None:
|
||||
return web.json_response({"workflow": None, "has_workflow": False})
|
||||
return web.json_response({"workflow": wf, "has_workflow": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def serve_image(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
|
||||
img_path = _service.serve_image(path)
|
||||
if img_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(img_path))
|
||||
return web.FileResponse(img_path, headers={
|
||||
"Content-Type": content_type or "image/png",
|
||||
"Cache-Control": "max-age=3600",
|
||||
})
|
||||
|
||||
|
||||
async def serve_thumb(request: web.Request) -> web.Response:
|
||||
"""サムネイル配信。Pillowで縮小したJPEGをディスクキャッシュして返す。"""
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
try:
|
||||
width = int(request.rel_url.query.get("w", "256"))
|
||||
width = max(32, min(512, width))
|
||||
except ValueError:
|
||||
width = 256
|
||||
|
||||
thumb_path = _service.serve_thumbnail(path, width)
|
||||
if thumb_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
ext = thumb_path.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
content_type = "image/jpeg"
|
||||
elif ext == ".gif":
|
||||
content_type = "image/gif"
|
||||
else:
|
||||
content_type = mimetypes.guess_type(str(thumb_path))[0] or "image/jpeg"
|
||||
|
||||
return web.FileResponse(thumb_path, headers={
|
||||
"Content-Type": content_type,
|
||||
"Cache-Control": "max-age=86400",
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バルク操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def bulk_favorite(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/bulk/favorite — 複数画像のお気に入りを一括設定"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
value = bool(body.get("value", True))
|
||||
if not paths:
|
||||
return web.json_response({"error": "paths required"}, status=400)
|
||||
result = _service.bulk_set_favorite(paths, value)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def bulk_group(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/bulk/group — 複数画像のグループ追加/削除を一括処理"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
group = body.get("group", "").strip()
|
||||
action = body.get("action", "add") # "add" or "remove"
|
||||
if not paths or not group:
|
||||
return web.json_response({"error": "paths and group required"}, status=400)
|
||||
if action not in ("add", "remove"):
|
||||
return web.json_response({"error": "action must be 'add' or 'remove'"}, status=400)
|
||||
result = _service.bulk_group_op(paths, group, action)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def save_image_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
data = {k: v for k, v in body.items() if k != "path"}
|
||||
ok = _service.save_image_meta(img_path, data)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
new_val = _service.toggle_favorite(img_path)
|
||||
return web.json_response({"favorite": new_val})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
|
||||
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "").strip()
|
||||
image_data = body.get("imageData", "")
|
||||
|
||||
if not filename or not image_data:
|
||||
return web.json_response({"error": "filename and imageData required"}, status=400)
|
||||
|
||||
if _service._allowed_root is None:
|
||||
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
|
||||
|
||||
# data URL から MIME タイプを判定し、拡張子を決定する
|
||||
raw = image_data
|
||||
mime = "image/png"
|
||||
if raw.startswith("data:"):
|
||||
header, _, raw = raw.partition(",")
|
||||
m = re.match(r"data:([^;]+)", header)
|
||||
if m:
|
||||
mime = m.group(1)
|
||||
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
|
||||
|
||||
# ファイル名サニタイズ(OSで使えない文字を除去)
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
|
||||
image_bytes = base64.b64decode(raw)
|
||||
|
||||
save_path = _service._allowed_root / safe
|
||||
save_path.write_bytes(image_bytes)
|
||||
|
||||
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
|
||||
return web.json_response({"ok": True, "path": str(save_path)})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_gallery error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_groups(request: web.Request) -> web.Response:
|
||||
return web.json_response({"groups": _service.list_groups()})
|
||||
|
||||
|
||||
async def create_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
ok = _service.create_group(name)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Group already exists"}, status=409)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_RESERVED_GROUPS = {"__Feeder__"}
|
||||
|
||||
|
||||
async def rename_group(request: web.Request) -> web.Response:
|
||||
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
|
||||
try:
|
||||
old_name = request.match_info.get("name", "")
|
||||
if old_name in _RESERVED_GROUPS:
|
||||
return web.json_response({"error": f"Group '{old_name}' is reserved and cannot be renamed"}, status=403)
|
||||
body = await request.json()
|
||||
new_name = body.get("new_name", "").strip()
|
||||
if not old_name or not new_name:
|
||||
return web.json_response({"error": "old_name and new_name required"}, status=400)
|
||||
ok = _service.rename_group(old_name, new_name)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Rename failed (not found or name conflict)"}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_group(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name", "")
|
||||
if name in _RESERVED_GROUPS:
|
||||
return web.json_response({"error": f"Group '{name}' is reserved and cannot be deleted"}, status=403)
|
||||
ok = _service.delete_group(name)
|
||||
return web.json_response({"ok": ok})
|
||||
|
||||
|
||||
async def add_to_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
ok = _service.add_to_group(img_path, name)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def remove_from_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
ok = _service.remove_from_group(img_path, name)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def list_group_images(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name", "")
|
||||
images = _service.list_images_in_group(name)
|
||||
return web.json_response({"images": images})
|
||||
|
||||
|
||||
async def clear_group_images(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/groups/{name}/clear - グループ内の全画像を除外"""
|
||||
name = request.match_info.get("name", "")
|
||||
if not name:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
ok = _service.clear_group(name)
|
||||
return web.json_response({"ok": ok})
|
||||
|
||||
|
||||
async def ensure_group(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/groups/ensure - グループが存在しない場合のみ作成"""
|
||||
try:
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
_service.ensure_group(name)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダ・ファイル操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_folder_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/folder - 新規フォルダ作成"""
|
||||
try:
|
||||
body = await request.json()
|
||||
parent = body.get("parent", "")
|
||||
name = body.get("name", "").strip()
|
||||
if not parent or not name:
|
||||
return web.json_response({"ok": False, "error": "parent and name required"}, status=400)
|
||||
result = _service.create_folder(parent, name)
|
||||
return web.json_response(result, status=200 if result["ok"] else 400)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_folder_route(request: web.Request) -> web.Response:
|
||||
"""DELETE /wfm/gallery/folder - フォルダ削除"""
|
||||
try:
|
||||
body = await request.json()
|
||||
folder = body.get("path", "")
|
||||
if not folder:
|
||||
return web.json_response({"ok": False, "error": "path required"}, status=400)
|
||||
result = _service.delete_folder(folder)
|
||||
return web.json_response(result, status=200 if result["ok"] else 400)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_images_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/images/delete - 画像削除(単体・複数)"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
return web.json_response({"deleted": [], "errors": ["paths required"]}, status=400)
|
||||
result = _service.delete_images(paths)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"deleted": [], "errors": [str(e)]}, status=500)
|
||||
|
||||
|
||||
async def move_images_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/images/move - 画像移動(単体・複数)"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
dest = body.get("dest", "")
|
||||
if not paths or not dest:
|
||||
return web.json_response({"moved": [], "errors": ["paths and dest required"]}, status=400)
|
||||
result = _service.move_images(paths, dest)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"moved": [], "errors": [str(e)]}, status=500)
|
||||
|
||||
|
||||
async def export_images_zip(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/images/export-zip - 複数画像をZIPファイルにしてダウンロード"""
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
return web.json_response({"error": "paths required"}, status=400)
|
||||
|
||||
# ZIPファイルをメモリ上に作成
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in paths:
|
||||
file_path = Path(path)
|
||||
# セキュリティ: 許可されたディレクトリ内のみ
|
||||
if not _service._check_path_allowed(file_path):
|
||||
continue
|
||||
if file_path.exists() and file_path.is_file():
|
||||
# ファイル名のみをアーカイブに入れる(パス情報は含めない)
|
||||
zf.write(file_path, arcname=file_path.name)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return web.Response(
|
||||
body=zip_buffer.getvalue(),
|
||||
content_type="application/zip",
|
||||
headers={"Content-Disposition": "attachment; filename=gallery_export.zip"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting images to ZIP: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""G'MIC-Qt integration API routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_settings = SettingsService()
|
||||
|
||||
# Global job store
|
||||
# key: job_id, value: dict {status, message, result_path, error}
|
||||
_gmic_jobs = {}
|
||||
_gmic_jobs_lock = threading.Lock()
|
||||
|
||||
def _gmic_run_gui(job_id: str, input_path: str, output_path: str, gmic_exe: str):
|
||||
"""Run gmic_qt.exe in a background process/thread and wait for completion.
|
||||
|
||||
G'MIC-Qt Standalone argument order: -o <output> <input>
|
||||
After OK the "filter output" window appears; closing it saves to output_path.
|
||||
"""
|
||||
args = [gmic_exe, "-o", output_path, input_path]
|
||||
logger.info("[gmic] Launching: %s", " ".join(args))
|
||||
try:
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id]["status"] = "processing"
|
||||
_gmic_jobs[job_id]["message"] = "G'MIC GUIで編集中..."
|
||||
|
||||
import time
|
||||
|
||||
kwargs = {}
|
||||
if os.name == "nt":
|
||||
# Required on Windows to show the GUI window from a background process
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE
|
||||
|
||||
proc = subprocess.Popen(args, **kwargs)
|
||||
proc.wait()
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id]["status"] = "completed"
|
||||
_gmic_jobs[job_id]["result_path"] = output_path
|
||||
_gmic_jobs[job_id]["message"] = "完了"
|
||||
logger.info("[gmic] Result saved: %s", output_path)
|
||||
else:
|
||||
raise ValueError("G'MIC GUIがキャンセルされました")
|
||||
except Exception as e:
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id]["status"] = "failed"
|
||||
_gmic_jobs[job_id]["error"] = str(e)
|
||||
_gmic_jobs[job_id]["message"] = f"エラー: {str(e)}"
|
||||
logger.error("[gmic] Error: %s", e)
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register G'MIC API routes."""
|
||||
app.router.add_post("/api/wfm/gmic/open", handle_open)
|
||||
app.router.add_get("/api/wfm/gmic/status/{job_id}", handle_status)
|
||||
app.router.add_post("/api/wfm/gmic/result", handle_result)
|
||||
|
||||
async def handle_open(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/gmic/open - Open image in G'MIC GUI."""
|
||||
try:
|
||||
body = await request.json()
|
||||
image_b64 = body.get("image_b64", "")
|
||||
if not image_b64:
|
||||
return web.json_response({"error": "image_b64 field is required"}, status=400)
|
||||
|
||||
# Retrieve setting for gmic path
|
||||
data = _settings.load()
|
||||
gmic_exe = data.get("gmic_qt_path", "")
|
||||
if not os.path.exists(gmic_exe):
|
||||
return web.json_response({
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure the path in settings."
|
||||
}, status=400)
|
||||
|
||||
ext = ".png"
|
||||
if image_b64.startswith("data:"):
|
||||
header, image_b64 = image_b64.split(",", 1)
|
||||
mime = header.split(";")[0].split(":")[1]
|
||||
guessed = mimetypes.guess_extension(mime)
|
||||
if guessed:
|
||||
ext = ".jpg" if guessed == ".jpe" else guessed
|
||||
|
||||
img_bytes = base64.b64decode(image_b64)
|
||||
|
||||
temp_dir = Path(DATA_DIR) / "gmic_temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
input_path = str(temp_dir / f"gmic_input_{now_str}{ext}")
|
||||
output_path = str(temp_dir / f"gmic_output_{now_str}_{str(uuid.uuid4())[:8]}{ext}")
|
||||
|
||||
with open(input_path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id] = {
|
||||
"status": "pending",
|
||||
"message": "G'MIC GUIを起動中...",
|
||||
"result_path": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Start G'MIC thread
|
||||
t = threading.Thread(
|
||||
target=_gmic_run_gui,
|
||||
args=(job_id, input_path, output_path, gmic_exe),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
return web.json_response({"job_id": job_id, "status": "pending", "message": "起動中"})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] open error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_status(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/gmic/status/{job_id} - Check job status."""
|
||||
try:
|
||||
job_id = request.match_info.get("job_id", "")
|
||||
with _gmic_jobs_lock:
|
||||
job = _gmic_jobs.get(job_id)
|
||||
if job is None:
|
||||
return web.json_response({"error": "Job not found"}, status=404)
|
||||
return web.json_response({
|
||||
"job_id": job_id,
|
||||
"status": job["status"],
|
||||
"message": job.get("message", ""),
|
||||
"result_path": job.get("result_path"),
|
||||
"error": job.get("error"),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] status error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_result(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/gmic/result - Get result image base64."""
|
||||
try:
|
||||
body = await request.json()
|
||||
result_path = body.get("result_path", "")
|
||||
if not result_path or not os.path.exists(result_path):
|
||||
return web.json_response({"error": f"Result file not found: {result_path}"}, status=404)
|
||||
|
||||
# Path traversal guard: only allow files inside gmic_temp
|
||||
allowed_dir = (Path(DATA_DIR) / "gmic_temp").resolve()
|
||||
try:
|
||||
Path(result_path).resolve().relative_to(allowed_dir)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Access denied: path outside gmic_temp"}, status=403)
|
||||
|
||||
with open(result_path, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
|
||||
ext = os.path.splitext(result_path)[1].lower()
|
||||
mime_map = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff"
|
||||
}
|
||||
mime = mime_map.get(ext, "image/png")
|
||||
b64 = base64.b64encode(img_bytes).decode("ascii")
|
||||
return web.json_response({"image_b64": f"data:{mime};base64,{b64}"})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] result error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,639 @@
|
||||
"""Model metadata API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.models_service import ModelsService
|
||||
from ..services.civitai_service import CivitaiService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = ModelsService()
|
||||
_civitai = CivitaiService()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all model management API routes."""
|
||||
app.router.add_get("/api/wfm/models/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/models/metadata", handle_save_metadata)
|
||||
app.router.add_get("/api/wfm/models/preview", handle_get_preview)
|
||||
app.router.add_get("/api/wfm/models/groups", handle_get_groups)
|
||||
app.router.add_post("/api/wfm/models/groups", handle_save_groups)
|
||||
app.router.add_post("/api/wfm/models/civitai/fetch", handle_civitai_fetch)
|
||||
app.router.add_get("/api/wfm/models/civitai/cache", handle_civitai_cache)
|
||||
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
|
||||
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
|
||||
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
|
||||
app.router.add_get("/api/wfm/models/disabled", handle_get_disabled)
|
||||
app.router.add_post("/api/wfm/models/toggle", handle_toggle_model)
|
||||
app.router.add_post("/api/wfm/models/group-toggle", handle_toggle_group)
|
||||
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
|
||||
app.router.add_get("/api/wfm/models/subdirs", handle_get_subdirs)
|
||||
app.router.add_post("/api/wfm/models/move", handle_move_models)
|
||||
app.router.add_get("/api/wfm/models/files", handle_list_model_files)
|
||||
|
||||
|
||||
# ── Model Metadata ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_metadata(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/metadata"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_all_metadata)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading model metadata: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_metadata(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/metadata"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_name = body.get("modelName", "")
|
||||
if not model_name:
|
||||
return web.json_response({"error": "modelName is required"}, status=400)
|
||||
updates = {k: v for k, v in body.items() if k != "modelName"}
|
||||
result = await asyncio.to_thread(_service.update_metadata, model_name, updates)
|
||||
return web.json_response({"status": "ok", "metadata": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving model metadata: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Model Groups ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_groups(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/groups?type=checkpoint"""
|
||||
model_type = request.query.get("type", "")
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_model_groups, model_type or None)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading model groups: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_groups(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/groups
|
||||
|
||||
Body: { "model_type": "checkpoint", "groups": { "groupName": [...] } }
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
groups = body.get("groups", {})
|
||||
if not model_type:
|
||||
return web.json_response({"error": "model_type required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.save_model_groups, groups, model_type)
|
||||
return web.json_response({"status": "ok", "groups": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving model groups: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── CivitAI Integration ──────────────────────────────────
|
||||
|
||||
|
||||
async def handle_civitai_fetch(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/civitai/fetch
|
||||
|
||||
Body: { "type": "checkpoint", "name": "model.safetensors" }
|
||||
Calculates SHA256 hash and fetches model info from CivitAI.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("type", "")
|
||||
model_name = body.get("name", "")
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "type and name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
|
||||
# Resolve file path
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
file_path = None
|
||||
for d in dirs:
|
||||
candidate = d / model_name
|
||||
if candidate.is_file():
|
||||
file_path = candidate
|
||||
break
|
||||
|
||||
if not file_path:
|
||||
return web.json_response({"error": "Model file not found"}, status=404)
|
||||
|
||||
# Check if we already have a cached hash in metadata
|
||||
meta = _service.get_all_metadata()
|
||||
model_meta = meta.get(model_name, {})
|
||||
sha256 = model_meta.get("sha256")
|
||||
|
||||
if not sha256:
|
||||
# Calculate hash (can be slow for large files)
|
||||
sha256 = await asyncio.to_thread(
|
||||
CivitaiService.calculate_sha256, file_path
|
||||
)
|
||||
if not sha256:
|
||||
return web.json_response({"error": "Failed to calculate hash"}, status=500)
|
||||
# Cache the hash in model metadata
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"sha256": sha256}
|
||||
)
|
||||
|
||||
# Fetch from CivitAI
|
||||
info = await asyncio.to_thread(_civitai.fetch_by_hash, sha256)
|
||||
if not info:
|
||||
return web.json_response({
|
||||
"status": "not_found",
|
||||
"sha256": sha256,
|
||||
"message": "Model not found on CivitAI"
|
||||
})
|
||||
|
||||
# Auto-set preview if none exists and CivitAI has images
|
||||
preview_saved = False
|
||||
if info.get("images"):
|
||||
has_preview = await asyncio.to_thread(_service.find_preview_image, model_type, model_name)
|
||||
if not has_preview:
|
||||
image_url = info["images"][0]
|
||||
preview_path = file_path.parent / (file_path.stem + ".preview.png")
|
||||
preview_saved = await asyncio.to_thread(
|
||||
CivitaiService.download_image, image_url, preview_path
|
||||
)
|
||||
if preview_saved:
|
||||
logger.info("Auto-saved preview for %s from CivitAI", model_name)
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"sha256": sha256,
|
||||
"civitai": info,
|
||||
"preview_saved": preview_saved,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("CivitAI fetch error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_civitai_cache(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/civitai/cache - Return all cached CivitAI data."""
|
||||
try:
|
||||
result = await asyncio.to_thread(_civitai.get_all_cached)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading CivitAI cache: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/civitai/batch - SSE stream for batch CivitAI fetch.
|
||||
|
||||
Body: { "type": "checkpoint", "models": ["model1.safetensors", ...] }
|
||||
Streams SSE events: progress, result, done.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.Response(status=400, text="Invalid JSON body")
|
||||
|
||||
model_type = body.get("type", "")
|
||||
model_names = body.get("models", [])
|
||||
if not model_type or not model_names:
|
||||
return web.Response(status=400, text="type and models required")
|
||||
|
||||
# Resolve file paths
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
model_files = []
|
||||
for name in model_names:
|
||||
if ".." in name:
|
||||
continue
|
||||
for d in dirs:
|
||||
p = d / name
|
||||
if p.is_file():
|
||||
model_files.append((name, p))
|
||||
break
|
||||
|
||||
if not model_files:
|
||||
return web.Response(status=404, text="No model files found")
|
||||
|
||||
# Set up SSE response
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
def send_sse(event, data):
|
||||
"""Format SSE message (returns bytes)."""
|
||||
msg = f"event: {event}\ndata: {_json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
return msg.encode("utf-8")
|
||||
|
||||
# Run batch fetch in thread with progress callback via queue
|
||||
import queue
|
||||
progress_q = queue.Queue()
|
||||
|
||||
def on_progress(current, total, model_name, status):
|
||||
progress_q.put({"current": current, "total": total, "model": model_name, "status": status})
|
||||
|
||||
# Start batch in background thread
|
||||
loop = asyncio.get_event_loop()
|
||||
fetch_task = loop.run_in_executor(None, _civitai.batch_fetch, model_files, on_progress)
|
||||
|
||||
# Stream progress events
|
||||
import time as _time
|
||||
|
||||
while not fetch_task.done():
|
||||
# Drain queue
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Drain remaining events
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get results and update metadata
|
||||
results = fetch_task.result()
|
||||
meta_updates = {}
|
||||
for model_name, result in results.items():
|
||||
sha256 = result.get("sha256")
|
||||
if sha256:
|
||||
meta_updates[model_name] = sha256
|
||||
|
||||
# Bulk update sha256 in metadata
|
||||
if meta_updates:
|
||||
all_meta = _service.get_all_metadata()
|
||||
for mname, sha in meta_updates.items():
|
||||
if mname not in all_meta:
|
||||
all_meta[mname] = {"tags": [], "favorite": False, "memo": ""}
|
||||
all_meta[mname]["sha256"] = sha
|
||||
_service._save_metadata(all_meta)
|
||||
|
||||
# Auto-set previews for models without preview image
|
||||
dirs = _get_model_dirs(model_type)
|
||||
preview_saved_count = 0
|
||||
for model_name, result in results.items():
|
||||
civitai = result.get("civitai")
|
||||
if not civitai or not civitai.get("images"):
|
||||
continue
|
||||
if _service.find_preview_image(model_type, model_name):
|
||||
continue
|
||||
model_file = None
|
||||
for d in dirs:
|
||||
p = d / model_name
|
||||
if p.is_file():
|
||||
model_file = p
|
||||
break
|
||||
if not model_file:
|
||||
continue
|
||||
preview_path = model_file.parent / (model_file.stem + ".preview.png")
|
||||
if CivitaiService.download_image(civitai["images"][0], preview_path):
|
||||
preview_saved_count += 1
|
||||
logger.info("Auto-saved preview for %s from CivitAI", model_name)
|
||||
|
||||
# Send final result
|
||||
summary = {
|
||||
"total": len(model_files),
|
||||
"found": sum(1 for r in results.values() if r.get("civitai")),
|
||||
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
|
||||
"errors": sum(1 for r in results.values() if r.get("error")),
|
||||
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
|
||||
"preview_saved": preview_saved_count,
|
||||
}
|
||||
await response.write(send_sse("done", summary))
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
|
||||
# ── Model Preview Image ───────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_preview(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/preview?type=checkpoint&name=model.safetensors"""
|
||||
model_type = request.query.get("type", "")
|
||||
model_name = request.query.get("name", "")
|
||||
if not model_type or not model_name:
|
||||
return web.Response(status=400, text="type and name are required")
|
||||
|
||||
# Security: reject path traversal
|
||||
if ".." in model_name:
|
||||
return web.Response(status=400, text="Invalid model name")
|
||||
|
||||
try:
|
||||
preview_path = await asyncio.to_thread(
|
||||
_service.find_preview_image, model_type, model_name
|
||||
)
|
||||
if not preview_path:
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(preview_path))
|
||||
return web.FileResponse(
|
||||
preview_path,
|
||||
headers={"Content-Type": content_type or "image/png",
|
||||
"Cache-Control": "no-cache"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error serving model preview: %s", e)
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
async def handle_change_preview(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/change-preview - Upload/change model preview image.
|
||||
|
||||
Multipart form: type, name, file
|
||||
Saves as {model_stem}.preview.png next to the model file.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
model_type = None
|
||||
model_name = None
|
||||
image_data = None
|
||||
|
||||
while True:
|
||||
part = await reader.next()
|
||||
if part is None:
|
||||
break
|
||||
if part.name == "type":
|
||||
model_type = (await part.read()).decode("utf-8")
|
||||
elif part.name == "name":
|
||||
model_name = (await part.read()).decode("utf-8")
|
||||
elif part.name == "file":
|
||||
image_data = await part.read()
|
||||
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "type and name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
if image_data is None:
|
||||
return web.json_response({"error": "file required"}, status=400)
|
||||
|
||||
# Resolve model file path (also check .disabled variants)
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
model_path = None
|
||||
for d in dirs:
|
||||
candidate = d / model_name
|
||||
if candidate.is_file():
|
||||
model_path = candidate
|
||||
break
|
||||
# disabled モデルも対象にする
|
||||
disabled_candidate = d / (model_name + ".disabled")
|
||||
if disabled_candidate.is_file():
|
||||
model_path = disabled_candidate
|
||||
break
|
||||
|
||||
if not model_path:
|
||||
return web.json_response({"error": "Model file not found"}, status=404)
|
||||
|
||||
# Save as {stem}.preview.png next to model file
|
||||
preview_path = model_path.parent / (model_path.stem + ".preview.png")
|
||||
|
||||
def _write():
|
||||
with open(preview_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
await asyncio.to_thread(_write)
|
||||
logger.info("Saved preview image: %s", preview_path)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error changing model preview: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_filepath(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/filepath?type=checkpoint&name=model.safetensors
|
||||
|
||||
Returns the full file path of the model.
|
||||
"""
|
||||
model_type = request.query.get("type", "")
|
||||
model_name = request.query.get("name", "")
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "type and name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
|
||||
try:
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
for d in dirs:
|
||||
candidate = d / model_name
|
||||
if candidate.is_file():
|
||||
return web.json_response({"path": str(candidate)})
|
||||
return web.json_response({"error": "Model file not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("Error resolving model filepath: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Model Enable / Disable ────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_disabled(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/disabled?type=checkpoint
|
||||
|
||||
Returns list of disabled model names (normalized, without .disabled suffix).
|
||||
"""
|
||||
model_type = request.query.get("type", "")
|
||||
if not model_type:
|
||||
return web.json_response({"error": "type required"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.scan_disabled_models, model_type)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error scanning disabled models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_toggle_model(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/toggle
|
||||
|
||||
Body: { "model_type": "checkpoint", "model_name": "v1-5.safetensors", "enabled": false }
|
||||
Renames the model file to add/remove .disabled suffix.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
model_name = body.get("model_name", "")
|
||||
enabled = body.get("enabled", True)
|
||||
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "model_type and model_name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
|
||||
if enabled:
|
||||
await asyncio.to_thread(_service.enable_model, model_type, model_name)
|
||||
else:
|
||||
await asyncio.to_thread(_service.disable_model, model_type, model_name)
|
||||
|
||||
return web.json_response({"status": "ok", "enabled": enabled})
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except OSError as e:
|
||||
logger.error("OS error toggling model: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
logger.error("Error toggling model: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_toggle_group(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/group-toggle
|
||||
|
||||
Body: { "model_type": "checkpoint", "group_name": "MyGroup", "enabled": true }
|
||||
Enables or disables all models belonging to the specified group.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
group_name = body.get("group_name", "")
|
||||
enabled = body.get("enabled", True)
|
||||
|
||||
if not model_type or not group_name:
|
||||
return web.json_response({"error": "model_type and group_name required"}, status=400)
|
||||
|
||||
groups = await asyncio.to_thread(_service.get_model_groups, model_type)
|
||||
members = groups.get(group_name, [])
|
||||
|
||||
ok_list = []
|
||||
error_list = []
|
||||
for model_name in members:
|
||||
if ".." in model_name:
|
||||
continue
|
||||
try:
|
||||
if enabled:
|
||||
await asyncio.to_thread(_service.enable_model, model_type, model_name)
|
||||
else:
|
||||
await asyncio.to_thread(_service.disable_model, model_type, model_name)
|
||||
ok_list.append(model_name)
|
||||
except Exception as e:
|
||||
error_list.append({"model": model_name, "error": str(e)})
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"enabled": enabled,
|
||||
"ok": ok_list,
|
||||
"errors": error_list,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error toggling group: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_models(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/delete
|
||||
|
||||
Body: { "model_type": "checkpoint", "model_names": ["a.safetensors", "b.safetensors"] }
|
||||
Deletes each model file and associated preview/sidecar files, then removes metadata.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
model_names = body.get("model_names", [])
|
||||
|
||||
if not model_type or not isinstance(model_names, list) or not model_names:
|
||||
return web.json_response({"error": "model_type and model_names[] required"}, status=400)
|
||||
|
||||
ok_list = []
|
||||
error_list = []
|
||||
for model_name in model_names:
|
||||
if ".." in str(model_name):
|
||||
error_list.append({"model": model_name, "error": "Invalid model name"})
|
||||
continue
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.delete_model, model_type, model_name)
|
||||
ok_list.append({"model": model_name, "deleted": result["deleted"]})
|
||||
logger.info("Deleted model: %s (%s files)", model_name, len(result["deleted"]))
|
||||
except FileNotFoundError as e:
|
||||
error_list.append({"model": model_name, "error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting model %s: %s", model_name, e)
|
||||
error_list.append({"model": model_name, "error": str(e)})
|
||||
|
||||
return web.json_response({"status": "ok", "ok": ok_list, "errors": error_list})
|
||||
except Exception as e:
|
||||
logger.error("Error in delete_models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_VALID_MODEL_TYPES = frozenset(
|
||||
["checkpoint", "lora", "vae", "controlnet", "unet", "textencoder", "hypernetwork", "embedding"]
|
||||
)
|
||||
|
||||
|
||||
async def handle_get_subdirs(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/subdirs?type=checkpoint
|
||||
Returns list of root-level subdirectory names for the given model type.
|
||||
"""
|
||||
try:
|
||||
model_type = request.rel_url.query.get("type", "")
|
||||
if model_type not in _VALID_MODEL_TYPES:
|
||||
return web.json_response({"error": "Invalid model type"}, status=400)
|
||||
result = await asyncio.to_thread(_service.get_subdirs, model_type)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_move_models(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/move
|
||||
|
||||
Body: { "model_type": "checkpoint", "model_names": [...], "dest": "sdxl" }
|
||||
dest: "" = root, or a root-level subfolder name (no path separators, no "..").
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
model_names = body.get("model_names", [])
|
||||
dest_subdir = body.get("dest", "")
|
||||
if model_type not in _VALID_MODEL_TYPES:
|
||||
return web.json_response({"error": "Invalid model type"}, status=400)
|
||||
if not isinstance(model_names, list) or not model_names:
|
||||
return web.json_response({"error": "model_names must be a non-empty list"}, status=400)
|
||||
if not isinstance(dest_subdir, str):
|
||||
return web.json_response({"error": "dest must be a string"}, status=400)
|
||||
result = await asyncio.to_thread(_service.move_models, model_type, model_names, dest_subdir)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error in move_models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Model File List ───────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_list_model_files(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/files?type=<model_type>
|
||||
Returns sorted list of model file names (with extension) for the given type.
|
||||
Used as a fallback when ComfyUI's /embeddings endpoint is not accessible.
|
||||
"""
|
||||
from ..services.models_service import MODEL_TYPE_FOLDER_KEYS
|
||||
model_type = request.query.get("type", "")
|
||||
if not model_type or model_type not in MODEL_TYPE_FOLDER_KEYS:
|
||||
return web.json_response({"error": "invalid type"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_model_files, model_type)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing model files for %s: %s", model_type, e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Node metadata and node sets API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.nodes_service import NodesService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = NodesService()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all node management API routes."""
|
||||
# Node metadata
|
||||
app.router.add_get("/api/wfm/nodes/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/nodes/metadata", handle_save_metadata)
|
||||
# Node groups
|
||||
app.router.add_get("/api/wfm/nodes/groups", handle_get_groups)
|
||||
app.router.add_post("/api/wfm/nodes/groups", handle_save_groups)
|
||||
# Node sets
|
||||
app.router.add_get("/api/wfm/node-sets", handle_list_sets)
|
||||
app.router.add_post("/api/wfm/node-sets", handle_create_set)
|
||||
app.router.add_post("/api/wfm/node-sets/update", handle_update_set)
|
||||
app.router.add_post("/api/wfm/node-sets/delete", handle_delete_set)
|
||||
app.router.add_get("/api/wfm/node-sets/export", handle_export_set)
|
||||
|
||||
|
||||
# ── Node Metadata ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_metadata(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/nodes/metadata"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_all_metadata)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading node metadata: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_metadata(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/nodes/metadata"""
|
||||
try:
|
||||
body = await request.json()
|
||||
node_name = body.get("nodeName", "")
|
||||
if not node_name:
|
||||
return web.json_response({"error": "nodeName is required"}, status=400)
|
||||
updates = {k: v for k, v in body.items() if k != "nodeName"}
|
||||
result = await asyncio.to_thread(_service.update_node_metadata, node_name, updates)
|
||||
return web.json_response({"status": "ok", "metadata": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving node metadata: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Node Groups ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_groups(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/nodes/groups"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_node_groups)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading node groups: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_groups(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/nodes/groups"""
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(_service.save_node_groups, body)
|
||||
return web.json_response({"status": "ok", "groups": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving node groups: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Node Sets ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_list_sets(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/node-sets"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_node_sets)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing node sets: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_create_set(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/node-sets"""
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(_service.create_node_set, body)
|
||||
return web.json_response({"status": "ok", "nodeSet": result})
|
||||
except Exception as e:
|
||||
logger.error("Error creating node set: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_update_set(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/node-sets/update"""
|
||||
try:
|
||||
body = await request.json()
|
||||
set_id = body.get("id", "")
|
||||
if not set_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
updates = {k: v for k, v in body.items() if k != "id"}
|
||||
result = await asyncio.to_thread(_service.update_node_set, set_id, updates)
|
||||
if result is None:
|
||||
return web.json_response({"error": "node set not found"}, status=404)
|
||||
return web.json_response({"status": "ok", "nodeSet": result})
|
||||
except Exception as e:
|
||||
logger.error("Error updating node set: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_set(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/node-sets/delete"""
|
||||
try:
|
||||
body = await request.json()
|
||||
set_id = body.get("id", "")
|
||||
if not set_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
await asyncio.to_thread(_service.delete_node_set, set_id)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting node set: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_export_set(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/node-sets/export?id=xxx"""
|
||||
try:
|
||||
set_id = request.query.get("id", "")
|
||||
if not set_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.export_node_set_json, set_id)
|
||||
if result is None:
|
||||
return web.json_response({"error": "node set not found"}, status=404)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting node set: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -82,7 +82,8 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
cfg = _get_ollama_config()
|
||||
model = body.get("model", cfg["model"])
|
||||
model = body.get("model") or cfg["model"]
|
||||
base_url = (body.get("url") or cfg["url"]).rstrip("/")
|
||||
messages = body.get("messages", [])
|
||||
|
||||
def _chat():
|
||||
@@ -92,7 +93,7 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
"stream": False,
|
||||
}).encode("utf-8")
|
||||
|
||||
url = f"{cfg['url']}/api/chat"
|
||||
url = f"{base_url}/api/chat"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Prompt presets API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.prompts_service import PromptsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = PromptsService()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all prompt management API routes."""
|
||||
app.router.add_get("/api/wfm/prompts", handle_list)
|
||||
app.router.add_post("/api/wfm/prompts", handle_create)
|
||||
app.router.add_post("/api/wfm/prompts/update", handle_update)
|
||||
app.router.add_post("/api/wfm/prompts/delete", handle_delete)
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/prompts"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_prompts)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing prompts: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Create ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_create(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/prompts"""
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(_service.create_prompt, body)
|
||||
return web.json_response({"status": "ok", "prompt": result})
|
||||
except Exception as e:
|
||||
logger.error("Error creating prompt: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Update ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_update(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/prompts/update"""
|
||||
try:
|
||||
body = await request.json()
|
||||
prompt_id = body.get("id", "")
|
||||
if not prompt_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
updates = {k: v for k, v in body.items() if k != "id"}
|
||||
result = await asyncio.to_thread(_service.update_prompt, prompt_id, updates)
|
||||
if result is None:
|
||||
return web.json_response({"error": "prompt not found"}, status=404)
|
||||
return web.json_response({"status": "ok", "prompt": result})
|
||||
except Exception as e:
|
||||
logger.error("Error updating prompt: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/prompts/delete"""
|
||||
try:
|
||||
body = await request.json()
|
||||
prompt_id = body.get("id", "")
|
||||
if not prompt_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
await asyncio.to_thread(_service.delete_prompt, prompt_id)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting prompt: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -1,18 +1,31 @@
|
||||
"""Settings API routes."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = SettingsService()
|
||||
|
||||
# Data files included in export/import
|
||||
_DATA_FILES = [
|
||||
"settings.json",
|
||||
"metadata.json",
|
||||
"node_metadata.json",
|
||||
"node_sets.json",
|
||||
"prompts.json",
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
"tagger_settings.json",
|
||||
]
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register settings API routes."""
|
||||
@@ -20,6 +33,11 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/settings", handle_post)
|
||||
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
|
||||
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
|
||||
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
|
||||
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
app.router.add_post("/api/wfm/settings/import", handle_import)
|
||||
app.router.add_get("/api/wfm/styles", handle_get_styles)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -92,3 +110,163 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error setting workflows dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _get_comfyui_output_dir() -> str:
|
||||
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
return str(folder_paths.get_output_directory())
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: config.pyのCOMFYUI_ROOTから推測
|
||||
from ..config import PLUGIN_DIR
|
||||
comfyui_root = PLUGIN_DIR.parent.parent
|
||||
output_dir = comfyui_root / "output"
|
||||
if output_dir.is_dir():
|
||||
return str(output_dir)
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_get_output_dir(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/output-dir - Get gallery output directory."""
|
||||
try:
|
||||
server_settings = await asyncio.to_thread(_service.load)
|
||||
saved = server_settings.get("gallery_output_dir", "").strip()
|
||||
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
|
||||
return web.json_response({
|
||||
"current": saved or default_dir,
|
||||
"default": default_dir,
|
||||
"saved": saved,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting output dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_set_output_dir(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/output-dir - Save gallery output directory."""
|
||||
try:
|
||||
body = await request.json()
|
||||
new_dir = body.get("gallery_output_dir", "").strip()
|
||||
await asyncio.to_thread(_service.update, {"gallery_output_dir": new_dir})
|
||||
|
||||
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
|
||||
resolved = new_dir or default_dir
|
||||
|
||||
# GalleryServiceの許可ルートを更新
|
||||
from ..routes.gallery_routes import _service as gallery_service
|
||||
gallery_service.update_output_root(resolved)
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"current": resolved,
|
||||
"default": default_dir,
|
||||
"saved": new_dir,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error setting output dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_data_file(filename: str):
|
||||
"""Load a single data file, return empty dict/list if missing."""
|
||||
path = DATA_DIR / filename
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# エクスポートから除外するsettings.jsonのキー(APIキーなどの機密情報)
|
||||
_SETTINGS_EXPORT_EXCLUDE = {"civitai_api_key"}
|
||||
|
||||
|
||||
def _build_export_bundle() -> dict:
|
||||
"""Collect all data files into a single export bundle."""
|
||||
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
|
||||
for filename in _DATA_FILES:
|
||||
data = _load_data_file(filename)
|
||||
if data is None:
|
||||
continue
|
||||
if filename == "settings.json" and isinstance(data, dict):
|
||||
data = {k: v for k, v in data.items() if k not in _SETTINGS_EXPORT_EXCLUDE}
|
||||
bundle[filename] = data
|
||||
return bundle
|
||||
|
||||
|
||||
async def handle_export(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
|
||||
try:
|
||||
bundle = await asyncio.to_thread(_build_export_bundle)
|
||||
body = json.dumps(bundle, ensure_ascii=False, indent=2)
|
||||
return web.Response(
|
||||
body=body.encode("utf-8"),
|
||||
content_type="application/json",
|
||||
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _apply_import_bundle(bundle: dict) -> dict:
|
||||
"""Write imported bundle back to individual data files. Returns summary."""
|
||||
summary = {"imported": [], "skipped": []}
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for filename in _DATA_FILES:
|
||||
if filename not in bundle:
|
||||
summary["skipped"].append(filename)
|
||||
continue
|
||||
try:
|
||||
path = DATA_DIR / filename
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
|
||||
summary["imported"].append(filename)
|
||||
except Exception as e:
|
||||
logger.error("Import failed for %s: %s", filename, e)
|
||||
summary["skipped"].append(filename)
|
||||
return summary
|
||||
|
||||
|
||||
async def handle_import(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
|
||||
try:
|
||||
bundle = await request.json()
|
||||
if not isinstance(bundle, dict):
|
||||
return web.json_response({"error": "Invalid bundle format"}, status=400)
|
||||
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
|
||||
return web.json_response({"status": "ok", **summary})
|
||||
except Exception as e:
|
||||
logger.error("Error importing data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory."""
|
||||
style_dir = DATA_DIR / "style"
|
||||
styles = []
|
||||
if not style_dir.is_dir():
|
||||
return styles
|
||||
for json_file in sorted(style_dir.glob("*.json")):
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
styles.extend(data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
return styles
|
||||
|
||||
|
||||
async def handle_get_styles(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/styles - Get all styles from style directory."""
|
||||
try:
|
||||
styles = await asyncio.to_thread(_load_styles)
|
||||
return web.json_response(styles)
|
||||
except Exception as e:
|
||||
logger.error("Error loading styles: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tagger API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import TAGGER_DB_FILE, TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE
|
||||
from ..services.tagger_db_service import TaggerDbService
|
||||
from ..services.tagger_service import TaggerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_svc = TaggerService(TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE)
|
||||
_db = TaggerDbService(TAGGER_DB_FILE)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/tagger/models", handle_models)
|
||||
app.router.add_post("/wfm/tagger/predict", handle_predict)
|
||||
app.router.add_get("/wfm/tagger/ollama/models", handle_ollama_models)
|
||||
app.router.add_post("/wfm/tagger/ollama/predict", handle_ollama_predict)
|
||||
app.router.add_post("/wfm/tagger/batch/start", handle_batch_start)
|
||||
app.router.add_get("/wfm/tagger/batch/status", handle_batch_status)
|
||||
app.router.add_post("/wfm/tagger/batch/stop", handle_batch_stop)
|
||||
app.router.add_get("/wfm/tagger/db/list", handle_db_list)
|
||||
app.router.add_get("/wfm/tagger/db/search", handle_db_search)
|
||||
app.router.add_put("/wfm/tagger/db/{id}", handle_db_update)
|
||||
app.router.add_delete("/wfm/tagger/db/{id}", handle_db_delete)
|
||||
app.router.add_get("/wfm/tagger/db/export", handle_db_export)
|
||||
app.router.add_post("/wfm/tagger/db/save", handle_db_save)
|
||||
app.router.add_post("/wfm/tagger/write_meta", handle_write_meta)
|
||||
app.router.add_get("/wfm/tagger/settings", handle_settings_get)
|
||||
app.router.add_post("/wfm/tagger/settings", handle_settings_save)
|
||||
|
||||
|
||||
async def handle_models(request: web.Request) -> web.Response:
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.list_models)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
logger.error("handle_models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
b64 = body.get("image_b64", "")
|
||||
model = body.get("model", "")
|
||||
if not b64 or not model:
|
||||
return web.json_response({"error": "image_b64 and model required"}, status=400)
|
||||
result = await asyncio.to_thread(
|
||||
_svc.predict, b64, model,
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("handle_predict: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_models(request: web.Request) -> web.Response:
|
||||
api_url = request.rel_url.query.get("api_url", "http://127.0.0.1:11434")
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.ollama_models, api_url)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.ollama_predict,
|
||||
body.get("image_b64", ""),
|
||||
body.get("api_url", "http://127.0.0.1:11434"),
|
||||
body.get("model", ""),
|
||||
body.get("prompt", ""),
|
||||
int(body.get("max_tags", 40)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.batch_start,
|
||||
body.get("folder", ""),
|
||||
body.get("model", ""),
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
bool(body.get("use_ollama", False)),
|
||||
body.get("ollama_api", "http://127.0.0.1:11434"),
|
||||
body.get("ollama_model", ""),
|
||||
body.get("ollama_prompt", ""),
|
||||
int(body.get("ollama_max_tags", 40)),
|
||||
bool(body.get("save_db", True)),
|
||||
bool(body.get("write_file", False)),
|
||||
bool(body.get("write_txt", False)),
|
||||
_db,
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_batch_status(request: web.Request) -> web.Response:
|
||||
return web.json_response(_svc.batch_status())
|
||||
|
||||
|
||||
async def handle_batch_stop(request: web.Request) -> web.Response:
|
||||
_svc.batch_stop()
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def handle_db_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.rel_url.query.get("limit", 100))
|
||||
offset = int(request.rel_url.query.get("offset", 0))
|
||||
rows = await asyncio.to_thread(_db.list, limit, offset)
|
||||
total = await asyncio.to_thread(_db.total)
|
||||
return web.json_response({"rows": rows, "total": total})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_search(request: web.Request) -> web.Response:
|
||||
try:
|
||||
q = request.rel_url.query.get("q", "")
|
||||
rows = await asyncio.to_thread(_db.search, q)
|
||||
return web.json_response({"rows": rows})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_update(request: web.Request) -> web.Response:
|
||||
try:
|
||||
row_id = int(request.match_info["id"])
|
||||
body = await request.json()
|
||||
await asyncio.to_thread(
|
||||
_db.update, row_id,
|
||||
body.get("interrogator_tags", ""),
|
||||
body.get("vlm_tags", ""),
|
||||
)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
row_id = int(request.match_info["id"])
|
||||
await asyncio.to_thread(_db.delete, row_id)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_export(request: web.Request) -> web.Response:
|
||||
try:
|
||||
csv_data = await asyncio.to_thread(_db.export_csv)
|
||||
return web.Response(
|
||||
body=csv_data.encode("utf-8"),
|
||||
content_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=tagger_tags.csv"},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
row_id = await asyncio.to_thread(
|
||||
_db.save,
|
||||
body.get("filename", ""),
|
||||
body.get("interrogator_tags", ""),
|
||||
body.get("vlm_tags", ""),
|
||||
)
|
||||
return web.json_response({"ok": True, "id": row_id})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_write_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.write_meta_to_file,
|
||||
body.get("path", ""),
|
||||
body.get("tags", ""),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_settings_get(request: web.Request) -> web.Response:
|
||||
return web.json_response(_svc.load_settings())
|
||||
|
||||
|
||||
async def handle_settings_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
await asyncio.to_thread(_svc.save_settings, body)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,430 @@
|
||||
"""CivitAI API integration service."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from ..config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_ssl_context():
|
||||
"""Return an SSL context with CA verification.
|
||||
|
||||
1. certifi CA bundle (available in virtually all ComfyUI envs via torch→requests→certifi)
|
||||
2. System default SSL context (OS certificate store)
|
||||
Returns None if both fail — callers fall back to urlopen without context
|
||||
(Python's own default, which also uses certifi when available).
|
||||
SSL verification is never disabled to avoid MitM exposure.
|
||||
"""
|
||||
try:
|
||||
import certifi
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return ssl.create_default_context()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("CivitAI: could not build SSL context; falling back to urllib default")
|
||||
return None
|
||||
|
||||
|
||||
_SSL_CONTEXT = _make_ssl_context # lazy sentinel
|
||||
|
||||
|
||||
def _get_ssl_context():
|
||||
global _SSL_CONTEXT
|
||||
if callable(_SSL_CONTEXT):
|
||||
_SSL_CONTEXT = _make_ssl_context()
|
||||
return _SSL_CONTEXT
|
||||
|
||||
|
||||
CIVITAI_API_BASE = "https://civitai.com/api/v1"
|
||||
CIVITAI_CACHE_FILE = DATA_DIR / "civitai_cache.json"
|
||||
|
||||
# HTTPステータスコード: 指数バックオフでリトライする対象
|
||||
_RETRY_CODES = {429, 500, 502, 503, 504}
|
||||
_MAX_RETRIES = 3
|
||||
# POST /model-versions/by-hash は最大100件まで一括送信可能
|
||||
_BATCH_CHUNK_SIZE = 100
|
||||
|
||||
|
||||
class CivitaiService:
|
||||
"""Fetch and cache CivitAI model metadata."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache = None
|
||||
|
||||
# ── キャッシュ管理 ────────────────────────────────────────
|
||||
|
||||
def _load_cache(self):
|
||||
if self._cache is not None:
|
||||
return self._cache
|
||||
if CIVITAI_CACHE_FILE.exists():
|
||||
try:
|
||||
with open(CIVITAI_CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
self._cache = json.load(f)
|
||||
return self._cache
|
||||
except Exception:
|
||||
pass
|
||||
self._cache = {}
|
||||
return self._cache
|
||||
|
||||
def _save_cache(self):
|
||||
CIVITAI_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CIVITAI_CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self._cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_cached(self, sha256_hash):
|
||||
"""Return cached CivitAI data for a given hash, or None."""
|
||||
cache = self._load_cache()
|
||||
return cache.get(sha256_hash.lower())
|
||||
|
||||
def get_all_cached(self):
|
||||
"""Return the full cache dict."""
|
||||
return self._load_cache()
|
||||
|
||||
# ── APIキー / ヘッダー ────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _get_api_key():
|
||||
"""Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over settings.json."""
|
||||
import os
|
||||
env_key = os.environ.get("CIVITAI_API_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
try:
|
||||
from ..services.settings_service import SettingsService
|
||||
return SettingsService().load().get("civitai_api_key", "").strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_headers(self):
|
||||
"""Build request headers, optionally including Bearer token."""
|
||||
headers = {"User-Agent": "ComfyUI-Workflow-Studio/1.0"}
|
||||
api_key = self._get_api_key()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
# ── ハッシュ計算 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def calculate_sha256(file_path, chunk_size=65536):
|
||||
"""Calculate SHA256 hash of a file."""
|
||||
h = hashlib.sha256()
|
||||
path = Path(file_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest() # 小文字16進数
|
||||
|
||||
# ── 単体フェッチ (GET) ────────────────────────────────────
|
||||
|
||||
def fetch_by_hash(self, sha256_hash):
|
||||
"""Fetch model version info from CivitAI by SHA256 hash (GET).
|
||||
|
||||
429/5xx は指数バックオフでリトライ。
|
||||
Returns dict with model info, or None if not found.
|
||||
Caches successful results.
|
||||
"""
|
||||
sha256_lower = sha256_hash.lower()
|
||||
cache = self._load_cache()
|
||||
if sha256_lower in cache:
|
||||
return cache[sha256_lower]
|
||||
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash.upper()}"
|
||||
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
try:
|
||||
req = Request(url, headers=self._build_headers())
|
||||
with urlopen(req, timeout=15, context=_get_ssl_context()) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if not data or "id" not in data:
|
||||
return None
|
||||
|
||||
info = self._extract_info(data)
|
||||
cache[sha256_lower] = info
|
||||
self._save_cache()
|
||||
return info
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 404:
|
||||
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
|
||||
return None
|
||||
if e.code in _RETRY_CODES and attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI %s: retrying in %ss (attempt %d/%d)",
|
||||
e.code, wait, attempt + 1, _MAX_RETRIES)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
|
||||
return None
|
||||
except (URLError, Exception) as e:
|
||||
if attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI request failed: %s — retrying in %ss", e, wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI request failed: %s", e)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
# ── 一括フェッチ (POST) ───────────────────────────────────
|
||||
|
||||
def _batch_fetch_post(self, sha256_hashes):
|
||||
"""POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。
|
||||
|
||||
レスポンスの files[].hashes.SHA256 でリクエストのハッシュと照合する。
|
||||
Returns: { sha256_lower: info_or_none }
|
||||
"""
|
||||
if not sha256_hashes:
|
||||
return {}
|
||||
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash"
|
||||
results = {h.lower(): None for h in sha256_hashes}
|
||||
cache = self._load_cache()
|
||||
|
||||
for chunk_start in range(0, len(sha256_hashes), _BATCH_CHUNK_SIZE):
|
||||
chunk = sha256_hashes[chunk_start:chunk_start + _BATCH_CHUNK_SIZE]
|
||||
chunk_lower = {h.lower() for h in chunk}
|
||||
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
try:
|
||||
body = json.dumps([h.upper() for h in chunk]).encode("utf-8")
|
||||
req = Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={**self._build_headers(), "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=30, context=_get_ssl_context()) as resp:
|
||||
versions = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
# 各バージョンを files[].hashes.SHA256 でリクエストのハッシュと照合
|
||||
for version_data in versions:
|
||||
info = self._extract_info(version_data)
|
||||
matched = False
|
||||
for f in version_data.get("files", []):
|
||||
file_sha256 = f.get("hashes", {}).get("SHA256", "").lower()
|
||||
if file_sha256 in chunk_lower:
|
||||
cache[file_sha256] = info
|
||||
results[file_sha256] = info
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
# files にハッシュがない場合は versionId で照合を試みる
|
||||
logger.debug("CivitAI: could not match version %s to a requested hash",
|
||||
version_data.get("id"))
|
||||
break # チャンク成功
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code in _RETRY_CODES and attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI batch POST %s: retrying in %ss", e.code, wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI batch POST error: %s %s", e.code, e.reason)
|
||||
break
|
||||
except (URLError, Exception) as e:
|
||||
if attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI batch POST failed: %s — retrying in %ss", e, wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI batch POST failed: %s", e)
|
||||
break
|
||||
|
||||
self._save_cache()
|
||||
return results
|
||||
|
||||
# ── 情報抽出 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_info(data):
|
||||
"""Extract relevant fields from CivitAI API response."""
|
||||
model = data.get("model", {})
|
||||
images = data.get("images", [])
|
||||
files = data.get("files", [])
|
||||
|
||||
# プライマリファイルを特定
|
||||
primary_file = next((f for f in files if f.get("primary")), None)
|
||||
if not primary_file and files:
|
||||
primary_file = files[0]
|
||||
|
||||
# 画像情報(URL・寸法・NSFWレベル)を最大5件取得
|
||||
image_list = []
|
||||
for img in images[:5]:
|
||||
img_url = img.get("url", "")
|
||||
if not img_url:
|
||||
continue
|
||||
image_list.append({
|
||||
"url": img_url,
|
||||
"width": img.get("width"),
|
||||
"height": img.get("height"),
|
||||
"nsfwLevel": img.get("nsfwLevel", 0),
|
||||
})
|
||||
|
||||
# プライマリファイルのメタ情報(精度・フォーマット)
|
||||
file_meta = {}
|
||||
file_hashes = {}
|
||||
if primary_file:
|
||||
pm = primary_file.get("metadata", {})
|
||||
file_meta = {
|
||||
"fp": pm.get("fp"),
|
||||
"size": pm.get("size"),
|
||||
"format": pm.get("format"),
|
||||
}
|
||||
# BLAKE3, SHA256, AutoV2 等のハッシュ
|
||||
file_hashes = primary_file.get("hashes", {})
|
||||
|
||||
stats = data.get("stats", {})
|
||||
|
||||
# バッチ POST API は model オブジェクト内に id を含まない場合がある
|
||||
# トップレベルの modelId をフォールバックとして使用する
|
||||
model_id = model.get("id") or data.get("modelId")
|
||||
version_id = data.get("id", "")
|
||||
|
||||
return {
|
||||
"versionId": version_id,
|
||||
"modelId": model_id,
|
||||
"modelName": model.get("name", ""),
|
||||
"versionName": data.get("name", ""),
|
||||
"type": model.get("type", ""),
|
||||
"description": data.get("description") or model.get("description", ""),
|
||||
"tags": model.get("tags", []),
|
||||
"nsfw": model.get("nsfw", False),
|
||||
"nsfwLevel": data.get("nsfwLevel", 0),
|
||||
"air": data.get("air", ""),
|
||||
"creator": data.get("creator", {}).get("username", ""),
|
||||
# 後方互換のため URLリストを維持しつつ詳細情報も保存
|
||||
"images": [img["url"] for img in image_list],
|
||||
"imageDetails": image_list,
|
||||
"trainedWords": data.get("trainedWords", []),
|
||||
"baseModel": data.get("baseModel", ""),
|
||||
"fileSize": primary_file.get("sizeKB", 0) if primary_file else 0,
|
||||
"fileMeta": file_meta,
|
||||
"fileHashes": file_hashes,
|
||||
"downloadUrl": data.get("downloadUrl", ""),
|
||||
"modelUrl": (
|
||||
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
|
||||
if model_id else (
|
||||
f"https://civitai.com/model-versions/{version_id}"
|
||||
if version_id else ""
|
||||
)
|
||||
),
|
||||
"stats": {
|
||||
"downloadCount": stats.get("downloadCount", 0),
|
||||
"thumbsUpCount": stats.get("thumbsUpCount", 0),
|
||||
"thumbsDownCount": stats.get("thumbsDownCount", 0),
|
||||
},
|
||||
"updatedAt": data.get("updatedAt", ""),
|
||||
"publishedAt": data.get("publishedAt", ""),
|
||||
}
|
||||
|
||||
# ── バッチフェッチ ────────────────────────────────────────
|
||||
|
||||
def batch_fetch(self, model_files, progress_callback=None):
|
||||
"""Batch fetch CivitAI info for multiple model files.
|
||||
|
||||
Phase 1: SHA256 計算("hashing")
|
||||
Phase 2: POST で一括取得("fetching")— キャッシュ済みはスキップ
|
||||
|
||||
Args:
|
||||
model_files: list of (model_name, file_path) tuples
|
||||
progress_callback: fn(current, total, model_name, status) called per model
|
||||
|
||||
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
|
||||
"""
|
||||
results = {}
|
||||
total = len(model_files)
|
||||
cache = self._load_cache()
|
||||
|
||||
# Phase 1: ハッシュ計算
|
||||
hashes_needed = [] # [(model_name, sha256_lower, original_index)]
|
||||
|
||||
for i, (model_name, file_path) in enumerate(model_files):
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "hashing")
|
||||
|
||||
sha256 = self.calculate_sha256(file_path)
|
||||
if not sha256:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total, model_name, "not_found")
|
||||
continue
|
||||
|
||||
sha256_lower = sha256.lower()
|
||||
results[model_name] = {"sha256": sha256_lower, "civitai": None}
|
||||
|
||||
if sha256_lower in cache:
|
||||
results[model_name]["civitai"] = cache[sha256_lower]
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total, model_name, "cached")
|
||||
else:
|
||||
hashes_needed.append((model_name, sha256_lower, i))
|
||||
|
||||
# Phase 2: POST で一括取得(未キャッシュ分)
|
||||
if hashes_needed:
|
||||
if progress_callback:
|
||||
progress_callback(len(results), total, "", "fetching")
|
||||
|
||||
# 同一ハッシュが複数モデルに対応する場合を考慮
|
||||
sha256_to_entries: dict[str, list] = {}
|
||||
for name, sha256_lower, idx in hashes_needed:
|
||||
sha256_to_entries.setdefault(sha256_lower, []).append((name, idx))
|
||||
|
||||
batch_results = self._batch_fetch_post(list(sha256_to_entries.keys()))
|
||||
|
||||
for sha256_lower, info in batch_results.items():
|
||||
for name, idx in sha256_to_entries.get(sha256_lower, []):
|
||||
results[name]["civitai"] = info
|
||||
if progress_callback:
|
||||
status = "found" if info else "not_found"
|
||||
progress_callback(idx + 1, total, name, status)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "", "done")
|
||||
|
||||
return results
|
||||
|
||||
# ── 画像ダウンロード ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def download_image(url, save_path, timeout=15):
|
||||
"""Download an image from URL and save to save_path. Returns True on success."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
|
||||
with urlopen(req, timeout=timeout, context=_get_ssl_context()) as resp:
|
||||
data = resp.read()
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download preview from %s: %s", url, e)
|
||||
return False
|
||||
|
||||
# ── キャッシュ操作 ────────────────────────────────────────
|
||||
|
||||
def clear_cache(self, sha256_hash=None):
|
||||
"""Clear cache for a specific hash or all."""
|
||||
cache = self._load_cache()
|
||||
if sha256_hash:
|
||||
cache.pop(sha256_hash.lower(), None)
|
||||
else:
|
||||
cache.clear()
|
||||
self._save_cache()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GalleryMetadataStore:
|
||||
"""
|
||||
gallery_metadata.json の構造:
|
||||
{
|
||||
"images": {
|
||||
"<abs_path>": {
|
||||
"favorite": bool,
|
||||
"tags": [str, ...],
|
||||
"memo": str,
|
||||
"groups": [str, ...]
|
||||
}
|
||||
},
|
||||
"groups": [
|
||||
{"name": str}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
self._data = {
|
||||
"images": loaded.get("images", {}),
|
||||
"groups": loaded.get("groups", []),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
|
||||
def _save_to_disk(self):
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
|
||||
def _normalize_path(self, image_path: str) -> str:
|
||||
return str(Path(image_path)).replace("\\", "/")
|
||||
|
||||
def get(self, image_path: str) -> dict:
|
||||
key = self._normalize_path(image_path)
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
|
||||
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
"""グループ内の全画像を除外する(グループ自体は残す)"""
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
"""グループが存在しない場合のみ作成する"""
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
|
||||
def delete(self, image_path: str) -> bool:
|
||||
"""画像のメタデータエントリを削除する"""
|
||||
key = self._normalize_path(image_path)
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def rename_path(self, old_path: str, new_path: str) -> bool:
|
||||
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
|
||||
old_key = self._normalize_path(old_path)
|
||||
new_key = self._normalize_path(new_path)
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
|
||||
"""folder_path 配下の存在しないファイルのメタデータを削除する。
|
||||
existing_paths: そのフォルダの現在のファイルパス集合(_normalize_path 済み)
|
||||
"""
|
||||
folder_key = self._normalize_path(folder_path)
|
||||
if not folder_key.endswith("/"):
|
||||
folder_key += "/"
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if key.startswith(folder_key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
@@ -0,0 +1,714 @@
|
||||
"""
|
||||
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
|
||||
|
||||
# PNGチャンク1個の最大許容サイズ (32MB)
|
||||
_PNG_CHUNK_MAX = 32 * 1024 * 1024
|
||||
# JPEGを読む際の最大ファイルサイズ (64MB)
|
||||
_JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 60.0
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
"""フォルダ単位の画像スキャン結果キャッシュ。
|
||||
フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。"""
|
||||
|
||||
def __init__(self):
|
||||
# folder_path -> (mtime, scan_time, [(name, path_str, size, mtime_f), ...])
|
||||
self._cache: dict[str, tuple[float, float, list]] = {}
|
||||
|
||||
def get(self, folder: Path) -> list | None:
|
||||
key = str(folder)
|
||||
if key not in self._cache:
|
||||
return None
|
||||
cached_mtime, scan_time, entries = self._cache[key]
|
||||
# TTL チェック
|
||||
if time.monotonic() - scan_time > _CACHE_TTL:
|
||||
return None
|
||||
# フォルダ自体のmtimeチェック
|
||||
try:
|
||||
current_mtime = folder.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
if current_mtime != cached_mtime:
|
||||
return None
|
||||
return entries
|
||||
|
||||
def set(self, folder: Path, entries: list):
|
||||
key = str(folder)
|
||||
try:
|
||||
folder_mtime = folder.stat().st_mtime
|
||||
except OSError:
|
||||
folder_mtime = 0.0
|
||||
self._cache[key] = (folder_mtime, time.monotonic(), entries)
|
||||
|
||||
def invalidate(self, folder: Path):
|
||||
self._cache.pop(str(folder), None)
|
||||
|
||||
|
||||
class GalleryService:
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = data_dir
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
self._allowed_root: Path | None = None
|
||||
self._folder_cache = _FolderCache()
|
||||
self._bg_cancel = threading.Event()
|
||||
self._bg_cancel.set() # 初期状態: キャンセル済み
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._allowed_root = p
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バックグラウンドインデックス
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def start_background_index(self, folder_path: str) -> None:
|
||||
"""フォルダ内の未キャッシュ画像をバックグラウンドでインデックスする。
|
||||
フォルダロード時に呼び出す。前回のインデックス処理は自動キャンセル。"""
|
||||
self._bg_cancel.set() # 前のスレッドをキャンセル
|
||||
cancel = threading.Event()
|
||||
self._bg_cancel = cancel
|
||||
t = threading.Thread(
|
||||
target=self._bg_index_folder,
|
||||
args=(folder_path, cancel),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
def _bg_index_folder(self, folder_path: str, cancel: threading.Event) -> None:
|
||||
"""バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。
|
||||
10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。"""
|
||||
try:
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir() or not self._check_path_allowed(folder):
|
||||
return
|
||||
entries = self._scan_folder(folder)
|
||||
batch: dict[str, dict] = {}
|
||||
processed = 0
|
||||
for name, abs_path, _, _ in entries:
|
||||
if cancel.is_set():
|
||||
return
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext not in (".png", ".jpg", ".jpeg"):
|
||||
continue
|
||||
meta = self.metadata_store.get(abs_path)
|
||||
if meta.get("prompt_cache"):
|
||||
continue
|
||||
embedded: dict = {}
|
||||
if ext == ".png":
|
||||
embedded = self._read_png_metadata(Path(abs_path))
|
||||
else:
|
||||
embedded = self._read_jpeg_metadata(Path(abs_path))
|
||||
if not embedded:
|
||||
continue
|
||||
prompt_cache = " ".join(
|
||||
str(v) for k, v in embedded.items()
|
||||
if k != "workflow" and isinstance(v, str) and len(v) < 50_000
|
||||
).strip()
|
||||
if not prompt_cache:
|
||||
continue
|
||||
batch[abs_path] = {"prompt_cache": prompt_cache}
|
||||
processed += 1
|
||||
if len(batch) >= 10:
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
batch = {}
|
||||
time.sleep(0.05) # 50ms 他の処理に譲る
|
||||
if batch and not cancel.is_set():
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
if processed:
|
||||
logger.debug("BG index done: %s (%d indexed)", folder_path, processed)
|
||||
except Exception as e:
|
||||
logger.debug("BG index error: %s", e)
|
||||
|
||||
def _check_path_allowed(self, path: Path) -> bool:
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
|
||||
if self._allowed_root is None:
|
||||
return False
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_folder_tree(self, root_path: str) -> dict:
|
||||
"""outputフォルダのフォルダツリーを返す"""
|
||||
root = Path(root_path).resolve()
|
||||
if not root.is_dir():
|
||||
return {"error": f"Directory not found: {root_path}"}
|
||||
if self._allowed_root is None:
|
||||
self._allowed_root = root
|
||||
|
||||
def build_tree(path: Path, rel_base: Path) -> dict:
|
||||
rel = str(path.relative_to(rel_base)).replace("\\", "/")
|
||||
image_count = 0
|
||||
children = []
|
||||
try:
|
||||
with os.scandir(path) as it:
|
||||
for entry in it:
|
||||
if entry.is_file(follow_symlinks=True):
|
||||
if Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
|
||||
image_count += 1
|
||||
elif entry.is_dir(follow_symlinks=True) and not entry.name.startswith("."):
|
||||
children.append(entry.name)
|
||||
except PermissionError:
|
||||
pass
|
||||
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": rel if rel != "." else "",
|
||||
"abs_path": str(path).replace("\\", "/"),
|
||||
"image_count": image_count,
|
||||
"children": children_nodes,
|
||||
}
|
||||
|
||||
return build_tree(root, root)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像一覧
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _scan_folder(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""os.scandir() で画像ファイルを1回のシステムコールで列挙。
|
||||
キャッシュがあればそれを返す。
|
||||
Returns: [(name, abs_path_str, size, mtime), ...]
|
||||
"""
|
||||
cached = self._folder_cache.get(folder)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with os.scandir(folder) as it:
|
||||
for entry in it:
|
||||
if not entry.is_file(follow_symlinks=True):
|
||||
continue
|
||||
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=True)
|
||||
entries.append((
|
||||
entry.name,
|
||||
str(Path(entry.path).resolve()).replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
self._folder_cache.set(folder, entries)
|
||||
return entries
|
||||
|
||||
def list_images(
|
||||
self,
|
||||
folder_path: str,
|
||||
search: str = "",
|
||||
sort_by: str = "date_desc",
|
||||
favorite_only: bool = False,
|
||||
tag_filter: str = "",
|
||||
group_filter: str = "",
|
||||
) -> list[dict]:
|
||||
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
if not self._check_path_allowed(folder):
|
||||
logger.warning("list_images: path not allowed: %s", folder)
|
||||
return []
|
||||
|
||||
# グループフィルタ: サーバーサイドで先にパスSetを取得(転送量削減)
|
||||
group_member_set: set | None = None
|
||||
if group_filter:
|
||||
group_member_set = self.metadata_store.get_group_member_set(group_filter)
|
||||
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder(folder)
|
||||
|
||||
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
self.metadata_store.cleanup_stale_images(str(folder), existing_paths)
|
||||
|
||||
results = []
|
||||
for name, abs_path, size, mtime in raw_entries:
|
||||
# グループフィルタ(サーバーサイド)
|
||||
if group_member_set is not None and abs_path not in group_member_set:
|
||||
continue
|
||||
|
||||
meta = self.metadata_store.get(abs_path)
|
||||
|
||||
# お気に入りフィルタ(サーバーサイド、早期スキップ)
|
||||
if favorite_only and not meta.get("favorite", False):
|
||||
continue
|
||||
|
||||
# タグフィルタ(サーバーサイド、早期スキップ)
|
||||
tags = meta.get("tags", [])
|
||||
if tag_filter and tag_filter not in tags:
|
||||
continue
|
||||
|
||||
ext = Path(name).suffix.lower()
|
||||
item = {
|
||||
"filename": name,
|
||||
"path": abs_path,
|
||||
"size": size,
|
||||
"mtime": mtime,
|
||||
"ext": ext,
|
||||
"favorite": meta.get("favorite", False),
|
||||
"tags": tags,
|
||||
"memo": meta.get("memo", ""),
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト)
|
||||
if search:
|
||||
s = search.lower()
|
||||
if not (
|
||||
s in name.lower()
|
||||
or s in item["memo"].lower()
|
||||
or any(s in t.lower() for t in tags)
|
||||
or s in meta.get("prompt_cache", "").lower()
|
||||
):
|
||||
continue
|
||||
|
||||
results.append(item)
|
||||
|
||||
# ソート
|
||||
if sort_by == "name_asc":
|
||||
results.sort(key=lambda x: x["filename"].lower())
|
||||
elif sort_by == "name_desc":
|
||||
results.sort(key=lambda x: x["filename"].lower(), reverse=True)
|
||||
elif sort_by == "date_asc":
|
||||
results.sort(key=lambda x: x["mtime"])
|
||||
else: # date_desc (default)
|
||||
results.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_image_metadata(self, image_path: str) -> dict:
|
||||
"""PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す"""
|
||||
path = Path(image_path).resolve()
|
||||
if not path.is_file():
|
||||
return {"error": "File not found"}
|
||||
if not self._check_path_allowed(path):
|
||||
return {"error": "Access denied"}
|
||||
|
||||
embedded = {}
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".png":
|
||||
embedded = self._read_png_metadata(path)
|
||||
elif ext in {".jpg", ".jpeg"}:
|
||||
embedded = self._read_jpeg_metadata(path)
|
||||
|
||||
saved = self.metadata_store.get(str(path))
|
||||
|
||||
# workflow キーを除く文字列フィールドをプロンプトキャッシュとして保存(検索用)
|
||||
prompt_text = " ".join(
|
||||
str(v) for k, v in embedded.items()
|
||||
if k != "workflow" and isinstance(v, str) and len(v) < 50_000
|
||||
).strip()
|
||||
if prompt_text and prompt_text != saved.get("prompt_cache", ""):
|
||||
self.metadata_store.save(str(path), {"prompt_cache": prompt_text})
|
||||
|
||||
stat = path.stat()
|
||||
return {
|
||||
"filename": path.name,
|
||||
"path": str(path).replace("\\", "/"),
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"ext": ext,
|
||||
"embedded": embedded,
|
||||
"favorite": saved.get("favorite", False),
|
||||
"tags": saved.get("tags", []),
|
||||
"memo": saved.get("memo", ""),
|
||||
"groups": saved.get("groups", []),
|
||||
}
|
||||
|
||||
def _read_png_metadata(self, path: Path) -> dict:
|
||||
"""PNGのtEXtチャンクからメタデータを抽出"""
|
||||
result = {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
sig = f.read(8)
|
||||
if sig != b"\x89PNG\r\n\x1a\n":
|
||||
return result
|
||||
while True:
|
||||
chunk_len_bytes = f.read(4)
|
||||
if len(chunk_len_bytes) < 4:
|
||||
break
|
||||
chunk_len = struct.unpack(">I", chunk_len_bytes)[0]
|
||||
chunk_type = f.read(4).decode("ascii", errors="ignore")
|
||||
if chunk_len > _PNG_CHUNK_MAX:
|
||||
f.seek(chunk_len + 4, 1)
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
continue
|
||||
chunk_data = f.read(chunk_len)
|
||||
f.read(4) # CRC
|
||||
|
||||
if chunk_type in ("tEXt", "iTXt"):
|
||||
try:
|
||||
if chunk_type == "tEXt":
|
||||
null_idx = chunk_data.index(b"\x00")
|
||||
key = chunk_data[:null_idx].decode("latin-1")
|
||||
value = chunk_data[null_idx + 1:].decode("latin-1")
|
||||
else:
|
||||
null_idx = chunk_data.index(b"\x00")
|
||||
key = chunk_data[:null_idx].decode("utf-8")
|
||||
rest = chunk_data[null_idx + 1:]
|
||||
rest = rest[2:]
|
||||
null_idx2 = rest.index(b"\x00")
|
||||
rest = rest[null_idx2 + 1:]
|
||||
null_idx3 = rest.index(b"\x00")
|
||||
value = rest[null_idx3 + 1:].decode("utf-8", errors="replace")
|
||||
result[key] = value
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("PNG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_jpeg_metadata(self, path: Path) -> dict:
|
||||
"""JPEGのEXIF/commentからメタデータを抽出(簡易)"""
|
||||
result = {}
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
read_size = min(file_size, _JPEG_READ_MAX)
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(read_size)
|
||||
idx = 0
|
||||
while idx < len(data) - 3:
|
||||
if data[idx] == 0xFF:
|
||||
marker = data[idx + 1]
|
||||
if marker == 0xFE: # COM
|
||||
length = struct.unpack(">H", data[idx + 2:idx + 4])[0]
|
||||
end = idx + 2 + length
|
||||
comment = data[idx + 4:end].decode("utf-8", errors="replace")
|
||||
result["Comment"] = comment
|
||||
break
|
||||
idx += 2
|
||||
else:
|
||||
idx += 1
|
||||
except Exception as e:
|
||||
logger.debug("JPEG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
|
||||
"""ワークフローを抽出する。
|
||||
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
|
||||
"""
|
||||
path = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(path):
|
||||
return None
|
||||
|
||||
if path.suffix.lower() == ".png":
|
||||
embedded = self._read_png_metadata(path)
|
||||
# 1. workflow キー (ComfyUI UI形式)
|
||||
for key in ("workflow", "Workflow"):
|
||||
s = embedded.get(key)
|
||||
if s:
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 2. prompt キー (ComfyUI API形式 — 大多数の生成画像)
|
||||
for key in ("prompt", "Prompt"):
|
||||
s = embedded.get(key)
|
||||
if s:
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 3. gallery_metadata.json に保存されたworkflow
|
||||
saved = self.metadata_store.get(str(path))
|
||||
return saved.get("workflow") or None
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# メタデータ保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def save_image_meta(self, image_path: str, data: dict) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
return self.metadata_store.save(image_path, data)
|
||||
|
||||
def toggle_favorite(self, image_path: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
new_val = not meta.get("favorite", False)
|
||||
self.metadata_store.save(image_path, {"favorite": new_val})
|
||||
return new_val
|
||||
|
||||
def bulk_set_favorite(self, paths: list[str], value: bool) -> dict:
|
||||
"""複数画像のお気に入りを一括設定する"""
|
||||
ok = fail = 0
|
||||
for p in paths:
|
||||
if not self._check_path_allowed(Path(p).resolve()):
|
||||
fail += 1
|
||||
continue
|
||||
if self.metadata_store.save(p, {"favorite": value}):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
return {"ok": ok, "fail": fail}
|
||||
|
||||
def bulk_group_op(self, paths: list[str], group_name: str, action: str = "add") -> dict:
|
||||
"""複数画像のグループ追加 / 削除を一括処理する。action は "add" または "remove"。"""
|
||||
ok = fail = 0
|
||||
for p in paths:
|
||||
success = self.add_to_group(p, group_name) if action == "add" else self.remove_from_group(p, group_name)
|
||||
if success:
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
return {"ok": ok, "fail": fail}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return self.metadata_store.list_groups()
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
return self.metadata_store.create_group(name)
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
return self.metadata_store.rename_group(old_name, new_name)
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
return self.metadata_store.delete_group(name)
|
||||
|
||||
def add_to_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = meta.get("groups", [])
|
||||
if group_name not in groups:
|
||||
groups.append(group_name)
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
return True
|
||||
|
||||
def remove_from_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = [g for g in meta.get("groups", []) if g != group_name]
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
all_paths = self.metadata_store.list_images_in_group(group_name)
|
||||
existing = [p for p in all_paths if Path(p).is_file()]
|
||||
stale = [p for p in all_paths if not Path(p).is_file()]
|
||||
if stale:
|
||||
self.metadata_store.remove_stale_paths_from_group(group_name, stale)
|
||||
logger.info("Cleaned up %d stale paths from group '%s'", len(stale), group_name)
|
||||
return existing
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
return self.metadata_store.clear_group(group_name)
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
return self.metadata_store.ensure_group(name)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像配信
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def create_folder(self, parent_path: str, name: str) -> dict:
|
||||
"""選択フォルダ内に新しいサブフォルダを作成する"""
|
||||
parent = Path(parent_path).resolve()
|
||||
if not parent.is_dir():
|
||||
return {"ok": False, "error": "Parent directory not found"}
|
||||
if not self._check_path_allowed(parent):
|
||||
return {"ok": False, "error": "Access denied"}
|
||||
name = name.strip()
|
||||
invalid_chars = set(r'\/:*?"<>|')
|
||||
if not name or any(c in invalid_chars for c in name):
|
||||
return {"ok": False, "error": "Invalid folder name"}
|
||||
new_folder = parent / name
|
||||
if new_folder.exists():
|
||||
return {"ok": False, "error": "Folder already exists"}
|
||||
try:
|
||||
new_folder.mkdir()
|
||||
self._folder_cache.invalidate(parent)
|
||||
return {"ok": True, "path": str(new_folder).replace("\\", "/")}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def delete_folder(self, folder_path: str) -> dict:
|
||||
"""フォルダを再帰的に削除する"""
|
||||
import shutil
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return {"ok": False, "error": "Directory not found"}
|
||||
if not self._check_path_allowed(folder):
|
||||
return {"ok": False, "error": "Access denied"}
|
||||
if self._allowed_root and folder == self._allowed_root:
|
||||
return {"ok": False, "error": "Cannot delete root folder"}
|
||||
try:
|
||||
parent = folder.parent
|
||||
shutil.rmtree(folder)
|
||||
self._folder_cache.invalidate(parent)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def delete_images(self, paths: list) -> dict:
|
||||
"""画像ファイルを削除する(複数対応)"""
|
||||
deleted = []
|
||||
errors = []
|
||||
for img_path in paths:
|
||||
p = Path(img_path).resolve()
|
||||
if not p.is_file():
|
||||
errors.append(f"{Path(img_path).name}: not found")
|
||||
continue
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
errors.append(f"{p.name}: not an image")
|
||||
continue
|
||||
if not self._check_path_allowed(p):
|
||||
errors.append(f"{p.name}: access denied")
|
||||
continue
|
||||
try:
|
||||
p.unlink()
|
||||
self.metadata_store.delete(str(p))
|
||||
self._folder_cache.invalidate(p.parent)
|
||||
deleted.append(str(p).replace("\\", "/"))
|
||||
except Exception as e:
|
||||
errors.append(f"{p.name}: {e}")
|
||||
return {"deleted": deleted, "errors": errors}
|
||||
|
||||
def move_images(self, paths: list, dest_folder: str) -> dict:
|
||||
"""画像ファイルを別フォルダへ移動する(複数対応)"""
|
||||
dest = Path(dest_folder).resolve()
|
||||
if not dest.is_dir():
|
||||
return {"ok": False, "error": "Destination folder not found", "moved": [], "errors": []}
|
||||
if not self._check_path_allowed(dest):
|
||||
return {"ok": False, "error": "Access denied", "moved": [], "errors": []}
|
||||
moved = []
|
||||
errors = []
|
||||
for img_path in paths:
|
||||
p = Path(img_path).resolve()
|
||||
if not p.is_file():
|
||||
errors.append(f"{Path(img_path).name}: not found")
|
||||
continue
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
errors.append(f"{p.name}: not an image")
|
||||
continue
|
||||
if not self._check_path_allowed(p):
|
||||
errors.append(f"{p.name}: access denied")
|
||||
continue
|
||||
dest_path = dest / p.name
|
||||
counter = 1
|
||||
while dest_path.exists():
|
||||
dest_path = dest / f"{p.stem}_{counter}{p.suffix}"
|
||||
counter += 1
|
||||
try:
|
||||
p.rename(dest_path)
|
||||
old_str = str(p).replace("\\", "/")
|
||||
new_str = str(dest_path).replace("\\", "/")
|
||||
self.metadata_store.rename_path(old_str, new_str)
|
||||
self._folder_cache.invalidate(p.parent)
|
||||
self._folder_cache.invalidate(dest)
|
||||
moved.append({"from": old_str, "to": new_str, "filename": dest_path.name})
|
||||
except Exception as e:
|
||||
errors.append(f"{p.name}: {e}")
|
||||
return {"moved": moved, "errors": errors}
|
||||
|
||||
def serve_image(self, image_path: str):
|
||||
"""画像のPathオブジェクトを返す(ルートで使用)"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
return None
|
||||
if not self._check_path_allowed(p):
|
||||
logger.warning("serve_image: path not allowed: %s", p)
|
||||
return None
|
||||
return p
|
||||
|
||||
def serve_thumbnail(self, image_path: str, width: int = 256) -> Path | None:
|
||||
"""縮小サムネイルのPathを返す。
|
||||
ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。
|
||||
GIFはアニメーション保持のため元ファイルをそのまま返す。
|
||||
Pillowが使えない場合は元ファイルにフォールバック。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
return None
|
||||
if not self._check_path_allowed(p):
|
||||
logger.warning("serve_thumbnail: path not allowed: %s", p)
|
||||
return None
|
||||
|
||||
# GIF はアニメーション保持のためそのまま返す
|
||||
if p.suffix.lower() == ".gif":
|
||||
return p
|
||||
|
||||
# SVG はベクター画像でPillowが開けないため、元ファイルをそのまま返す
|
||||
# (<img>タグはブラウザ側で任意サイズにスケーリングして表示する)
|
||||
if p.suffix.lower() == ".svg":
|
||||
return p
|
||||
|
||||
try:
|
||||
mtime = int(p.stat().st_mtime * 1000)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
cache_key = hashlib.md5(f"{p}:{mtime}:{width}".encode()).hexdigest()
|
||||
cache_dir = self.data_dir / "thumb_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
thumb_path = cache_dir / f"{cache_key}.jpg"
|
||||
|
||||
if thumb_path.exists():
|
||||
return thumb_path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(p) as img:
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((width, width), Image.LANCZOS)
|
||||
img.save(thumb_path, "JPEG", quality=85, optimize=True)
|
||||
return thumb_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_thumbnail: failed for %s: %s", p, e)
|
||||
return p # フォールバック: 元ファイル
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Model metadata management service."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import MODEL_METADATA_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCAN_CACHE_TTL = 30 # seconds — reuse scan result within this window
|
||||
|
||||
# Preview image extensions to search for (in priority order)
|
||||
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
|
||||
".png", ".jpg", ".jpeg", ".webp"]
|
||||
|
||||
# All sidecar extensions to delete when a model is deleted
|
||||
_SIDECAR_EXTENSIONS = [
|
||||
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
|
||||
".png", ".jpg", ".jpeg", ".webp",
|
||||
".metadata.json", ".cm-info.json", ".json", ".civitai.info", ".info",
|
||||
]
|
||||
|
||||
_DISABLED_SUFFIX = ".disabled"
|
||||
|
||||
_MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".pt2"}
|
||||
|
||||
# ComfyUI model type → folder_paths key mapping
|
||||
MODEL_TYPE_FOLDER_KEYS = {
|
||||
"checkpoint": "checkpoints",
|
||||
"lora": "loras",
|
||||
"vae": "vae",
|
||||
"controlnet": "controlnet",
|
||||
"unet": "diffusion_models",
|
||||
"textencoder": "text_encoders",
|
||||
"hypernetwork": "hypernetworks",
|
||||
"embedding": "embeddings",
|
||||
}
|
||||
|
||||
|
||||
def _get_model_dirs(model_type):
|
||||
"""Get all model directories for a type using ComfyUI's folder_paths.
|
||||
|
||||
Returns a list of Path objects for all configured model directories
|
||||
(includes extra_model_paths.yaml settings).
|
||||
Falls back to plugin-relative path if folder_paths is unavailable.
|
||||
"""
|
||||
folder_key = MODEL_TYPE_FOLDER_KEYS.get(model_type)
|
||||
if not folder_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
import folder_paths # type: ignore # ComfyUI module
|
||||
paths = folder_paths.get_folder_paths(folder_key)
|
||||
result = [Path(p) for p in paths if Path(p).is_dir()]
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug("folder_paths unavailable (%s), using fallback", e)
|
||||
|
||||
# Fallback: custom_nodes/../../models/{folder_key}
|
||||
plugin_dir = Path(__file__).resolve().parent.parent.parent
|
||||
models_dir = plugin_dir.parent.parent / "models" / folder_key
|
||||
if models_dir.is_dir():
|
||||
return [models_dir]
|
||||
return []
|
||||
|
||||
|
||||
class ModelsService:
|
||||
"""Manages user-defined model metadata (favorites, tags, memo)."""
|
||||
|
||||
def __init__(self):
|
||||
self.metadata_file = MODEL_METADATA_FILE
|
||||
self._scan_cache: dict = {} # model_type -> (timestamp, frozenset)
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def _load_metadata(self):
|
||||
if self.metadata_file.exists():
|
||||
try:
|
||||
with open(self.metadata_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_metadata(self, data):
|
||||
self.metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.metadata_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_all_metadata(self):
|
||||
return self._load_metadata()
|
||||
|
||||
def update_metadata(self, model_name, updates):
|
||||
data = self._load_metadata()
|
||||
if model_name not in data:
|
||||
data[model_name] = {"tags": [], "favorite": False, "memo": ""}
|
||||
entry = data[model_name]
|
||||
if "tags" in updates:
|
||||
entry["tags"] = updates["tags"]
|
||||
if "favorite" in updates:
|
||||
entry["favorite"] = updates["favorite"]
|
||||
if "memo" in updates:
|
||||
entry["memo"] = updates["memo"]
|
||||
if "sha256" in updates:
|
||||
entry["sha256"] = updates["sha256"]
|
||||
if "badges" in updates:
|
||||
entry["badges"] = updates["badges"]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
data[model_name] = entry
|
||||
self._save_metadata(data)
|
||||
return entry
|
||||
|
||||
def _scan_model_names(self, model_type: str) -> set:
|
||||
"""モデルタイプの全ファイル名をスキャンして返す(相対パス、/区切り、.disabled除く)。
|
||||
結果は30秒キャッシュされ、起動時の多重スキャンによる高負荷を防ぐ。"""
|
||||
now = time.monotonic()
|
||||
cached = self._scan_cache.get(model_type)
|
||||
if cached is not None:
|
||||
ts, names = cached
|
||||
if now - ts < _SCAN_CACHE_TTL:
|
||||
return names
|
||||
|
||||
dirs = _get_model_dirs(model_type)
|
||||
names = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
rel = rel[: -len(_DISABLED_SUFFIX)]
|
||||
names.add(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self._scan_cache[model_type] = (now, names)
|
||||
return names
|
||||
|
||||
def list_model_files(self, model_type: str) -> list:
|
||||
"""モデルタイプのモデルファイル名をソートして返す(拡張子付き、/区切り)。
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
seen = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
name = f.name
|
||||
if name.endswith(_DISABLED_SUFFIX):
|
||||
name = name[: -len(_DISABLED_SUFFIX)]
|
||||
if Path(name).suffix.lower() not in _MODEL_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
rel = rel[: -len(_DISABLED_SUFFIX)]
|
||||
seen.add(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
return sorted(seen)
|
||||
|
||||
def get_model_groups(self, model_type=None):
|
||||
data = self._load_metadata()
|
||||
raw = data.get("_groups", {})
|
||||
|
||||
# Migrate old flat format { "groupName": [...] } → per-type { "checkpoint": {...} }
|
||||
if raw and not any(k in MODEL_TYPE_FOLDER_KEYS for k in raw):
|
||||
logger.info("Migrating groups to per-type format (old data moved to _groups_legacy)")
|
||||
data["_groups_legacy"] = raw
|
||||
data["_groups"] = {}
|
||||
self._save_metadata(data)
|
||||
raw = {}
|
||||
|
||||
if model_type:
|
||||
groups = raw.get(model_type, {})
|
||||
|
||||
# Guard: if no model directories are accessible (e.g. network drive disconnected),
|
||||
# skip cleanup entirely to prevent mass-deletion of group members.
|
||||
accessible_dirs = [d for d in _get_model_dirs(model_type) if d.is_dir()]
|
||||
if not accessible_dirs:
|
||||
configured_dirs = _get_model_dirs(model_type)
|
||||
if configured_dirs:
|
||||
logger.warning(
|
||||
"No accessible directories for model_type=%s — skipping group cleanup "
|
||||
"to avoid data loss (drives may be disconnected)", model_type
|
||||
)
|
||||
return {g: [m.replace("\\", "/") for m in members]
|
||||
for g, members in groups.items()}
|
||||
# No directories configured at all — return as-is (nothing to validate against)
|
||||
return {g: [m.replace("\\", "/") for m in members] for g, members in groups.items()}
|
||||
|
||||
valid_names = self._scan_model_names(model_type)
|
||||
cleaned = {}
|
||||
dirty = False
|
||||
for g_name, members in groups.items():
|
||||
# Normalize backslashes (Windows ComfyUI paths) to forward slashes
|
||||
normalized = [m.replace("\\", "/") for m in members]
|
||||
filtered = [m for m in normalized if m in valid_names]
|
||||
cleaned[g_name] = filtered
|
||||
if len(filtered) != len(members) or normalized != list(members):
|
||||
dirty = True
|
||||
if dirty:
|
||||
removed = sum(len(groups[g]) - len(cleaned[g]) for g in groups)
|
||||
if removed > 0:
|
||||
logger.info("Cleaned up %d stale group entries for model_type=%s", removed, model_type)
|
||||
if not isinstance(data.get("_groups"), dict):
|
||||
data["_groups"] = {}
|
||||
data["_groups"][model_type] = cleaned
|
||||
self._save_metadata(data)
|
||||
return cleaned
|
||||
return raw
|
||||
|
||||
def save_model_groups(self, groups, model_type):
|
||||
# Normalize all member names to forward slashes (ComfyUI on Windows uses backslashes)
|
||||
normalized = {g: [m.replace("\\", "/") for m in members] for g, members in groups.items()}
|
||||
data = self._load_metadata()
|
||||
if not isinstance(data.get("_groups"), dict):
|
||||
data["_groups"] = {}
|
||||
data["_groups"][model_type] = normalized
|
||||
self._save_metadata(data)
|
||||
# Also invalidate scan cache so next get_model_groups sees the fresh data
|
||||
self._scan_cache.pop(model_type, None)
|
||||
return normalized
|
||||
|
||||
def find_model_file(self, model_type, model_name):
|
||||
"""Find a model file, checking both enabled and disabled states.
|
||||
|
||||
Returns (Path, is_enabled) or (None, None) if not found.
|
||||
"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
for d in dirs:
|
||||
enabled = d / model_name
|
||||
if enabled.is_file():
|
||||
return enabled, True
|
||||
disabled = d / (model_name + _DISABLED_SUFFIX)
|
||||
if disabled.is_file():
|
||||
return disabled, False
|
||||
return None, None
|
||||
|
||||
def enable_model(self, model_type, model_name):
|
||||
"""Rename model.safetensors.disabled → model.safetensors."""
|
||||
path, is_enabled = self.find_model_file(model_type, model_name)
|
||||
if path is None:
|
||||
raise FileNotFoundError(f"Model not found: {model_name}")
|
||||
if is_enabled:
|
||||
return
|
||||
target = path.parent / path.name[: -len(_DISABLED_SUFFIX)]
|
||||
path.rename(target)
|
||||
logger.info("Enabled model: %s", model_name)
|
||||
|
||||
def disable_model(self, model_type, model_name):
|
||||
"""Rename model.safetensors → model.safetensors.disabled."""
|
||||
path, is_enabled = self.find_model_file(model_type, model_name)
|
||||
if path is None:
|
||||
raise FileNotFoundError(f"Model not found: {model_name}")
|
||||
if not is_enabled:
|
||||
return
|
||||
target = path.parent / (path.name + _DISABLED_SUFFIX)
|
||||
path.rename(target)
|
||||
logger.info("Disabled model: %s", model_name)
|
||||
|
||||
def scan_disabled_models(self, model_type):
|
||||
"""Scan directories for .disabled files.
|
||||
|
||||
Returns list of normalized model names (without .disabled suffix).
|
||||
"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
disabled = []
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
|
||||
try:
|
||||
rel = str(f.relative_to(d))
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
rel = rel[: -len(_DISABLED_SUFFIX)]
|
||||
disabled.append(rel.replace("\\", "/"))
|
||||
except ValueError:
|
||||
pass
|
||||
return disabled
|
||||
|
||||
def find_preview_image(self, model_type, model_name):
|
||||
"""Find preview image for a model file.
|
||||
|
||||
Searches all configured directories for the model type
|
||||
(via ComfyUI's folder_paths, which includes extra_model_paths.yaml).
|
||||
|
||||
Looks for files like:
|
||||
modelname.preview.png, modelname.png, etc.
|
||||
next to the model file.
|
||||
|
||||
Returns: absolute Path to preview image, or None.
|
||||
"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
if not dirs:
|
||||
logger.debug("Preview: no dirs for model_type=%s", model_type)
|
||||
return None
|
||||
|
||||
for type_dir in dirs:
|
||||
# model_name can include subdirectory (e.g., "subdir/model.safetensors")
|
||||
model_path = type_dir / model_name
|
||||
if not model_path.is_file():
|
||||
continue
|
||||
|
||||
stem = model_path.stem
|
||||
parent = model_path.parent
|
||||
|
||||
for ext in _PREVIEW_EXTENSIONS:
|
||||
preview = parent / (stem + ext)
|
||||
if preview.is_file() and preview.stat().st_size >= 100:
|
||||
logger.debug("Preview found: %s", preview)
|
||||
return preview
|
||||
|
||||
logger.debug("Preview: no preview for %s (stem=%s, dir=%s)",
|
||||
model_name, stem, parent)
|
||||
return None
|
||||
|
||||
logger.debug("Preview: model file not found in any dir: %s", model_name)
|
||||
return None
|
||||
|
||||
def delete_model(self, model_type, model_name):
|
||||
"""Delete a model file and all associated preview/sidecar files.
|
||||
|
||||
Also removes the model's metadata entry.
|
||||
Returns dict with deleted file paths.
|
||||
"""
|
||||
if ".." in model_name:
|
||||
raise ValueError(f"Invalid model name: {model_name}")
|
||||
|
||||
path, _ = self.find_model_file(model_type, model_name)
|
||||
if path is None:
|
||||
raise FileNotFoundError(f"Model not found: {model_name}")
|
||||
|
||||
deleted = []
|
||||
parent = path.parent
|
||||
|
||||
# Compute stem from the original (non-disabled) filename.
|
||||
# path.name for a disabled file is e.g. "model.safetensors.disabled"
|
||||
# so we strip the .disabled suffix first, then take Path.stem.
|
||||
orig_name = path.name
|
||||
if orig_name.endswith(_DISABLED_SUFFIX):
|
||||
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
|
||||
stem = Path(orig_name).stem # "model.safetensors" -> "model"
|
||||
|
||||
# Delete the model file itself
|
||||
path.unlink()
|
||||
deleted.append(str(path))
|
||||
logger.info("Deleted model file: %s", path)
|
||||
|
||||
# Delete all sidecar files (previews, metadata, info)
|
||||
for ext in _SIDECAR_EXTENSIONS:
|
||||
sidecar = parent / (stem + ext)
|
||||
if sidecar.is_file():
|
||||
sidecar.unlink()
|
||||
deleted.append(str(sidecar))
|
||||
logger.info("Deleted sidecar: %s", sidecar)
|
||||
|
||||
# Remove metadata entry
|
||||
data = self._load_metadata()
|
||||
if model_name in data:
|
||||
del data[model_name]
|
||||
self._save_metadata(data)
|
||||
|
||||
return {"deleted": deleted}
|
||||
|
||||
def get_subdirs(self, model_type):
|
||||
"""Return sorted list of root-level subdirectory names for a model type."""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
subdirs = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for item in d.iterdir():
|
||||
if item.is_dir():
|
||||
subdirs.add(item.name)
|
||||
return sorted(subdirs)
|
||||
|
||||
def move_models(self, model_type, model_names, dest_subdir):
|
||||
"""Move model files + sidecar files to dest_subdir (root-level subfolder name).
|
||||
|
||||
dest_subdir: "" = model root, "sdxl" = <root>/sdxl/
|
||||
Creates the destination directory if it does not exist.
|
||||
Updates metadata keys to reflect new paths.
|
||||
Returns {"moved": [{"from": ..., "to": ...}], "errors": [...]}
|
||||
"""
|
||||
# Validate dest_subdir: must be a plain folder name (no separators, no "..", not absolute)
|
||||
if dest_subdir:
|
||||
if ".." in dest_subdir:
|
||||
return {"moved": [], "errors": [{"model": "*", "error": "Invalid destination: '..' not allowed"}]}
|
||||
if "/" in dest_subdir or "\\" in dest_subdir:
|
||||
return {"moved": [], "errors": [{"model": "*", "error": "Invalid destination: must be a single folder name"}]}
|
||||
if Path(dest_subdir).is_absolute():
|
||||
return {"moved": [], "errors": [{"model": "*", "error": "Invalid destination: absolute paths not allowed"}]}
|
||||
|
||||
moved = []
|
||||
errors = []
|
||||
model_dirs = _get_model_dirs(model_type)
|
||||
|
||||
meta_data = self._load_metadata()
|
||||
meta_changed = False
|
||||
renames = [] # [(old_name, new_name)]
|
||||
|
||||
for model_name in model_names:
|
||||
if ".." in model_name:
|
||||
errors.append({"model": model_name, "error": "Invalid model name"})
|
||||
continue
|
||||
|
||||
path, _is_enabled = self.find_model_file(model_type, model_name)
|
||||
if path is None:
|
||||
errors.append({"model": model_name, "error": f"Model not found: {model_name}"})
|
||||
continue
|
||||
|
||||
try:
|
||||
# Determine which root dir this model lives in
|
||||
root_dir = None
|
||||
for d in model_dirs:
|
||||
try:
|
||||
path.relative_to(d)
|
||||
root_dir = d
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if root_dir is None:
|
||||
errors.append({"model": model_name, "error": "Cannot determine root directory"})
|
||||
continue
|
||||
|
||||
# Destination directory — verify it stays within root_dir after resolution
|
||||
dest_dir = root_dir / dest_subdir if dest_subdir else root_dir
|
||||
try:
|
||||
dest_dir.resolve().relative_to(root_dir.resolve())
|
||||
except ValueError:
|
||||
errors.append({"model": model_name, "error": "Destination is outside model root"})
|
||||
continue
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Original filename without .disabled suffix
|
||||
orig_name = path.name
|
||||
if orig_name.endswith(_DISABLED_SUFFIX):
|
||||
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
|
||||
stem = Path(orig_name).stem
|
||||
|
||||
new_path = dest_dir / path.name
|
||||
if new_path.resolve() == path.resolve():
|
||||
errors.append({"model": model_name, "error": "Already in destination"})
|
||||
continue
|
||||
|
||||
# Refuse to overwrite an existing file
|
||||
if new_path.exists():
|
||||
errors.append({"model": model_name, "error": f"Destination already exists: {path.name}"})
|
||||
continue
|
||||
|
||||
# Move the model file
|
||||
shutil.move(str(path), str(new_path))
|
||||
logger.info("Moved model: %s → %s", path, new_path)
|
||||
|
||||
# Move all sidecar files (skip if destination already exists)
|
||||
for ext in _SIDECAR_EXTENSIONS:
|
||||
sidecar = path.parent / (stem + ext)
|
||||
if sidecar.is_file():
|
||||
sidecar_dest = dest_dir / (stem + ext)
|
||||
if sidecar_dest.exists():
|
||||
logger.warning("Sidecar destination exists, skipping: %s", sidecar_dest)
|
||||
continue
|
||||
try:
|
||||
shutil.move(str(sidecar), str(sidecar_dest))
|
||||
except Exception as se:
|
||||
logger.warning("Could not move sidecar %s: %s", sidecar, se)
|
||||
|
||||
# Compute new logical model name (relative to root, forward slashes)
|
||||
new_rel = str(new_path.relative_to(root_dir)).replace("\\", "/")
|
||||
if new_rel.endswith(_DISABLED_SUFFIX):
|
||||
new_rel = new_rel[: -len(_DISABLED_SUFFIX)]
|
||||
|
||||
# Update metadata key
|
||||
if model_name in meta_data:
|
||||
meta_data[new_rel] = meta_data.pop(model_name)
|
||||
meta_changed = True
|
||||
|
||||
moved.append({"from": model_name, "to": new_rel})
|
||||
renames.append((model_name, new_rel))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error moving model %s: %s", model_name, e)
|
||||
errors.append({"model": model_name, "error": str(e)})
|
||||
|
||||
# Update group entries for renamed/moved models
|
||||
if renames:
|
||||
groups_for_type = meta_data.get("_groups", {}).get(model_type, {})
|
||||
groups_dirty = False
|
||||
for old_name, new_name in renames:
|
||||
for members in groups_for_type.values():
|
||||
for i, m in enumerate(members):
|
||||
if m == old_name:
|
||||
members[i] = new_name
|
||||
groups_dirty = True
|
||||
if groups_dirty:
|
||||
if not isinstance(meta_data.get("_groups"), dict):
|
||||
meta_data["_groups"] = {}
|
||||
meta_data["_groups"][model_type] = groups_for_type
|
||||
meta_changed = True
|
||||
|
||||
if meta_changed:
|
||||
self._save_metadata(meta_data)
|
||||
|
||||
return {"moved": moved, "errors": errors}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Node metadata and node sets management service."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..config import NODE_METADATA_FILE, NODE_SETS_FILE
|
||||
|
||||
|
||||
class NodesService:
|
||||
"""Manages user-defined node metadata (tags, favorites, groups) and node sets."""
|
||||
|
||||
def __init__(self):
|
||||
self.node_metadata_file = NODE_METADATA_FILE
|
||||
self.node_sets_file = NODE_SETS_FILE
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ── Node Metadata ──────────────────────────────────────────
|
||||
|
||||
def _load_node_metadata(self):
|
||||
if self.node_metadata_file.exists():
|
||||
try:
|
||||
with open(self.node_metadata_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_node_metadata(self, data):
|
||||
self.node_metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.node_metadata_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_all_metadata(self):
|
||||
return self._load_node_metadata()
|
||||
|
||||
def update_node_metadata(self, node_name, updates):
|
||||
data = self._load_node_metadata()
|
||||
if node_name not in data:
|
||||
data[node_name] = {"tags": [], "favorite": False}
|
||||
entry = data[node_name]
|
||||
if "tags" in updates:
|
||||
entry["tags"] = updates["tags"]
|
||||
if "favorite" in updates:
|
||||
entry["favorite"] = updates["favorite"]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
data[node_name] = entry
|
||||
self._save_node_metadata(data)
|
||||
return entry
|
||||
|
||||
# ── Node Groups ────────────────────────────────────────────
|
||||
|
||||
def get_node_groups(self):
|
||||
data = self._load_node_metadata()
|
||||
return data.get("_groups", {})
|
||||
|
||||
def save_node_groups(self, groups):
|
||||
data = self._load_node_metadata()
|
||||
data["_groups"] = groups
|
||||
self._save_node_metadata(data)
|
||||
return groups
|
||||
|
||||
# ── Node Sets ──────────────────────────────────────────────
|
||||
|
||||
def _load_node_sets(self):
|
||||
if self.node_sets_file.exists():
|
||||
try:
|
||||
with open(self.node_sets_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _save_node_sets(self, sets):
|
||||
self.node_sets_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.node_sets_file, "w", encoding="utf-8") as f:
|
||||
json.dump(sets, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def list_node_sets(self):
|
||||
return self._load_node_sets()
|
||||
|
||||
def create_node_set(self, set_data):
|
||||
sets = self._load_node_sets()
|
||||
set_data["id"] = str(uuid.uuid4())
|
||||
set_data["created_at"] = self._now_iso()
|
||||
sets.append(set_data)
|
||||
self._save_node_sets(sets)
|
||||
return set_data
|
||||
|
||||
def update_node_set(self, set_id, updates):
|
||||
sets = self._load_node_sets()
|
||||
for s in sets:
|
||||
if s["id"] == set_id:
|
||||
for key in ("name", "description", "tags", "nodes", "links"):
|
||||
if key in updates:
|
||||
s[key] = updates[key]
|
||||
s["updated_at"] = self._now_iso()
|
||||
self._save_node_sets(sets)
|
||||
return s
|
||||
return None
|
||||
|
||||
def delete_node_set(self, set_id):
|
||||
sets = self._load_node_sets()
|
||||
sets = [s for s in sets if s["id"] != set_id]
|
||||
self._save_node_sets(sets)
|
||||
|
||||
def export_node_set_json(self, set_id):
|
||||
"""Export a node set as ComfyUI-compatible partial workflow JSON."""
|
||||
sets = self._load_node_sets()
|
||||
target = None
|
||||
for s in sets:
|
||||
if s["id"] == set_id:
|
||||
target = s
|
||||
break
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
nodes = target.get("nodes", [])
|
||||
links = target.get("links", [])
|
||||
|
||||
# Build prompt-format dict
|
||||
prompt = {}
|
||||
base_id = 100
|
||||
for i, node in enumerate(nodes):
|
||||
node_id = str(base_id + i)
|
||||
inputs = {}
|
||||
# Apply links: set linked inputs as [source_node_id, slot]
|
||||
for link in links:
|
||||
if link.get("to_node") == i:
|
||||
input_name = link.get("to_input", "")
|
||||
from_id = str(base_id + link["from_node"])
|
||||
inputs[input_name] = [from_id, link.get("from_slot", 0)]
|
||||
prompt[node_id] = {
|
||||
"class_type": node["class_type"],
|
||||
"inputs": inputs,
|
||||
}
|
||||
if node.get("title"):
|
||||
prompt[node_id]["_meta"] = {"title": node["title"]}
|
||||
|
||||
return {
|
||||
"name": target.get("name", ""),
|
||||
"prompt": prompt,
|
||||
"node_count": len(nodes),
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Prompt presets management service."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..config import PROMPTS_FILE
|
||||
|
||||
|
||||
class PromptsService:
|
||||
"""Manages prompt presets (CRUD, categories, favorites)."""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_file = PROMPTS_FILE
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ── Persistence ─────────────────────────────────────────
|
||||
|
||||
def _load(self):
|
||||
if self.prompts_file.exists():
|
||||
try:
|
||||
with open(self.prompts_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _save(self, prompts):
|
||||
self.prompts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.prompts_file, "w", encoding="utf-8") as f:
|
||||
json.dump(prompts, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── List ────────────────────────────────────────────────
|
||||
|
||||
def list_prompts(self):
|
||||
return self._load()
|
||||
|
||||
# ── Create ──────────────────────────────────────────────
|
||||
|
||||
def create_prompt(self, data):
|
||||
prompts = self._load()
|
||||
prompt = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": data.get("name", "Untitled"),
|
||||
"text": data.get("text", ""),
|
||||
"negText": data.get("negText", ""),
|
||||
"category": data.get("category", ""),
|
||||
"tags": data.get("tags", []),
|
||||
"favorite": data.get("favorite", False),
|
||||
"created_at": self._now_iso(),
|
||||
"updated_at": self._now_iso(),
|
||||
}
|
||||
prompts.append(prompt)
|
||||
self._save(prompts)
|
||||
return prompt
|
||||
|
||||
# ── Update ──────────────────────────────────────────────
|
||||
|
||||
def update_prompt(self, prompt_id, updates):
|
||||
prompts = self._load()
|
||||
for p in prompts:
|
||||
if p["id"] == prompt_id:
|
||||
for key in ("name", "text", "negText", "category", "tags", "favorite"):
|
||||
if key in updates:
|
||||
p[key] = updates[key]
|
||||
p["updated_at"] = self._now_iso()
|
||||
self._save(prompts)
|
||||
return p
|
||||
return None
|
||||
|
||||
# ── Delete ──────────────────────────────────────────────
|
||||
|
||||
def delete_prompt(self, prompt_id):
|
||||
prompts = self._load()
|
||||
prompts = [p for p in prompts if p["id"] != prompt_id]
|
||||
self._save(prompts)
|
||||
|
||||
# ── Categories (derived) ────────────────────────────────
|
||||
|
||||
def list_categories(self):
|
||||
prompts = self._load()
|
||||
cats = set()
|
||||
for p in prompts:
|
||||
c = p.get("category", "").strip()
|
||||
if c:
|
||||
cats.add(c)
|
||||
return sorted(cats)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tagger タグ DB サービス(SQLite)。"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaggerDbService:
|
||||
def __init__(self, db_file: Path):
|
||||
self.db_file = db_file
|
||||
self._init_db()
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(str(self.db_file))
|
||||
|
||||
def _init_db(self):
|
||||
self.db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT,
|
||||
interrogator_tags TEXT,
|
||||
vlm_tags TEXT,
|
||||
all_tags TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
for sql in [
|
||||
"CREATE INDEX IF NOT EXISTS idx_all_tags ON images(all_tags)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_filename ON images(filename)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_created_at ON images(created_at DESC)",
|
||||
]:
|
||||
conn.execute(sql)
|
||||
conn.commit()
|
||||
|
||||
def save(self, filename: str, interrogator_tags: str, vlm_tags: str) -> int:
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
with self._conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO images (filename, interrogator_tags, vlm_tags, all_tags) VALUES (?, ?, ?, ?)",
|
||||
(filename, interrogator_tags, vlm_tags, all_tags),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def list(self, limit: int = 100, offset: int = 0) -> list:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at "
|
||||
"FROM images ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [self._row(r) for r in rows]
|
||||
|
||||
def total(self) -> int:
|
||||
with self._conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM images").fetchone()[0]
|
||||
|
||||
def search(self, q: str) -> list:
|
||||
pattern = f"%{q}%"
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at "
|
||||
"FROM images WHERE all_tags LIKE ? OR filename LIKE ? ORDER BY id DESC LIMIT 200",
|
||||
(pattern, pattern),
|
||||
).fetchall()
|
||||
return [self._row(r) for r in rows]
|
||||
|
||||
def update(self, row_id: int, interrogator_tags: str, vlm_tags: str):
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE images SET interrogator_tags=?, vlm_tags=?, all_tags=? WHERE id=?",
|
||||
(interrogator_tags, vlm_tags, all_tags, row_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete(self, row_id: int):
|
||||
with self._conn() as conn:
|
||||
conn.execute("DELETE FROM images WHERE id=?", (row_id,))
|
||||
conn.commit()
|
||||
|
||||
def export_csv(self) -> str:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at FROM images ORDER BY id"
|
||||
).fetchall()
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["id", "filename", "interrogator_tags", "vlm_tags", "all_tags", "created_at"])
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _row(r) -> dict:
|
||||
return {
|
||||
"id": r[0],
|
||||
"filename": r[1] or "",
|
||||
"interrogator_tags": r[2] or "",
|
||||
"vlm_tags": r[3] or "",
|
||||
"all_tags": r[4] or "",
|
||||
"created_at": r[5] or "",
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"""WD Tagger / DeepDanbooru / Ollama VLM 推論サービス。"""
|
||||
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, PngImagePlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_model_lock = threading.Lock()
|
||||
|
||||
|
||||
class TaggerService:
|
||||
def __init__(self, models_dir: Path, settings_file: Path):
|
||||
self.models_dir = models_dir
|
||||
self.settings_file = settings_file
|
||||
self._cache: dict = {}
|
||||
self._batch_state: dict = {
|
||||
"running": False, "total": 0, "done": 0, "log": [], "stop": False,
|
||||
}
|
||||
|
||||
# ── 設定 ──────────────────────────────────────────────────
|
||||
|
||||
def load_settings(self) -> dict:
|
||||
if self.settings_file.exists():
|
||||
try:
|
||||
with open(self.settings_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_settings(self, data: dict):
|
||||
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _effective_models_dir(self) -> Path:
|
||||
custom = self.load_settings().get("models_dir", "").strip()
|
||||
if custom:
|
||||
p = Path(custom)
|
||||
if p.is_dir():
|
||||
return p
|
||||
return self.models_dir
|
||||
|
||||
# ── モデル一覧 ─────────────────────────────────────────────
|
||||
|
||||
def list_models(self) -> list:
|
||||
mdir = self._effective_models_dir()
|
||||
models = []
|
||||
if not mdir.is_dir():
|
||||
return models
|
||||
for item in sorted(mdir.iterdir()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
exts = {f.suffix.lower() for f in item.iterdir() if f.is_file()}
|
||||
if ".onnx" in exts:
|
||||
models.append({"name": item.name, "type": "onnx"})
|
||||
elif ".h5" in exts:
|
||||
models.append({"name": item.name, "type": "deepdanbooru"})
|
||||
return models
|
||||
|
||||
# ── モデルロード ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _validate_model_name(name: str) -> bool:
|
||||
"""パストラバーサル防止: セパレータ・NUL・相対参照を拒否"""
|
||||
return bool(name) and "/" not in name and "\\" not in name and "\x00" not in name and name not in (".", "..")
|
||||
|
||||
def _load_model(self, model_name: str) -> Optional[dict]:
|
||||
if not self._validate_model_name(model_name):
|
||||
logger.error("Invalid model name rejected: %r", model_name)
|
||||
return None
|
||||
if model_name in self._cache:
|
||||
return self._cache[model_name]
|
||||
with _model_lock:
|
||||
if model_name in self._cache:
|
||||
return self._cache[model_name]
|
||||
mdir = self._effective_models_dir()
|
||||
model_dir = mdir / model_name
|
||||
if not model_dir.is_dir():
|
||||
logger.error("Model dir not found: %s", model_dir)
|
||||
return None
|
||||
files = list(model_dir.iterdir())
|
||||
onnx = next((f for f in files if f.suffix == ".onnx"), None)
|
||||
h5 = next((f for f in files if f.suffix == ".h5"), None)
|
||||
if onnx:
|
||||
return self._load_onnx(model_name, onnx, model_dir)
|
||||
if h5:
|
||||
return self._load_deepdanbooru(model_name, h5, model_dir)
|
||||
logger.error("No .onnx or .h5 in %s", model_dir)
|
||||
return None
|
||||
|
||||
def _load_onnx(self, name: str, onnx_file: Path, model_dir: Path) -> Optional[dict]:
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
providers = []
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
providers.append("CUDAExecutionProvider")
|
||||
except ImportError:
|
||||
pass
|
||||
providers.append("CPUExecutionProvider")
|
||||
session = ort.InferenceSession(str(onnx_file), providers=providers)
|
||||
labels = self._load_labels(model_dir)
|
||||
data = {
|
||||
"session": session,
|
||||
"labels": labels,
|
||||
"input_name": session.get_inputs()[0].name,
|
||||
"input_shape": session.get_inputs()[0].shape,
|
||||
"type": "onnx",
|
||||
}
|
||||
self._cache[name] = data
|
||||
logger.info("ONNX loaded: %s (%d labels)", name, len(labels))
|
||||
return data
|
||||
except ImportError:
|
||||
logger.error("onnxruntime not installed")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("ONNX load error %s: %s", name, e)
|
||||
return None
|
||||
|
||||
def _load_deepdanbooru(self, name: str, h5_file: Path, model_dir: Path) -> Optional[dict]:
|
||||
try:
|
||||
import tensorflow as tf
|
||||
except ImportError:
|
||||
logger.warning("TensorFlow not installed — DeepDanbooru unavailable")
|
||||
return None
|
||||
try:
|
||||
model = tf.keras.models.load_model(str(h5_file))
|
||||
labels = self._load_labels(model_dir)
|
||||
data = {"session": model, "labels": labels, "type": "deepdanbooru"}
|
||||
self._cache[name] = data
|
||||
logger.info("DeepDanbooru loaded: %s (%d labels)", name, len(labels))
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error("DeepDanbooru load error %s: %s", name, e)
|
||||
return None
|
||||
|
||||
def _load_labels(self, model_dir: Path) -> list:
|
||||
for fname in ("selected_tags.csv", "tags.csv", "tags.txt", "tags-general.txt"):
|
||||
p = model_dir / fname
|
||||
if not p.exists():
|
||||
continue
|
||||
try:
|
||||
if p.suffix == ".csv":
|
||||
with open(p, "r", encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader, None)
|
||||
return [row[1] for row in reader if len(row) > 1]
|
||||
else:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return [ln.strip() for ln in f if ln.strip()]
|
||||
except Exception as e:
|
||||
logger.warning("Label load failed %s: %s", p, e)
|
||||
return []
|
||||
|
||||
# ── 推論(WD Tagger / DeepDanbooru) ────────────────────
|
||||
|
||||
def predict(self, image_b64: str, model_name: str, threshold: float = 0.35, char_threshold: float = 0.85) -> dict:
|
||||
img = self._decode_b64(image_b64)
|
||||
data = self._load_model(model_name)
|
||||
if data is None:
|
||||
return {"error": f"Model '{model_name}' could not be loaded"}
|
||||
try:
|
||||
if data["type"] == "deepdanbooru":
|
||||
tags = self._predict_dd(img, data, threshold, char_threshold)
|
||||
else:
|
||||
tags = self._predict_onnx(img, data, threshold, char_threshold)
|
||||
return {"tags": ", ".join(tags), "count": len(tags)}
|
||||
except Exception as e:
|
||||
logger.error("predict error: %s", e)
|
||||
return {"error": str(e)}
|
||||
|
||||
def _predict_onnx(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
|
||||
shape = data["input_shape"]
|
||||
fmt = "NHWC"
|
||||
size = (224, 224)
|
||||
if len(shape) == 4:
|
||||
if shape[1] == 3 and isinstance(shape[2], int) and shape[2] > 0:
|
||||
fmt = "NCHW"
|
||||
size = (int(shape[3]), int(shape[2]))
|
||||
elif shape[3] == 3 and isinstance(shape[1], int) and shape[1] > 0:
|
||||
fmt = "NHWC"
|
||||
size = (int(shape[2]), int(shape[1]))
|
||||
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
arr = np.array(img.resize(size, Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
|
||||
if fmt == "NCHW":
|
||||
arr = arr.transpose((2, 0, 1))
|
||||
arr = np.expand_dims(arr, 0)
|
||||
preds = data["session"].run(None, {data["input_name"]: arr})[0][0]
|
||||
return self._filter_tags(preds, data["labels"], thr, char_thr)
|
||||
|
||||
def _predict_dd(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
arr = np.array(img.resize((512, 512), Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
|
||||
arr = np.expand_dims(arr, 0)
|
||||
preds = data["session"].predict(arr)[0]
|
||||
return self._filter_tags(preds, data["labels"], thr, char_thr)
|
||||
|
||||
def _filter_tags(self, preds, labels: list, thr: float, char_thr: float) -> list:
|
||||
tags = []
|
||||
for i, score in enumerate(preds):
|
||||
if i >= len(labels):
|
||||
break
|
||||
label = labels[i]
|
||||
if float(score) >= (char_thr if label.startswith("character:") else thr):
|
||||
tags.append(label.replace("character:", ""))
|
||||
return tags
|
||||
|
||||
# ── Ollama VLM ────────────────────────────────────────────
|
||||
|
||||
def ollama_models(self, api_url: str) -> list:
|
||||
try:
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
except Exception as e:
|
||||
logger.error("ollama_models: %s", e)
|
||||
return []
|
||||
|
||||
def ollama_predict(self, image_b64: str, api_url: str, model: str, prompt: str, max_tags: int = 40) -> dict:
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096},
|
||||
}).encode("utf-8")
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
content = data.get("message", {}).get("content", "")
|
||||
tags = [t.strip() for t in content.split(",") if t.strip()][:max_tags]
|
||||
return {"tags": ", ".join(tags), "count": len(tags)}
|
||||
except Exception as e:
|
||||
logger.error("ollama_predict: %s", e)
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── ファイルメタデータ書込 ────────────────────────────────
|
||||
|
||||
_ALLOWED_WRITE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
|
||||
|
||||
def write_meta_to_file(self, image_path: str, tags: str) -> dict:
|
||||
path = Path(image_path).resolve()
|
||||
if path.suffix.lower() not in self._ALLOWED_WRITE_EXTS:
|
||||
return {"error": "Unsupported file type"}
|
||||
if not path.is_file():
|
||||
return {"error": f"File not found: {image_path}"}
|
||||
try:
|
||||
import piexif
|
||||
except ImportError:
|
||||
piexif = None
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
img.load()
|
||||
ext = path.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg") and piexif:
|
||||
self._write_jpeg(img, path, tags, piexif)
|
||||
elif ext == ".png":
|
||||
self._write_png(img, path, tags)
|
||||
else:
|
||||
self._write_sidecar(path, tags)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
logger.error("write_meta: %s", e)
|
||||
return {"error": str(e)}
|
||||
|
||||
def _write_jpeg(self, img: Image.Image, path: Path, tags: str, piexif):
|
||||
exif_data = img.info.get("exif")
|
||||
if exif_data:
|
||||
try:
|
||||
exif_dict = piexif.load(exif_data)
|
||||
except Exception:
|
||||
exif_dict = {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}}
|
||||
else:
|
||||
exif_dict = {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}}
|
||||
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = tags.encode("utf-8")
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
img.save(str(tmp_path), "JPEG", quality=95, exif=exif_bytes)
|
||||
tmp_path.replace(path)
|
||||
|
||||
def _write_png(self, img: Image.Image, path: Path, tags: str):
|
||||
meta = PngImagePlugin.PngInfo()
|
||||
for k, v in img.info.items():
|
||||
if isinstance(k, str) and isinstance(v, str):
|
||||
meta.add_text(k, v)
|
||||
meta.add_text("Tags", tags)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
img.save(str(tmp_path), "PNG", pnginfo=meta)
|
||||
tmp_path.replace(path)
|
||||
|
||||
def _write_sidecar(self, path: Path, tags: str):
|
||||
sidecar = path.with_suffix(".tags.json")
|
||||
with open(sidecar, "w", encoding="utf-8") as f:
|
||||
json.dump({"tags": tags}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── バッチ処理 ────────────────────────────────────────────
|
||||
|
||||
def batch_status(self) -> dict:
|
||||
s = self._batch_state
|
||||
return {
|
||||
"running": s["running"],
|
||||
"total": s["total"],
|
||||
"done": s["done"],
|
||||
"log": s["log"][-50:],
|
||||
}
|
||||
|
||||
def batch_stop(self):
|
||||
self._batch_state["stop"] = True
|
||||
|
||||
def batch_start(self, folder: str, model_name: str, threshold: float, char_threshold: float,
|
||||
use_ollama: bool, ollama_api: str, ollama_model: str, ollama_prompt: str,
|
||||
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service) -> dict:
|
||||
if self._batch_state["running"]:
|
||||
return {"error": "Batch already running"}
|
||||
folder_path = Path(folder)
|
||||
if not folder_path.is_dir():
|
||||
return {"error": f"Folder not found: {folder}"}
|
||||
|
||||
exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
|
||||
files = sorted(f for f in folder_path.iterdir() if f.is_file() and f.suffix.lower() in exts)
|
||||
if not files:
|
||||
return {"error": "No image files found"}
|
||||
|
||||
self._batch_state = {
|
||||
"running": True, "total": len(files), "done": 0, "log": [], "stop": False,
|
||||
}
|
||||
|
||||
def _run():
|
||||
for fpath in files:
|
||||
if self._batch_state["stop"]:
|
||||
self._batch_state["log"].append("Stopped.")
|
||||
break
|
||||
try:
|
||||
with Image.open(fpath) as img:
|
||||
img.load()
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
interrogator_tags = ""
|
||||
if model_name:
|
||||
res = self.predict(b64, model_name, threshold, char_threshold)
|
||||
interrogator_tags = res.get("tags", "")
|
||||
|
||||
vlm_tags = ""
|
||||
if use_ollama and ollama_model:
|
||||
res2 = self.ollama_predict(b64, ollama_api, ollama_model, ollama_prompt, ollama_max_tags)
|
||||
vlm_tags = res2.get("tags", "")
|
||||
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
|
||||
if save_db:
|
||||
db_service.save(str(fpath), interrogator_tags, vlm_tags)
|
||||
if write_file and all_tags:
|
||||
self.write_meta_to_file(str(fpath), all_tags)
|
||||
if write_txt and all_tags:
|
||||
txt_path = fpath.with_suffix(".txt")
|
||||
with open(txt_path, "w", encoding="utf-8") as f:
|
||||
f.write(all_tags)
|
||||
|
||||
n = len([t for t in all_tags.split(",") if t.strip()])
|
||||
self._batch_state["log"].append(f"✓ {fpath.name}: {n} tags")
|
||||
except Exception as e:
|
||||
self._batch_state["log"].append(f"✗ {fpath.name}: {e}")
|
||||
self._batch_state["done"] += 1
|
||||
|
||||
self._batch_state["running"] = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"ok": True, "total": len(files)}
|
||||
|
||||
# ── ユーティリティ ────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _decode_b64(b64: str) -> Image.Image:
|
||||
return Image.open(io.BytesIO(base64.b64decode(b64)))
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Wildcard file management service."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_EXTS = {"txt", "yaml", "yml"}
|
||||
|
||||
|
||||
class WildcardService:
|
||||
def __init__(self, wildcard_dir: Path):
|
||||
self.wildcard_dir = wildcard_dir
|
||||
# Only mkdir if it is a real directory (not a junction/symlink)
|
||||
if not self.wildcard_dir.exists() and not self._is_junction(self.wildcard_dir):
|
||||
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_wildcards(self) -> list[dict]:
|
||||
"""Return sorted list of wildcard files recursively (follows junction/symlink)."""
|
||||
if not self.wildcard_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for path in sorted(self.wildcard_dir.rglob("*")):
|
||||
if path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS:
|
||||
rel = path.relative_to(self.wildcard_dir)
|
||||
dir_posix = rel.parent.as_posix()
|
||||
if dir_posix == ".":
|
||||
dir_posix = ""
|
||||
files.append({
|
||||
"name": path.stem,
|
||||
"filename": rel.as_posix(),
|
||||
"ext": path.suffix.lstrip(".").lower(),
|
||||
"size": path.stat().st_size,
|
||||
"dir": dir_posix,
|
||||
"wc_name": rel.with_suffix("").as_posix(),
|
||||
})
|
||||
return files
|
||||
|
||||
def get_content(self, filename: str) -> str:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def save_file(self, filename: str, content: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
rel = path.relative_to(self.wildcard_dir)
|
||||
dir_posix = rel.parent.as_posix()
|
||||
if dir_posix == ".":
|
||||
dir_posix = ""
|
||||
return {
|
||||
"name": path.stem,
|
||||
"filename": rel.as_posix(),
|
||||
"ext": path.suffix.lstrip(".").lower(),
|
||||
"size": path.stat().st_size,
|
||||
"dir": dir_posix,
|
||||
"wc_name": rel.with_suffix("").as_posix(),
|
||||
}
|
||||
|
||||
def delete_file(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
|
||||
def _safe_path(self, filename: str) -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
# Normalize to forward slashes and strip leading/trailing slashes
|
||||
filename = filename.replace("\\", "/").strip("/")
|
||||
# Validate each path component — block traversal and empty parts
|
||||
parts = filename.split("/")
|
||||
for p in parts:
|
||||
if not p or p == ".." or p == ".":
|
||||
return None
|
||||
ext = Path(parts[-1]).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.wildcard_dir.joinpath(*parts)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Impact Pack integration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def find_impact_pack_wildcards(comfyui_root: Path) -> Path | None:
|
||||
"""Return Impact Pack wildcards directory path, or None if not installed."""
|
||||
custom_nodes = comfyui_root / "custom_nodes"
|
||||
if not custom_nodes.is_dir():
|
||||
return None
|
||||
for d in custom_nodes.iterdir():
|
||||
if d.is_dir() and "impact-pack" in d.name.lower():
|
||||
wildcards = d / "wildcards"
|
||||
if wildcards.is_dir():
|
||||
return wildcards
|
||||
return None
|
||||
|
||||
def get_link_status(self, comfyui_root: Path) -> dict:
|
||||
"""Return current Impact Pack integration status."""
|
||||
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
|
||||
is_linked = self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()
|
||||
|
||||
resolved_target = None
|
||||
if is_linked:
|
||||
try:
|
||||
resolved_target = str(self.wildcard_dir.resolve())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"impact_pack_installed": impact_dir is not None,
|
||||
"impact_pack_wildcards_dir": str(impact_dir) if impact_dir else None,
|
||||
"wfs_wildcard_dir": str(self.wildcard_dir),
|
||||
"is_linked": is_linked,
|
||||
"link_target": resolved_target,
|
||||
}
|
||||
|
||||
def create_link(self, comfyui_root: Path) -> dict:
|
||||
"""Replace WFS wildcard dir with a junction/symlink → Impact Pack wildcards."""
|
||||
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
|
||||
if impact_dir is None:
|
||||
raise RuntimeError("ComfyUI-Impact-Pack is not installed.")
|
||||
|
||||
if self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink():
|
||||
raise RuntimeError("Link already exists.")
|
||||
|
||||
# Migrate existing WFS files to Impact Pack dir
|
||||
migrated: list[str] = []
|
||||
if self.wildcard_dir.is_dir():
|
||||
for f in self.wildcard_dir.iterdir():
|
||||
if f.is_file():
|
||||
dest = impact_dir / f.name
|
||||
if not dest.exists():
|
||||
shutil.copy2(str(f), str(dest))
|
||||
migrated.append(f.name)
|
||||
shutil.rmtree(str(self.wildcard_dir))
|
||||
|
||||
# Create junction (Windows) or symlink (others)
|
||||
self._create_junction_or_symlink(self.wildcard_dir, impact_dir)
|
||||
logger.info("Wildcard link created: %s -> %s", self.wildcard_dir, impact_dir)
|
||||
return {"migrated_files": migrated}
|
||||
|
||||
def remove_link(self) -> None:
|
||||
"""Remove junction/symlink and restore WFS wildcard dir as a plain directory."""
|
||||
if not (self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()):
|
||||
raise RuntimeError("No link to remove.")
|
||||
self._remove_junction_or_symlink(self.wildcard_dir)
|
||||
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Wildcard link removed, restored plain dir: %s", self.wildcard_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Platform helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_junction(path: Path) -> bool:
|
||||
"""Return True if path is a Windows directory junction."""
|
||||
if platform.system() != "Windows":
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x0400
|
||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
|
||||
return attrs != 0xFFFFFFFF and bool(attrs & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _create_junction_or_symlink(link: Path, target: Path) -> None:
|
||||
if platform.system() == "Windows":
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"mklink /J failed: {result.stderr.strip()}")
|
||||
else:
|
||||
os.symlink(target, link, target_is_directory=True)
|
||||
|
||||
@staticmethod
|
||||
def _remove_junction_or_symlink(path: Path) -> None:
|
||||
if platform.system() == "Windows":
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "rmdir", str(path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"rmdir failed: {result.stderr.strip()}")
|
||||
else:
|
||||
os.unlink(str(path))
|
||||
@@ -3,23 +3,102 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
# プロンプトを直接持つノードタイプ(CLIPTextEncode以外の複合ノード含む)
|
||||
_PROMPT_NODE_TYPES = {
|
||||
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner",
|
||||
"CLIPTextEncodeEditPlus", "TextEncodeQwenImageEditPlus", "TextEncodeMageFlowEdit",
|
||||
"SDXLPromptStyler", "SDXLPromptStylerAdvanced", "ImageMetadataPromptLoader",
|
||||
"WFS_PromptText", "ImpactWildcardEncode", "ImpactWildcardProcessor",
|
||||
}
|
||||
|
||||
# 画像を最終出力として保存するノードタイプ
|
||||
_OUTPUT_IMAGE_NODE_TYPES = {"SaveImage", "SaveImageAdvanced"}
|
||||
|
||||
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
|
||||
_CLIP_TYPE_TO_MODEL = {
|
||||
"flux": "Flux",
|
||||
"flux2": "Flux2",
|
||||
"newbie": "NewBie",
|
||||
"ovis": "Ovis",
|
||||
"qwen_image": "Qwen",
|
||||
"sd3": "SD3",
|
||||
"sd3.5": "SD3",
|
||||
"hidream_i": "HiDream",
|
||||
"hidream_e": "HiDream",
|
||||
"hidream": "HiDream",
|
||||
"wan": "Wan",
|
||||
"cosmos": "Cosmos",
|
||||
"lumina": "Lumina",
|
||||
"lumina2": "Z-IMAGE",
|
||||
}
|
||||
|
||||
|
||||
def _model_name_from_ui_node(node):
|
||||
"""Extract model filename from UI format node (lowercase)."""
|
||||
widgets = node.get("widgets_values", [])
|
||||
return (widgets[0] if widgets else "").lower()
|
||||
val = widgets[0] if widgets else ""
|
||||
return val.lower() if isinstance(val, str) else ""
|
||||
|
||||
|
||||
def _clip_type_from_ui_node(node):
|
||||
"""Extract CLIPLoader type string from widgets_values (lowercase)."""
|
||||
ntype = node.get("type", "")
|
||||
vals = node.get("widgets_values", [])
|
||||
if ntype == "CLIPLoader" and len(vals) > 1:
|
||||
return str(vals[1]).lower() if isinstance(vals[1], str) else ""
|
||||
if ntype in ("DualCLIPLoader", "TripleCLIPLoader") and len(vals) > 2:
|
||||
return str(vals[2]).lower() if isinstance(vals[2], str) else ""
|
||||
return ""
|
||||
|
||||
|
||||
def _model_name_from_api_node(node):
|
||||
"""Extract model filename from API format node (lowercase)."""
|
||||
inputs = node.get("inputs", {})
|
||||
for key in ("ckpt_name", "unet_name", "model_name"):
|
||||
for key in ("ckpt_name", "unet_name", "model_name", "clip_name1", "clip_name"):
|
||||
val = inputs.get(key, "")
|
||||
if isinstance(val, str) and val:
|
||||
return val.lower()
|
||||
return ""
|
||||
|
||||
|
||||
def _collect_all_ui_nodes(workflow_data):
|
||||
"""Yield all UI-format nodes including those inside definitions.subgraphs."""
|
||||
for node in workflow_data.get("nodes", []):
|
||||
yield node
|
||||
defs = workflow_data.get("definitions", {})
|
||||
if isinstance(defs, dict):
|
||||
for sg in defs.get("subgraphs", []):
|
||||
if isinstance(sg, dict):
|
||||
for node in sg.get("nodes", []):
|
||||
yield node
|
||||
|
||||
|
||||
def _detect_model_type_from_name(mn_base, model_types):
|
||||
"""Detect model type from a model filename (already lowercased basename)."""
|
||||
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
|
||||
model_types.add("SD1.5")
|
||||
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
|
||||
model_types.add("SDXL")
|
||||
elif re.search(r"flux[-_.]?2", mn_base):
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in mn_base:
|
||||
model_types.add("Flux")
|
||||
elif re.search(r"sd[._-]?3", mn_base):
|
||||
model_types.add("SD3")
|
||||
elif "qwen" in mn_base:
|
||||
model_types.add("Qwen")
|
||||
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
|
||||
model_types.add("Z-IMAGE")
|
||||
elif "newbie" in mn_base:
|
||||
model_types.add("NewBie")
|
||||
elif "ovis" in mn_base:
|
||||
model_types.add("Ovis")
|
||||
elif "hidream" in mn_base:
|
||||
model_types.add("HiDream")
|
||||
elif "wan" in mn_base and re.search(r"wan[-_.]?\d|wan[-_]video", mn_base):
|
||||
model_types.add("Wan")
|
||||
|
||||
|
||||
def analyze_workflow(workflow_data, filename=""):
|
||||
"""Analyze workflow JSON and return model types, input/output node counts."""
|
||||
model_types = set()
|
||||
@@ -35,23 +114,33 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
model_types.add("Qwen")
|
||||
if "zimage" in fn or "z-image" in fn or "zit" in fn:
|
||||
model_types.add("Z-IMAGE")
|
||||
if "flux2" in fn:
|
||||
if re.search(r"flux[-_.]?2", fn):
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in fn:
|
||||
model_types.add("Flux")
|
||||
if "sd3" in fn:
|
||||
model_types.add("SD3")
|
||||
if "newbie" in fn:
|
||||
model_types.add("NewBie")
|
||||
if "ovis" in fn:
|
||||
model_types.add("Ovis")
|
||||
if "hidream" in fn:
|
||||
model_types.add("HiDream")
|
||||
if "wan" in fn and re.search(r"wan[-_.]?\d|wan[-_]video", fn):
|
||||
model_types.add("Wan")
|
||||
|
||||
# Support both UI format (nodes array) and API format (node ID object)
|
||||
raw_nodes = workflow_data.get("nodes", None)
|
||||
if raw_nodes is not None:
|
||||
# UI / app format: collect all nodes including subgraph definitions
|
||||
node_iter = [
|
||||
(
|
||||
node.get("type", ""),
|
||||
node.get("title", "").lower(),
|
||||
_model_name_from_ui_node(node),
|
||||
_clip_type_from_ui_node(node),
|
||||
)
|
||||
for node in raw_nodes
|
||||
for node in _collect_all_ui_nodes(workflow_data)
|
||||
]
|
||||
else:
|
||||
node_iter = []
|
||||
@@ -61,33 +150,32 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
ntype = node.get("class_type", "")
|
||||
title = node.get("_meta", {}).get("title", "").lower()
|
||||
mn = _model_name_from_api_node(node)
|
||||
node_iter.append((ntype, title, mn))
|
||||
node_iter.append((ntype, title, mn, ""))
|
||||
|
||||
for ntype, title, mn in node_iter:
|
||||
for ntype, title, mn, clip_type in node_iter:
|
||||
if (
|
||||
"CheckpointLoader" in ntype
|
||||
or "UNETLoader" in ntype
|
||||
or "UnetLoader" in ntype # UnetLoaderGGUF 等を含む
|
||||
or "unet_name" in ntype
|
||||
):
|
||||
mn_base = os.path.basename(mn.replace("\\", "/"))
|
||||
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
|
||||
model_types.add("SD1.5")
|
||||
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
|
||||
model_types.add("SDXL")
|
||||
elif "flux2" in mn_base:
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in mn_base:
|
||||
model_types.add("Flux")
|
||||
elif re.search(r"sd[._-]?3", mn_base):
|
||||
model_types.add("SD3")
|
||||
elif "qwen" in mn_base:
|
||||
model_types.add("Qwen")
|
||||
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
|
||||
model_types.add("Z-IMAGE")
|
||||
_detect_model_type_from_name(mn_base, model_types)
|
||||
|
||||
# CLIPLoader 系: type フィールドまたはファイル名からモデル種別を検出
|
||||
# QuadrupleCLIPLoader 等、type フィールドを持たない新型ローダーにも対応
|
||||
if "CLIPLoader" in ntype:
|
||||
if clip_type:
|
||||
detected = _CLIP_TYPE_TO_MODEL.get(clip_type)
|
||||
if detected:
|
||||
model_types.add(detected)
|
||||
mn_base = os.path.basename(mn.replace("\\", "/"))
|
||||
if mn_base:
|
||||
_detect_model_type_from_name(mn_base, model_types)
|
||||
|
||||
if "sdxl" in title:
|
||||
model_types.add("SDXL")
|
||||
if "flux2" in title:
|
||||
if re.search(r"flux[-_.]?2", title):
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in title:
|
||||
model_types.add("Flux")
|
||||
@@ -97,20 +185,50 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
model_types.add("Qwen")
|
||||
if "zimage" in title or "z-image" in title or "zit" in title:
|
||||
model_types.add("Z-IMAGE")
|
||||
if "newbie" in title:
|
||||
model_types.add("NewBie")
|
||||
if "ovis" in title:
|
||||
model_types.add("Ovis")
|
||||
if "hidream" in title:
|
||||
model_types.add("HiDream")
|
||||
|
||||
if ntype == "CLIPTextEncode" or "prompt" in title:
|
||||
if ntype in _PROMPT_NODE_TYPES or "prompt" in title:
|
||||
inputs["prompts"] += 1
|
||||
if ntype in ("LoadImage", "LoadImageMask"):
|
||||
inputs["images"] += 1
|
||||
if ntype == "LoadVideo" or "load video" in title:
|
||||
inputs["videos"] += 1
|
||||
if ntype == "SaveImage":
|
||||
if ntype in _OUTPUT_IMAGE_NODE_TYPES:
|
||||
outputs["images"] += 1
|
||||
if ntype in ("SaveVideo", "VH_VideoCombine"):
|
||||
outputs["videos"] += 1
|
||||
|
||||
# Detect workflow format
|
||||
if raw_nodes is not None:
|
||||
# App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
# A workflow that merely *contains* subgraphs (definitions.subgraphs) is still a
|
||||
# normal node graph (comfyui-workflow.js's convertUiToApi flattens subgraphs before
|
||||
# conversion), so it's treated as "ui" here too — must match detectFormat() in
|
||||
# static/js/comfyui-workflow.js or the App/UI badge shown to the user disagrees
|
||||
# with what GenerateUI actually does with the file.
|
||||
extra = workflow_data.get("extra", {})
|
||||
if extra.get("linearMode") is True:
|
||||
wf_format = "app"
|
||||
elif filename and filename.lower().endswith(".app.json"):
|
||||
wf_format = "app"
|
||||
else:
|
||||
wf_format = "ui"
|
||||
else:
|
||||
# Check if all top-level values have class_type (API format)
|
||||
has_class = any(
|
||||
isinstance(v, dict) and "class_type" in v
|
||||
for v in workflow_data.values()
|
||||
)
|
||||
wf_format = "api" if has_class else "unknown"
|
||||
|
||||
return {
|
||||
"modelTypes": sorted(model_types),
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"format": wf_format,
|
||||
}
|
||||
|
||||
@@ -42,8 +42,23 @@ class WorkflowService:
|
||||
"""Validate filename has no path traversal."""
|
||||
if not fname or "/" in fname or "\\" in fname:
|
||||
return False
|
||||
if fname in (".", "..") or "\x00" in fname:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _safe_path(self, filename):
|
||||
"""Resolve path and ensure it stays within workflows_dir.
|
||||
|
||||
Raises ValueError if path traversal is detected.
|
||||
"""
|
||||
if not self._validate_filename(filename):
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
resolved = (self.workflows_dir / filename).resolve()
|
||||
workflows_resolved = self.workflows_dir.resolve()
|
||||
if not str(resolved).startswith(str(workflows_resolved) + os.sep) and resolved != workflows_resolved:
|
||||
raise ValueError(f"Path traversal detected: {filename}")
|
||||
return resolved
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -69,6 +84,7 @@ class WorkflowService:
|
||||
"modelTypes": [],
|
||||
"inputs": {"prompts": 0, "images": 0, "videos": 0},
|
||||
"outputs": {"images": 0, "videos": 0},
|
||||
"format": "unknown",
|
||||
}
|
||||
|
||||
stat = fpath.stat()
|
||||
@@ -96,6 +112,7 @@ class WorkflowService:
|
||||
"summary": meta.get("summary", ""),
|
||||
"modelTypesOverride": override_types,
|
||||
"favorite": bool(meta.get("favorite", False)),
|
||||
"badges": meta.get("badges", []),
|
||||
},
|
||||
"mtime": stat.st_mtime,
|
||||
"thumbnail": thumbnail,
|
||||
@@ -106,7 +123,7 @@ class WorkflowService:
|
||||
|
||||
def get_raw(self, filename):
|
||||
"""Get raw workflow JSON content."""
|
||||
fpath = self.workflows_dir / filename
|
||||
fpath = self._safe_path(filename)
|
||||
if not fpath.is_file():
|
||||
return None
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
@@ -116,7 +133,7 @@ class WorkflowService:
|
||||
"""Save metadata (tags, memo, summary, etc.) for a workflow."""
|
||||
metadata = self._load_metadata()
|
||||
entry = metadata.get(filename, {})
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite"):
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges"):
|
||||
if key in updates:
|
||||
entry[key] = updates[key]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
@@ -132,10 +149,16 @@ class WorkflowService:
|
||||
ext = os.path.splitext(original_name)[1].lower()
|
||||
base = os.path.splitext(original_name)[0]
|
||||
|
||||
if not self._validate_filename(original_name):
|
||||
results.append(
|
||||
{"name": original_name, "status": "error", "message": "Invalid filename"}
|
||||
)
|
||||
continue
|
||||
|
||||
if ext == ".json":
|
||||
try:
|
||||
wf_data = json.loads(file_data.decode("utf-8"))
|
||||
out_path = self.workflows_dir / original_name
|
||||
out_path = self._safe_path(original_name)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(wf_data, f, ensure_ascii=False, indent=2)
|
||||
results.append({"name": original_name, "status": "success"})
|
||||
@@ -158,13 +181,13 @@ class WorkflowService:
|
||||
try:
|
||||
# Save PNG as thumbnail
|
||||
with open(
|
||||
self.workflows_dir / original_name, "wb"
|
||||
self._safe_path(original_name), "wb"
|
||||
) as f:
|
||||
f.write(file_data)
|
||||
# Save extracted workflow as JSON
|
||||
json_name = base + ".json"
|
||||
with open(
|
||||
self.workflows_dir / json_name, "w", encoding="utf-8"
|
||||
self._safe_path(json_name), "w", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(wf_data, f, ensure_ascii=False, indent=2)
|
||||
results.append({"name": json_name, "status": "success"})
|
||||
@@ -190,8 +213,8 @@ class WorkflowService:
|
||||
def rename(self, old_name, new_stem):
|
||||
"""Rename workflow and its associated thumbnail."""
|
||||
new_name = new_stem + ".json"
|
||||
old_json = self.workflows_dir / old_name
|
||||
new_json = self.workflows_dir / new_name
|
||||
old_json = self._safe_path(old_name)
|
||||
new_json = self._safe_path(new_name)
|
||||
|
||||
if not old_json.is_file():
|
||||
return {"error": "file not found"}, 404
|
||||
@@ -203,8 +226,8 @@ class WorkflowService:
|
||||
# Rename associated thumbnail
|
||||
old_stem = old_name[:-5] if old_name.endswith(".json") else old_name
|
||||
for ext in (".png", ".webp"):
|
||||
old_thumb = self.workflows_dir / (old_stem + ext)
|
||||
new_thumb = self.workflows_dir / (new_stem + ext)
|
||||
old_thumb = self._safe_path(old_stem + ext)
|
||||
new_thumb = self._safe_path(new_stem + ext)
|
||||
if old_thumb.is_file():
|
||||
os.rename(old_thumb, new_thumb)
|
||||
|
||||
@@ -218,13 +241,13 @@ class WorkflowService:
|
||||
|
||||
def delete(self, filename):
|
||||
"""Delete workflow JSON and associated thumbnail."""
|
||||
json_path = self.workflows_dir / filename
|
||||
json_path = self._safe_path(filename)
|
||||
if json_path.is_file():
|
||||
os.remove(json_path)
|
||||
|
||||
base = filename[:-5] if filename.endswith(".json") else filename
|
||||
for ext in (".png", ".webp"):
|
||||
thumb_path = self.workflows_dir / (base + ext)
|
||||
thumb_path = self._safe_path(base + ext)
|
||||
if thumb_path.is_file():
|
||||
os.remove(thumb_path)
|
||||
|
||||
@@ -235,7 +258,7 @@ class WorkflowService:
|
||||
|
||||
def analyze(self, filename):
|
||||
"""Re-analyze a workflow and save results to metadata."""
|
||||
fpath = self.workflows_dir / filename
|
||||
fpath = self._safe_path(filename)
|
||||
if not fpath.is_file():
|
||||
return None
|
||||
|
||||
@@ -280,6 +303,7 @@ class WorkflowService:
|
||||
|
||||
def change_thumbnail(self, filename, image_data, original_image_name):
|
||||
"""Change workflow thumbnail image."""
|
||||
self._safe_path(filename) # validate filename
|
||||
orig_name_lower = original_image_name.lower()
|
||||
if orig_name_lower.endswith(".webp"):
|
||||
new_ext = ".webp"
|
||||
@@ -292,15 +316,15 @@ class WorkflowService:
|
||||
|
||||
# Backup existing thumbnails
|
||||
for ext in (".png", ".webp", ".jpg", ".jpeg"):
|
||||
old_thumb = self.workflows_dir / (stem + ext)
|
||||
old_thumb = self._safe_path(stem + ext)
|
||||
if old_thumb.is_file():
|
||||
backup = self.workflows_dir / ("old_" + stem + ext)
|
||||
backup = self._safe_path("old_" + stem + ext)
|
||||
if backup.is_file():
|
||||
os.remove(backup)
|
||||
os.rename(old_thumb, backup)
|
||||
|
||||
# Save new thumbnail
|
||||
new_thumb_path = self.workflows_dir / (stem + new_ext)
|
||||
new_thumb_path = self._safe_path(stem + new_ext)
|
||||
with open(new_thumb_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
|
||||
@@ -61,11 +61,18 @@ class WorkflowStudio:
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
ollama_routes.setup_routes(app)
|
||||
eagle_routes.setup_routes(app)
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
models_routes.setup_routes(app)
|
||||
gallery_routes.setup_routes(app)
|
||||
wildcard_routes.setup_routes(app)
|
||||
tagger_routes.setup_routes(app)
|
||||
gmic_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
|
||||
version = "0.3.78"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "statsu"
|
||||
DisplayName = "Workflow Studio"
|
||||
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/docs/thumb.png"
|
||||
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
|
||||
@@ -0,0 +1,21 @@
|
||||
# ComfyUI-Workflow-Studio - Tagger tab dependencies
|
||||
#
|
||||
# numpy and Pillow are already bundled with ComfyUI and do not need to be installed.
|
||||
# torch is managed by ComfyUI and must NOT be pinned here.
|
||||
#
|
||||
# Install into ComfyUI's embedded Python:
|
||||
# python_embeded\python.exe -m pip install -r requirements.txt
|
||||
#
|
||||
# For GPU-accelerated ONNX inference, install onnxruntime-gpu instead of onnxruntime:
|
||||
# python_embeded\python.exe -m pip install onnxruntime-gpu
|
||||
|
||||
# -- Required for WD Tagger / SwinV2 ONNX inference --
|
||||
onnxruntime>=1.16
|
||||
|
||||
# -- Required for JPEG EXIF tag writing --
|
||||
piexif>=1.1.3
|
||||
|
||||
# -- Optional: DeepDanbooru (.h5 model) support --
|
||||
# Omit if you do not use DeepDanbooru models.
|
||||
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
|
||||
# tensorflow>=2.10
|
||||
@@ -0,0 +1,835 @@
|
||||
/* ============================================================
|
||||
Gallery Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── 3カラムレイアウト ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px));
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── 左: フォルダツリーパネル ──────────────────────────────── */
|
||||
|
||||
.wfm-gallery-tree-panel {
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
.wfm-gallery-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item:hover {
|
||||
background: var(--wfm-bg-hover, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item.selected {
|
||||
background: var(--wfm-accent, #4f8ef7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item.selected .wfm-gallery-tree-count {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-arrow {
|
||||
font-size: 10px;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--wfm-text-secondary);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-count {
|
||||
font-size: 10px;
|
||||
background: var(--wfm-accent-muted, rgba(79,142,247,0.2));
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-children {
|
||||
/* 子ノードコンテナ */
|
||||
}
|
||||
|
||||
/* ── 中: 画像一覧パネル ────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-list-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
}
|
||||
|
||||
.wfm-gallery-grid {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* サムネイルビュー */
|
||||
.wfm-gallery-view-thumb {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card {
|
||||
position: relative;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover {
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card.selected {
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
box-shadow: 0 0 0 2px var(--wfm-accent, #4f8ef7);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card.multi-selected {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 2px #f59e0b;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wfm-bg-hover, #222);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* お気に入りボタン(サムネイルカード右上) */
|
||||
.wfm-gallery-thumb-fav-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-fav-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-fav-btn.active {
|
||||
color: #fbbf24;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-fav-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* Feederグループボタン(サムネイルカード左上) */
|
||||
.wfm-gallery-thumb-feeder-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-feeder-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-feeder-btn.active {
|
||||
color: #38bdf8;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-feeder-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
/* テーブルビューのお気に入りボタン */
|
||||
.wfm-gallery-table-fav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
padding: 2px 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-fav-btn:hover,
|
||||
.wfm-gallery-table-fav-btn.active {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* テーブルビュー */
|
||||
.wfm-gallery-view-table {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-gallery-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-gallery-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr:hover {
|
||||
background: var(--wfm-bg-hover, rgba(255,255,255,0.05));
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr.selected {
|
||||
background: rgba(79,142,247,0.15);
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr.multi-selected {
|
||||
background: rgba(245,158,11,0.15);
|
||||
}
|
||||
|
||||
.wfm-gallery-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── タグバッジ共通 ────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: rgba(79,142,247,0.15);
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.wfm-gallery-tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── 右: 詳細パネル ────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-detail-panel {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-header {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-filename {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* プレビュー */
|
||||
.wfm-gallery-detail-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-preview-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-gallery-preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.wfm-gallery-preview-wrapper:hover .wfm-gallery-preview-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-download-btn {
|
||||
background: var(--wfm-accent, #4f8ef7);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.wfm-gallery-download-btn:hover {
|
||||
background: var(--wfm-accent-hover, #3f7fe7);
|
||||
}
|
||||
|
||||
/* タブコンテンツ */
|
||||
.wfm-gallery-detail-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.wfm-gallery-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-primary);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-gallery-info-section {
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-info-row {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-gallery-memo {
|
||||
width: 100%;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wfm-text-primary);
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.wfm-gallery-memo:focus {
|
||||
outline: none;
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
}
|
||||
|
||||
/* ── Metadataタブ(Workflow全幅表示) ─────────────────────── */
|
||||
|
||||
.wfm-gallery-meta-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wfm-gallery-meta-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-gallery-meta-status {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-workflow-json {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--wfm-text-primary);
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* グループ */
|
||||
.wfm-gallery-current-groups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-gallery-groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-gallery-group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-gallery-group-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--wfm-text-primary);
|
||||
}
|
||||
|
||||
/* ── 一括操作バー ──────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: rgba(245,158,11,0.12);
|
||||
border-bottom: 1px solid rgba(245,158,11,0.35);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-bulk-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
white-space: nowrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ── ライトボックス ─────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.9);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-img {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-footer {
|
||||
margin-top: 8px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-close {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-close:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ── 画像比較ライトボックス ──────────────────────────────────── */
|
||||
|
||||
.wfm-lightbox-compare .wfm-lightbox-compare-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 96vw;
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--compare-cols, 2), 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-img {
|
||||
max-width: calc(92vw / var(--compare-cols, 2) - 12px);
|
||||
max-height: 78vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-label {
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-close {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
/* ── 詳細パネル アクションボタン行 ───────────────────────────── */
|
||||
|
||||
.wfm-gallery-detail-action-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 詳細パネル アクションボタン ─────────────────────────────── */
|
||||
|
||||
.wfm-gallery-action-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
margin: 2px 3px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-gallery-action-btn-green {
|
||||
background: var(--wfm-success, #22c55e);
|
||||
border-color: var(--wfm-success, #22c55e);
|
||||
}
|
||||
.wfm-gallery-action-btn-green:hover {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.wfm-gallery-action-btn-primary {
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-gallery-action-btn-primary:hover {
|
||||
background: var(--wfm-primary-hover);
|
||||
border-color: var(--wfm-primary-hover);
|
||||
}
|
||||
|
||||
.wfm-gallery-action-btn-tagger {
|
||||
background: var(--wfm-accent, #7c5cfc);
|
||||
border-color: var(--wfm-accent, #7c5cfc);
|
||||
}
|
||||
.wfm-gallery-action-btn-tagger:hover {
|
||||
background: #6a4de0;
|
||||
border-color: #6a4de0;
|
||||
}
|
||||
|
||||
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
|
||||
|
||||
.wfm-gallery-file-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.wfm-gallery-file-actions .wfm-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── 移動先選択モーダル ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-move-modal {
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wfm-gallery-move-modal-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-primary);
|
||||
}
|
||||
|
||||
.wfm-gallery-move-dest-sel {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-gallery-move-modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/* =============================================
|
||||
Image Edit Tab
|
||||
============================================= */
|
||||
|
||||
#wfm-tab-image-edit {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-editor {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Left Toolbar
|
||||
============================================= */
|
||||
|
||||
.ie-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 5px;
|
||||
background: var(--wfm-surface);
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-tool-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ie-tool-btn:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
border-color: var(--wfm-border);
|
||||
}
|
||||
|
||||
.ie-tool-btn.active {
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ie-tool-placeholder {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.ie-tool-placeholder::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--wfm-text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Center column
|
||||
============================================= */
|
||||
|
||||
.ie-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Action bar */
|
||||
.ie-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tool options bar */
|
||||
.ie-tool-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
min-height: 36px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ie-opt-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ie-opt-group label {
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-opt-select {
|
||||
font-size: 12px;
|
||||
padding: 2px 5px;
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ie-opt-input {
|
||||
font-size: 12px;
|
||||
padding: 2px 5px;
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ie-opt-active {
|
||||
background: var(--wfm-primary) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--wfm-primary) !important;
|
||||
}
|
||||
|
||||
/* Canvas wrap */
|
||||
.ie-canvas-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
background-image:
|
||||
linear-gradient(45deg, #222 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #222 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #222 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #222 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
}
|
||||
|
||||
.ie-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: top left;
|
||||
line-height: 0;
|
||||
box-shadow: 0 2px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.ie-canvas-container canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#ie-canvas-bg {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#ie-canvas-draw {
|
||||
z-index: 2;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
#ie-canvas-overlay {
|
||||
z-index: 3;
|
||||
cursor: default;
|
||||
pointer-events: none; /* SelectTool.activate()でautoに変更 */
|
||||
}
|
||||
|
||||
.ie-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ie-placeholder-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Zoom bar */
|
||||
.ie-zoom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
#ie-zoom-label {
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Properties Pane (left of Layers panel)
|
||||
============================================= */
|
||||
|
||||
.ie-props-pane {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-props-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-props-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ie-props-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ie-props-row label {
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-props-row input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ie-props-row span {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Mask layer item highlight */
|
||||
.ie-layer-item[data-type="mask"] .ie-layer-type-icon {
|
||||
color: #e25252;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Right Panel (Layers)
|
||||
============================================= */
|
||||
|
||||
.ie-right-panel {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-layers-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-layers-header > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ie-layer-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.ie-layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ie-layer-item:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.ie-layer-item.active {
|
||||
background: rgba(90, 110, 220, 0.22);
|
||||
outline: 1px solid rgba(90, 110, 220, 0.4);
|
||||
}
|
||||
|
||||
.ie-layer-thumb {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
background:
|
||||
repeating-conic-gradient(#888 0% 25%, transparent 0% 50%)
|
||||
0 0 / 8px 8px;
|
||||
}
|
||||
|
||||
.ie-layer-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-layer-vis-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--wfm-text);
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.ie-layer-vis-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ie-opacity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.ie-opacity-row input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#ie-layer-opacity-label {
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Text Tool Overlay
|
||||
============================================= */
|
||||
|
||||
.ie-text-overlay {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ie-text-textarea {
|
||||
width: 200px;
|
||||
resize: vertical;
|
||||
background: var(--wfm-bg);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.ie-text-btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Scrollbar
|
||||
============================================= */
|
||||
|
||||
.ie-layer-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.ie-layer-list::-webkit-scrollbar-thumb {
|
||||
background: var(--wfm-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/* ============================================================
|
||||
Tagger Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── サブタブナビ ────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-subtab-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn:hover {
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn.active {
|
||||
color: var(--wfm-primary);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
/* ── サブタブ共通 ────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-subtab {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ── セクション ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-section {
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-tagger-section-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── フォームレイアウト ─────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-tagger-form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── スライダー ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
accent-color: var(--wfm-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-tagger-slider-val {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Single サブタブ ─────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-single-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-image-pane {
|
||||
width: 300px;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-image-sources {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-preview-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--wfm-surface);
|
||||
border: 2px dashed var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-tagger-preview-wrap.drag-over {
|
||||
border-color: var(--wfm-primary);
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
.wfm-tagger-image-name {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.wfm-tagger-controls-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-result-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Batch サブタブ ──────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-batch-layout {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.wfm-tagger-batch-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wfm-tagger-progress-bar-wrap {
|
||||
height: 6px;
|
||||
background: var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-tagger-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--wfm-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── DB サブタブ ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-db-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table th,
|
||||
.wfm-tagger-db-table td {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table th {
|
||||
background: var(--wfm-bg-secondary);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr.selected {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-edit-panel {
|
||||
flex-shrink: 0;
|
||||
margin-top: 10px;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* ── ユーティリティ ─────────────────────────────────────────── */
|
||||
|
||||
#wfm-tab-tagger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--wfm-header-height, 56px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#wfm-tab-tagger.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wfm-placeholder {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
/**
|
||||
* AI Tab - Translation, VLM, Settings
|
||||
* Supports Ollama, LM Studio, and Lemonade as backends
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_ai_settings";
|
||||
|
||||
const LANG_NAMES = {
|
||||
ja: "Japanese",
|
||||
en: "English",
|
||||
zh: "Chinese",
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Settings persistence
|
||||
// ============================================
|
||||
|
||||
function loadAiSettings() {
|
||||
return readJsonStorage(SETTINGS_KEY);
|
||||
}
|
||||
|
||||
function saveAiSettings(patch) {
|
||||
const data = { ...loadAiSettings(), ...patch };
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
const VLM_PROMPTS = {
|
||||
describe: "Describe this image in detail.",
|
||||
prompt: "Create a detailed Stable Diffusion image generation prompt based on this image. Output only the prompt text, nothing else.",
|
||||
tags: "Generate a list of descriptive tags for this image. Output only comma-separated tags in English, nothing else.",
|
||||
};
|
||||
|
||||
function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const b64 = e.target.result.split(",")[1];
|
||||
resolve({ base64: b64, mimeType: file.type });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).response || "";
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// URL validation
|
||||
// ============================================
|
||||
|
||||
function isValidBackendUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API helpers
|
||||
// ============================================
|
||||
|
||||
async function fetchModels(url, backend) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models || []).map((m) => m.name);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.data || []).map((m) => m.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(url, backend) {
|
||||
const models = await fetchModels(url, backend);
|
||||
return models.length;
|
||||
}
|
||||
|
||||
async function callLLM(url, backend, model, prompt) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, stream: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.response || "";
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content || "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Translation prompt builder
|
||||
// ============================================
|
||||
|
||||
function buildTranslationPrompt(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "Auto") : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English") : LANG_NAMES[dstLang];
|
||||
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
|
||||
return `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Translation tab
|
||||
// ============================================
|
||||
|
||||
function initTranslateTab() {
|
||||
const srcLangSel = document.getElementById("wfm-ai-src-lang");
|
||||
const dstLangSel = document.getElementById("wfm-ai-dst-lang");
|
||||
const swapBtn = document.getElementById("wfm-ai-trans-swap-btn");
|
||||
const inputEl = document.getElementById("wfm-ai-trans-input");
|
||||
const outputEl = document.getElementById("wfm-ai-trans-output");
|
||||
const transBtn = document.getElementById("wfm-ai-trans-btn");
|
||||
const copyBtn = document.getElementById("wfm-ai-trans-copy-btn");
|
||||
const statusEl = document.getElementById("wfm-ai-trans-status");
|
||||
|
||||
if (!transBtn) return;
|
||||
|
||||
// Restore saved language selections
|
||||
const saved = loadAiSettings();
|
||||
if (saved.srcLang && srcLangSel) srcLangSel.value = saved.srcLang;
|
||||
if (saved.dstLang && dstLangSel) dstLangSel.value = saved.dstLang;
|
||||
|
||||
// Persist language selections
|
||||
srcLangSel?.addEventListener("change", () => saveAiSettings({ srcLang: srcLangSel.value }));
|
||||
dstLangSel?.addEventListener("change", () => saveAiSettings({ dstLang: dstLangSel.value }));
|
||||
|
||||
// Swap languages
|
||||
swapBtn?.addEventListener("click", () => {
|
||||
const tmp = srcLangSel.value;
|
||||
srcLangSel.value = dstLangSel.value;
|
||||
dstLangSel.value = tmp;
|
||||
saveAiSettings({ srcLang: srcLangSel.value, dstLang: dstLangSel.value });
|
||||
// Swap text content too
|
||||
const tmpText = inputEl.value;
|
||||
inputEl.value = outputEl.value;
|
||||
outputEl.value = tmpText;
|
||||
});
|
||||
|
||||
// Translate
|
||||
transBtn.addEventListener("click", async () => {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) {
|
||||
showToast(t("aiToastNoText"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
showToast(t("aiToastNoModel"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
transBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusTranslating");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
outputEl.value = "";
|
||||
|
||||
try {
|
||||
const prompt = buildTranslationPrompt(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
outputEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastTransFailed") + err.message, "error");
|
||||
} finally {
|
||||
transBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Copy translated text
|
||||
copyBtn?.addEventListener("click", () => {
|
||||
const text = outputEl.value;
|
||||
if (!text) {
|
||||
showToast(t("aiToastNoCopyText"), "error");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(t("aiToastCopied"), "success");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Settings tab
|
||||
// ============================================
|
||||
|
||||
async function refreshModels() {
|
||||
const settings = loadAiSettings();
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
const url = document.getElementById("wfm-ai-backend-url")?.value?.trim() || "";
|
||||
const modelSel = document.getElementById("wfm-ai-model-select");
|
||||
const refreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
|
||||
|
||||
if (!modelSel) return;
|
||||
|
||||
if (refreshBtn) refreshBtn.disabled = true;
|
||||
try {
|
||||
const models = await fetchModels(url, backend);
|
||||
modelSel.innerHTML = `<option value="">-- ${t("aiSettingsModelPlaceholder")} --</option>`;
|
||||
models.forEach((name) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
if (name === settings.model) opt.selected = true;
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
if (models.length === 0) showToast(t("aiToastNoModels"), "error");
|
||||
} catch (err) {
|
||||
showToast(t("aiToastModelsFailed") + err.message, "error");
|
||||
} finally {
|
||||
if (refreshBtn) refreshBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSettingsTab() {
|
||||
const saved = loadAiSettings();
|
||||
|
||||
// Restore saved values
|
||||
const backendRadios = document.querySelectorAll("input[name='wfm-ai-backend']");
|
||||
backendRadios.forEach((r) => {
|
||||
if (r.value === (saved.backend || "ollama")) r.checked = true;
|
||||
});
|
||||
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
// Switch URL to the new backend's default when the user changes backend
|
||||
backendRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
if (!urlInput) return;
|
||||
urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
});
|
||||
});
|
||||
|
||||
// Connection test
|
||||
document.getElementById("wfm-ai-test-btn")?.addEventListener("click", async () => {
|
||||
const testBtn = document.getElementById("wfm-ai-test-btn");
|
||||
const resultEl = document.getElementById("wfm-ai-test-result");
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
const url = urlInput?.value?.trim() || "";
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = t("aiToastInvalidUrlInput");
|
||||
resultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (testBtn) testBtn.disabled = true;
|
||||
if (resultEl) {
|
||||
resultEl.textContent = t("aiStatusConnecting");
|
||||
resultEl.className = "wfm-ai-test-result wfm-ai-status-working";
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await testConnection(url, backend);
|
||||
if (resultEl) {
|
||||
resultEl.textContent = `${t("aiStatusConnectOk")} (${count} ${t("aiModels")})`;
|
||||
resultEl.className = "wfm-ai-test-result wfm-ai-status-ok";
|
||||
}
|
||||
await refreshModels();
|
||||
} catch (err) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
resultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
}
|
||||
} finally {
|
||||
if (testBtn) testBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Model refresh
|
||||
document.getElementById("wfm-ai-model-refresh-btn")?.addEventListener("click", () => refreshModels());
|
||||
|
||||
// Free language inputs
|
||||
const freeSrcInput = document.getElementById("wfm-ai-free-src-lang");
|
||||
const freeDstInput = document.getElementById("wfm-ai-free-dst-lang");
|
||||
if (freeSrcInput && saved.freeSrcLang) freeSrcInput.value = saved.freeSrcLang;
|
||||
if (freeDstInput && saved.freeDstLang) freeDstInput.value = saved.freeDstLang;
|
||||
|
||||
// Chat image generation — dedicated workflow selection (T2I)
|
||||
const chatGenCheckbox = document.getElementById("wfm-ai-chatgen-dedicated-checkbox");
|
||||
const chatGenRow = document.getElementById("wfm-ai-chatgen-dedicated-row");
|
||||
const chatGenSelect = document.getElementById("wfm-ai-chatgen-dedicated-select");
|
||||
|
||||
if (chatGenCheckbox) chatGenCheckbox.checked = !!saved.chatGenDedicatedEnabled;
|
||||
if (chatGenRow) chatGenRow.style.display = saved.chatGenDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenCheckbox?.addEventListener("change", () => {
|
||||
if (chatGenRow) chatGenRow.style.display = chatGenCheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
// Chat I2I generation — dedicated workflow selection
|
||||
const chatGenI2ICheckbox = document.getElementById("wfm-ai-chatgen-i2i-dedicated-checkbox");
|
||||
const chatGenI2IRow = document.getElementById("wfm-ai-chatgen-i2i-dedicated-row");
|
||||
const chatGenI2ISelect = document.getElementById("wfm-ai-chatgen-i2i-dedicated-select");
|
||||
|
||||
if (chatGenI2ICheckbox) chatGenI2ICheckbox.checked = !!saved.chatGenI2IDedicatedEnabled;
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = saved.chatGenI2IDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenI2ICheckbox?.addEventListener("change", () => {
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = chatGenI2ICheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
if (chatGenSelect) {
|
||||
fetch(`${comfyUI.baseUrl}/api/wfm/workflows`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list) => {
|
||||
const filenames = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
const optionsHtml = `<option value="">-- select workflow --</option>` +
|
||||
filenames.map((f) => `<option value="${f}">${f}</option>`).join("");
|
||||
chatGenSelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenDedicatedFilename) chatGenSelect.value = saved.chatGenDedicatedFilename;
|
||||
if (chatGenI2ISelect) {
|
||||
chatGenI2ISelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenI2IDedicatedFilename) chatGenI2ISelect.value = saved.chatGenI2IDedicatedFilename;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Save settings
|
||||
document.getElementById("wfm-ai-settings-save-btn")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
const url = urlInput?.value?.trim() || "";
|
||||
const model = document.getElementById("wfm-ai-model-select")?.value || "";
|
||||
const freeSrcLang = freeSrcInput?.value?.trim() || "";
|
||||
const freeDstLang = freeDstInput?.value?.trim() || "";
|
||||
const chatGenDedicatedEnabled = !!chatGenCheckbox?.checked;
|
||||
const chatGenDedicatedFilename = chatGenSelect?.value || "";
|
||||
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
|
||||
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
});
|
||||
|
||||
// Load models on init if settings exist
|
||||
if (saved.backendUrl && saved.backend) {
|
||||
refreshModels().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chat tab
|
||||
// ============================================
|
||||
|
||||
const IMAGE_GEN_TOOLS = [{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_image",
|
||||
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prompt: { type: "string", description: "Positive prompt describing the desired image." },
|
||||
negative_prompt: { type: "string", description: "What to avoid in the image. Optional." },
|
||||
},
|
||||
required: ["prompt"],
|
||||
},
|
||||
},
|
||||
}];
|
||||
|
||||
// chatHistory の内部共通形式 {role, content, images?:[{base64,mimeType}]} を
|
||||
// バックエンドごとのワイヤーフォーマットへ変換する(callVLM() と同じ規約)
|
||||
function _formatMessagesForBackend(messages, backend) {
|
||||
return messages.map((m) => {
|
||||
if (!m.images?.length) return { role: m.role, content: m.content };
|
||||
if (backend === "ollama") {
|
||||
return { role: m.role, content: m.content, images: m.images.map((i) => i.base64) };
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content: [
|
||||
{ type: "text", text: m.content },
|
||||
...m.images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mimeType};base64,${i.base64}` } })),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function callChat(url, backend, model, messages, tools) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) body.tools = tools;
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).choices?.[0]?.message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
}
|
||||
}
|
||||
|
||||
function _parseToolArgs(toolCall) {
|
||||
const raw = toolCall.function?.arguments ?? toolCall.arguments;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw); } catch { return {}; }
|
||||
}
|
||||
return raw || {};
|
||||
}
|
||||
|
||||
async function _loadDedicatedChatGenWorkflow(filename) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let workflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(workflow, filename);
|
||||
if (format === "ui") {
|
||||
workflow = await comfyWorkflow.convertUiToApi(workflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
|
||||
}
|
||||
|
||||
async function generateImageFromChat(prompt, negativePrompt) {
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const isI2I = !!_toolsImage;
|
||||
let workflow, analysis;
|
||||
|
||||
if (isI2I && settings.chatGenI2IDedicatedEnabled && settings.chatGenI2IDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenI2IDedicatedFilename));
|
||||
} else if (!isI2I && settings.chatGenDedicatedEnabled && settings.chatGenDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenDedicatedFilename));
|
||||
} else {
|
||||
if (!comfyUI.currentWorkflow || !comfyUI.currentAnalysis) throw new Error(t("aiChatNoWorkflowLoaded"));
|
||||
workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
|
||||
analysis = comfyUI.currentAnalysis;
|
||||
}
|
||||
|
||||
if (isI2I) {
|
||||
if (!(analysis.load_image_nodes?.length > 0)) throw new Error(t("aiChatNoLoadImageNode"));
|
||||
await comfyEditor.applyImageToSlot(_toolsImage.file, 0, { workflow, analysis });
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
|
||||
|
||||
const result = await window._wfmGenerateTab.generate(workflow);
|
||||
const images = result?.images || [];
|
||||
if (images.length === 0) throw new Error(t("aiChatNoImageProduced"));
|
||||
|
||||
const img = images[0];
|
||||
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
}
|
||||
|
||||
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
|
||||
function _extractSvgCode(text) {
|
||||
if (!text) return null;
|
||||
const fenced = text.match(/```(?:svg|xml)?\s*([\s\S]*?<svg[\s\S]*?<\/svg>)[\s\S]*?```/i);
|
||||
if (fenced) return fenced[1].trim();
|
||||
const bare = text.match(/<svg[\s\S]*?<\/svg>/i);
|
||||
if (bare) return bare[0].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
// SVGコードをプレビュー表示し、Galleryへ保存するボタンを追加する。
|
||||
// <img src="data:..."> でのレンダリングはブラウザがスクリプト実行を無効化するため安全。
|
||||
function _appendSvgPreview(container, svgCode) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "wfm-ai-chat-svg-wrapper";
|
||||
|
||||
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgCode)))}`;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = dataUrl;
|
||||
img.className = "wfm-ai-chat-svg-img";
|
||||
img.alt = "Generated SVG";
|
||||
wrapper.appendChild(img);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "wfm-btn wfm-btn-sm";
|
||||
saveBtn.textContent = t("aiChatSaveSvgToGallery");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: `chat_svg_${Date.now()}.svg`, imageData: dataUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
showToast(t("aiChatSvgSaved"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("aiChatSvgSaveFailed") + e.message, "error");
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
wrapper.appendChild(saveBtn);
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
|
||||
if (content) div.textContent = content;
|
||||
if (opts.imageUrl) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.imageUrl;
|
||||
img.className = "wfm-ai-chat-img";
|
||||
div.appendChild(img);
|
||||
}
|
||||
if (opts.attachThumb) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.attachThumb;
|
||||
img.className = "wfm-ai-chat-attach-thumb";
|
||||
div.appendChild(img);
|
||||
}
|
||||
if (role === "assistant" && content) {
|
||||
const svgCode = _extractSvgCode(content);
|
||||
if (svgCode) _appendSvgPreview(div, svgCode);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function initChatTab() {
|
||||
const messagesEl = document.getElementById("wfm-ai-chat-messages");
|
||||
const inputEl = document.getElementById("wfm-ai-chat-input");
|
||||
const sendBtn = document.getElementById("wfm-ai-chat-send-btn");
|
||||
const clearBtn = document.getElementById("wfm-ai-chat-clear-btn");
|
||||
const statusEl = document.getElementById("wfm-ai-chat-status");
|
||||
const imgGenToggle = document.getElementById("wfm-ai-chat-imggen-toggle");
|
||||
|
||||
if (!messagesEl || !inputEl || !sendBtn) return;
|
||||
|
||||
if (imgGenToggle) {
|
||||
const savedToggle = loadAiSettings();
|
||||
imgGenToggle.checked = savedToggle.chatImageGenEnabled !== false;
|
||||
imgGenToggle.addEventListener("change", () => {
|
||||
saveAiSettings({ chatImageGenEnabled: imgGenToggle.checked });
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("wfm-ai-chat-attachment-clear")?.addEventListener("click", () => {
|
||||
_clearToolsImage();
|
||||
});
|
||||
|
||||
let chatHistory = [];
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
if (!model) {
|
||||
showToast(t("aiToastNoModel"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
inputEl.value = "";
|
||||
inputEl.style.height = "";
|
||||
const userEntry = { role: "user", content: text };
|
||||
if (_toolsImage) userEntry.images = [{ base64: _toolsImage.base64, mimeType: _toolsImage.mimeType }];
|
||||
chatHistory.push(userEntry);
|
||||
appendChatBubble(messagesEl, "user", text, _toolsImage ? { attachThumb: `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}` } : {});
|
||||
|
||||
sendBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusChatting");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
|
||||
try {
|
||||
const tools = imgGenToggle?.checked !== false ? IMAGE_GEN_TOOLS : undefined;
|
||||
const reply = await callChat(url, backend, model, chatHistory, tools);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
|
||||
if (toolCall) {
|
||||
chatHistory.push({ role: "assistant", content: reply.content || "" });
|
||||
if (reply.content) appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
|
||||
statusEl.textContent = t("aiStatusGeneratingImage");
|
||||
const args = _parseToolArgs(toolCall);
|
||||
try {
|
||||
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
|
||||
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
|
||||
} catch (genErr) {
|
||||
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
|
||||
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
|
||||
}
|
||||
} else {
|
||||
chatHistory.push({ role: "assistant", content: reply.content });
|
||||
appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
}
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wfm-ai-trans-status";
|
||||
} catch (err) {
|
||||
chatHistory.pop();
|
||||
if (messagesEl.lastChild) messagesEl.removeChild(messagesEl.lastChild);
|
||||
inputEl.value = text;
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastChatFailed") + err.message, "error");
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener("click", sendMessage);
|
||||
|
||||
inputEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
chatHistory = [];
|
||||
messagesEl.innerHTML = "";
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wfm-ai-trans-status";
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TOOLS ↔ Chat shared image attachment
|
||||
// ============================================
|
||||
|
||||
let _toolsImage = null; // { file: File, base64: string, mimeType: string }
|
||||
|
||||
function _syncAttachmentUI() {
|
||||
const vlmPreview = document.getElementById("wfm-ai-vlm-preview");
|
||||
const vlmLabel = document.getElementById("wfm-ai-vlm-label");
|
||||
const vlmClear = document.getElementById("wfm-ai-vlm-clear");
|
||||
const chatAttach = document.getElementById("wfm-ai-chat-attachment");
|
||||
const chatThumb = document.getElementById("wfm-ai-chat-attachment-thumb");
|
||||
const chatLabel = document.getElementById("wfm-ai-chat-attachment-label");
|
||||
|
||||
if (_toolsImage) {
|
||||
const dataUrl = `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}`;
|
||||
if (vlmPreview) { vlmPreview.src = dataUrl; vlmPreview.style.display = "block"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "none";
|
||||
if (vlmClear) vlmClear.style.display = "flex";
|
||||
if (chatAttach) chatAttach.style.display = "flex";
|
||||
if (chatThumb) chatThumb.src = dataUrl;
|
||||
if (chatLabel) chatLabel.textContent = t("aiChatAttachmentLabel");
|
||||
} else {
|
||||
if (vlmPreview) { vlmPreview.src = ""; vlmPreview.style.display = "none"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "";
|
||||
if (vlmClear) vlmClear.style.display = "none";
|
||||
if (chatAttach) chatAttach.style.display = "none";
|
||||
if (chatThumb) chatThumb.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _clearToolsImage() {
|
||||
_toolsImage = null;
|
||||
_syncAttachmentUI();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VLM tab
|
||||
// ============================================
|
||||
|
||||
function initVlmTab() {
|
||||
const dropEl = document.getElementById("wfm-ai-vlm-drop");
|
||||
const clearEl = document.getElementById("wfm-ai-vlm-clear");
|
||||
const fileInput = document.getElementById("wfm-ai-vlm-file");
|
||||
const taskSel = document.getElementById("wfm-ai-vlm-task");
|
||||
const runBtn = document.getElementById("wfm-ai-vlm-run");
|
||||
const statusEl = document.getElementById("wfm-ai-vlm-status");
|
||||
const resultEl = document.getElementById("wfm-ai-vlm-result");
|
||||
const copyBtn = document.getElementById("wfm-ai-vlm-copy");
|
||||
const wcInputs = document.getElementById("wfm-ai-wc-inputs");
|
||||
const wcNameEl = document.getElementById("wfm-ai-wc-name");
|
||||
const wcCountEl = document.getElementById("wfm-ai-wc-count");
|
||||
|
||||
if (!dropEl) return;
|
||||
|
||||
function updateTaskUI() {
|
||||
const isWildcard = taskSel?.value === "wildcard";
|
||||
dropEl.style.display = isWildcard ? "none" : "";
|
||||
if (wcInputs) wcInputs.style.display = isWildcard ? "" : "none";
|
||||
}
|
||||
taskSel?.addEventListener("change", updateTaskUI);
|
||||
updateTaskUI();
|
||||
|
||||
const loadImage = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const { base64, mimeType } = await fileToBase64(file);
|
||||
_toolsImage = { file, base64, mimeType };
|
||||
_syncAttachmentUI();
|
||||
};
|
||||
|
||||
dropEl.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", e => { if (e.target.files[0]) loadImage(e.target.files[0]); });
|
||||
dropEl.addEventListener("dragover", e => { e.preventDefault(); dropEl.classList.add("drag-over"); });
|
||||
dropEl.addEventListener("dragleave", () => dropEl.classList.remove("drag-over"));
|
||||
dropEl.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropEl.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]);
|
||||
});
|
||||
clearEl?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_clearToolsImage();
|
||||
if (fileInput) fileInput.value = "";
|
||||
});
|
||||
|
||||
runBtn.addEventListener("click", async () => {
|
||||
const task = taskSel?.value || "describe";
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
|
||||
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
|
||||
|
||||
if (task === "wildcard") {
|
||||
const name = wcNameEl?.value.trim() || "";
|
||||
const count = Math.max(1, parseInt(wcCountEl?.value) || 20);
|
||||
if (!name) { showToast(t("aiToastWcNoName"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
resultEl.value = "";
|
||||
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
|
||||
try {
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
resultEl.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
.filter(l => l.length > 0)
|
||||
.join("\n");
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastVlmFailed") + err.message, "error");
|
||||
} finally {
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_toolsImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
resultEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastVlmFailed") + err.message, "error");
|
||||
} finally {
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
copyBtn.addEventListener("click", () => {
|
||||
const text = resultEl.value;
|
||||
if (!text) { showToast(t("aiToastNoCopyText"), "error"); return; }
|
||||
navigator.clipboard.writeText(text).then(() => showToast(t("aiToastCopied"), "success"));
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Export
|
||||
// ============================================
|
||||
|
||||
export function initAiTab() {
|
||||
initTranslateTab();
|
||||
initChatTab();
|
||||
initVlmTab();
|
||||
initSettingsTab();
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { initI18n, t } from "./i18n.js";
|
||||
import { getSettings } from "./util.js";
|
||||
|
||||
// Initialize i18n before anything else
|
||||
initI18n();
|
||||
@@ -90,7 +91,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings" };
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", "image-edit": "tabImageEdit", settings: "tabSettings", help: "tabHelp", ai: "tabAi", tagger: "tabTagger" };
|
||||
document.querySelectorAll(".wfm-tab").forEach((tab) => {
|
||||
const key = tabMap[tab.dataset.tab];
|
||||
if (key) tab.textContent = t(key);
|
||||
@@ -114,7 +115,7 @@ function applyI18nToHtml() {
|
||||
|
||||
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
|
||||
if (listOpenComfyBtn) {
|
||||
listOpenComfyBtn.textContent = t("openInComfyUI");
|
||||
listOpenComfyBtn.textContent = t("sendToCanvas");
|
||||
if (listOpenComfyBtn.disabled) listOpenComfyBtn.title = t("selectCardFirst");
|
||||
}
|
||||
|
||||
@@ -142,6 +143,85 @@ function applyI18nToHtml() {
|
||||
const copyBtn = document.getElementById("wfm-json-copy-btn");
|
||||
if (copyBtn) copyBtn.textContent = t("copy");
|
||||
|
||||
// Nodes tab
|
||||
const nodesSubBtns = document.querySelectorAll(".wfm-nodes-subview-btn");
|
||||
nodesSubBtns.forEach(btn => {
|
||||
if (btn.dataset.subview === "browser") btn.textContent = t("nodesAllNodes");
|
||||
if (btn.dataset.subview === "sets") btn.textContent = t("nodesNodeSets");
|
||||
});
|
||||
const nodesSearch = document.getElementById("wfm-nodes-search");
|
||||
if (nodesSearch) nodesSearch.placeholder = t("nodesSearchPlaceholder");
|
||||
const nodesRefreshBtn = document.getElementById("wfm-nodes-refresh-btn");
|
||||
if (nodesRefreshBtn) nodesRefreshBtn.textContent = t("nodesRefresh");
|
||||
const nodeSetCreateBtn = document.getElementById("wfm-node-set-create-btn");
|
||||
if (nodeSetCreateBtn) nodeSetCreateBtn.textContent = t("nodesCreateSet");
|
||||
const nodesSideTabBtns = document.querySelectorAll(".wfm-nodes-side-tab-btn");
|
||||
nodesSideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.sideTab === "details") btn.textContent = t("nodesDetails");
|
||||
if (btn.dataset.sideTab === "io") btn.textContent = t("nodesIoSpec");
|
||||
if (btn.dataset.sideTab === "nodegroup") btn.textContent = t("nodesGroups");
|
||||
});
|
||||
const nodesPlaceholder = document.getElementById("wfm-nodes-placeholder");
|
||||
if (nodesPlaceholder) nodesPlaceholder.textContent = t("nodesClickToLoad");
|
||||
|
||||
// Models tab
|
||||
const modelsTypeBtns = document.querySelectorAll(".wfm-models-type-btn");
|
||||
modelsTypeBtns.forEach(btn => {
|
||||
const key = "models" + btn.dataset.modelType.charAt(0).toUpperCase() + btn.dataset.modelType.slice(1);
|
||||
const label = t(key);
|
||||
if (label && label !== key) btn.textContent = label;
|
||||
});
|
||||
const modelsSearch = document.getElementById("wfm-models-search");
|
||||
if (modelsSearch) modelsSearch.placeholder = t("modelsSearchPlaceholder");
|
||||
const modelsRefreshBtn = document.getElementById("wfm-models-refresh-btn");
|
||||
if (modelsRefreshBtn) modelsRefreshBtn.textContent = t("modelsRefresh");
|
||||
const modelsPlaceholder = document.getElementById("wfm-models-placeholder");
|
||||
if (modelsPlaceholder) modelsPlaceholder.textContent = t("modelsClickToLoad");
|
||||
const modelsTagFilter = document.getElementById("wfm-models-tag-filter");
|
||||
if (modelsTagFilter) {
|
||||
const firstOpt = modelsTagFilter.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = t("modelsAllTags");
|
||||
}
|
||||
const modelsSideTabBtns = document.querySelectorAll(".wfm-models-side-tab-btn");
|
||||
modelsSideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.sideTab === "info") btn.textContent = t("modelsSideInfo");
|
||||
if (btn.dataset.sideTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
|
||||
});
|
||||
|
||||
// Gallery tab
|
||||
const gallerySideTabBtns = document.querySelectorAll(".wfm-gallery-detail-tab-btn");
|
||||
gallerySideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
|
||||
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
|
||||
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
});
|
||||
const galleryBulkDeselect = document.getElementById("wfm-gallery-bulk-deselect");
|
||||
if (galleryBulkDeselect) galleryBulkDeselect.textContent = t("galleryBulkDeselectAll");
|
||||
const galleryBulkSelectAll = document.getElementById("wfm-gallery-bulk-select-all");
|
||||
if (galleryBulkSelectAll) galleryBulkSelectAll.textContent = t("galleryBulkSelectAll");
|
||||
const galleryBulkGroupSelect = document.getElementById("wfm-gallery-bulk-group-select");
|
||||
if (galleryBulkGroupSelect) {
|
||||
const opt = galleryBulkGroupSelect.querySelector("option[value='']");
|
||||
if (opt) opt.textContent = t("galleryBulkAddToGroup");
|
||||
}
|
||||
const galleryBulkGroupAdd = document.getElementById("wfm-gallery-bulk-group-add");
|
||||
if (galleryBulkGroupAdd) galleryBulkGroupAdd.textContent = t("galleryBulkAdd");
|
||||
const galleryBulkGroupRemove = document.getElementById("wfm-gallery-bulk-group-remove");
|
||||
if (galleryBulkGroupRemove) galleryBulkGroupRemove.textContent = t("galleryBulkGroupRemove");
|
||||
const galleryBulkFav = document.getElementById("wfm-gallery-bulk-fav");
|
||||
if (galleryBulkFav) galleryBulkFav.textContent = t("galleryBulkFavAll");
|
||||
const galleryBulkUnfav = document.getElementById("wfm-gallery-bulk-unfav");
|
||||
if (galleryBulkUnfav) galleryBulkUnfav.textContent = t("galleryBulkUnfavAll");
|
||||
const galleryBulkMove = document.getElementById("wfm-gallery-bulk-move");
|
||||
if (galleryBulkMove) galleryBulkMove.textContent = t("galleryBulkMoveTo");
|
||||
const galleryBulkExport = document.getElementById("wfm-gallery-bulk-export");
|
||||
if (galleryBulkExport) galleryBulkExport.textContent = t("galleryBulkExport");
|
||||
const galleryBulkDelete = document.getElementById("wfm-gallery-bulk-delete");
|
||||
if (galleryBulkDelete) galleryBulkDelete.textContent = t("galleryBulkDelete");
|
||||
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
|
||||
if (galleryOpenTaggerBtn) galleryOpenTaggerBtn.textContent = t("tabTagger");
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
@@ -163,10 +243,83 @@ function applyI18nToHtml() {
|
||||
if (en2jaBtn) en2jaBtn.textContent = t("en2ja");
|
||||
const presetApplyBtn = document.getElementById("wfm-preset-apply-btn");
|
||||
if (presetApplyBtn) presetApplyBtn.textContent = t("applyPreset");
|
||||
const presetDeleteBtn = document.getElementById("wfm-preset-delete-btn");
|
||||
if (presetDeleteBtn) presetDeleteBtn.textContent = t("deletePreset");
|
||||
const presetSaveBtn = document.getElementById("wfm-preset-save-btn");
|
||||
if (presetSaveBtn) presetSaveBtn.textContent = t("savePreset");
|
||||
const presetCopyPosBtn = document.getElementById("wfm-preset-copy-pos-btn");
|
||||
if (presetCopyPosBtn) presetCopyPosBtn.textContent = t("copyPositivePrompt");
|
||||
const presetCopyNegBtn = document.getElementById("wfm-preset-copy-neg-btn");
|
||||
if (presetCopyNegBtn) presetCopyNegBtn.textContent = t("copyNegativePrompt");
|
||||
|
||||
// AI tab
|
||||
document.querySelectorAll(".wfm-ai-subtab-btn").forEach(btn => {
|
||||
if (btn.dataset.aiSubtab === "translate") btn.textContent = t("aiSubTabTranslate");
|
||||
if (btn.dataset.aiSubtab === "vlm") btn.textContent = t("aiSubTabVlm");
|
||||
if (btn.dataset.aiSubtab === "ai-settings") btn.textContent = t("aiSubTabSettings");
|
||||
});
|
||||
const aiLangMap = { ja: "aiLangJa", en: "aiLangEn", zh: "aiLangZh", free: "aiLangFree" };
|
||||
[document.getElementById("wfm-ai-src-lang"), document.getElementById("wfm-ai-dst-lang")].forEach(sel => {
|
||||
if (!sel) return;
|
||||
sel.querySelectorAll("option").forEach(opt => {
|
||||
const key = aiLangMap[opt.value];
|
||||
if (key) opt.textContent = t(key);
|
||||
});
|
||||
});
|
||||
const aiTransInput = document.getElementById("wfm-ai-trans-input");
|
||||
if (aiTransInput) aiTransInput.placeholder = t("aiTransInputPlaceholder");
|
||||
const aiTransOutput = document.getElementById("wfm-ai-trans-output");
|
||||
if (aiTransOutput) aiTransOutput.placeholder = t("aiTransOutputPlaceholder");
|
||||
const aiTransBtn = document.getElementById("wfm-ai-trans-btn");
|
||||
if (aiTransBtn) aiTransBtn.textContent = t("aiTranslateBtn");
|
||||
const aiTransCopyBtn = document.getElementById("wfm-ai-trans-copy-btn");
|
||||
if (aiTransCopyBtn) aiTransCopyBtn.textContent = t("aiCopyBtn");
|
||||
const aiVlmLabel = document.getElementById("wfm-ai-vlm-label");
|
||||
if (aiVlmLabel) aiVlmLabel.textContent = t("aiVlmDropLabel");
|
||||
const aiVlmTask = document.getElementById("wfm-ai-vlm-task");
|
||||
if (aiVlmTask) {
|
||||
aiVlmTask.querySelectorAll("option").forEach(opt => {
|
||||
if (opt.value === "describe") opt.textContent = t("aiVlmDescribe");
|
||||
if (opt.value === "prompt") opt.textContent = t("aiVlmPromptCreate");
|
||||
});
|
||||
}
|
||||
const aiVlmRunBtn = document.getElementById("wfm-ai-vlm-run");
|
||||
if (aiVlmRunBtn) aiVlmRunBtn.textContent = t("aiVlmRunBtn");
|
||||
const aiVlmResult = document.getElementById("wfm-ai-vlm-result");
|
||||
if (aiVlmResult) aiVlmResult.placeholder = t("aiVlmResultPlaceholder");
|
||||
const aiVlmCopyBtn = document.getElementById("wfm-ai-vlm-copy");
|
||||
if (aiVlmCopyBtn) aiVlmCopyBtn.textContent = t("aiCopyBtn");
|
||||
const aiBackendTitle = document.getElementById("wfm-ai-settings-backend-title");
|
||||
if (aiBackendTitle) aiBackendTitle.textContent = t("aiSettingsBackend");
|
||||
const aiConnTitle = document.getElementById("wfm-ai-settings-conn-title");
|
||||
if (aiConnTitle) aiConnTitle.textContent = t("aiSettingsConnection");
|
||||
const aiTestBtn = document.getElementById("wfm-ai-test-btn");
|
||||
if (aiTestBtn) aiTestBtn.textContent = t("aiSettingsTestBtn");
|
||||
const aiModelTitle = document.getElementById("wfm-ai-settings-model-title");
|
||||
if (aiModelTitle) aiModelTitle.textContent = t("aiSettingsModelSection");
|
||||
const aiModelSel = document.getElementById("wfm-ai-model-select");
|
||||
if (aiModelSel) {
|
||||
const firstOpt = aiModelSel.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = t("aiSettingsModelPlaceholder");
|
||||
}
|
||||
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
|
||||
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
|
||||
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
|
||||
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
|
||||
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
|
||||
if (aiFreeSrcLabel) aiFreeSrcLabel.textContent = t("aiSettingsInputLang");
|
||||
const aiFreeDstLabel = document.getElementById("wfm-ai-free-dst-label");
|
||||
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
|
||||
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
|
||||
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
|
||||
const aiChatImgGenLabel = document.getElementById("wfm-ai-chat-imggen-label-text");
|
||||
if (aiChatImgGenLabel) aiChatImgGenLabel.textContent = t("aiChatImgGenToggle");
|
||||
const aiChatGenTitle = document.getElementById("wfm-ai-settings-chatgen-title");
|
||||
if (aiChatGenTitle) aiChatGenTitle.textContent = t("aiSettingsChatGenTitle");
|
||||
const aiChatGenDedicatedLabel = document.getElementById("wfm-ai-chatgen-dedicated-label");
|
||||
if (aiChatGenDedicatedLabel) aiChatGenDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
const aiChatGenI2ITitle = document.getElementById("wfm-ai-settings-chatgen-i2i-title");
|
||||
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
|
||||
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
|
||||
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
@@ -193,6 +346,199 @@ function applyI18nToHtml() {
|
||||
|
||||
const removeBtn = document.getElementById("wfm-remove-group-btn");
|
||||
if (removeBtn) removeBtn.textContent = t("removeFromGroup");
|
||||
|
||||
// Help & Support tab
|
||||
const helpManualTitle = document.getElementById("wfm-help-manual-title");
|
||||
if (helpManualTitle) helpManualTitle.textContent = t("helpManualTitle");
|
||||
|
||||
const helpIdMap = {
|
||||
"wfm-help-about-title": "helpAboutTitle",
|
||||
"wfm-help-about-desc": "helpAboutDesc",
|
||||
"wfm-help-about-1": "helpAbout1", "wfm-help-about-2": "helpAbout2",
|
||||
"wfm-help-wf-1": "helpWf1", "wfm-help-wf-2": "helpWf2", "wfm-help-wf-3": "helpWf3",
|
||||
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5", "wfm-help-wf-6": "helpWf6",
|
||||
"wfm-help-wf-7": "helpWf7",
|
||||
"wfm-help-gen-1": "helpGen1", "wfm-help-gen-2": "helpGen2",
|
||||
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
|
||||
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
|
||||
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
|
||||
"wfm-help-gen-11": "helpGen11", "wfm-help-gen-12": "helpGen12",
|
||||
"wfm-help-gen-13": "helpGen13", "wfm-help-gen-14": "helpGen14",
|
||||
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
|
||||
"wfm-help-gen-17": "helpGen17", "wfm-help-gen-18": "helpGen18",
|
||||
"wfm-help-gen-19": "helpGen19",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
"wfm-help-feeder-imgloop-desc": "helpFeederImgloopDesc",
|
||||
"wfm-help-feeder-1": "helpFeeder1", "wfm-help-feeder-2": "helpFeeder2",
|
||||
"wfm-help-feeder-3": "helpFeeder3", "wfm-help-feeder-4": "helpFeeder4",
|
||||
"wfm-help-feeder-5": "helpFeeder5", "wfm-help-feeder-6": "helpFeeder6",
|
||||
"wfm-help-feeder-7": "helpFeeder7", "wfm-help-feeder-8": "helpFeeder8",
|
||||
"wfm-help-feeder-9": "helpFeeder9",
|
||||
"wfm-help-feeder-gal-title": "helpFeederGalTitle",
|
||||
"wfm-help-feeder-gal-desc": "helpFeederGalDesc",
|
||||
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
|
||||
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
|
||||
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
|
||||
"wfm-help-prompt-9": "helpPrompt9",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13",
|
||||
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
|
||||
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
|
||||
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
|
||||
"wfm-help-nodes-7": "helpNodes7",
|
||||
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
|
||||
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
|
||||
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
|
||||
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
|
||||
"wfm-help-models-9": "helpModels9", "wfm-help-models-10": "helpModels10",
|
||||
"wfm-help-models-11": "helpModels11", "wfm-help-models-12": "helpModels12",
|
||||
"wfm-help-gallery-title": "helpGalleryTitle",
|
||||
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
|
||||
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
|
||||
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
|
||||
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
|
||||
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
|
||||
"wfm-help-gallery-11": "helpGallery11", "wfm-help-gallery-12": "helpGallery12",
|
||||
"wfm-help-gallery-13": "helpGallery13", "wfm-help-gallery-14": "helpGallery14",
|
||||
"wfm-help-gallery-15": "helpGallery15", "wfm-help-gallery-16": "helpGallery16",
|
||||
"wfm-help-gallery-17": "helpGallery17",
|
||||
"wfm-help-tagger-title": "helpTaggerTitle",
|
||||
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
|
||||
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
|
||||
"wfm-help-tagger-5": "helpTagger5", "wfm-help-tagger-6": "helpTagger6",
|
||||
"wfm-help-tagger-7": "helpTagger7", "wfm-help-tagger-8": "helpTagger8",
|
||||
"wfm-help-tagger-9": "helpTagger9",
|
||||
"wfm-help-sidepanel-title": "helpSidepanelTitle",
|
||||
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
|
||||
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
|
||||
"wfm-help-sidepanel-5": "helpSidepanel5", "wfm-help-sidepanel-6": "helpSidepanel6",
|
||||
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
|
||||
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
|
||||
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-12": "helpSidepanel12",
|
||||
"wfm-help-sidepanel-13": "helpSidepanel13",
|
||||
"wfm-help-sidepanel-14": "helpSidepanel14", "wfm-help-sidepanel-15": "helpSidepanel15",
|
||||
"wfm-help-sidepanel-16": "helpSidepanel16",
|
||||
"wfm-help-sidepanel-17": "helpSidepanel17",
|
||||
"wfm-help-ai-title": "helpAiTitle",
|
||||
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-5": "helpAi5", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"wfm-help-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
"wfm-help-imageedit-text-title": "helpImageEditTextTitle",
|
||||
"wfm-help-imageedit-export-title": "helpImageEditExportTitle",
|
||||
"wfm-help-imageedit-kbd-title": "helpImageEditKbdTitle",
|
||||
"wfm-help-imageedit-1": "helpImageEdit1", "wfm-help-imageedit-2": "helpImageEdit2", "wfm-help-imageedit-3": "helpImageEdit3",
|
||||
"wfm-help-imageedit-4": "helpImageEdit4", "wfm-help-imageedit-5": "helpImageEdit5",
|
||||
"wfm-help-imageedit-6": "helpImageEdit6", "wfm-help-imageedit-7": "helpImageEdit7",
|
||||
"wfm-help-imageedit-8": "helpImageEdit8", "wfm-help-imageedit-9": "helpImageEdit9",
|
||||
"wfm-help-imageedit-10": "helpImageEdit10", "wfm-help-imageedit-11": "helpImageEdit11",
|
||||
"wfm-help-imageedit-12": "helpImageEdit12", "wfm-help-imageedit-13": "helpImageEdit13",
|
||||
"wfm-help-imageedit-14": "helpImageEdit14", "wfm-help-imageedit-15": "helpImageEdit15",
|
||||
"wfm-help-imageedit-16": "helpImageEdit16", "wfm-help-imageedit-17": "helpImageEdit17",
|
||||
"wfm-help-imageedit-18": "helpImageEdit18", "wfm-help-imageedit-19": "helpImageEdit19",
|
||||
"wfm-help-imageedit-20": "helpImageEdit20", "wfm-help-imageedit-21": "helpImageEdit21",
|
||||
"wfm-help-imageedit-22": "helpImageEdit22", "wfm-help-imageedit-23": "helpImageEdit23",
|
||||
"wfm-help-imageedit-24": "helpImageEdit24", "wfm-help-imageedit-25": "helpImageEdit25",
|
||||
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
|
||||
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
|
||||
"wfm-help-imageedit-30": "helpImageEdit30",
|
||||
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
|
||||
"wfm-help-imageedit-8b": "helpImageEdit8b",
|
||||
"wfm-help-imageedit-blur-title": "helpImageEditBlurTitle",
|
||||
"wfm-help-imageedit-blur-1": "helpImageEditBlur1", "wfm-help-imageedit-blur-2": "helpImageEditBlur2",
|
||||
"wfm-help-imageedit-blur-3": "helpImageEditBlur3", "wfm-help-imageedit-blur-4": "helpImageEditBlur4",
|
||||
"wfm-help-imageedit-blur-5": "helpImageEditBlur5", "wfm-help-imageedit-blur-6": "helpImageEditBlur6",
|
||||
"wfm-help-imageedit-bgremove-title": "helpImageEditBgRemoveTitle",
|
||||
"wfm-help-imageedit-bgremove-1": "helpImageEditBgRemove1", "wfm-help-imageedit-bgremove-2": "helpImageEditBgRemove2",
|
||||
"wfm-help-imageedit-bgremove-3": "helpImageEditBgRemove3", "wfm-help-imageedit-bgremove-4": "helpImageEditBgRemove4",
|
||||
"wfm-help-imageedit-bgremove-5": "helpImageEditBgRemove5",
|
||||
"wfm-help-imageedit-mask-title": "helpImageEditMaskTitle",
|
||||
"wfm-help-imageedit-mask-1": "helpImageEditMask1", "wfm-help-imageedit-mask-2": "helpImageEditMask2",
|
||||
"wfm-help-imageedit-mask-3": "helpImageEditMask3", "wfm-help-imageedit-mask-4": "helpImageEditMask4",
|
||||
"wfm-help-imageedit-mask-5": "helpImageEditMask5", "wfm-help-imageedit-mask-6": "helpImageEditMask6",
|
||||
"wfm-help-imageedit-mask-7": "helpImageEditMask7", "wfm-help-imageedit-mask-8": "helpImageEditMask8",
|
||||
"wfm-help-imageedit-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
|
||||
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
|
||||
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
|
||||
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
|
||||
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
|
||||
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
|
||||
"wfm-help-imageedit-gmic-install-title": "helpImageEditGmicInstallTitle",
|
||||
"wfm-help-imageedit-gmic-install-1": "helpImageEditGmicInstall1",
|
||||
"wfm-help-imageedit-gmic-install-2": "helpImageEditGmicInstall2",
|
||||
"wfm-help-imageedit-gmic-install-3": "helpImageEditGmicInstall3",
|
||||
"wfm-help-imageedit-tabkbd-title": "helpImageEditTabKbdTitle",
|
||||
"wfm-help-imageedit-tabkbd-1": "helpImageEdit22", "wfm-help-imageedit-tabkbd-2": "helpImageEdit23",
|
||||
"wfm-help-imageedit-tabkbd-3": "helpImageEdit24", "wfm-help-imageedit-tabkbd-4": "helpImageEdit25",
|
||||
"wfm-help-imageedit-tabkbd-5": "helpImageEdit26", "wfm-help-imageedit-tabkbd-6": "helpImageEdit27",
|
||||
"wfm-help-imageedit-tabkbd-7": "helpImageEdit28", "wfm-help-imageedit-tabkbd-8": "helpImageEdit29",
|
||||
"wfm-help-shortcuts-title": "helpShortcutsTitle",
|
||||
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
|
||||
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
|
||||
"wfm-help-trouble-title": "helpTroubleTitle",
|
||||
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
|
||||
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
|
||||
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
|
||||
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
"wfm-help-kofi-desc": "helpKofiDesc",
|
||||
"wfm-help-thanks": "helpThanks",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
// Search box placeholder
|
||||
const helpSearch = document.getElementById("wfm-help-search");
|
||||
if (helpSearch) {
|
||||
helpSearch.placeholder = t("helpSearchPlaceholder");
|
||||
helpSearch.addEventListener("input", _onHelpSearch);
|
||||
}
|
||||
}
|
||||
|
||||
function _onHelpSearch(e) {
|
||||
const q = e.target.value.trim().toLowerCase();
|
||||
const navItems = document.querySelectorAll("[data-help-page]");
|
||||
if (!q) {
|
||||
navItems.forEach(btn => btn.classList.remove("search-hidden"));
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedPages = new Set();
|
||||
document.querySelectorAll(".wfm-help-page").forEach(page => {
|
||||
const pageId = page.id.replace("wfm-help-page-", "");
|
||||
if (page.textContent.toLowerCase().includes(q)) matchedPages.add(pageId);
|
||||
});
|
||||
|
||||
let firstBtn = null;
|
||||
navItems.forEach(btn => {
|
||||
const pageId = btn.dataset.helpPage;
|
||||
if (matchedPages.has(pageId)) {
|
||||
btn.classList.remove("search-hidden");
|
||||
if (!firstBtn) firstBtn = btn;
|
||||
} else {
|
||||
btn.classList.add("search-hidden");
|
||||
}
|
||||
});
|
||||
|
||||
if (firstBtn && !firstBtn.classList.contains("active")) firstBtn.click();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -227,18 +573,55 @@ function initModal() {
|
||||
// ============================================
|
||||
|
||||
import { initWorkflowTab } from "./workflow-tab.js";
|
||||
import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab } from "./settings-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
import { initMetadataTab } from "./metadata-tab.js";
|
||||
import { initAiTab } from "./ai-tab.js";
|
||||
import { initTaggerTab } from "./tagger-tab.js";
|
||||
import { imageEditTab } from "./image-edit-tab.js";
|
||||
|
||||
// Apply saved theme immediately to prevent flash of default theme
|
||||
applyTheme(getSavedTheme());
|
||||
|
||||
// Apply saved textarea font size & JSON highlight colors
|
||||
try {
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
applyJsonColors(_s.jsonColors);
|
||||
} catch {}
|
||||
|
||||
function initHelpTab() {
|
||||
document.querySelectorAll(".wfm-help-nav-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const page = btn.dataset.helpPage;
|
||||
document.querySelectorAll(".wfm-help-nav-item").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".wfm-help-page").forEach((p) => p.classList.remove("active"));
|
||||
document.getElementById(`wfm-help-page-${page}`)?.classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
applyI18nToHtml();
|
||||
initTabs();
|
||||
initModal();
|
||||
initHelpTab();
|
||||
initSettingsTab(); // Settings first (applies saved URL)
|
||||
initWorkflowTab();
|
||||
initNodesTab();
|
||||
initModelsTab();
|
||||
initGenerateTab();
|
||||
initPromptTab();
|
||||
initMetadataTab();
|
||||
initGalleryTab();
|
||||
initAiTab();
|
||||
initTaggerTab();
|
||||
imageEditTab.init();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -70,19 +70,36 @@ export const comfyUI = {
|
||||
});
|
||||
},
|
||||
|
||||
// Model fetching
|
||||
// Node info fetching
|
||||
async fetchObjectInfo(nodeClass) {
|
||||
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
async fetchAllObjectInfo() {
|
||||
const res = await fetch(`${this.baseUrl}/object_info`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
async _fetchModelList(nodeClasses, inputKey) {
|
||||
for (const cls of nodeClasses) {
|
||||
try {
|
||||
const info = await this.fetchObjectInfo(cls);
|
||||
if (info?.[cls]?.input?.required?.[inputKey]) {
|
||||
return info[cls].input.required[inputKey][0] || [];
|
||||
const inputDef = info[cls].input.required[inputKey];
|
||||
const first = inputDef[0];
|
||||
// V1: [["model1", "model2"], {}]
|
||||
if (Array.isArray(first)) return first;
|
||||
// V2: ["COMBO", { "values": [...] }]
|
||||
if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) {
|
||||
return inputDef[1].values;
|
||||
}
|
||||
// V3 ComfyNode: ["COMBO", { "options": [...] }]
|
||||
if (typeof first === "string" && Array.isArray(inputDef[1]?.options)) {
|
||||
return inputDef[1].options;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -115,19 +132,43 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
async fetchDiffusionModels() {
|
||||
return this._fetchModelList(
|
||||
["UNETLoader", "UnetLoaderGGUF"],
|
||||
"unet_name"
|
||||
);
|
||||
const [standard, gguf] = await Promise.all([
|
||||
this._fetchModelList(["UNETLoader", "UnetLoaderGGUF"], "unet_name"),
|
||||
this._fetchModelList(["LoaderGGUF", "LoaderGGUFAdvanced"], "gguf_name"),
|
||||
]);
|
||||
return [...new Set([...standard, ...gguf])];
|
||||
},
|
||||
|
||||
async fetchTextEncoders() {
|
||||
const [dual, single] = await Promise.all([
|
||||
this._fetchModelList(["DualCLIPLoader", "DualClipLoaderGGUF"], "clip_name1"),
|
||||
this._fetchModelList(["CLIPLoader", "ClipLoaderGGUF"], "clip_name"),
|
||||
]);
|
||||
return [...new Set([...dual, ...single])];
|
||||
},
|
||||
|
||||
async fetchHypernetworks() {
|
||||
return this._fetchModelList(
|
||||
["DualCLIPLoader", "CLIPLoader"],
|
||||
"clip_name1"
|
||||
["HypernetworkLoader"],
|
||||
"hypernetwork_name"
|
||||
);
|
||||
},
|
||||
|
||||
async fetchEmbeddings() {
|
||||
// Use WFS backend API (reliable, goes through same aiohttp server)
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/files?type=embedding");
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
console.warn("[WFS] /api/wfm/models/files?type=embedding returned", res.status);
|
||||
} catch (e) {
|
||||
console.error("[WFS] fetchEmbeddings error:", e);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
async fetchSamplers() {
|
||||
return this._fetchModelList(["KSampler"], "sampler_name");
|
||||
},
|
||||
@@ -137,14 +178,16 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
// Generation
|
||||
async queuePrompt(workflow) {
|
||||
async queuePrompt(workflow, extraData = null) {
|
||||
const body = {
|
||||
prompt: workflow,
|
||||
client_id: this.clientId,
|
||||
};
|
||||
if (extraData) body.extra_data = extraData;
|
||||
const res = await fetch(`${this.baseUrl}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: workflow,
|
||||
client_id: this.clientId,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
@@ -197,7 +240,13 @@ export const comfyUI = {
|
||||
});
|
||||
const res = await fetch(`${this.baseUrl}/view?${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch image: HTTP ${res.status}`);
|
||||
return await res.blob();
|
||||
const blob = await res.blob();
|
||||
// /view はSVGをセキュリティ上application/octet-streamで返すため、
|
||||
// <img>で表示できるよう拡張子から正しいMIMEタイプへ付け替える
|
||||
if (imageData.filename?.toLowerCase().endsWith(".svg") && blob.type !== "image/svg+xml") {
|
||||
return blob.slice(0, blob.size, "image/svg+xml");
|
||||
}
|
||||
return blob;
|
||||
},
|
||||
|
||||
async uploadImage(file, filename, overwrite = true) {
|
||||
@@ -244,8 +293,8 @@ export const comfyUI = {
|
||||
throw new Error("Failed to connect WebSocket");
|
||||
}
|
||||
|
||||
// Queue prompt
|
||||
const result = await this.queuePrompt(workflow);
|
||||
// Queue prompt — pass workflow as extra_pnginfo so SaveImage embeds it in PNG metadata
|
||||
const result = await this.queuePrompt(workflow, { extra_pnginfo: { workflow } });
|
||||
this.currentPromptId = result.prompt_id;
|
||||
|
||||
// Track progress
|
||||
@@ -257,6 +306,9 @@ export const comfyUI = {
|
||||
|
||||
// Extract images from SaveImage outputs only
|
||||
const images = [];
|
||||
// comfyui-tosvg の "Save SVG String" ノードは images ではなく
|
||||
// saved_svg(ファイル名) / path(絶対パス) という独自キーで返す
|
||||
const svgOutputs = [];
|
||||
const outputs = history.outputs || {};
|
||||
for (const nodeOutput of Object.values(outputs)) {
|
||||
if (nodeOutput.images) {
|
||||
@@ -264,10 +316,23 @@ export const comfyUI = {
|
||||
images.push(img);
|
||||
}
|
||||
}
|
||||
if (nodeOutput.saved_svg) {
|
||||
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
|
||||
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
|
||||
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
|
||||
const joinIfCharArray = (v) =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
|
||||
? v.join("")
|
||||
: v;
|
||||
svgOutputs.push({
|
||||
filename: joinIfCharArray(nodeOutput.saved_svg),
|
||||
path: joinIfCharArray(nodeOutput.path) || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(images, seed);
|
||||
return { images, seed };
|
||||
return { images, seed, svgOutputs };
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
throw err;
|
||||
|
||||
@@ -5,6 +5,50 @@
|
||||
// Cache for /object_info (loaded once, shared)
|
||||
let _objectInfoCache = null;
|
||||
|
||||
// convertUiToApi() の COMBO フォールバック(未一致時に選択肢の先頭へ無警告で差し替え)が
|
||||
// ckpt_name に対して発生した場合の記録。呼び出し元が読み込み直後に確認して警告表示できるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastCheckpointSubstitutions = [];
|
||||
|
||||
// convertUiToApi() で除外された Bypass(mode:4) / Mute(mode:2) ノードの記録。
|
||||
// API形式には mode 情報が残らないため、呼び出し元がRaw JSON表示時に
|
||||
// 「このワークフローにはバイパス/ミュート中のノードがあった」と伝えられるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastBypassedNodes = [];
|
||||
let _lastMutedNodes = [];
|
||||
|
||||
/**
|
||||
* Trace a link's source through any Bypass-mode (mode 4) nodes, matching each bypassed
|
||||
* node's output slot to an input slot of the same type and following the connection
|
||||
* upstream (recursively, for chained bypasses). Mirrors ComfyUI's own Bypass behavior,
|
||||
* where a bypassed node is removed but its wires are reconnected end-to-end.
|
||||
* Returns null when no pass-through source exists (the matching input is unconnected),
|
||||
* in which case the caller should treat the input as unlinked.
|
||||
*/
|
||||
function _resolveBypassSource(nodeById, linkMap, nodeId, slot, visited) {
|
||||
const idStr = String(nodeId);
|
||||
const node = nodeById[idStr];
|
||||
if (!node || node.mode !== 4) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass chain guard
|
||||
|
||||
visited.add(idStr);
|
||||
const outputs = node.outputs || [];
|
||||
const inputs = node.inputs || [];
|
||||
const outType = outputs[slot]?.type;
|
||||
|
||||
let inIdx = -1;
|
||||
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
|
||||
inIdx = slot;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
}
|
||||
if (inIdx === -1) return null;
|
||||
|
||||
const upstream = linkMap[idStr][inIdx];
|
||||
if (!upstream) return null;
|
||||
return _resolveBypassSource(nodeById, linkMap, upstream[0], upstream[1], visited);
|
||||
}
|
||||
|
||||
async function _loadObjectInfo() {
|
||||
if (_objectInfoCache) return _objectInfoCache;
|
||||
try {
|
||||
@@ -37,7 +81,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
|
||||
@@ -51,7 +95,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +106,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
|
||||
/**
|
||||
* Get ordered widget input types for a node class (parallel to _getWidgetInputNames).
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", or "COMBO".
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", or "COMFY_DYNAMICCOMBO_V3".
|
||||
*/
|
||||
function _getWidgetInputTypes(objectInfo, classType) {
|
||||
const info = objectInfo[classType];
|
||||
@@ -77,7 +121,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +132,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +140,19 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
|
||||
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
|
||||
*/
|
||||
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
|
||||
const info = objectInfo[classType];
|
||||
const spec = info?.input?.required?.[widgetName] || info?.input?.optional?.[widgetName];
|
||||
const options = spec?.[1]?.options;
|
||||
if (!Array.isArray(options)) return [];
|
||||
const opt = options.find((o) => o.key === selectedKey) || options[0];
|
||||
return Object.keys(opt?.inputs?.required || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a widget value is a frontend-only extra (like "control_after_generate").
|
||||
* These values appear in widgets_values but are not in object_info inputs.
|
||||
@@ -123,10 +180,17 @@ function _isExtraWidgetValue(val, expectedType) {
|
||||
*
|
||||
* Strategy: expand internal nodes into the parent, redirect parent links through
|
||||
* the boundary ports, and convert internal links from object to array format.
|
||||
* Widget values in internal nodes are preserved as-is (they are synced at save time
|
||||
* by ComfyUI frontend). convertUiToApi handles widget mapping via object_info.
|
||||
*
|
||||
* Widget values: the outer subgraph node's own widgets_values hold the values actually
|
||||
* set via the subgraph's collapsed form (what the user sees/edits). The internal nodes'
|
||||
* widgets_values, stored in the subgraph template (definitions.subgraphs[].nodes), are
|
||||
* only stale defaults from when the template was authored — ComfyUI does NOT keep them
|
||||
* synced in saved workflow JSON. Un-linked outer widget inputs are therefore copied onto
|
||||
* the matching internal node's widgets_values (via objectInfo to get the correct index,
|
||||
* since the internal node's real class type is known). Linked outer inputs are left to
|
||||
* the "Redirect parent links" step above, which takes priority.
|
||||
*/
|
||||
function _flattenSubgraphs(workflow) {
|
||||
function _flattenSubgraphs(workflow, objectInfo) {
|
||||
const defs = workflow.definitions?.subgraphs;
|
||||
if (!defs || defs.length === 0) return workflow;
|
||||
|
||||
@@ -192,6 +256,12 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Redirect parent links ===
|
||||
// Track which {internal node, input slot} pairs end up resolved by a real outer
|
||||
// link after redirection — needed below to tell apart "boundary-only" links
|
||||
// (prompt/model-name ports that are actually widget values, see injection step)
|
||||
// from ports that truly carry a link end-to-end (e.g. width/height wired to a
|
||||
// ResolutionSelector node).
|
||||
const redirectedTargets = new Set();
|
||||
for (const link of wf.links) {
|
||||
if (link[3] === node.id) {
|
||||
const dstSlot = link[4];
|
||||
@@ -204,6 +274,7 @@ function _flattenSubgraphs(workflow) {
|
||||
if (target) {
|
||||
link[3] = target.targetNodeId;
|
||||
link[4] = target.targetSlot;
|
||||
redirectedTargets.add(`${target.targetNodeId}:${target.targetSlot}`);
|
||||
}
|
||||
}
|
||||
if (link[1] === node.id) {
|
||||
@@ -217,12 +288,82 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Add internal nodes (remapped IDs) ===
|
||||
const remappedByOrigId = {};
|
||||
const normalizedNodeIds = new Set();
|
||||
for (const iNode of sgDef.nodes) {
|
||||
const remapped = JSON.parse(JSON.stringify(iNode));
|
||||
remapped.id = nodeIdRemap[iNode.id];
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format
|
||||
// (one entry per widget input, including linked ones), while convertUiToApi's
|
||||
// widget-mapping loop assumes the "modern" format (linked widget inputs have no
|
||||
// entry at all). Detect the full format by comparing lengths and strip the
|
||||
// entries belonging to inputs that are *actually* linked end-to-end (per
|
||||
// redirectedTargets above) so downstream mapping stays index-aligned. Boundary
|
||||
// links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// stripped — their value lives in the outer node's widgets_values instead and
|
||||
// is injected below.
|
||||
const allWidgetInputs = (remapped.inputs || []).filter((i) => i.widget);
|
||||
if (remapped.widgets_values && remapped.widgets_values.length === allWidgetInputs.length) {
|
||||
const linkedIdx = new Set();
|
||||
allWidgetInputs.forEach((inp, i) => {
|
||||
const slotIdx = remapped.inputs.indexOf(inp);
|
||||
if (redirectedTargets.has(`${remapped.id}:${slotIdx}`)) linkedIdx.add(i);
|
||||
});
|
||||
if (linkedIdx.size > 0) {
|
||||
remapped.widgets_values = remapped.widgets_values.filter((_, i) => !linkedIdx.has(i));
|
||||
normalizedNodeIds.add(remapped.id);
|
||||
}
|
||||
}
|
||||
|
||||
addedNodes.push(remapped);
|
||||
remappedByOrigId[iNode.id] = remapped;
|
||||
}
|
||||
|
||||
// === Inject outer subgraph-node widget values into internal nodes ===
|
||||
// Only un-linked widget inputs — linked ones are already redirected above.
|
||||
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
|
||||
outerWidgetInputs.forEach((inp, widgetIdx) => {
|
||||
if (inp.link != null) return;
|
||||
if (widgetIdx >= (node.widgets_values || []).length) return;
|
||||
const outerValue = node.widgets_values[widgetIdx];
|
||||
|
||||
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
|
||||
if (portIdx === -1) return;
|
||||
const target = inputPortTargets[portIdx];
|
||||
if (!target) return;
|
||||
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) return;
|
||||
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) return;
|
||||
|
||||
// When the internal node's widgets_values was normalized above (stripped of
|
||||
// linked-slot entries), the index must be computed against that same reduced
|
||||
// set of widget names — object_info's full name order (which still includes
|
||||
// e.g. width/height) would be off by however many entries were stripped.
|
||||
let iWidgetIdx;
|
||||
if (normalizedNodeIds.has(remappedNode.id)) {
|
||||
const remainingNames = (remappedNode.inputs || [])
|
||||
.filter((i) => i.widget)
|
||||
.filter((i) => {
|
||||
const slotIdx = remappedNode.inputs.indexOf(i);
|
||||
return !redirectedTargets.has(`${remappedNode.id}:${slotIdx}`);
|
||||
})
|
||||
.map((i) => i.name);
|
||||
iWidgetIdx = remainingNames.indexOf(targetInputDef.name);
|
||||
} else {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
iWidgetIdx = widgetNames.indexOf(targetInputDef.name);
|
||||
}
|
||||
if (iWidgetIdx === -1) return;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
});
|
||||
|
||||
// === Add internal links (object format → array format) ===
|
||||
for (const iLink of (sgDef.links || [])) {
|
||||
if (iLink.origin_id === -10 || iLink.target_id === -20) continue;
|
||||
@@ -250,9 +391,17 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
export const comfyWorkflow = {
|
||||
detectFormat(workflow) {
|
||||
detectFormat(workflow, filename) {
|
||||
if (!workflow || typeof workflow !== "object") return "unknown";
|
||||
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) return "ui";
|
||||
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) {
|
||||
// App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
// A workflow that merely *contains* subgraphs (definitions.subgraphs) is still
|
||||
// a normal node graph — convertUiToApi() flattens subgraphs before conversion,
|
||||
// so it's treated as "ui" rather than blocked as unsupported "app" format.
|
||||
if (workflow.extra?.linearMode === true) return "app";
|
||||
if (filename && /\.app\.json$/i.test(filename)) return "app";
|
||||
return "ui";
|
||||
}
|
||||
// API format: top-level keys are node IDs with class_type
|
||||
const keys = Object.keys(workflow);
|
||||
if (keys.length > 0 && keys.every((k) => workflow[k]?.class_type)) return "api";
|
||||
@@ -262,14 +411,30 @@ export const comfyWorkflow = {
|
||||
/**
|
||||
* Convert UI-format workflow to API-format using /object_info for accurate widget mapping.
|
||||
*/
|
||||
getLastCheckpointSubstitutions() {
|
||||
return _lastCheckpointSubstitutions;
|
||||
},
|
||||
|
||||
getLastBypassedNodes() {
|
||||
return _lastBypassedNodes;
|
||||
},
|
||||
|
||||
getLastMutedNodes() {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
_lastMutedNodes = [];
|
||||
if (!workflow.nodes || !workflow.links) return {};
|
||||
|
||||
// Flatten subgraphs before conversion
|
||||
const flatWorkflow = _flattenSubgraphs(workflow);
|
||||
|
||||
const objectInfo = await _loadObjectInfo();
|
||||
|
||||
// Flatten subgraphs before conversion (needs objectInfo to correctly map outer
|
||||
// subgraph-node widget values onto internal nodes' widgets_values indices)
|
||||
const flatWorkflow = _flattenSubgraphs(workflow, objectInfo);
|
||||
|
||||
// Build link map: dstNode -> { dstSlot -> [srcNodeId, srcSlot] }
|
||||
const linkMap = {};
|
||||
for (const link of flatWorkflow.links) {
|
||||
@@ -277,10 +442,23 @@ export const comfyWorkflow = {
|
||||
if (!linkMap[dstNode]) linkMap[dstNode] = {};
|
||||
linkMap[dstNode][dstSlot] = [String(srcNode), srcSlot];
|
||||
}
|
||||
const nodeById = {};
|
||||
for (const n of flatWorkflow.nodes) nodeById[String(n.id)] = n;
|
||||
|
||||
const api = {};
|
||||
for (const node of flatWorkflow.nodes) {
|
||||
if (node.mode === 4) continue; // muted/bypassed
|
||||
// Mute (mode 2): excluded, no pass-through — matches ComfyUI itself, where
|
||||
// muting a node with required downstream dependents is expected to error.
|
||||
// Bypass (mode 4): excluded here too, but its wires are rerouted below via
|
||||
// _resolveBypassSource() so downstream nodes reconnect to its upstream source.
|
||||
if (node.mode === 2) {
|
||||
_lastMutedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
if (node.mode === 4) {
|
||||
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
// Skip note/display-only nodes that are unknown to ComfyUI backend
|
||||
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
|
||||
const nodeId = String(node.id);
|
||||
@@ -290,16 +468,38 @@ export const comfyWorkflow = {
|
||||
const nodeLinks = linkMap[node.id] || {};
|
||||
const inputDefs = node.inputs || [];
|
||||
const linkedInputNames = new Set();
|
||||
// UI slot inputs with an actual link don't have a widget value entry in
|
||||
// widgets_values (modern ComfyUI format). Track these separately so the
|
||||
// widget-mapping loop won't advance wIdx for them.
|
||||
const linkedSlotNames = new Set();
|
||||
inputDefs.forEach((inp, idx) => {
|
||||
if (nodeLinks[idx]) {
|
||||
inputs[inp.name] = nodeLinks[idx];
|
||||
const resolved = _resolveBypassSource(nodeById, linkMap, nodeLinks[idx][0], nodeLinks[idx][1], new Set());
|
||||
if (!resolved) return; // Bypass chain with no upstream source — leave unlinked
|
||||
inputs[inp.name] = resolved;
|
||||
linkedInputNames.add(inp.name);
|
||||
linkedSlotNames.add(inp.name);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Map widgets_values to widget input names using object_info
|
||||
const widgets = node.widgets_values || [];
|
||||
if (widgets.length > 0) {
|
||||
// Lora Loader (LoraManager): use __lm_widget_ids from node properties
|
||||
// widgets_values: [autocomplete_meta_obj, text_str, loras_array]
|
||||
// API format: {__lm_autocomplete_meta_text: obj, text: str, loras: {__value__: arr}}
|
||||
const lmWidgetIds = node.properties?.__lm_widget_ids;
|
||||
if (lmWidgetIds && Array.isArray(lmWidgetIds)) {
|
||||
lmWidgetIds.forEach((name, idx) => {
|
||||
if (idx >= widgets.length || linkedInputNames.has(name)) return;
|
||||
let val = widgets[idx];
|
||||
if (name === "loras" && Array.isArray(val)) {
|
||||
val = { "__value__": val };
|
||||
}
|
||||
inputs[name] = val;
|
||||
});
|
||||
} else {
|
||||
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, node.type);
|
||||
|
||||
if (widgetNames.length > 0) {
|
||||
@@ -316,13 +516,15 @@ export const comfyWorkflow = {
|
||||
const expectedType = widgetTypes[nIdx];
|
||||
// Skip if this input is already linked
|
||||
if (linkedInputNames.has(name)) {
|
||||
// Still consume widget value(s) since widgets_values
|
||||
// includes entries for linked inputs too
|
||||
wIdx++;
|
||||
// Also skip control_after_generate if present
|
||||
if (wIdx < widgets.length && _isExtraWidgetValue(widgets[wIdx], expectedType)) {
|
||||
if (!linkedSlotNames.has(name)) {
|
||||
// Not a UI slot input — old-style linked input that still
|
||||
// occupies a slot in widgets_values; consume it.
|
||||
wIdx++;
|
||||
if (wIdx < widgets.length && _isExtraWidgetValue(widgets[wIdx], expectedType)) {
|
||||
wIdx++;
|
||||
}
|
||||
}
|
||||
// UI slot inputs with a real link have no widget value entry — don't advance wIdx.
|
||||
continue;
|
||||
}
|
||||
// Check if current widget value matches expected type
|
||||
@@ -338,6 +540,48 @@ export const comfyWorkflow = {
|
||||
wIdx++;
|
||||
}
|
||||
}
|
||||
// DynamicCombo (e.g. SaveImageAdvanced's "format"): val is the selected
|
||||
// option's key (e.g. "png"). Per comfy_api/latest/_io.py
|
||||
// DynamicCombo._expand_schema_for_dynamic, the backend looks up flat
|
||||
// dot-prefixed keys ("format", "format.bit_depth", ...) in the raw
|
||||
// inputs dict and assembles them into a single dict itself at execution
|
||||
// time — it does NOT accept a pre-built dict under the "format" key.
|
||||
if (expectedType === "COMFY_DYNAMICCOMBO_V3") {
|
||||
inputs[name] = val;
|
||||
const subNames = _getDynamicComboSubNames(objectInfo, node.type, name, val);
|
||||
for (const subName of subNames) {
|
||||
if (wIdx >= widgets.length) break;
|
||||
inputs[`${name}.${subName}`] = widgets[wIdx];
|
||||
wIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// For COMBO inputs, validate the value against the available choices.
|
||||
// Dynamic COMBO options (e.g. Impact Pack's "Select to add Wildcard") may
|
||||
// differ between the saved workflow and the current ComfyUI instance
|
||||
// (e.g. "Select Wildcard 🟢 Full Cache" vs "Select Wildcard"), causing
|
||||
// "Prompt outputs failed validation". Fall back to first choice when mismatch.
|
||||
if (expectedType === "COMBO") {
|
||||
const allInputDefs = {
|
||||
...(objectInfo[node.type]?.input?.required || {}),
|
||||
...(objectInfo[node.type]?.input?.optional || {}),
|
||||
};
|
||||
const spec = allInputDefs[name];
|
||||
if (spec) {
|
||||
const choices = Array.isArray(spec[0]) ? spec[0] : null;
|
||||
if (choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (name === "ckpt_name") {
|
||||
_lastCheckpointSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
title: node.title || node.type,
|
||||
original: val,
|
||||
replaced: choices[0],
|
||||
});
|
||||
}
|
||||
val = choices[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
inputs[name] = val;
|
||||
}
|
||||
} else {
|
||||
@@ -351,6 +595,15 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
}
|
||||
} // end else (non-LoraManager path)
|
||||
}
|
||||
|
||||
// ImpactWildcardEncode / ImpactWildcardProcessor: widgets[0] is always wildcard_text.
|
||||
// Always overwrite: object_info ordering may misalign when widget order differs between
|
||||
// impact-pack versions; widgets[0] is stable across all known versions.
|
||||
if ((node.type === "ImpactWildcardEncode" || node.type === "ImpactWildcardProcessor")
|
||||
&& widgets.length > 0) {
|
||||
inputs["wildcard_text"] = widgets[0];
|
||||
}
|
||||
|
||||
api[nodeId] = {
|
||||
@@ -433,15 +686,18 @@ export const comfyWorkflow = {
|
||||
vae_nodes: [],
|
||||
save_nodes: [],
|
||||
load_image_nodes: [],
|
||||
inpaint_encode_nodes: [],
|
||||
mask_editor_one_nodes: [],
|
||||
text_encoder_nodes: [],
|
||||
diffusion_model_nodes: [],
|
||||
controlnet_nodes: [],
|
||||
hypernetwork_nodes: [],
|
||||
all_nodes: [],
|
||||
};
|
||||
|
||||
if (!workflow) return result;
|
||||
|
||||
// Build sampler connections for prompt classification
|
||||
// === Pass 1: Collect sampler refs + all_nodes ===
|
||||
const samplerPositiveRef = {};
|
||||
const samplerNegativeRef = {};
|
||||
|
||||
@@ -457,40 +713,250 @@ export const comfyWorkflow = {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
|
||||
// KSamplerAdvanced uses noise_seed instead of seed
|
||||
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title,
|
||||
seed: inputs.seed, steps: inputs.steps, cfg: inputs.cfg,
|
||||
seed: inputs.seed ?? inputs.noise_seed,
|
||||
seedKey,
|
||||
steps: inputs.steps, cfg: inputs.cfg,
|
||||
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
|
||||
denoise: inputs.denoise,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
|
||||
// Nodes that pass CONDITIONING through without changing positive/negative role
|
||||
const COND_PASSTHROUGH = new Set([
|
||||
"ConditioningCombine", "ConditioningConcat", "ConditioningAverage",
|
||||
"ConditioningSetTimestepRange",
|
||||
// ConditioningZeroOut is intentionally excluded: it discards upstream text entirely
|
||||
// (used as a no-text negative in ZIT/Lumina2 workflows). Propagating the negative
|
||||
// role upstream would incorrectly mark the shared positive CLIPTextEncode as "unknown".
|
||||
"ControlNetApply", "ControlNetApplyAdvanced",
|
||||
"IPAdapterApply", "IPAdapterApplyFaceID",
|
||||
"StyleModelApply",
|
||||
]);
|
||||
|
||||
for (let iter = 0; iter < 5; iter++) {
|
||||
for (const [id, node] of Object.entries(workflow)) {
|
||||
if (!node?.class_type) continue;
|
||||
const ct = node.class_type;
|
||||
const inputs = node.inputs || {};
|
||||
const isPos = !!samplerPositiveRef[id];
|
||||
const isNeg = !!samplerNegativeRef[id];
|
||||
if (!isPos && !isNeg) continue;
|
||||
|
||||
if (COND_PASSTHROUGH.has(ct)) {
|
||||
for (const v of Object.values(inputs)) {
|
||||
if (Array.isArray(v)) {
|
||||
if (isPos) samplerPositiveRef[v[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[v[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate role through text encoder linked inputs (CLIPTextEncode → upstream text source)
|
||||
if (ct === "CLIPTextEncode") {
|
||||
const tv = inputs.text;
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
|
||||
for (const key of ["text_g", "text_l"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
|
||||
const tv = inputs.wildcard_text;
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
// CLIPTextEncodeEditPlus — propagate role to its STRING inputs (text1, text2)
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
for (const key of ["text1", "text2"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Pass 2: Main analysis ===
|
||||
for (const [id, node] of Object.entries(workflow)) {
|
||||
if (!node?.class_type) continue;
|
||||
const ct = node.class_type;
|
||||
const inputs = node.inputs || {};
|
||||
const title = node._meta?.title || ct;
|
||||
|
||||
if (ct.includes("CheckpointLoader")) {
|
||||
const isPos = !!samplerPositiveRef[id];
|
||||
const isNeg = !!samplerNegativeRef[id];
|
||||
|
||||
const getRole = () => {
|
||||
if (isPos && !isNeg) return "positive";
|
||||
if (isNeg && !isPos) return "negative";
|
||||
if (/pos|正/i.test(title)) return "positive";
|
||||
if (/neg|負/i.test(title)) return "negative";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
// --- Checkpoint nodes ---
|
||||
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
|
||||
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
|
||||
result.checkpoint_nodes.push({
|
||||
id, type: ct, title,
|
||||
ckpt_name: inputs.ckpt_name,
|
||||
});
|
||||
}
|
||||
|
||||
// ImageMetadataPromptLoader — checkpoint + positive/negative prompts in one node
|
||||
if (ct === "ImageMetadataPromptLoader") {
|
||||
result.checkpoint_nodes.push({ id, type: ct, title, ckpt_name: inputs.ckpt_name });
|
||||
if (typeof inputs.positive_text === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.positive_text, textKey: "positive_text",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_text === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_text, textKey: "negative_text",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt nodes ---
|
||||
|
||||
if (ct === "CLIPTextEncode") {
|
||||
let role = "unknown";
|
||||
if (samplerPositiveRef[id]) role = "positive";
|
||||
else if (samplerNegativeRef[id]) role = "negative";
|
||||
else if (/pos|正/i.test(title)) role = "positive";
|
||||
else if (/neg|負/i.test(title)) role = "negative";
|
||||
// Only add when text is a direct string (if linked, upstream node will be detected)
|
||||
const textVal = inputs.text;
|
||||
if (typeof textVal === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "text",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SDXL dual-text encoder
|
||||
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
|
||||
const textG = inputs.text_g;
|
||||
const textL = inputs.text_l;
|
||||
// Add only if at least one text field is a direct string
|
||||
if (typeof textG === "string" || typeof textL === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: typeof textG === "string" ? textG : (typeof textL === "string" ? textL : ""),
|
||||
textKey: typeof textG === "string" ? "text_g" : "text_l",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CLIPTextEncodeEditPlus — text_edit is the locally editable override; text1 is always a link.
|
||||
// Skip when text_edit is empty: it means upstream nodes (ImpactWildcardEncode etc.) supply
|
||||
// the actual prompt text and the edit field is just an optional local override.
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
const textVal = inputs.text_edit;
|
||||
if (typeof textVal === "string" && textVal !== "") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "text_edit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen-based image+text encoder
|
||||
if (ct === "TextEncodeQwenImageEditPlus") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role,
|
||||
text: inputs.text,
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: inputs.prompt || "",
|
||||
textKey: "prompt",
|
||||
});
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
|
||||
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
|
||||
if (inputs.text_positive !== undefined) {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.text_positive || "", textKey: "text_positive",
|
||||
});
|
||||
}
|
||||
if (inputs.text_negative !== undefined) {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.text_negative || "", textKey: "text_negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ImpactWildcardEncode / ImpactWildcardProcessor — treat wildcard_text as prompt
|
||||
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
|
||||
const textVal = inputs.wildcard_text;
|
||||
if (typeof textVal === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "wildcard_text",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WFS_PromptText — 2出力ノード。output[0]=positive / output[1]=negative
|
||||
if (ct === "WFS_PromptText") {
|
||||
if (typeof inputs.positive === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.positive, textKey: "positive",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative, textKey: "negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PrimitiveStringMultiline / PrimitiveString — when feeding a prompt node
|
||||
if ((ct === "PrimitiveStringMultiline" || ct === "PrimitiveString") && (isPos || isNeg)) {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: inputs.value || "",
|
||||
textKey: "value",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Latent nodes ---
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
@@ -499,6 +965,21 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// TextEncodeMageFlowEdit also carries width/height/batch_size directly
|
||||
// (it builds the latent internally instead of a separate EmptyLatentImage node).
|
||||
// width/height are commonly wired to a ResolutionSelector-style helper node
|
||||
// (array link ref) rather than a direct number — leave those as undefined so
|
||||
// the settings-tab UI falls back to its default instead of rendering a link ref.
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
});
|
||||
}
|
||||
|
||||
// --- LoRA nodes ---
|
||||
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
|
||||
result.lora_nodes.push({
|
||||
id, type: ct, title,
|
||||
@@ -508,14 +989,51 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Power Lora Loader (rgthree) — dynamic lora_N inputs in API format
|
||||
if (ct === "Power Lora Loader (rgthree)") {
|
||||
for (const [k, v] of Object.entries(inputs)) {
|
||||
if (/^lora_\d+$/.test(k) && typeof v === "string") {
|
||||
result.lora_nodes.push({
|
||||
id, type: ct, title,
|
||||
lora_name: v,
|
||||
strength_model: inputs[k.replace("lora_", "strength_")] ?? 1,
|
||||
strength_clip: inputs[k.replace("lora_", "strength_clip_")] ?? 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lora Loader (LoraManager) — loras stored in inputs.loras.__value__
|
||||
if (ct === "Lora Loader (LoraManager)") {
|
||||
result.lora_nodes.push({ id, type: ct, title, is_lora_manager: true });
|
||||
}
|
||||
|
||||
// ImageMetadataLoRALoader — up to 3 LoRA slots; skip "None" entries
|
||||
if (ct === "ImageMetadataLoRALoader") {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const loraName = inputs[`lora_${i}`];
|
||||
if (typeof loraName === "string" && loraName !== "None") {
|
||||
result.lora_nodes.push({
|
||||
id, type: ct, title,
|
||||
lora_name: loraName,
|
||||
strength_model: inputs[`strength_model_${i}`] ?? 1.0,
|
||||
strength_clip: inputs[`strength_clip_${i}`] ?? 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- VAE nodes ---
|
||||
if (ct === "VAELoader") {
|
||||
result.vae_nodes.push({ id, type: ct, title, vae_name: inputs.vae_name });
|
||||
}
|
||||
|
||||
// --- Save/preview nodes ---
|
||||
if (ct === "SaveImage" || ct === "PreviewImage") {
|
||||
result.save_nodes.push({ id, type: ct, title });
|
||||
}
|
||||
|
||||
// --- Load image nodes ---
|
||||
if (ct === "LoadImage" || ct === "LoadImageMask"
|
||||
|| (ct.toLowerCase().includes("load") && ct.toLowerCase().includes("image") && inputs.image !== undefined)) {
|
||||
result.load_image_nodes.push({
|
||||
@@ -523,26 +1041,79 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
if (ct === "DualCLIPLoader" || ct === "CLIPLoader") {
|
||||
// --- VAE Encode (for Inpainting) nodes ---
|
||||
if (ct === "VAEEncodeForInpaint") {
|
||||
result.inpaint_encode_nodes.push({
|
||||
id, type: ct, title,
|
||||
grow_mask_by: inputs.grow_mask_by,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mask Editor One nodes (comfyui-mask-editor-one) ---
|
||||
// 画像とマスクをノード自身が内包する(LoadImageを介さない)インペイント画像ソース。
|
||||
// image出力はサーバー側キャッシュ(bg_image_b64、node_idキー)から、mask出力は
|
||||
// layer_data(JSON文字列)ウィジェットの各レイヤーのアルファチャンネルから合成される。
|
||||
if (ct === "MaskEditorOne") {
|
||||
result.mask_editor_one_nodes.push({ id, type: ct, title });
|
||||
}
|
||||
|
||||
// --- Text encoder (CLIP) nodes ---
|
||||
if (ct === "DualCLIPLoader" || ct === "CLIPLoader" || ct === "ClipLoaderGGUF" || ct === "DualClipLoaderGGUF") {
|
||||
result.text_encoder_nodes.push({
|
||||
id, type: ct, title,
|
||||
clip_name1: inputs.clip_name1 || inputs.clip_name,
|
||||
clip_name2: inputs.clip_name2,
|
||||
clip_type: inputs.type,
|
||||
device: inputs.device,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Diffusion model nodes ---
|
||||
if (ct === "UNETLoader" || ct === "UnetLoaderGGUF") {
|
||||
result.diffusion_model_nodes.push({
|
||||
id, type: ct, title, unet_name: inputs.unet_name,
|
||||
id, type: ct, title, unet_name: inputs.unet_name, inputKey: "unet_name",
|
||||
});
|
||||
}
|
||||
if (ct === "LoaderGGUF" || ct === "LoaderGGUFAdvanced") {
|
||||
result.diffusion_model_nodes.push({
|
||||
id, type: ct, title, unet_name: inputs.gguf_name, inputKey: "gguf_name",
|
||||
});
|
||||
}
|
||||
|
||||
// --- ControlNet loader nodes ---
|
||||
if (ct.includes("ControlNetLoader")) {
|
||||
result.controlnet_nodes.push({
|
||||
id, type: ct, title,
|
||||
control_net_name: inputs.control_net_name,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hypernetwork loader nodes ---
|
||||
if (ct === "HypernetworkLoader") {
|
||||
result.hypernetwork_nodes.push({
|
||||
id, type: ct, title,
|
||||
hypernetwork_name: inputs.hypernetwork_name,
|
||||
strength: inputs.strength ?? 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Post-pass: flag LoadImage nodes whose MASK output (slot 1) is wired to something
|
||||
// downstream (directly, or via a pass-through node like MaskEditorNode). These are the
|
||||
// "inpaint-capable" slots — image + mask both come from the same node's two outputs, so
|
||||
// painting a mask for them only requires re-uploading that one node's image with the mask
|
||||
// baked into the alpha channel (matches ComfyUI's native LoadImage alpha→MASK extraction). ---
|
||||
const loadImageIds = new Set(result.load_image_nodes.filter(n => n.type === "LoadImage").map(n => n.id));
|
||||
if (loadImageIds.size > 0) {
|
||||
for (const node of Object.values(workflow)) {
|
||||
if (!node?.inputs) continue;
|
||||
for (const v of Object.values(node.inputs)) {
|
||||
if (Array.isArray(v) && v.length === 2 && Number(v[1]) === 1 && loadImageIds.has(String(v[0]))) {
|
||||
const target = result.load_image_nodes.find(n => n.id === String(v[0]));
|
||||
if (target) target.mask_used = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.all_nodes.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
@@ -587,17 +1158,30 @@ function _getWidgetMapping(nodeType) {
|
||||
CheckpointLoaderSimple: ["ckpt_name"],
|
||||
KSampler: ["seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
|
||||
CLIPTextEncode: ["text"],
|
||||
CLIPTextEncodeEditPlus: ["text_edit", "mode"],
|
||||
ImageMetadataCheckpointLoader: ["ckpt_name", "vae_name", "_metadata_json"],
|
||||
ImageMetadataPromptLoader: ["ckpt_name", "vae_name", "positive_text", "negative_text", "_metadata_json"],
|
||||
ImageMetadataLoRALoader: ["lora_1", "strength_model_1", "strength_clip_1", "lora_2", "strength_model_2", "strength_clip_2", "lora_3", "strength_model_3", "strength_clip_3"],
|
||||
EmptyLatentImage: ["width", "height", "batch_size"],
|
||||
LoraLoader: ["lora_name", "strength_model", "strength_clip"],
|
||||
VAELoader: ["vae_name"],
|
||||
SaveImage: ["filename_prefix"],
|
||||
LoadImage: ["image", "upload"],
|
||||
UNETLoader: ["unet_name", "weight_dtype"],
|
||||
LoaderGGUF: ["gguf_name"],
|
||||
LoaderGGUFAdvanced: ["gguf_name", "dequant_dtype", "patch_dtype", "patch_on_device"],
|
||||
ResolutionSelector: ["aspect_ratio", "megapixels"],
|
||||
EmptySD3LatentImage: ["width", "height", "batch_size"],
|
||||
CLIPLoader: ["clip_name", "type", "device"],
|
||||
ClipLoaderGGUF: ["clip_name", "type"],
|
||||
DualClipLoaderGGUF: ["clip_name1", "clip_name2", "type"],
|
||||
FluxGuidance: ["guidance"],
|
||||
CFGNorm: ["strength"],
|
||||
// Impact Pack wildcard nodes — only map wildcard_text (index 0); remaining widgets
|
||||
// vary between versions and include frontend-only extras, so we leave them unmapped.
|
||||
ImpactWildcardProcessor: ["wildcard_text"],
|
||||
ImpactWildcardEncode: ["wildcard_text"],
|
||||
WFS_PromptText: ["positive", "negative"],
|
||||
};
|
||||
return mappings[nodeType] || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
/**
|
||||
* Feeder Tab - ImageFeeder node control (Image Loop mode)
|
||||
* + WFS_GalleryFeeder node control (Gallery mode)
|
||||
*/
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { FEEDER_GROUP, ensureFeederGroup } from "./gallery-tab.js";
|
||||
|
||||
// ── Module State ──────────────────────────────────────────────────
|
||||
const _s = {
|
||||
dir: "", // current folder relative path (loop mode)
|
||||
images: [], // filenames in current folder (loop mode)
|
||||
selected: new Set(),// selected filenames (loop mode)
|
||||
presets: {}, // {presetName: {directory, selected_files}}
|
||||
running: false, // feeder run loop active
|
||||
mode: localStorage.getItem("wfm_feeder_mode") || "loop", // "loop" | "gallery"
|
||||
};
|
||||
|
||||
// Gallery mode state
|
||||
const _gs = {
|
||||
groups: [], // [{name}]
|
||||
group: FEEDER_GROUP, // selected group name
|
||||
images: [], // absolute paths in selected group
|
||||
idx: 0, // current index
|
||||
};
|
||||
|
||||
// ── Node helpers ──────────────────────────────────────────────────
|
||||
|
||||
function _feederNodes() {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf) return [];
|
||||
return Object.entries(wf)
|
||||
.filter(([, n]) => n?.class_type === "ImageFeeder")
|
||||
.map(([id, n]) => ({ id, title: n._meta?.title || `ImageFeeder` }));
|
||||
}
|
||||
|
||||
function _galFeederNodes() {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf) return [];
|
||||
return Object.entries(wf)
|
||||
.filter(([, n]) => n?.class_type === "WFS_GalleryFeeder")
|
||||
.map(([id, n]) => ({ id, title: n._meta?.title || `GalleryFeeder` }));
|
||||
}
|
||||
|
||||
export function refreshFeederNodeList() {
|
||||
if (_s.mode === "gallery") {
|
||||
_refreshGalNodeList();
|
||||
} else {
|
||||
const sel = document.getElementById("wfm-feeder-node-sel");
|
||||
if (!sel) return;
|
||||
const nodes = _feederNodes();
|
||||
if (nodes.length === 0) {
|
||||
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
|
||||
return;
|
||||
}
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = nodes.map(n =>
|
||||
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
|
||||
).join("");
|
||||
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
|
||||
_loadFromNode(sel.value || nodes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
function _refreshGalNodeList() {
|
||||
const sel = document.getElementById("wfm-feeder-gal-node-sel");
|
||||
if (!sel) return;
|
||||
const nodes = _galFeederNodes();
|
||||
if (nodes.length === 0) {
|
||||
sel.innerHTML = `<option value="">-- No WFS_GalleryFeeder node --</option>`;
|
||||
return;
|
||||
}
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = nodes.map(n =>
|
||||
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
|
||||
).join("");
|
||||
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
|
||||
_loadFromGalNode(sel.value || nodes[0].id);
|
||||
}
|
||||
|
||||
function _loadFromNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf?.[nodeId]) return;
|
||||
const inp = wf[nodeId].inputs || {};
|
||||
|
||||
_setVal("wfm-feeder-dir-input", inp.directory || "");
|
||||
_setVal("wfm-feeder-sort", inp.sort_mode || "ascending");
|
||||
_setVal("wfm-feeder-index", inp.index ?? 0);
|
||||
_setVal("wfm-feeder-start", inp.start_index ?? 0);
|
||||
_setVal("wfm-feeder-end", inp.end_index ?? 0);
|
||||
_setVal("wfm-feeder-batch", inp.batch_size ?? 1);
|
||||
_setVal("wfm-feeder-seed", inp.seed ?? 0);
|
||||
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
|
||||
|
||||
_s.selected.clear();
|
||||
try {
|
||||
const arr = JSON.parse(inp.selected_files || "[]");
|
||||
if (Array.isArray(arr)) arr.forEach(f => _s.selected.add(f));
|
||||
} catch {}
|
||||
|
||||
const dir = inp.directory || "";
|
||||
if (_s.dir !== dir) {
|
||||
_s.dir = dir;
|
||||
_loadImages(dir);
|
||||
_highlightTreeRow(dir);
|
||||
} else {
|
||||
_refreshGridCbs();
|
||||
}
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _loadFromGalNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf?.[nodeId]) return;
|
||||
const inp = wf[nodeId].inputs || {};
|
||||
|
||||
const group = inp.group_name || FEEDER_GROUP;
|
||||
_setVal("wfm-feeder-gal-sort", inp.sort_mode || "filename_asc");
|
||||
_setVal("wfm-feeder-gal-index", inp.index ?? 0);
|
||||
_setVal("wfm-feeder-gal-seed", inp.seed ?? 0);
|
||||
_gs.idx = inp.index ?? 0;
|
||||
|
||||
if (_gs.group !== group) {
|
||||
_gs.group = group;
|
||||
_setGalGroupSelect(group);
|
||||
_loadGalImages(group);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyToWorkflow() {
|
||||
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!nodeId || !wf?.[nodeId]) {
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
_applyToNode(nodeId);
|
||||
showToast(t("appliedToWorkflow"), "success");
|
||||
}
|
||||
|
||||
function _applyGalToWorkflow() {
|
||||
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!nodeId || !wf?.[nodeId]) {
|
||||
showToast(t("feederGalNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
_applyToGalNode(nodeId);
|
||||
showToast(t("appliedToWorkflow"), "success");
|
||||
}
|
||||
|
||||
// ── Run Loop ──────────────────────────────────────────────────────
|
||||
|
||||
function _setRunUI(running) {
|
||||
const runBtn = document.getElementById("wfm-feeder-run-btn");
|
||||
const stopBtn = document.getElementById("wfm-feeder-stop-btn");
|
||||
if (runBtn) runBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
}
|
||||
|
||||
function _setGalRunUI(running) {
|
||||
const runBtn = document.getElementById("wfm-feeder-gal-run-btn");
|
||||
const stopBtn = document.getElementById("wfm-feeder-gal-stop-btn");
|
||||
if (runBtn) runBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
}
|
||||
|
||||
function _applyToNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
inp.directory = _s.dir;
|
||||
inp.sort_mode = document.getElementById("wfm-feeder-sort")?.value || "ascending";
|
||||
inp.index = parseInt(document.getElementById("wfm-feeder-index")?.value) || 0;
|
||||
inp.start_index = parseInt(document.getElementById("wfm-feeder-start")?.value) || 0;
|
||||
inp.end_index = parseInt(document.getElementById("wfm-feeder-end")?.value) || 0;
|
||||
inp.batch_size = parseInt(document.getElementById("wfm-feeder-batch")?.value) || 1;
|
||||
inp.seed = parseInt(document.getElementById("wfm-feeder-seed")?.value) || 0;
|
||||
inp.use_selection = document.getElementById("wfm-feeder-use-sel")?.checked ?? true;
|
||||
inp.selected_files = JSON.stringify([..._s.selected]);
|
||||
}
|
||||
|
||||
function _applyToGalNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
inp.group_name = _gs.group;
|
||||
inp.index = _gs.idx;
|
||||
inp.sort_mode = document.getElementById("wfm-feeder-gal-sort")?.value || "filename_asc";
|
||||
inp.seed = parseInt(document.getElementById("wfm-feeder-gal-seed")?.value) || 0;
|
||||
}
|
||||
|
||||
async function _startRun() {
|
||||
if (_s.running) return;
|
||||
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
|
||||
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
|
||||
|
||||
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
|
||||
|
||||
_s.running = true;
|
||||
_setRunUI(true);
|
||||
|
||||
const progressBar = document.getElementById("wfm-gen-progress-bar");
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
|
||||
let _lastSync = null;
|
||||
const _syncHandler = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "image_loop_node_sync" && String(msg.data?.node_id) === String(nodeId)) {
|
||||
_lastSync = msg.data;
|
||||
if (msg.data.thumbnail_path) {
|
||||
const p = msg.data.thumbnail_path;
|
||||
_showPreview(p, p.split("/").pop());
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
comfyUI.socket.addEventListener("message", _syncHandler);
|
||||
|
||||
let count = 0;
|
||||
try {
|
||||
while (_s.running) {
|
||||
_lastSync = null;
|
||||
_applyToNode(nodeId);
|
||||
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = `#${++count} 0%`;
|
||||
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
{ ...wf },
|
||||
{
|
||||
seedMode,
|
||||
seedValue,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `#${count} ${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seedEl = document.getElementById("wfm-gen-seed-value");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0 && resultImg) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
resultImg.src = URL.createObjectURL(blob);
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (!_s.running) break;
|
||||
|
||||
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
|
||||
if (control === "fixed") {
|
||||
// index を変えない
|
||||
} else if (_lastSync) {
|
||||
const indexEl = document.getElementById("wfm-feeder-index");
|
||||
if (indexEl) indexEl.value = _lastSync.next_index;
|
||||
if (control === "increment" && !_lastSync.has_next) {
|
||||
_s.running = false;
|
||||
showToast(t("feederComplete", count), "success");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (_s.running) showToast(t("feederRunError", err.message), "error");
|
||||
} finally {
|
||||
comfyUI.socket?.removeEventListener("message", _syncHandler);
|
||||
_s.running = false;
|
||||
_setRunUI(false);
|
||||
if (progressText) progressText.textContent = `Done (${count} generated)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _stopRun() {
|
||||
_s.running = false;
|
||||
try { await comfyUI.interrupt(); } catch {}
|
||||
_setRunUI(false);
|
||||
showToast(t("feederStopped"), "info");
|
||||
}
|
||||
|
||||
// ── Gallery Mode Run Loop ─────────────────────────────────────────
|
||||
|
||||
async function _startGalRun() {
|
||||
if (_s.running) return;
|
||||
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
|
||||
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
|
||||
|
||||
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
|
||||
showToast(t("feederGalNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
if (_gs.images.length === 0) {
|
||||
showToast(t("feederGalEmptyGroup"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
|
||||
|
||||
_s.running = true;
|
||||
_setGalRunUI(true);
|
||||
|
||||
const progressBar = document.getElementById("wfm-gen-progress-bar");
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
const indexEl = document.getElementById("wfm-feeder-gal-index");
|
||||
|
||||
const total = _gs.images.length;
|
||||
let count = 0;
|
||||
try {
|
||||
while (_s.running) {
|
||||
_applyToGalNode(nodeId);
|
||||
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = `#${++count} [${_gs.idx + 1}/${total}] 0%`;
|
||||
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
{ ...comfyUI.currentWorkflow },
|
||||
{
|
||||
seedMode,
|
||||
seedValue,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `#${count} [${_gs.idx + 1}/${total}] ${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seedEl = document.getElementById("wfm-gen-seed-value");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0 && resultImg) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
resultImg.src = URL.createObjectURL(blob);
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (!_s.running) break;
|
||||
|
||||
const control = document.getElementById("wfm-feeder-gal-control-after")?.value || "loop";
|
||||
const nextIdx = (_gs.idx + 1) % total;
|
||||
const isLast = _gs.idx === total - 1;
|
||||
|
||||
if (control === "fixed") {
|
||||
// idx を変えない
|
||||
} else if (control === "increment" && isLast) {
|
||||
_s.running = false;
|
||||
showToast(t("feederComplete", count), "success");
|
||||
break;
|
||||
} else {
|
||||
_gs.idx = nextIdx;
|
||||
if (indexEl) indexEl.value = _gs.idx;
|
||||
_highlightGalCard(_gs.images[_gs.idx]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (_s.running) showToast(t("feederRunError", err.message), "error");
|
||||
} finally {
|
||||
_s.running = false;
|
||||
_setGalRunUI(false);
|
||||
if (progressText) progressText.textContent = `Done (${count} generated)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _stopGalRun() {
|
||||
_s.running = false;
|
||||
try { await comfyUI.interrupt(); } catch {}
|
||||
_setGalRunUI(false);
|
||||
showToast(t("feederStopped"), "info");
|
||||
}
|
||||
|
||||
// ── Folder Tree (loop mode) ───────────────────────────────────────
|
||||
|
||||
async function _loadTree() {
|
||||
const container = document.getElementById("wfm-feeder-tree");
|
||||
if (!container) return;
|
||||
container.innerHTML = `<div class="wfm-feeder-tree-msg">Loading...</div>`;
|
||||
try {
|
||||
const res = await fetch("/image_loop/tree");
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_renderTree(data.tree || [], container);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="wfm-feeder-tree-msg">Cannot reach image-loop API</div>`;
|
||||
console.warn("[Feeder] tree load failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderTree(items, container, depth = 0) {
|
||||
if (depth === 0) {
|
||||
container.innerHTML = "";
|
||||
container.appendChild(_makeTreeRow("", "🏠 (root)", 0));
|
||||
}
|
||||
for (const item of items) {
|
||||
const icon = item.children?.length > 0 ? "📂" : "📁";
|
||||
container.appendChild(_makeTreeRow(item.path, `${icon} ${item.name}`, depth + 1));
|
||||
if (item.children?.length > 0) _renderTree(item.children, container, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function _makeTreeRow(path, label, depth) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "wfm-feeder-tree-row" + (_s.dir === path ? " active" : "");
|
||||
row.style.paddingLeft = `${8 + depth * 12}px`;
|
||||
row.dataset.path = path;
|
||||
row.textContent = label;
|
||||
row.title = path || "(root)";
|
||||
row.addEventListener("click", () => _selectDir(path));
|
||||
return row;
|
||||
}
|
||||
|
||||
function _selectDir(path) {
|
||||
if (_s.dir !== path) {
|
||||
_s.selected.clear();
|
||||
_s.dir = path;
|
||||
_setVal("wfm-feeder-dir-input", path);
|
||||
}
|
||||
_highlightTreeRow(path);
|
||||
_loadImages(path);
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _highlightTreeRow(path) {
|
||||
document.querySelectorAll(".wfm-feeder-tree-row").forEach(el => {
|
||||
el.classList.toggle("active", el.dataset.path === path);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image Grid (loop mode) ────────────────────────────────────────
|
||||
|
||||
async function _loadImages(dir) {
|
||||
const grid = document.getElementById("wfm-feeder-grid");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
|
||||
try {
|
||||
const url = "/image_loop/images" + (dir ? `?dir=${encodeURIComponent(dir)}` : "");
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_s.images = data.images || [];
|
||||
_renderGrid();
|
||||
} catch (err) {
|
||||
_s.images = [];
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
}
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _renderGrid() {
|
||||
const grid = document.getElementById("wfm-feeder-grid");
|
||||
if (!grid) return;
|
||||
if (_s.images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images found</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
for (const fname of _s.images) grid.appendChild(_makeCard(fname));
|
||||
}
|
||||
|
||||
function _makeCard(fname) {
|
||||
const relPath = _s.dir ? `${_s.dir}/${fname}` : fname;
|
||||
const checked = _s.selected.has(fname);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-feeder-card" + (checked ? " selected" : "");
|
||||
card.dataset.name = fname;
|
||||
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.className = "wfm-feeder-card-cb";
|
||||
cb.checked = checked;
|
||||
cb.addEventListener("change", e => {
|
||||
e.stopPropagation();
|
||||
if (cb.checked) _s.selected.add(fname);
|
||||
else _s.selected.delete(fname);
|
||||
card.classList.toggle("selected", cb.checked);
|
||||
_updateStatus();
|
||||
});
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "wfm-feeder-card-img";
|
||||
img.loading = "lazy";
|
||||
img.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
|
||||
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-feeder-card-name";
|
||||
name.textContent = fname;
|
||||
name.title = fname;
|
||||
|
||||
card.append(cb, img, name);
|
||||
card.addEventListener("click", e => {
|
||||
if (e.target === cb) return;
|
||||
_showPreview(relPath, fname);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function _refreshGridCbs() {
|
||||
document.querySelectorAll(".wfm-feeder-card").forEach(card => {
|
||||
const sel = _s.selected.has(card.dataset.name);
|
||||
card.classList.toggle("selected", sel);
|
||||
const cb = card.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.checked = sel;
|
||||
});
|
||||
}
|
||||
|
||||
function _selectAll() {
|
||||
_s.images.forEach(f => _s.selected.add(f));
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _deselectAll() {
|
||||
_s.images.forEach(f => _s.selected.delete(f));
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _updateStatus() {
|
||||
const el = document.getElementById("wfm-feeder-status");
|
||||
if (!el) return;
|
||||
const inCurrent = _s.images.filter(f => _s.selected.has(f)).length;
|
||||
el.textContent = `📂 ${_s.dir || "(root)"} | ${inCurrent} / ${_s.images.length} selected`;
|
||||
}
|
||||
|
||||
// ── Gallery Mode: Group & Image Grid ─────────────────────────────
|
||||
|
||||
async function _loadGalGroups() {
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/groups");
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_gs.groups = data.groups || [];
|
||||
_renderGalGroupSelect();
|
||||
} catch (err) {
|
||||
console.warn("[Feeder] loadGalGroups failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGalGroupSelect() {
|
||||
const sel = document.getElementById("wfm-feeder-gal-group");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = _gs.groups
|
||||
.map(g => `<option value="${g.name}"${g.name === _gs.group ? " selected" : ""}>${g.name}</option>`)
|
||||
.join("");
|
||||
if (!_gs.groups.find(g => g.name === _gs.group) && _gs.groups.length > 0) {
|
||||
_gs.group = _gs.groups[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
function _setGalGroupSelect(group) {
|
||||
const sel = document.getElementById("wfm-feeder-gal-group");
|
||||
if (sel) sel.value = group;
|
||||
}
|
||||
|
||||
async function _loadGalImages(group) {
|
||||
const grid = document.getElementById("wfm-feeder-gal-grid");
|
||||
const statusEl = document.getElementById("wfm-feeder-gal-status");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/groups/${encodeURIComponent(group)}/images`);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_gs.images = data.images || [];
|
||||
_gs.idx = 0;
|
||||
const idxEl = document.getElementById("wfm-feeder-gal-index");
|
||||
if (idxEl) idxEl.value = 0;
|
||||
_renderGalGrid();
|
||||
if (statusEl) statusEl.textContent = `${_gs.images.length} image(s) in "${group}"`;
|
||||
} catch (err) {
|
||||
_gs.images = [];
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGalGrid() {
|
||||
const grid = document.getElementById("wfm-feeder-gal-grid");
|
||||
if (!grid) return;
|
||||
if (_gs.images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images in this group</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
_gs.images.forEach((absPath, i) => grid.appendChild(_makeGalCard(absPath, i)));
|
||||
}
|
||||
|
||||
function _makeGalCard(absPath, idx) {
|
||||
const fname = absPath.replace(/\\/g, "/").split("/").pop();
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-feeder-card" + (idx === _gs.idx ? " selected" : "");
|
||||
card.dataset.path = absPath;
|
||||
card.dataset.idx = idx;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "wfm-feeder-card-img";
|
||||
img.loading = "lazy";
|
||||
img.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
|
||||
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-feeder-card-name";
|
||||
name.textContent = fname;
|
||||
name.title = fname;
|
||||
|
||||
card.append(img, name);
|
||||
card.addEventListener("click", () => {
|
||||
_gs.idx = idx;
|
||||
const idxEl = document.getElementById("wfm-feeder-gal-index");
|
||||
if (idxEl) idxEl.value = idx;
|
||||
_highlightGalCard(absPath);
|
||||
_showGalPreview(absPath, fname);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function _highlightGalCard(absPath) {
|
||||
document.querySelectorAll("#wfm-feeder-gal-grid .wfm-feeder-card").forEach(c => {
|
||||
c.classList.toggle("selected", c.dataset.path === absPath);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Preview Panel ────────────────────────────────────────────────
|
||||
|
||||
async function _showPreview(relPath, fname) {
|
||||
const imgEl = document.getElementById("wfm-feeder-preview-img");
|
||||
const nameEl = document.getElementById("wfm-feeder-preview-name");
|
||||
const infoEl = document.getElementById("wfm-feeder-preview-info");
|
||||
|
||||
if (imgEl) {
|
||||
imgEl.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
|
||||
imgEl.style.display = "block";
|
||||
}
|
||||
if (nameEl) nameEl.textContent = fname;
|
||||
if (infoEl) infoEl.textContent = "Loading...";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/image_loop/image_info?path=${encodeURIComponent(relPath)}`);
|
||||
if (res.ok && infoEl) {
|
||||
const d = await res.json();
|
||||
const kb = d.size_bytes ? (d.size_bytes / 1024).toFixed(1) + " KB" : "";
|
||||
infoEl.textContent = `${d.width ?? "?"}×${d.height ?? "?"} px\n${kb}`;
|
||||
}
|
||||
} catch { if (infoEl) infoEl.textContent = ""; }
|
||||
}
|
||||
|
||||
async function _showGalPreview(absPath, fname) {
|
||||
const imgEl = document.getElementById("wfm-feeder-preview-img");
|
||||
const nameEl = document.getElementById("wfm-feeder-preview-name");
|
||||
const infoEl = document.getElementById("wfm-feeder-preview-info");
|
||||
|
||||
if (imgEl) {
|
||||
imgEl.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
|
||||
imgEl.style.display = "block";
|
||||
}
|
||||
if (nameEl) nameEl.textContent = fname;
|
||||
if (infoEl) infoEl.textContent = "";
|
||||
}
|
||||
|
||||
// ── Presets (loop mode) ───────────────────────────────────────────
|
||||
|
||||
async function _loadPresets() {
|
||||
try {
|
||||
const res = await fetch("/image_feeder/presets");
|
||||
if (!res.ok) return;
|
||||
_s.presets = await res.json();
|
||||
} catch { _s.presets = {}; }
|
||||
_renderPresets();
|
||||
}
|
||||
|
||||
function _renderPresets() {
|
||||
const sel = document.getElementById("wfm-feeder-preset-sel");
|
||||
if (!sel) return;
|
||||
const names = Object.keys(_s.presets).sort();
|
||||
sel.innerHTML = `<option value="">-- Select preset --</option>` +
|
||||
names.map(n => `<option value="${n}">${n}</option>`).join("");
|
||||
}
|
||||
|
||||
async function _savePreset() {
|
||||
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
|
||||
if (!name) { showToast(t("enterPresetName"), "error"); return; }
|
||||
const sel = _s.images.filter(f => _s.selected.has(f));
|
||||
try {
|
||||
const res = await fetch("/image_feeder/presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(t("presetSavedName", name), "success");
|
||||
const nameInput = document.getElementById("wfm-feeder-preset-name");
|
||||
if (nameInput) nameInput.value = "";
|
||||
await _loadPresets();
|
||||
const selEl = document.getElementById("wfm-feeder-preset-sel");
|
||||
if (selEl) selEl.value = name;
|
||||
} else { showToast(t("saveFailed"), "error"); }
|
||||
} catch (err) { showToast(t("saveFailed", err.message), "error"); }
|
||||
}
|
||||
|
||||
async function _applyPreset() {
|
||||
const selEl = document.getElementById("wfm-feeder-preset-sel");
|
||||
const name = selEl?.value;
|
||||
if (!name || !_s.presets[name]) { showToast(t("selectPresetFirst"), "error"); return; }
|
||||
const preset = _s.presets[name];
|
||||
const dir = preset.directory || "";
|
||||
|
||||
_s.selected.clear();
|
||||
(preset.selected_files || []).forEach(f => _s.selected.add(f));
|
||||
_setVal("wfm-feeder-dir-input", dir);
|
||||
|
||||
if (_s.dir !== dir) {
|
||||
_s.dir = dir;
|
||||
_highlightTreeRow(dir);
|
||||
await _loadImages(dir);
|
||||
}
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
showToast(t("presetLoaded", name), "success");
|
||||
}
|
||||
|
||||
async function _deletePreset() {
|
||||
const name = document.getElementById("wfm-feeder-preset-sel")?.value;
|
||||
if (!name) return;
|
||||
try {
|
||||
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
showToast(t("presetDeleted", name), "info");
|
||||
await _loadPresets();
|
||||
} catch (err) { showToast(t("deleteFailed"), "error"); }
|
||||
}
|
||||
|
||||
// ── Mode Switch ───────────────────────────────────────────────────
|
||||
|
||||
function _setMode(mode) {
|
||||
_s.mode = mode;
|
||||
localStorage.setItem("wfm_feeder_mode", mode);
|
||||
|
||||
const loopBtn = document.getElementById("wfm-feeder-mode-loop");
|
||||
const galBtn = document.getElementById("wfm-feeder-mode-gallery");
|
||||
const loopSec = document.getElementById("wfm-feeder-loop-section");
|
||||
const galSec = document.getElementById("wfm-feeder-gal-section");
|
||||
const loopLib = document.getElementById("wfm-feeder-loop-library");
|
||||
const galLib = document.getElementById("wfm-feeder-gal-library");
|
||||
|
||||
const isGallery = mode === "gallery";
|
||||
|
||||
if (loopBtn) loopBtn.classList.toggle("wfm-btn-primary", !isGallery);
|
||||
if (galBtn) galBtn.classList.toggle("wfm-btn-primary", isGallery);
|
||||
if (loopSec) loopSec.style.display = isGallery ? "none" : "";
|
||||
if (galSec) galSec.style.display = isGallery ? "" : "none";
|
||||
if (loopLib) loopLib.style.display = isGallery ? "none" : "";
|
||||
if (galLib) galLib.style.display = isGallery ? "" : "none";
|
||||
|
||||
if (isGallery) {
|
||||
_loadGalGroups().then(() => _loadGalImages(_gs.group));
|
||||
_refreshGalNodeList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function _setVal(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = val ?? "";
|
||||
}
|
||||
function _setCheck(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.checked = !!val;
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function initFeederTab() {
|
||||
// Mode toggle
|
||||
document.getElementById("wfm-feeder-mode-loop")?.addEventListener("click", () => _setMode("loop"));
|
||||
document.getElementById("wfm-feeder-mode-gallery")?.addEventListener("click", () => _setMode("gallery"));
|
||||
|
||||
// ── Image Loop mode events ──
|
||||
document.getElementById("wfm-feeder-node-sel")?.addEventListener("change", e => {
|
||||
_loadFromNode(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-apply-btn")?.addEventListener("click", _applyToWorkflow);
|
||||
|
||||
document.getElementById("wfm-feeder-dir-input")?.addEventListener("change", e => {
|
||||
const val = e.target.value.trim();
|
||||
if (_s.dir !== val) {
|
||||
_s.dir = val;
|
||||
_highlightTreeRow(val);
|
||||
_loadImages(val);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-reload-tree")?.addEventListener("click", _loadTree);
|
||||
document.getElementById("wfm-feeder-sel-all")?.addEventListener("click", _selectAll);
|
||||
document.getElementById("wfm-feeder-desel-all")?.addEventListener("click", _deselectAll);
|
||||
|
||||
document.getElementById("wfm-feeder-preset-save")?.addEventListener("click", _savePreset);
|
||||
document.getElementById("wfm-feeder-preset-load")?.addEventListener("click", _applyPreset);
|
||||
document.getElementById("wfm-feeder-preset-del")?.addEventListener("click", _deletePreset);
|
||||
|
||||
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
|
||||
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
|
||||
|
||||
// ── Gallery mode events ──
|
||||
document.getElementById("wfm-feeder-gal-node-sel")?.addEventListener("change", e => {
|
||||
_loadFromGalNode(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-apply-btn")?.addEventListener("click", _applyGalToWorkflow);
|
||||
|
||||
document.getElementById("wfm-feeder-gal-group")?.addEventListener("change", e => {
|
||||
_gs.group = e.target.value;
|
||||
_gs.idx = 0;
|
||||
_loadGalImages(_gs.group);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-index")?.addEventListener("change", e => {
|
||||
_gs.idx = Math.max(0, parseInt(e.target.value) || 0);
|
||||
if (_gs.images[_gs.idx]) _highlightGalCard(_gs.images[_gs.idx]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-run-btn")?.addEventListener("click", _startGalRun);
|
||||
document.getElementById("wfm-feeder-gal-stop-btn")?.addEventListener("click", _stopGalRun);
|
||||
|
||||
// 初期化
|
||||
await ensureFeederGroup();
|
||||
_setMode(_s.mode);
|
||||
|
||||
if (_s.mode === "loop") {
|
||||
await Promise.all([_loadTree(), _loadPresets()]);
|
||||
_selectDir("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Image Edit Tab - Draw Tool
|
||||
* Inspired by comfyui-mask-editor-one PaintTool.js
|
||||
* Supports color drawing and erasing with a soft round brush.
|
||||
* Also supports image brushes loaded from Mask Editor One's ABR brush library.
|
||||
*/
|
||||
|
||||
export class DrawTool {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas?.getContext("2d") ?? null;
|
||||
this.brushSize = 20;
|
||||
this.hardness = 0.8; // 0.0 = very soft, 1.0 = hard edge
|
||||
this.opacity = 1.0;
|
||||
this.color = "#ff0000";
|
||||
this.mode = "draw"; // "draw" | "erase"
|
||||
this.spacing = 0.25; // stamp interval as fraction of brushSize (image brush only)
|
||||
this.angle = 0; // degrees [0, 359]
|
||||
this.sizeJitter = false;
|
||||
this.sizeJitterAmount = 0.5;
|
||||
this.rotationJitter = false;
|
||||
|
||||
// Image brush from ABR library (null = default circle brush)
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
|
||||
this._drawing = false;
|
||||
this._lastX = 0;
|
||||
this._lastY = 0;
|
||||
|
||||
// Cached circle stamp
|
||||
this._stamp = null;
|
||||
this._stampSize = 0;
|
||||
this._stampHard = 0;
|
||||
this._stampColor = null;
|
||||
|
||||
// Cached image stamp
|
||||
this._imgStamp = null;
|
||||
this._imgStampSize = 0;
|
||||
this._imgStampImg = null;
|
||||
this._imgStampColor = null;
|
||||
|
||||
// Stroke buffer: prevents intra-stroke opacity accumulation (image brush only)
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d");
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
setImageBrush(img, name) {
|
||||
this.brushImage = img;
|
||||
this.brushName = name;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
clearImageBrush() {
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this.canvas) this.canvas.style.cursor = "none";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this._drawing) {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
this._baseCanvas = document.createElement("canvas");
|
||||
this._baseCanvas.width = w;
|
||||
this._baseCanvas.height = h;
|
||||
this._baseCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
|
||||
this._strokeCanvas = document.createElement("canvas");
|
||||
this._strokeCanvas.width = w;
|
||||
this._strokeCanvas.height = h;
|
||||
this._strokeCtx = this._strokeCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
_clearStrokeBuffer() {
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
}
|
||||
|
||||
_mergeStroke() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(this._baseCanvas, 0, 0);
|
||||
ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
ctx.globalAlpha = this.opacity;
|
||||
ctx.drawImage(this._strokeCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const step = Math.max(1, this.brushSize * this.spacing);
|
||||
const steps = Math.ceil(dist / step);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampColor === this.color
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
const sctx = sc.getContext("2d");
|
||||
const cx = size / 2, cy = size / 2, r = size / 2;
|
||||
|
||||
const innerR = r * (1 - Math.min(this.hardness, 0.99)) * 0.95;
|
||||
const grd = sctx.createRadialGradient(cx, cy, innerR, cx, cy, r);
|
||||
grd.addColorStop(0, this.color);
|
||||
grd.addColorStop(1, this.color + "00");
|
||||
sctx.fillStyle = grd;
|
||||
sctx.beginPath();
|
||||
sctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
sctx.fill();
|
||||
|
||||
this._stamp = sc;
|
||||
this._stampSize = size;
|
||||
this._stampHard = this.hardness;
|
||||
this._stampColor = this.color;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = this.opacity;
|
||||
if (this.mode === "erase") {
|
||||
this.ctx.globalCompositeOperation = "destination-out";
|
||||
}
|
||||
this.ctx.drawImage(stamp, x - s / 2, y - s / 2);
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const spacing = Math.max(1, this.brushSize * 0.2);
|
||||
const steps = Math.max(1, Math.ceil(dist / spacing));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image brush ──────────────────────────────────────────────────
|
||||
|
||||
_getImageStamp(size) {
|
||||
size = Math.max(1, size);
|
||||
if (
|
||||
this._imgStamp &&
|
||||
this._imgStampSize === size &&
|
||||
this._imgStampImg === this.brushImage &&
|
||||
this._imgStampColor === this.color
|
||||
) return this._imgStamp;
|
||||
|
||||
const img = this.brushImage;
|
||||
const srcW = img.naturalWidth || img.width;
|
||||
const srcH = img.naturalHeight || img.height;
|
||||
const aspect = (srcW > 0 && srcH > 0) ? srcW / srcH : 1;
|
||||
|
||||
const stampH = size;
|
||||
const stampW = Math.max(1, Math.round(size * aspect));
|
||||
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = stampW;
|
||||
sc.height = stampH;
|
||||
const stx = sc.getContext("2d");
|
||||
stx.drawImage(img, 0, 0, stampW, stampH);
|
||||
|
||||
const imgData = stx.getImageData(0, 0, stampW, stampH);
|
||||
const d = imgData.data;
|
||||
|
||||
// Detect alpha channel (ABR-exported PNGs carry alpha = brush density)
|
||||
let hasAlpha = false;
|
||||
for (let i = 3; i < d.length; i += 4) {
|
||||
if (d[i] < 250) { hasAlpha = true; break; }
|
||||
}
|
||||
|
||||
let invertLum = false;
|
||||
if (!hasAlpha) {
|
||||
const corners = [0, stampW - 1, stampW * (stampH - 1), stampW * stampH - 1];
|
||||
let bgLum = 0;
|
||||
for (const ci of corners) {
|
||||
const ii = ci * 4;
|
||||
bgLum += (d[ii] * 0.299 + d[ii + 1] * 0.587 + d[ii + 2] * 0.114) / 255;
|
||||
}
|
||||
invertLum = (bgLum / corners.length) > 0.5;
|
||||
}
|
||||
|
||||
// Parse target color
|
||||
const hex = this.color.replace("#", "");
|
||||
const cr = parseInt(hex.slice(0, 2), 16);
|
||||
const cg = parseInt(hex.slice(2, 4), 16);
|
||||
const cb = parseInt(hex.slice(4, 6), 16);
|
||||
|
||||
// Convert to target color pixels; alpha = brush density
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let alpha;
|
||||
if (hasAlpha) {
|
||||
alpha = d[i + 3] / 255;
|
||||
} else {
|
||||
const lum = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255;
|
||||
alpha = invertLum ? 1 - lum : lum;
|
||||
}
|
||||
d[i] = cr; d[i + 1] = cg; d[i + 2] = cb;
|
||||
d[i + 3] = Math.round(alpha * 255);
|
||||
}
|
||||
stx.putImageData(imgData, 0, 0);
|
||||
|
||||
this._imgStamp = sc;
|
||||
this._imgStampSize = size;
|
||||
this._imgStampImg = this.brushImage;
|
||||
this._imgStampColor = this.color;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
this.ctx.translate(Math.round(x), Math.round(y));
|
||||
this.ctx.rotate((this.angle * Math.PI) / 180);
|
||||
this.ctx.drawImage(stamp, -stamp.width / 2, -stamp.height / 2);
|
||||
this.ctx.restore();
|
||||
} else {
|
||||
this.ctx.drawImage(stamp, Math.round(x - stamp.width / 2), Math.round(y - stamp.height / 2));
|
||||
}
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
static getCanvasPos(canvas, event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Image Edit Tab - Layer & LayerManager
|
||||
* Inspired by comfyui-mask-editor-one LayerManager.js
|
||||
*/
|
||||
|
||||
export class Layer {
|
||||
constructor(name, type, contentW, contentH) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.name = name;
|
||||
this.type = type; // 'draw' | 'image' | 'text'
|
||||
// layer.canvas のサイズ = コンテンツのネイティブ解像度
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.width = contentW;
|
||||
this.canvas.height = contentH;
|
||||
this.ctx = this.canvas.getContext("2d");
|
||||
// 表示プロパティ
|
||||
this.visible = true;
|
||||
this.opacity = 1.0;
|
||||
this.blendMode = "source-over";
|
||||
// 変換プロパティ(キャンバス座標系での配置)
|
||||
this.x = 0; // 左上X
|
||||
this.y = 0; // 左上Y
|
||||
this.displayW = contentW; // 表示幅
|
||||
this.displayH = contentH; // 表示高さ
|
||||
this.rotation = 0; // 度
|
||||
this.flipX = false;
|
||||
this.flipY = false;
|
||||
this.textProps = null; // テキストレイヤー用プロパティ(再編集・再描画に使用)
|
||||
this.locked = false; // ロック中は SelectTool の変形を禁止
|
||||
this.maskApply = false; // マスクレイヤー: true=クリッピングマスクとして機能
|
||||
this.operation = "add"; // マスクレイヤー合成モード: "add" | "subtract"
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
getThumbnailDataURL(size = 32) {
|
||||
const thumb = document.createElement("canvas");
|
||||
thumb.width = size;
|
||||
thumb.height = size;
|
||||
const ctx = thumb.getContext("2d");
|
||||
|
||||
if (this.type === "mask") {
|
||||
// マスクレイヤー: 黒背景に白マスクのグレースケール表示
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.drawImage(this.canvas, 0, 0, size, size);
|
||||
} else {
|
||||
// 通常レイヤー: チェッカーボード背景(透過表示用)
|
||||
ctx.fillStyle = "#aaa";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = "#fff";
|
||||
for (let x = 0; x < size; x += 8)
|
||||
for (let y = 0; y < size; y += 8)
|
||||
if ((x / 8 + y / 8) % 2 === 0) ctx.fillRect(x, y, 8, 8);
|
||||
ctx.drawImage(this.canvas, 0, 0, size, size);
|
||||
}
|
||||
|
||||
return thumb.toDataURL("image/png");
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id, name: this.name, type: this.type,
|
||||
imageData: this.canvas.toDataURL("image/png"),
|
||||
visible: this.visible, opacity: this.opacity, blendMode: this.blendMode,
|
||||
x: this.x, y: this.y, displayW: this.displayW, displayH: this.displayH,
|
||||
rotation: this.rotation, flipX: this.flipX, flipY: this.flipY,
|
||||
textProps: this.textProps ?? null,
|
||||
locked: this.locked ?? false,
|
||||
maskApply: this.maskApply ?? false,
|
||||
operation: this.operation ?? "add",
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json) {
|
||||
const cw = json.contentW ?? json.displayW ?? 512;
|
||||
const ch = json.contentH ?? json.displayH ?? 512;
|
||||
const layer = new Layer(json.name, json.type, cw, ch);
|
||||
layer.id = json.id;
|
||||
layer.visible = json.visible ?? true;
|
||||
layer.opacity = json.opacity ?? 1.0;
|
||||
layer.blendMode = json.blendMode ?? "source-over";
|
||||
layer.x = json.x ?? 0;
|
||||
layer.y = json.y ?? 0;
|
||||
layer.displayW = json.displayW ?? cw;
|
||||
layer.displayH = json.displayH ?? ch;
|
||||
layer.rotation = json.rotation ?? 0;
|
||||
layer.flipX = json.flipX ?? false;
|
||||
layer.flipY = json.flipY ?? false;
|
||||
layer.textProps = json.textProps ?? null;
|
||||
layer.locked = json.locked ?? false;
|
||||
layer.maskApply = json.maskApply ?? false;
|
||||
layer.operation = json.operation ?? "add";
|
||||
|
||||
if (json.imageData) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => { layer.ctx.drawImage(img, 0, 0, cw, ch); resolve(layer); };
|
||||
img.onerror = () => resolve(layer);
|
||||
img.src = json.imageData;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(layer);
|
||||
}
|
||||
|
||||
/** 変換行列を ctx に適用してから drawImage できる状態にする */
|
||||
static applyTransform(ctx, layer) {
|
||||
const cx = layer.x + layer.displayW / 2;
|
||||
const cy = layer.y + layer.displayH / 2;
|
||||
ctx.translate(cx, cy);
|
||||
if (layer.rotation) ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
ctx.scale(
|
||||
(layer.flipX ? -1 : 1) * (layer.displayW / layer.canvas.width),
|
||||
(layer.flipY ? -1 : 1) * (layer.displayH / layer.canvas.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class LayerManager {
|
||||
constructor(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.layers = []; // 先頭 = 最前面
|
||||
this.activeIndex = 0;
|
||||
this._listeners = [];
|
||||
}
|
||||
|
||||
get activeLayer() { return this.layers[this.activeIndex] ?? null; }
|
||||
|
||||
/**
|
||||
* @param {string} type "draw" | "image" | "text"
|
||||
* @param {string} name
|
||||
* @param {object} opts { contentW, contentH, displayW, displayH, x, y }
|
||||
*/
|
||||
addLayer(type = "draw", name = null, opts = {}) {
|
||||
const n = name ?? `Layer ${this.layers.length + 1}`;
|
||||
const cw = opts.contentW ?? this.width;
|
||||
const ch = opts.contentH ?? this.height;
|
||||
const layer = new Layer(n, type, cw, ch);
|
||||
layer.displayW = opts.displayW ?? cw;
|
||||
layer.displayH = opts.displayH ?? ch;
|
||||
layer.x = opts.x ?? 0;
|
||||
layer.y = opts.y ?? 0;
|
||||
this.layers.splice(this.activeIndex, 0, layer);
|
||||
this._emit("change");
|
||||
return layer;
|
||||
}
|
||||
|
||||
deleteLayer(id) {
|
||||
if (this.layers.length <= 1) return;
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx < 0) return;
|
||||
this.layers.splice(idx, 1);
|
||||
this.activeIndex = Math.max(0, Math.min(this.activeIndex, this.layers.length - 1));
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
setActive(id) {
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx >= 0) { this.activeIndex = idx; this._emit("activeChange", this.layers[idx]); }
|
||||
}
|
||||
|
||||
moveUp(id) {
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx <= 0) return;
|
||||
[this.layers[idx - 1], this.layers[idx]] = [this.layers[idx], this.layers[idx - 1]];
|
||||
if (this.activeIndex === idx) this.activeIndex = idx - 1;
|
||||
else if (this.activeIndex === idx - 1) this.activeIndex = idx;
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
moveDown(id) {
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx < 0 || idx >= this.layers.length - 1) return;
|
||||
[this.layers[idx], this.layers[idx + 1]] = [this.layers[idx + 1], this.layers[idx]];
|
||||
if (this.activeIndex === idx) this.activeIndex = idx + 1;
|
||||
else if (this.activeIndex === idx + 1) this.activeIndex = idx;
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
toggleVisible(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer) { layer.visible = !layer.visible; this._emit("change"); }
|
||||
}
|
||||
|
||||
toggleLocked(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer) { layer.locked = !layer.locked; this._emit("change"); }
|
||||
}
|
||||
|
||||
toggleMaskApply(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer && layer.type === "mask") { layer.maskApply = !layer.maskApply; this._emit("change"); }
|
||||
}
|
||||
|
||||
toggleOperation(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer && layer.type === "mask") {
|
||||
layer.operation = layer.operation === "add" ? "subtract" : "add";
|
||||
this._emit("change");
|
||||
}
|
||||
}
|
||||
|
||||
setOpacity(id, opacity) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer) { layer.opacity = Math.max(0, Math.min(1, opacity)); this._emit("change"); }
|
||||
}
|
||||
|
||||
/** 全レイヤーを下→上の順で target canvas に変換付きで合成 */
|
||||
composite(target) {
|
||||
const ctx = target.getContext("2d");
|
||||
ctx.clearRect(0, 0, target.width, target.height);
|
||||
for (let i = this.layers.length - 1; i >= 0; i--) {
|
||||
const layer = this.layers[i];
|
||||
if (!layer.visible) continue;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = layer.opacity;
|
||||
ctx.globalCompositeOperation = layer.blendMode;
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
on(event, fn) { this._listeners.push({ event, fn }); }
|
||||
|
||||
_emit(event, data) {
|
||||
for (const l of this._listeners) if (l.event === event) l.fn(data);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { layers: this.layers.map(l => l.toJSON()), width: this.width, height: this.height };
|
||||
}
|
||||
|
||||
async fromJSON(json) {
|
||||
this.layers = await Promise.all((json.layers || []).map(lj => Layer.fromJSON(lj)));
|
||||
this.activeIndex = 0;
|
||||
this._emit("change");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Image Edit Tab - Mask Editor One 互換追加ツール
|
||||
* Color, Alpha, Text, Vector, Shape の各マスクツール。
|
||||
* マスクレイヤー canvas に直接白(add)または destination-out(erase)で描画する。
|
||||
*/
|
||||
|
||||
// ── Color Tool ───────────────────────────────────────────────────────────────
|
||||
// bgCanvas(背景合成画像)のクリック色に近いピクセルをマスク選択する。
|
||||
export class MaskColorTool {
|
||||
constructor() {
|
||||
this.tolerance = 40; // 0-255
|
||||
this.feather = 5; // 0-100 (tolerance に対する%)
|
||||
this.mode = "add"; // "add" | "subtract"
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._bgCanvas = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setBgCanvas(canvas) { this._bgCanvas = canvas; }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
|
||||
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown(x, y) { this._selectByColor(Math.round(x), Math.round(y)); }
|
||||
onMouseMove() {}
|
||||
onMouseUp() {}
|
||||
onMouseLeave() {}
|
||||
|
||||
_selectByColor(px, py) {
|
||||
if (!this._bgCanvas || !this._ctx || !this._canvas) return;
|
||||
const bgCtx = this._bgCanvas.getContext("2d");
|
||||
const bw = this._bgCanvas.width;
|
||||
const bh = this._bgCanvas.height;
|
||||
if (bw === 0 || bh === 0) return;
|
||||
|
||||
const srcData = bgCtx.getImageData(0, 0, bw, bh);
|
||||
const dw = this._canvas.width;
|
||||
const dh = this._canvas.height;
|
||||
const sx = bw / dw;
|
||||
const sy = bh / dh;
|
||||
|
||||
const bpx = Math.min(bw - 1, Math.round(px * sx));
|
||||
const bpy = Math.min(bh - 1, Math.round(py * sy));
|
||||
const idx = (bpy * bw + bpx) * 4;
|
||||
if (idx < 0 || idx + 3 >= srcData.data.length) return;
|
||||
|
||||
const tr = srcData.data[idx];
|
||||
const tg = srcData.data[idx + 1];
|
||||
const tb = srcData.data[idx + 2];
|
||||
|
||||
const outData = new Uint8ClampedArray(dw * dh * 4);
|
||||
const tol = this.tolerance;
|
||||
|
||||
for (let dy = 0; dy < dh; dy++) {
|
||||
for (let dx = 0; dx < dw; dx++) {
|
||||
const bx = Math.min(bw - 1, Math.round(dx * sx));
|
||||
const by = Math.min(bh - 1, Math.round(dy * sy));
|
||||
const si = (by * bw + bx) * 4;
|
||||
const r = srcData.data[si];
|
||||
const g = srcData.data[si + 1];
|
||||
const b = srcData.data[si + 2];
|
||||
const dist = Math.sqrt((r - tr) ** 2 + (g - tg) ** 2 + (b - tb) ** 2);
|
||||
|
||||
let alpha;
|
||||
if (tol <= 0) {
|
||||
alpha = dist === 0 ? 255 : 0;
|
||||
} else {
|
||||
const fStart = tol * (1 - this.feather / 100);
|
||||
if (dist <= fStart) alpha = 255;
|
||||
else if (dist <= tol) alpha = Math.round(255 * (1 - (dist - fStart) / (tol - fStart)));
|
||||
else alpha = 0;
|
||||
}
|
||||
|
||||
const j = (dy * dw + dx) * 4;
|
||||
outData[j] = outData[j + 1] = outData[j + 2] = 255;
|
||||
outData[j + 3] = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = dw; tmp.height = dh;
|
||||
tmp.getContext("2d").putImageData(new ImageData(outData, dw, dh), 0, 0);
|
||||
|
||||
const ctx = this._ctx;
|
||||
ctx.globalCompositeOperation = this.mode === "subtract" ? "destination-out" : "lighten";
|
||||
ctx.drawImage(tmp, 0, 0);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alpha Tool ───────────────────────────────────────────────────────────────
|
||||
// 元画像のアルファチャンネルをマスクとして抽出する。
|
||||
export class MaskAlphaTool {
|
||||
constructor() {
|
||||
this.threshold = 128; // 0-255
|
||||
this.invert = false;
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._srcImg = null; // HTMLImageElement or HTMLCanvasElement
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setSourceImage(img) { this._srcImg = img; }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "default"; }
|
||||
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown() {}
|
||||
onMouseMove() {}
|
||||
onMouseUp() {}
|
||||
onMouseLeave() {}
|
||||
|
||||
extract() {
|
||||
if (!this._srcImg || !this._ctx || !this._canvas) return;
|
||||
const w = this._canvas.width;
|
||||
const h = this._canvas.height;
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = w; tmp.height = h;
|
||||
tmp.getContext("2d").drawImage(this._srcImg, 0, 0, w, h);
|
||||
const src = tmp.getContext("2d").getImageData(0, 0, w, h);
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
for (let i = 0; i < src.data.length; i += 4) {
|
||||
let a = src.data[i + 3];
|
||||
if (this.threshold > 0) a = a >= this.threshold ? 255 : 0;
|
||||
if (this.invert) a = 255 - a;
|
||||
out[i] = out[i + 1] = out[i + 2] = 255;
|
||||
out[i + 3] = a;
|
||||
}
|
||||
this._ctx.clearRect(0, 0, w, h);
|
||||
this._ctx.putImageData(new ImageData(out, w, h), 0, 0);
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text Tool ────────────────────────────────────────────────────────────────
|
||||
// クリック位置にテキストを白(add)または消去(erase)でスタンプする。
|
||||
export const MASK_TEXT_FONTS = [
|
||||
"Arial", "Arial Black", "Georgia", "Times New Roman",
|
||||
"Courier New", "Verdana", "Trebuchet MS", "Impact",
|
||||
"Comic Sans MS", "Helvetica", "Tahoma",
|
||||
];
|
||||
|
||||
export class MaskTextTool {
|
||||
constructor() {
|
||||
this.text = "Hello";
|
||||
this.fontFamily = "Arial";
|
||||
this.fontSize = 64;
|
||||
this.bold = false;
|
||||
this.italic = false;
|
||||
this.align = "left";
|
||||
this.mode = "add";
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._overlay = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
|
||||
deactivate() { this._closeOverlay(); if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (this._overlay) { this._closeOverlay(); return; }
|
||||
this._showOverlay(x, y);
|
||||
}
|
||||
onMouseMove() {}
|
||||
onMouseUp() {}
|
||||
onMouseLeave() {}
|
||||
|
||||
_getFont() {
|
||||
const parts = [];
|
||||
if (this.italic) parts.push("italic");
|
||||
if (this.bold) parts.push("bold");
|
||||
parts.push(`${this.fontSize}px`);
|
||||
parts.push(`"${this.fontFamily}", sans-serif`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
_showOverlay(canvasX, canvasY) {
|
||||
const cv = this._canvas;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const cssX = Math.round(canvasX * (rect.width / cv.width)) + rect.left;
|
||||
const cssY = Math.round(canvasY * (rect.height / cv.height)) + rect.top;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = [
|
||||
`position:fixed;left:${cssX}px;top:${cssY}px;`,
|
||||
"background:var(--wfm-surface,#2a2a2a);",
|
||||
"border:1px solid var(--wfm-border,#444);",
|
||||
"border-radius:4px;padding:6px;z-index:99999;",
|
||||
"box-shadow:0 4px 16px rgba(0,0,0,0.5);min-width:200px;",
|
||||
].join("");
|
||||
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = this.text;
|
||||
ta.rows = 3;
|
||||
ta.placeholder = "Enter text…";
|
||||
ta.style.cssText = [
|
||||
"width:100%;resize:vertical;box-sizing:border-box;",
|
||||
"background:var(--wfm-bg,#1a1a1a);color:var(--wfm-text,#ddd);",
|
||||
"border:1px solid var(--wfm-border,#444);border-radius:3px;",
|
||||
"padding:4px;font-size:12px;",
|
||||
].join("");
|
||||
overlay.appendChild(ta);
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.style.cssText = "display:flex;gap:4px;margin-top:4px;";
|
||||
|
||||
const okBtn = document.createElement("button");
|
||||
okBtn.textContent = "Stamp";
|
||||
okBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
|
||||
okBtn.style.flex = "1";
|
||||
okBtn.onclick = () => {
|
||||
if (ta.value.trim()) { this.text = ta.value; this._stamp(canvasX, canvasY); }
|
||||
this._closeOverlay();
|
||||
};
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.className = "wfm-btn wfm-btn-sm";
|
||||
cancelBtn.style.flex = "1";
|
||||
cancelBtn.onclick = () => this._closeOverlay();
|
||||
|
||||
btnRow.appendChild(okBtn);
|
||||
btnRow.appendChild(cancelBtn);
|
||||
overlay.appendChild(btnRow);
|
||||
|
||||
const hint = document.createElement("div");
|
||||
hint.textContent = "Ctrl+Enter: Stamp / Esc: Cancel";
|
||||
hint.style.cssText = "font-size:10px;color:var(--wfm-text-secondary,#888);margin-top:3px;";
|
||||
overlay.appendChild(hint);
|
||||
|
||||
ta.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); okBtn.click(); }
|
||||
else if (e.key === "Escape") cancelBtn.click();
|
||||
e.stopPropagation();
|
||||
});
|
||||
overlay.addEventListener("mousedown", e => e.stopPropagation());
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
ta.focus(); ta.select();
|
||||
}
|
||||
|
||||
_closeOverlay() {
|
||||
if (this._overlay) { this._overlay.remove(); this._overlay = null; }
|
||||
}
|
||||
|
||||
_stamp(x, y) {
|
||||
const ctx = this._ctx;
|
||||
ctx.save();
|
||||
ctx.font = this._getFont();
|
||||
ctx.textAlign = this.align;
|
||||
ctx.textBaseline = "top";
|
||||
if (this.mode === "erase") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "rgba(0,0,0,1)";
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "white";
|
||||
}
|
||||
const lines = this.text.split("\n");
|
||||
const lineHeight = this.fontSize * 1.2;
|
||||
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], x, y + i * lineHeight);
|
||||
ctx.restore();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vector Tool ──────────────────────────────────────────────────────────────
|
||||
// クリックでポイント追加、Catmull-Rom スプラインで閉じたパスを塗りつぶす。
|
||||
const VECTOR_CLOSE_R = 12;
|
||||
|
||||
export class MaskVectorTool {
|
||||
constructor() {
|
||||
this.mode = "add";
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._previewCanvas = null;
|
||||
this._points = [];
|
||||
this._hoverX = null;
|
||||
this._hoverY = null;
|
||||
this.onBeforeCommit = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setPreviewCanvas(canvas) { this._previewCanvas = canvas; }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; this._renderPreview(); }
|
||||
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; this._clearPreview(); }
|
||||
|
||||
reset() { this._points = []; this._hoverX = null; this._hoverY = null; this._clearPreview(); }
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (this._points.length >= 3) {
|
||||
const f = this._points[0];
|
||||
if (Math.hypot(x - f.x, y - f.y) <= VECTOR_CLOSE_R) { this._commitPath(true); return; }
|
||||
}
|
||||
this._points.push({ x, y });
|
||||
this._renderPreview();
|
||||
}
|
||||
|
||||
onMouseMove(x, y) { this._hoverX = x; this._hoverY = y; this._renderPreview(); }
|
||||
onMouseLeave() { this._hoverX = null; this._hoverY = null; this._renderPreview(); }
|
||||
onMouseUp() {}
|
||||
|
||||
onKeyDown(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault(); this.reset();
|
||||
} else if (e.key === "Enter" && this._points.length >= 2) {
|
||||
e.preventDefault(); this._commitPath(false);
|
||||
} else if ((e.key === "Backspace" || e.key === "Delete") && this._points.length > 0) {
|
||||
e.preventDefault(); this._points.pop(); this._renderPreview();
|
||||
}
|
||||
}
|
||||
|
||||
_commitPath(closed) {
|
||||
if (this._points.length < 2) return;
|
||||
if (this.onBeforeCommit) this.onBeforeCommit();
|
||||
const ctx = this._ctx;
|
||||
ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "rgba(0,0,0,1)";
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "white";
|
||||
}
|
||||
ctx.beginPath();
|
||||
this._buildSpline(ctx, this._points, closed);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
this.reset();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
|
||||
_buildSpline(ctx, pts, closed) {
|
||||
const n = pts.length;
|
||||
if (n === 0) return;
|
||||
if (n === 1) { ctx.moveTo(pts[0].x, pts[0].y); return; }
|
||||
if (n === 2) {
|
||||
ctx.moveTo(pts[0].x, pts[0].y); ctx.lineTo(pts[1].x, pts[1].y);
|
||||
if (closed) ctx.closePath(); return;
|
||||
}
|
||||
let ext;
|
||||
if (closed) {
|
||||
ext = [pts[n - 1], ...pts, pts[0], pts[1]];
|
||||
} else {
|
||||
ext = [
|
||||
{ x: 2 * pts[0].x - pts[1].x, y: 2 * pts[0].y - pts[1].y },
|
||||
...pts,
|
||||
{ x: 2 * pts[n-1].x - pts[n-2].x, y: 2 * pts[n-1].y - pts[n-2].y },
|
||||
];
|
||||
}
|
||||
ctx.moveTo(ext[1].x, ext[1].y);
|
||||
const count = closed ? n : n - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [p0, p1, p2, p3] = [ext[i], ext[i+1], ext[i+2], ext[i+3]];
|
||||
ctx.bezierCurveTo(
|
||||
p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
|
||||
p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
|
||||
p2.x, p2.y,
|
||||
);
|
||||
}
|
||||
if (closed) ctx.closePath();
|
||||
}
|
||||
|
||||
_renderPreview() {
|
||||
if (!this._previewCanvas) return;
|
||||
const pctx = this._previewCanvas.getContext("2d");
|
||||
const w = this._previewCanvas.width, h = this._previewCanvas.height;
|
||||
pctx.clearRect(0, 0, w, h);
|
||||
const pts = this._points, hx = this._hoverX, hy = this._hoverY;
|
||||
if (pts.length === 0 && hx === null) return;
|
||||
|
||||
if (pts.length >= 2) {
|
||||
pctx.save();
|
||||
pctx.strokeStyle = "rgba(255,210,40,0.9)"; pctx.lineWidth = 1.5; pctx.setLineDash([]);
|
||||
pctx.beginPath(); this._buildSpline(pctx, pts, false); pctx.stroke();
|
||||
pctx.restore();
|
||||
}
|
||||
|
||||
if (pts.length >= 1 && hx !== null) {
|
||||
const canClose = pts.length >= 3 && Math.hypot(hx - pts[0].x, hy - pts[0].y) <= VECTOR_CLOSE_R;
|
||||
pctx.save();
|
||||
if (canClose) {
|
||||
pctx.strokeStyle = "rgba(255,100,100,0.55)"; pctx.lineWidth = 1.5; pctx.setLineDash([4, 3]);
|
||||
pctx.beginPath(); this._buildSpline(pctx, pts, true); pctx.stroke();
|
||||
} else {
|
||||
pctx.strokeStyle = "rgba(255,210,40,0.5)"; pctx.lineWidth = 1; pctx.setLineDash([4, 3]);
|
||||
pctx.beginPath();
|
||||
pctx.moveTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
|
||||
pctx.lineTo(hx, hy); pctx.stroke();
|
||||
}
|
||||
pctx.restore();
|
||||
}
|
||||
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i], isFirst = i === 0;
|
||||
const closeable = isFirst && pts.length >= 3 && hx !== null && Math.hypot(hx - p.x, hy - p.y) <= VECTOR_CLOSE_R;
|
||||
pctx.beginPath();
|
||||
pctx.arc(p.x, p.y, closeable ? 7 : (isFirst ? 5 : 4), 0, Math.PI * 2);
|
||||
pctx.fillStyle = closeable ? "rgba(255,80,80,0.95)" : (isFirst ? "rgba(255,210,40,0.95)" : "rgba(255,210,40,0.8)");
|
||||
pctx.strokeStyle = "rgba(0,0,0,0.6)"; pctx.lineWidth = 1;
|
||||
pctx.fill(); pctx.stroke();
|
||||
}
|
||||
|
||||
if (hx !== null) {
|
||||
const onFirst = pts.length >= 3 && Math.hypot(hx - pts[0].x, hy - pts[0].y) <= VECTOR_CLOSE_R;
|
||||
if (!onFirst) {
|
||||
pctx.beginPath(); pctx.arc(hx, hy, 3, 0, Math.PI * 2);
|
||||
pctx.fillStyle = "rgba(255,255,255,0.6)"; pctx.strokeStyle = "rgba(200,200,200,0.5)"; pctx.lineWidth = 1;
|
||||
pctx.fill(); pctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_clearPreview() {
|
||||
if (!this._previewCanvas) return;
|
||||
const c = this._previewCanvas;
|
||||
c.getContext("2d").clearRect(0, 0, c.width, c.height);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shape Tool ───────────────────────────────────────────────────────────────
|
||||
// ドラッグで矩形または楕円をマスクとして描画する(Shift = 正方形/正円)。
|
||||
export class MaskShapeTool {
|
||||
constructor() {
|
||||
this.mode = "add";
|
||||
this.shape = "rect"; // "rect" | "ellipse"
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._previewCanvas = null;
|
||||
this._pCtx = null;
|
||||
this._startX = 0;
|
||||
this._startY = 0;
|
||||
this._curX = 0;
|
||||
this._curY = 0;
|
||||
this._curShift = false;
|
||||
this._drawing = false;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setPreviewCanvas(canvas) { this._previewCanvas = canvas; this._pCtx = canvas.getContext("2d"); }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
|
||||
deactivate() { this._drawing = false; this._clearPreview(); if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._startX = x; this._startY = y;
|
||||
this._curX = x; this._curY = y; this._curShift = false;
|
||||
this._drawing = true;
|
||||
this._drawPreview(x, y, false);
|
||||
}
|
||||
|
||||
onMouseMove(x, y, e) {
|
||||
if (!this._drawing) return;
|
||||
this._curX = x; this._curY = y; this._curShift = e?.shiftKey ?? false;
|
||||
this._drawPreview(x, y, this._curShift);
|
||||
}
|
||||
|
||||
// x/y を省略した場合は最後に記録した座標を使用(mouseupイベント非経由での呼び出し対応)
|
||||
onMouseUp(x, y, e) {
|
||||
if (!this._drawing) return;
|
||||
const cx = x ?? this._curX;
|
||||
const cy = y ?? this._curY;
|
||||
const shift = e?.shiftKey ?? this._curShift;
|
||||
this._drawing = false;
|
||||
this._clearPreview();
|
||||
this._commit(cx, cy, shift);
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
|
||||
onMouseLeave() {}
|
||||
|
||||
_getRect(x, y, shift) {
|
||||
const sx = this._startX, sy = this._startY;
|
||||
if (shift) {
|
||||
const half = Math.max(Math.abs(x - sx), Math.abs(y - sy));
|
||||
return { x: sx - half, y: sy - half, w: half * 2, h: half * 2 };
|
||||
}
|
||||
return { x: Math.min(sx, x), y: Math.min(sy, y), w: Math.abs(x - sx), h: Math.abs(y - sy) };
|
||||
}
|
||||
|
||||
_drawPreview(x, y, shift) {
|
||||
if (!this._pCtx) return;
|
||||
const pc = this._previewCanvas;
|
||||
this._pCtx.clearRect(0, 0, pc.width, pc.height);
|
||||
const { x: rx, y: ry, w, h } = this._getRect(x, y, shift);
|
||||
if (w < 1 || h < 1) return;
|
||||
this._pCtx.save();
|
||||
this._pCtx.strokeStyle = "rgba(0,180,255,0.9)"; this._pCtx.lineWidth = 1.5; this._pCtx.setLineDash([5, 3]);
|
||||
this._pCtx.beginPath();
|
||||
if (this.shape === "rect") this._pCtx.rect(rx, ry, w, h);
|
||||
else this._pCtx.ellipse(rx + w / 2, ry + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
this._pCtx.stroke(); this._pCtx.restore();
|
||||
}
|
||||
|
||||
_clearPreview() {
|
||||
if (!this._pCtx) return;
|
||||
this._pCtx.clearRect(0, 0, this._previewCanvas.width, this._previewCanvas.height);
|
||||
}
|
||||
|
||||
_commit(x, y, shift) {
|
||||
const { x: rx, y: ry, w, h } = this._getRect(x, y, shift);
|
||||
if (w < 1 || h < 1) return;
|
||||
const ctx = this._ctx;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = this.mode === "erase" ? "destination-out" : "source-over";
|
||||
ctx.fillStyle = "white";
|
||||
ctx.beginPath();
|
||||
if (this.shape === "rect") ctx.rect(rx, ry, w, h);
|
||||
else ctx.ellipse(rx + w / 2, ry + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Image Edit Tab - Mask Tool
|
||||
* White-paint / erase brush for mask layers.
|
||||
* paint mode: draws white (opaque) = mask present
|
||||
* erase mode: destination-out (transparent) = mask absent
|
||||
*
|
||||
* Supports circular soft brush (built-in) and image brushes loaded
|
||||
* from Mask Editor One's ABR brush library (optional).
|
||||
*/
|
||||
|
||||
export class MaskTool {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas?.getContext("2d");
|
||||
this.brushSize = 30;
|
||||
this.hardness = 0.85;
|
||||
this.spacing = 0.25; // stamp interval as fraction of brushSize (image brush only)
|
||||
this.mode = "paint"; // "paint" | "erase"
|
||||
|
||||
// Image brush from ABR library (null = default circle brush)
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
this.angle = 0; // degrees [0, 359]
|
||||
this.sizeJitter = false;
|
||||
this.sizeJitterAmount = 0.5; // 0–1: fraction of brushSize that can be removed
|
||||
this.rotationJitter = false;
|
||||
|
||||
this._drawing = false;
|
||||
this._lastX = 0;
|
||||
this._lastY = 0;
|
||||
|
||||
// Cached circle stamp
|
||||
this._stamp = null;
|
||||
this._stampSize = 0;
|
||||
this._stampHard = 0;
|
||||
this._stampMode = null;
|
||||
|
||||
// Cached image stamp
|
||||
this._imgStamp = null;
|
||||
this._imgStampSize = 0;
|
||||
this._imgStampImg = null;
|
||||
|
||||
// Stroke buffer: prevents intra-stroke opacity accumulation (image brush only)
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d");
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
setImageBrush(img, name) {
|
||||
this.brushImage = img;
|
||||
this.brushName = name;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
clearImageBrush() {
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this.canvas) this.canvas.style.cursor = "none";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this._drawing) {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
this._baseCanvas = document.createElement("canvas");
|
||||
this._baseCanvas.width = w;
|
||||
this._baseCanvas.height = h;
|
||||
this._baseCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
|
||||
this._strokeCanvas = document.createElement("canvas");
|
||||
this._strokeCanvas.width = w;
|
||||
this._strokeCanvas.height = h;
|
||||
this._strokeCtx = this._strokeCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
_clearStrokeBuffer() {
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
}
|
||||
|
||||
_mergeStroke() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(this._baseCanvas, 0, 0);
|
||||
ctx.globalCompositeOperation = this.mode === "erase" ? "destination-out" : "lighten";
|
||||
ctx.drawImage(this._strokeCanvas, 0, 0);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const step = Math.max(1, this.brushSize * this.spacing);
|
||||
const steps = Math.ceil(dist / step);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampMode === this.mode
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
const sctx = sc.getContext("2d");
|
||||
const cx = size / 2, cy = size / 2, r = size / 2;
|
||||
|
||||
const color = this.mode === "paint" ? "#ffffff" : "#000000";
|
||||
const colorAlpha = this.mode === "paint" ? "rgba(255,255,255,0)" : "rgba(0,0,0,0)";
|
||||
|
||||
const innerR = r * (1 - Math.min(this.hardness, 0.99)) * 0.95;
|
||||
const grd = sctx.createRadialGradient(cx, cy, innerR, cx, cy, r);
|
||||
grd.addColorStop(0, color);
|
||||
grd.addColorStop(1, colorAlpha);
|
||||
sctx.fillStyle = grd;
|
||||
sctx.beginPath();
|
||||
sctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
sctx.fill();
|
||||
|
||||
this._stamp = sc;
|
||||
this._stampSize = size;
|
||||
this._stampHard = this.hardness;
|
||||
this._stampMode = this.mode;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
this.ctx.globalCompositeOperation = "destination-out";
|
||||
}
|
||||
this.ctx.drawImage(stamp, x - s / 2, y - s / 2);
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const spacing = Math.max(1, this.brushSize * 0.2);
|
||||
const steps = Math.max(1, Math.ceil(dist / spacing));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image brush ──────────────────────────────────────────────────
|
||||
|
||||
_getImageStamp(size) {
|
||||
size = Math.max(1, size);
|
||||
if (
|
||||
this._imgStamp &&
|
||||
this._imgStampSize === size &&
|
||||
this._imgStampImg === this.brushImage
|
||||
) return this._imgStamp;
|
||||
|
||||
const img = this.brushImage;
|
||||
const srcW = img.naturalWidth || img.width;
|
||||
const srcH = img.naturalHeight || img.height;
|
||||
const aspect = (srcW > 0 && srcH > 0) ? srcW / srcH : 1;
|
||||
|
||||
const stampH = size;
|
||||
const stampW = Math.max(1, Math.round(size * aspect));
|
||||
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = stampW;
|
||||
sc.height = stampH;
|
||||
const stx = sc.getContext("2d");
|
||||
stx.drawImage(img, 0, 0, stampW, stampH);
|
||||
|
||||
const imgData = stx.getImageData(0, 0, stampW, stampH);
|
||||
const d = imgData.data;
|
||||
|
||||
// Detect alpha channel (ABR-exported PNGs carry alpha = brush density)
|
||||
let hasAlpha = false;
|
||||
for (let i = 3; i < d.length; i += 4) {
|
||||
if (d[i] < 250) { hasAlpha = true; break; }
|
||||
}
|
||||
|
||||
let invertLum = false;
|
||||
if (!hasAlpha) {
|
||||
const corners = [0, stampW - 1, stampW * (stampH - 1), stampW * stampH - 1];
|
||||
let bgLum = 0;
|
||||
for (const ci of corners) {
|
||||
const ii = ci * 4;
|
||||
bgLum += (d[ii] * 0.299 + d[ii + 1] * 0.587 + d[ii + 2] * 0.114) / 255;
|
||||
}
|
||||
invertLum = (bgLum / corners.length) > 0.5;
|
||||
}
|
||||
|
||||
// Convert to white pixels; alpha = brush density
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let alpha;
|
||||
if (hasAlpha) {
|
||||
alpha = d[i + 3] / 255;
|
||||
} else {
|
||||
const lum = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255;
|
||||
alpha = invertLum ? 1 - lum : lum;
|
||||
}
|
||||
d[i] = d[i + 1] = d[i + 2] = 255;
|
||||
d[i + 3] = Math.round(alpha * 255);
|
||||
}
|
||||
stx.putImageData(imgData, 0, 0);
|
||||
|
||||
this._imgStamp = sc;
|
||||
this._imgStampSize = size;
|
||||
this._imgStampImg = this.brushImage;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
this.ctx.translate(Math.round(x), Math.round(y));
|
||||
this.ctx.rotate((this.angle * Math.PI) / 180);
|
||||
this.ctx.drawImage(stamp, -stamp.width / 2, -stamp.height / 2);
|
||||
this.ctx.restore();
|
||||
} else {
|
||||
this.ctx.drawImage(stamp, Math.round(x - stamp.width / 2), Math.round(y - stamp.height / 2));
|
||||
}
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Image Edit Tab - Select Tool
|
||||
* Handles object selection, move, resize, rotate, flip on the overlay canvas.
|
||||
*/
|
||||
|
||||
const HANDLE_R = 5; // ハンドル半径(px)
|
||||
const ROTATE_DIST = 32; // 回転ハンドルまでの距離(px)
|
||||
const MIN_SIZE = 10; // 最小表示サイズ(px)
|
||||
|
||||
export class SelectTool {
|
||||
constructor() {
|
||||
this._overlay = null;
|
||||
this._octx = null;
|
||||
this._layer = null; // 選択中レイヤー
|
||||
this._dragMode = null; // "move"|"rotate"|"resize-tl"|"resize-tr"|"resize-br"|"resize-bl"
|
||||
this._dragStart = null;
|
||||
this._origTransform = null;
|
||||
this._onChange = null;
|
||||
this._cursor = "default";
|
||||
}
|
||||
|
||||
setCanvas(overlayCanvas) {
|
||||
this._overlay = overlayCanvas;
|
||||
this._octx = overlayCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() {
|
||||
if (this._overlay) this._overlay.style.pointerEvents = "auto";
|
||||
this._drawOverlay();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._clearOverlay();
|
||||
if (this._overlay) this._overlay.style.pointerEvents = "none";
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this._layer = null;
|
||||
this._dragMode = null;
|
||||
this._clearOverlay();
|
||||
}
|
||||
|
||||
setLayer(layer) {
|
||||
this._layer = layer;
|
||||
this._drawOverlay();
|
||||
}
|
||||
|
||||
getSelectedLayer() { return this._layer; }
|
||||
|
||||
// ── マウスイベント ───────────────────────────
|
||||
|
||||
onMouseDown(x, y, layerMgr) {
|
||||
if (this._layer) {
|
||||
const hit = this._hitHandle(x, y);
|
||||
// ロック中はハンドル操作を禁止(選択済み表示は維持)
|
||||
if (hit && !this._layer.locked) {
|
||||
this._startDrag(hit, x, y);
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
// レイヤーヒットテスト(前面から)
|
||||
for (let i = 0; i < layerMgr.layers.length; i++) {
|
||||
const layer = layerMgr.layers[i];
|
||||
if (!layer.visible) continue;
|
||||
if (this._isPointInLayer(x, y, layer)) {
|
||||
this._layer = layer;
|
||||
this._drawOverlay();
|
||||
// ロック中は選択のみ(移動ドラッグは開始しない)
|
||||
if (!layer.locked) this._startDrag("move", x, y);
|
||||
return "select";
|
||||
}
|
||||
}
|
||||
|
||||
// 空白クリック → 選択解除
|
||||
this._layer = null;
|
||||
this._clearOverlay();
|
||||
return null;
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._dragMode || !this._layer) {
|
||||
// カーソル変更のみ
|
||||
this._updateCursor(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = x - this._dragStart.x;
|
||||
const dy = y - this._dragStart.y;
|
||||
const ot = this._origTransform;
|
||||
|
||||
if (this._dragMode === "move") {
|
||||
this._layer.x = ot.x + dx;
|
||||
this._layer.y = ot.y + dy;
|
||||
} else if (this._dragMode === "rotate") {
|
||||
const cx = ot.x + ot.displayW / 2;
|
||||
const cy = ot.y + ot.displayH / 2;
|
||||
const a0 = Math.atan2(this._dragStart.y - cy, this._dragStart.x - cx);
|
||||
const a1 = Math.atan2(y - cy, x - cx);
|
||||
this._layer.rotation = ot.rotation + (a1 - a0) * 180 / Math.PI;
|
||||
} else if (this._dragMode.startsWith("resize-")) {
|
||||
this._applyResize(dx, dy);
|
||||
}
|
||||
|
||||
this._drawOverlay();
|
||||
if (this._onChange) this._onChange("transforming");
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this._dragMode) {
|
||||
this._dragMode = null;
|
||||
this._dragStart = null;
|
||||
this._origTransform = null;
|
||||
if (this._onChange) this._onChange("transformEnd");
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() { this.onMouseUp(); }
|
||||
|
||||
// ── 反転 ─────────────────────────────────────
|
||||
|
||||
flipH() {
|
||||
if (!this._layer) return;
|
||||
this._layer.flipX = !this._layer.flipX;
|
||||
this._drawOverlay();
|
||||
if (this._onChange) this._onChange("transformEnd");
|
||||
}
|
||||
|
||||
flipV() {
|
||||
if (!this._layer) return;
|
||||
this._layer.flipY = !this._layer.flipY;
|
||||
this._drawOverlay();
|
||||
if (this._onChange) this._onChange("transformEnd");
|
||||
}
|
||||
|
||||
// ── 内部ユーティリティ ─────────────────────────
|
||||
|
||||
_startDrag(mode, x, y) {
|
||||
this._dragMode = mode;
|
||||
this._dragStart = { x, y };
|
||||
this._origTransform = {
|
||||
x: this._layer.x, y: this._layer.y,
|
||||
displayW: this._layer.displayW, displayH: this._layer.displayH,
|
||||
rotation: this._layer.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
// 回転を考慮した4頂点を返す(name: "tl"|"tr"|"br"|"bl")
|
||||
_getCorners(layer) {
|
||||
const hw = layer.displayW / 2;
|
||||
const hh = layer.displayH / 2;
|
||||
const cx = layer.x + hw;
|
||||
const cy = layer.y + hh;
|
||||
const r = (layer.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
return [
|
||||
{ name: "tl", lx: -hw, ly: -hh },
|
||||
{ name: "tr", lx: hw, ly: -hh },
|
||||
{ name: "br", lx: hw, ly: hh },
|
||||
{ name: "bl", lx: -hw, ly: hh },
|
||||
].map(p => ({
|
||||
name: p.name,
|
||||
x: cx + p.lx * cosR - p.ly * sinR,
|
||||
y: cy + p.lx * sinR + p.ly * cosR,
|
||||
}));
|
||||
}
|
||||
|
||||
// 上辺中央から ROTATE_DIST だけ上に出た回転ハンドル
|
||||
_getRotateHandle(layer) {
|
||||
const hw = layer.displayW / 2;
|
||||
const hh = layer.displayH / 2;
|
||||
const cx = layer.x + hw;
|
||||
const cy = layer.y + hh;
|
||||
const r = (layer.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
const dist = hh + ROTATE_DIST;
|
||||
// ローカル座標 (0, -dist) を回転: x' = dist*sin(r), y' = -dist*cos(r)
|
||||
return {
|
||||
x: cx + sinR * dist,
|
||||
y: cy - cosR * dist,
|
||||
};
|
||||
}
|
||||
|
||||
_hitHandle(x, y) {
|
||||
if (!this._layer) return null;
|
||||
const rh = this._getRotateHandle(this._layer);
|
||||
if (Math.hypot(x - rh.x, y - rh.y) <= HANDLE_R * 2.5) return "rotate";
|
||||
|
||||
const corners = this._getCorners(this._layer);
|
||||
for (const c of corners) {
|
||||
if (Math.hypot(x - c.x, y - c.y) <= HANDLE_R * 2.5) return `resize-${c.name}`;
|
||||
}
|
||||
|
||||
if (this._isPointInLayer(x, y, this._layer)) return "move";
|
||||
return null;
|
||||
}
|
||||
|
||||
_isPointInLayer(x, y, layer) {
|
||||
const hw = layer.displayW / 2;
|
||||
const hh = layer.displayH / 2;
|
||||
const cx = layer.x + hw;
|
||||
const cy = layer.y + hh;
|
||||
// 逆回転で点をローカル座標に変換
|
||||
const r = -(layer.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
const lx = (x - cx) * cosR - (y - cy) * sinR;
|
||||
const ly = (x - cx) * sinR + (y - cy) * cosR;
|
||||
return Math.abs(lx) <= hw && Math.abs(ly) <= hh;
|
||||
}
|
||||
|
||||
_applyResize(dx, dy) {
|
||||
const ot = this._origTransform;
|
||||
const mode = this._dragMode;
|
||||
// 回転の逆行列でdx/dyをローカル座標に変換
|
||||
const r = -(ot.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
const ldx = dx * cosR - dy * sinR;
|
||||
const ldy = dx * sinR + dy * cosR;
|
||||
|
||||
let { x, y, displayW, displayH } = ot;
|
||||
|
||||
if (mode === "resize-tl") { x += ldx; y += ldy; displayW -= ldx; displayH -= ldy; }
|
||||
else if (mode === "resize-tr") { y += ldy; displayW += ldx; displayH -= ldy; }
|
||||
else if (mode === "resize-br") { displayW += ldx; displayH += ldy; }
|
||||
else if (mode === "resize-bl") { x += ldx; displayW -= ldx; displayH += ldy; }
|
||||
|
||||
if (displayW < MIN_SIZE) { if (mode.includes("l")) x -= MIN_SIZE - displayW; displayW = MIN_SIZE; }
|
||||
if (displayH < MIN_SIZE) { if (mode.includes("t")) y -= MIN_SIZE - displayH; displayH = MIN_SIZE; }
|
||||
|
||||
// 回転あり: x/yのずれを回転軸中心に合わせて再計算
|
||||
// ローカル左上の変位を逆回転して戻す
|
||||
const r2 = (ot.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR2 = Math.cos(r2), sinR2 = Math.sin(r2);
|
||||
const ox = ot.x + ot.displayW / 2;
|
||||
const oy = ot.y + ot.displayH / 2;
|
||||
// 新しい左上 (ローカル -hw2, -hh2 を回転して絶対座標)
|
||||
const hw2 = displayW / 2;
|
||||
const hh2 = displayH / 2;
|
||||
const dx2 = -hw2 * cosR2 + hh2 * sinR2; // ローカル(-hw2, -hh2)を回転
|
||||
const dy2 = -hw2 * sinR2 - hh2 * cosR2;
|
||||
// ... でも回転付きリサイズは複雑なので、回転がほぼ0のときのみシンプル版を使用
|
||||
if (Math.abs(ot.rotation ?? 0) < 1) {
|
||||
this._layer.x = x; this._layer.y = y;
|
||||
}
|
||||
// 回転あり時は displayW/H のみ更新(位置は維持)
|
||||
this._layer.displayW = displayW;
|
||||
this._layer.displayH = displayH;
|
||||
}
|
||||
|
||||
_updateCursor(x, y) {
|
||||
if (!this._overlay) return;
|
||||
const hit = this._layer ? this._hitHandle(x, y) : null;
|
||||
const cursorMap = {
|
||||
"move": "move", "rotate": "crosshair",
|
||||
"resize-tl": "nwse-resize", "resize-br": "nwse-resize",
|
||||
"resize-tr": "nesw-resize", "resize-bl": "nesw-resize",
|
||||
};
|
||||
this._overlay.style.cursor = hit ? (cursorMap[hit] ?? "default") : "default";
|
||||
}
|
||||
|
||||
// ── オーバーレイ描画 ───────────────────────────
|
||||
|
||||
_drawOverlay() {
|
||||
this._clearOverlay();
|
||||
if (!this._layer || !this._octx) return;
|
||||
|
||||
const ctx = this._octx;
|
||||
const layer = this._layer;
|
||||
const locked = !!layer.locked;
|
||||
const color = locked ? "#e2a04a" : "#4a90e2"; // ロック中はオレンジ
|
||||
const corners = this._getCorners(layer);
|
||||
const rh = this._getRotateHandle(layer);
|
||||
const tl = corners[0], tr = corners[1];
|
||||
const topMid = { x: (tl.x + tr.x) / 2, y: (tl.y + tr.y) / 2 };
|
||||
|
||||
ctx.save();
|
||||
|
||||
// バウンディングボックス
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
corners.forEach((c, i) => i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y));
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
if (!locked) {
|
||||
// 回転ライン+ハンドル(ロック中は非表示)
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(topMid.x, topMid.y);
|
||||
ctx.lineTo(rh.x, rh.y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rh.x, rh.y, HANDLE_R, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// コーナーハンドル
|
||||
for (const c of corners) {
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.rect(c.x - HANDLE_R, c.y - HANDLE_R, HANDLE_R * 2, HANDLE_R * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// ロック中: 中央に鍵アイコン
|
||||
const cx = layer.x + layer.displayW / 2;
|
||||
const cy = layer.y + layer.displayH / 2;
|
||||
ctx.font = "18px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText("🔒", cx, cy);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_clearOverlay() {
|
||||
if (this._octx && this._overlay) {
|
||||
this._octx.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* ShapeTool — rect / ellipse / line / freeline drawing tool for Image Edit tab.
|
||||
* Preview is drawn on the overlay canvas; committed shapes become new draw layers.
|
||||
*/
|
||||
export class ShapeTool {
|
||||
constructor() {
|
||||
// shape settings
|
||||
this.shape = "rect";
|
||||
this.rounded = false;
|
||||
this.fillColor = "#ff0000";
|
||||
this.fillNone = false;
|
||||
this.strokeColor = "#000000";
|
||||
this.strokeNone = true;
|
||||
this.strokeWidth = 5;
|
||||
this.opacity = 1.0;
|
||||
|
||||
// internal state
|
||||
this._active = false;
|
||||
this._canvas = null; // overlay canvas (preview)
|
||||
this._ctx = null;
|
||||
this._dragging = false;
|
||||
this._dragStart = null;
|
||||
this._curPt = null;
|
||||
this._points = []; // freeline points
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
// overlay canvas used for live preview
|
||||
setCanvas(canvas) {
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas ? canvas.getContext("2d") : null;
|
||||
}
|
||||
|
||||
onChange(cb) { this._onChange = cb; }
|
||||
|
||||
activate() {
|
||||
this._active = true;
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._active = false;
|
||||
this._dragging = false;
|
||||
this._clearOverlay();
|
||||
if (this._canvas) this._canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (!this._active) return;
|
||||
this._dragging = true;
|
||||
this._dragStart = { x, y };
|
||||
this._curPt = { x, y };
|
||||
this._points = this.shape === "freeline" ? [{ x, y }] : [];
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._dragging) return;
|
||||
this._curPt = { x, y };
|
||||
if (this.shape === "freeline") this._points.push({ x, y });
|
||||
this._drawPreview(x, y);
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._clearOverlay();
|
||||
this._commit();
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._clearOverlay();
|
||||
this._commit();
|
||||
}
|
||||
|
||||
// ── private ───────────────────────────────────────
|
||||
|
||||
_clearOverlay() {
|
||||
if (this._ctx && this._canvas) {
|
||||
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
_drawPreview(curX, curY) {
|
||||
if (!this._ctx || !this._canvas) return;
|
||||
const ctx = this._ctx;
|
||||
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
|
||||
const s = this._dragStart;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = this.opacity;
|
||||
|
||||
if (this.shape === "freeline") {
|
||||
// freeline: always draw with stroke color (strokeNone has no meaning here)
|
||||
if (this._points.length >= 2) {
|
||||
ctx.strokeStyle = this.strokeColor;
|
||||
ctx.lineWidth = this.strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this._points[0].x, this._points[0].y);
|
||||
for (let i = 1; i < this._points.length; i++) ctx.lineTo(this._points[i].x, this._points[i].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// dashed blue outline for rect / ellipse / line
|
||||
ctx.strokeStyle = "#0077ff";
|
||||
ctx.lineWidth = Math.max(1, this.strokeWidth || 1);
|
||||
ctx.setLineDash([4, 2]);
|
||||
|
||||
const x1 = Math.min(s.x, curX), y1 = Math.min(s.y, curY);
|
||||
const w = Math.abs(curX - s.x), h = Math.abs(curY - s.y);
|
||||
|
||||
if (this.shape === "ellipse") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x1 + w / 2, y1 + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (this.shape === "line") {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(curX, curY);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// rect
|
||||
if (this.rounded) {
|
||||
const r = Math.min(w, h) * 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x1, y1, w, h, r);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.strokeRect(x1, y1, w, h);
|
||||
}
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_commit() {
|
||||
const s = this._dragStart, c = this._curPt;
|
||||
if (!s || !c) return;
|
||||
|
||||
const isFreehand = this.shape === "freeline";
|
||||
const dx = Math.abs(c.x - s.x), dy = Math.abs(c.y - s.y);
|
||||
if (!isFreehand && dx < 2 && dy < 2) return;
|
||||
if (isFreehand && this._points.length < 2) return;
|
||||
|
||||
const isStrokeMandatory = ["line", "freeline"].includes(this.shape);
|
||||
const shapeObj = {
|
||||
kind: this.shape,
|
||||
s: { ...s },
|
||||
c: { ...c },
|
||||
points: isFreehand ? this._points.map(p => ({ ...p })) : [],
|
||||
fillColor: this.fillNone ? null : this.fillColor,
|
||||
// line / freeline always use stroke; ignore strokeNone
|
||||
strokeColor: (isStrokeMandatory || !this.strokeNone) ? this.strokeColor : null,
|
||||
strokeWidth: (isStrokeMandatory || !this.strokeNone) ? this.strokeWidth : 0,
|
||||
opacity: this.opacity,
|
||||
rounded: this.rounded,
|
||||
};
|
||||
|
||||
this._onChange?.(shapeObj);
|
||||
}
|
||||
|
||||
// ── static renderer (used by onChange callback to bake shape into layer) ──
|
||||
|
||||
static drawShape(ctx, sh) {
|
||||
const { kind, s, c, points, fillColor, strokeColor, strokeWidth, opacity, rounded } = sh;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity ?? 1;
|
||||
|
||||
if (kind === "freeline") {
|
||||
if (strokeColor && strokeWidth > 0 && points.length >= 2) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (kind === "ellipse") {
|
||||
const x1 = Math.min(s.x, c.x), y1 = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x1 + w / 2, y1 + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
|
||||
if (strokeColor && strokeWidth > 0) { ctx.strokeStyle = strokeColor; ctx.lineWidth = strokeWidth; ctx.stroke(); }
|
||||
} else if (kind === "line") {
|
||||
if (strokeColor && strokeWidth > 0) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(c.x, c.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// rect
|
||||
const x1 = Math.min(s.x, c.x), y1 = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
if (rounded) {
|
||||
const r = Math.min(w, h) * 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x1, y1, w, h, r);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.rect(x1, y1, w, h);
|
||||
}
|
||||
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
|
||||
if (strokeColor && strokeWidth > 0) { ctx.strokeStyle = strokeColor; ctx.lineWidth = strokeWidth; ctx.stroke(); }
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// coordinate helper (same signature as DrawTool.getCanvasPos)
|
||||
static getCanvasPos(canvas, event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Image Edit Tab - Text Tool
|
||||
* Measures text bounding box, renders to an off-screen canvas,
|
||||
* and calls onChange(clickX, clickY) so the caller can create
|
||||
* a proper text layer with the exact content size.
|
||||
*/
|
||||
|
||||
export const TEXT_FONTS = [
|
||||
"Arial", "Arial Black", "Georgia", "Times New Roman",
|
||||
"Courier New", "Verdana", "Trebuchet MS", "Impact",
|
||||
"Comic Sans MS", "Tahoma",
|
||||
];
|
||||
|
||||
const TEXT_PADDING = 4; // テキスト描画時の余白(px)
|
||||
|
||||
export class TextTool {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.text = "Hello";
|
||||
this.fontFamily = "Arial";
|
||||
this.fontSize = 64;
|
||||
this.bold = false;
|
||||
this.italic = false;
|
||||
this.align = "left";
|
||||
this.color = "#ffffff";
|
||||
this._overlay = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this._closeOverlay();
|
||||
this.canvas = canvas;
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() {
|
||||
if (this.canvas) this.canvas.style.cursor = "text";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._closeOverlay();
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (this._overlay) { this._closeOverlay(); return; }
|
||||
this._showOverlay(x, y);
|
||||
}
|
||||
|
||||
/** 既存テキスト再編集用: プロパティを外部でセット後に呼ぶ */
|
||||
openAt(canvasX, canvasY) {
|
||||
this._closeOverlay();
|
||||
this._showOverlay(canvasX, canvasY);
|
||||
}
|
||||
|
||||
onMouseMove() {}
|
||||
onMouseLeave() {}
|
||||
onMouseUp() {}
|
||||
|
||||
/** テキストのバウンディングボックスサイズのcanvasを返す */
|
||||
createLayerData(clickX, clickY) {
|
||||
const lines = this.text.split("\n");
|
||||
const lineH = this.fontSize * 1.2;
|
||||
const font = this._getCSSFont();
|
||||
|
||||
// measureText 用の仮canvas
|
||||
const tmp = document.createElement("canvas");
|
||||
const tmpCtx = tmp.getContext("2d");
|
||||
tmpCtx.font = font;
|
||||
|
||||
let maxW = 0;
|
||||
for (const line of lines) {
|
||||
const w = line.length > 0 ? tmpCtx.measureText(line).width : this.fontSize * 0.3;
|
||||
if (w > maxW) maxW = w;
|
||||
}
|
||||
|
||||
const tw = Math.max(1, Math.ceil(maxW + TEXT_PADDING * 2));
|
||||
const th = Math.max(1, Math.ceil(lines.length * lineH + TEXT_PADDING * 2));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = tw;
|
||||
canvas.height = th;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.textBaseline = "top";
|
||||
ctx.textAlign = this.align;
|
||||
|
||||
let drawX = TEXT_PADDING;
|
||||
if (this.align === "center") drawX = tw / 2;
|
||||
else if (this.align === "right") drawX = tw - TEXT_PADDING;
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, drawX, TEXT_PADDING + i * lineH);
|
||||
});
|
||||
|
||||
return {
|
||||
canvas,
|
||||
width: tw,
|
||||
height: th,
|
||||
x: clickX,
|
||||
y: clickY,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
_getCSSFont() {
|
||||
const parts = [];
|
||||
if (this.italic) parts.push("italic");
|
||||
if (this.bold) parts.push("bold");
|
||||
parts.push(`${this.fontSize}px`);
|
||||
parts.push(`"${this.fontFamily}", sans-serif`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
_showOverlay(canvasX, canvasY) {
|
||||
const cv = this.canvas;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = rect.width / cv.width;
|
||||
const scaleY = rect.height / cv.height;
|
||||
const cssX = Math.round(canvasX * scaleX);
|
||||
const cssY = Math.round(canvasY * scaleY);
|
||||
|
||||
const container = cv.parentElement;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "ie-text-overlay";
|
||||
overlay.style.left = cssX + "px";
|
||||
overlay.style.top = cssY + "px";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "ie-text-textarea";
|
||||
textarea.value = this.text;
|
||||
textarea.rows = 3;
|
||||
textarea.placeholder = "Enter text…";
|
||||
overlay.appendChild(textarea);
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.className = "ie-text-btn-row";
|
||||
|
||||
const okBtn = document.createElement("button");
|
||||
okBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
|
||||
okBtn.textContent = "OK";
|
||||
okBtn.onclick = () => {
|
||||
this.text = textarea.value;
|
||||
this._closeOverlay();
|
||||
// drawCanvasには描画せず、呼び出し側でレイヤーとして処理する
|
||||
if (this._onChange) this._onChange(canvasX, canvasY);
|
||||
};
|
||||
btnRow.appendChild(okBtn);
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "wfm-btn wfm-btn-sm";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.onclick = () => this._closeOverlay();
|
||||
btnRow.appendChild(cancelBtn);
|
||||
|
||||
overlay.appendChild(btnRow);
|
||||
container.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
textarea.focus();
|
||||
|
||||
textarea.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); okBtn.click(); }
|
||||
else if (e.key === "Escape") this._closeOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
_closeOverlay() {
|
||||
if (this._overlay) { this._overlay.remove(); this._overlay = null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* Metadata Tab
|
||||
* Drop a PNG/WebP/JSON file to extract and display model/prompt metadata.
|
||||
* Parsing logic adapted from model-and-prompt-from-metadata (workflow_utils.js).
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
// ── File size limit ───────────────────────────────────────────
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
// ── Sanitize JSON (NaN/Infinity → null) ──────────────────────
|
||||
function sanitizeJSON(text) {
|
||||
return text
|
||||
.replace(/-Infinity\b/g, "null")
|
||||
.replace(/\bInfinity\b/g, "null")
|
||||
.replace(/\bNaN\b/g, "null");
|
||||
}
|
||||
|
||||
// ── WebP EXIF ────────────────────────────────────────────────
|
||||
async function readWebPEXIFChunk(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
const ascii = new TextDecoder("latin1");
|
||||
if (bytes.byteLength < 12) return null;
|
||||
if (ascii.decode(bytes.slice(0, 4)) !== "RIFF") return null;
|
||||
if (ascii.decode(bytes.slice(8, 12)) !== "WEBP") return null;
|
||||
let offset = 12;
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const fourcc = ascii.decode(bytes.slice(offset, offset + 4));
|
||||
const chunkSize = view.getUint32(offset + 4, true);
|
||||
if (fourcc === "EXIF") return bytes.slice(offset + 8, offset + 8 + chunkSize);
|
||||
offset += 8 + chunkSize;
|
||||
if (chunkSize % 2 === 1) offset++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractWorkflowFromEXIF(exifBytes) {
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: false });
|
||||
const text = utf8.decode(exifBytes);
|
||||
for (const key of ["workflow:", "prompt:"]) {
|
||||
const idx = text.indexOf(key + "{");
|
||||
if (idx < 0) continue;
|
||||
let jsonStr = text.slice(idx + key.length);
|
||||
const nullIdx = jsonStr.indexOf("\x00");
|
||||
if (nullIdx >= 0) jsonStr = jsonStr.slice(0, nullIdx);
|
||||
try { return JSON.parse(sanitizeJSON(jsonStr)); } catch {
|
||||
const lb = jsonStr.lastIndexOf("}");
|
||||
if (lb > 0) { try { return JSON.parse(sanitizeJSON(jsonStr.slice(0, lb + 1))); } catch {} }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── PNG text chunks ───────────────────────────────────────────
|
||||
function findNull(arr, start = 0) {
|
||||
for (let i = start; i < arr.length; i++) if (arr[i] === 0) return i;
|
||||
return -1;
|
||||
}
|
||||
function parseTEXtChunk(data, latin1) {
|
||||
const np = findNull(data);
|
||||
if (np === -1) return null;
|
||||
return { keyword: latin1.decode(data.slice(0, np)), text: latin1.decode(data.slice(np + 1)) };
|
||||
}
|
||||
function parseITXtChunk(data, latin1, utf8) {
|
||||
const np = findNull(data);
|
||||
if (np === -1) return null;
|
||||
const keyword = latin1.decode(data.slice(0, np));
|
||||
let pos = np + 3;
|
||||
pos = findNull(data, pos); if (pos === -1) return null; pos++;
|
||||
pos = findNull(data, pos); if (pos === -1) return null; pos++;
|
||||
return { keyword, text: utf8.decode(data.slice(pos)) };
|
||||
}
|
||||
async function readAllPNGTextChunks(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return null;
|
||||
const view = new DataView(buffer);
|
||||
const latin1 = new TextDecoder("latin1");
|
||||
const utf8 = new TextDecoder("utf-8");
|
||||
let offset = 8;
|
||||
const chunks = {};
|
||||
while (offset + 12 <= buffer.byteLength) {
|
||||
const length = view.getUint32(offset);
|
||||
if (offset + 12 + length > buffer.byteLength) break;
|
||||
const type = latin1.decode(bytes.slice(offset + 4, offset + 8));
|
||||
const data = bytes.slice(offset + 8, offset + 8 + length);
|
||||
if (type === "tEXt") { const c = parseTEXtChunk(data, latin1); if (c) chunks[c.keyword] = c.text; }
|
||||
else if (type === "iTXt") { const c = parseITXtChunk(data, latin1, utf8); if (c) chunks[c.keyword] = c.text; }
|
||||
offset += 12 + length;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ── Workflow extraction helpers ───────────────────────────────
|
||||
function collectUnique(arr) {
|
||||
const seen = new Set(), out = [];
|
||||
for (const v of arr) { if (v && typeof v === "string" && !seen.has(v)) { seen.add(v); out.push(v); } }
|
||||
return out;
|
||||
}
|
||||
function collectAllNodes(workflow) {
|
||||
if (!Array.isArray(workflow.nodes)) return [];
|
||||
const all = [...workflow.nodes];
|
||||
for (const sg of workflow.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) all.push(...sg.nodes);
|
||||
return all;
|
||||
}
|
||||
const META_NODE_TYPES = new Set(["ImageMetadataCheckpointLoader", "ImageMetadataPromptLoader"]);
|
||||
const VAE_NONE = "None";
|
||||
|
||||
function extractCheckpoints(wf) {
|
||||
if (!wf || typeof wf !== "object") return [];
|
||||
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => n.type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n.type)).map(n => n.widgets_values?.[0]));
|
||||
return collectUnique(Object.values(wf).filter(n => n?.class_type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n?.class_type)).map(n => n.inputs?.ckpt_name));
|
||||
}
|
||||
function extractVAEs(wf) {
|
||||
if (!wf || typeof wf !== "object") return [];
|
||||
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).flatMap(n => { if (n.type === "VAELoader") return [n.widgets_values?.[0]]; if (META_NODE_TYPES.has(n.type ?? "")) { const v = n.widgets_values?.[1]; return v && v !== VAE_NONE ? [v] : []; } return []; }));
|
||||
return collectUnique(Object.values(wf).flatMap(n => { if (!n || typeof n !== "object") return []; if (n.class_type === "VAELoader") return [n.inputs?.vae_name]; if (META_NODE_TYPES.has(n.class_type ?? "")) { const v = n.inputs?.vae_name; return v && v !== VAE_NONE ? [v] : []; } return []; }));
|
||||
}
|
||||
function extractDiffusionModels(wf) {
|
||||
if (!wf || typeof wf !== "object") return [];
|
||||
const UNET_TYPES = new Set(["UNETLoader", "UnetLoaderGGUF", "UNETLoaderGGUF"]);
|
||||
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => UNET_TYPES.has(n.type)).map(n => n.widgets_values?.[0]));
|
||||
return collectUnique(Object.values(wf).filter(n => UNET_TYPES.has(n?.class_type)).map(n => n.inputs?.unet_name));
|
||||
}
|
||||
function extractTextEncoders(wf) {
|
||||
if (!wf || typeof wf !== "object") return [];
|
||||
const names = [];
|
||||
if (Array.isArray(wf.nodes)) {
|
||||
for (const n of collectAllNodes(wf)) {
|
||||
if (n.type === "CLIPLoader") { if (n.widgets_values?.[0]) names.push(n.widgets_values[0]); }
|
||||
else if (n.type === "DualCLIPLoader") { [0,1].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
|
||||
else if (n.type === "TripleCLIPLoader") { [0,1,2].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
|
||||
else if (n.type === "QuadrupleCLIPLoader") { [0,1,2,3].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
|
||||
}
|
||||
} else {
|
||||
for (const n of Object.values(wf)) {
|
||||
if (!n || typeof n !== "object") continue;
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "CLIPLoader") { if (n.inputs?.clip_name) names.push(n.inputs.clip_name); }
|
||||
else if (ct === "DualCLIPLoader") { if (n.inputs?.clip_name1) names.push(n.inputs.clip_name1); if (n.inputs?.clip_name2) names.push(n.inputs.clip_name2); }
|
||||
else if (ct === "TripleCLIPLoader") { ["clip_name1","clip_name2","clip_name3"].forEach(k => { if (n.inputs?.[k]) names.push(n.inputs[k]); }); }
|
||||
else if (ct === "QuadrupleCLIPLoader") { ["clip_name1","clip_name2","clip_name3","clip_name4"].forEach(k => { if (n.inputs?.[k]) names.push(n.inputs[k]); }); }
|
||||
}
|
||||
}
|
||||
return collectUnique(names);
|
||||
}
|
||||
function extractLoRAs(wf) {
|
||||
if (!wf || typeof wf !== "object") return [];
|
||||
const results = [], seen = new Set();
|
||||
function add(name, sm, sc) {
|
||||
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
const smNum = parseFloat(sm);
|
||||
const scNum = parseFloat(sc);
|
||||
results.push({ name, strength_model: isNaN(smNum) ? 1.0 : smNum, strength_clip: isNaN(scNum) ? 1.0 : scNum });
|
||||
}
|
||||
if (Array.isArray(wf.nodes)) {
|
||||
for (const n of collectAllNodes(wf)) {
|
||||
const type = n.type ?? "";
|
||||
if (type === "LoraLoader") add(n.widgets_values?.[0], n.widgets_values?.[1], n.widgets_values?.[2]);
|
||||
else if (type === "LoraLoaderModelOnly") add(n.widgets_values?.[0], n.widgets_values?.[1], 1.0);
|
||||
else if (type === "ImageMetadataLoRALoader") { for (let i = 0; i < 3; i++) add(n.widgets_values?.[i*3], n.widgets_values?.[i*3+1], n.widgets_values?.[i*3+2]); }
|
||||
else if (type === "Lora Loader (LoraManager)") { const list = n.widgets_values?.find(v => Array.isArray(v)); if (list) for (const l of list) { if (l?.active !== false) add(l?.name, l?.strength ?? 1.0, l?.clipStrength ?? l?.strength ?? 1.0); } }
|
||||
}
|
||||
} else {
|
||||
for (const n of Object.values(wf)) {
|
||||
if (!n || typeof n !== "object") continue;
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "LoraLoader") add(n.inputs?.lora_name, n.inputs?.strength_model, n.inputs?.strength_clip);
|
||||
else if (ct === "LoraLoaderModelOnly") add(n.inputs?.lora_name, n.inputs?.strength, 1.0);
|
||||
else if (ct === "Lora Loader (LoraManager)") {
|
||||
const lorasData = n.inputs?.loras;
|
||||
const list = lorasData?.__value__ ?? (Array.isArray(lorasData) ? lorasData : null);
|
||||
if (list) for (const l of list) { if (l?.active !== false) add(l?.name, l?.strength ?? 1.0, l?.clipStrength ?? l?.strength ?? 1.0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("TextEncode") || ct.includes("TextEncoderSD"); }
|
||||
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
|
||||
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
|
||||
|
||||
function extractPrompts(wf) {
|
||||
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
|
||||
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
|
||||
}
|
||||
|
||||
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
|
||||
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
|
||||
function extractPromptsFromNodeSet(nodes, links) {
|
||||
const nodeMap = new Map();
|
||||
for (const n of nodes) nodeMap.set(n.id, n);
|
||||
const linkOrigin = new Map(), linkSlot = new Map();
|
||||
if (Array.isArray(links)) {
|
||||
for (const lk of links) {
|
||||
if (Array.isArray(lk)) {
|
||||
linkOrigin.set(lk[0], lk[1]);
|
||||
linkSlot.set(lk[0], lk[2] ?? 0);
|
||||
} else if (lk && typeof lk === "object") {
|
||||
const id = lk.id ?? lk[0], origin = lk.origin_id ?? lk[1], slot = lk.origin_slot ?? lk[2] ?? 0;
|
||||
if (id != null && origin != null) { linkOrigin.set(id, origin); linkSlot.set(id, slot); }
|
||||
}
|
||||
}
|
||||
}
|
||||
const textMap = new Map();
|
||||
for (const n of nodes) {
|
||||
if (!isTextEncoderNode(n.type ?? "")) continue;
|
||||
const text = n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") {
|
||||
textMap.set(n.id, text);
|
||||
} else if (Array.isArray(n.inputs)) {
|
||||
const textInput = n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt");
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const srcNode = originId != null ? nodeMap.get(originId) : null;
|
||||
if (srcNode) {
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[originSlot];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
} else {
|
||||
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const pos = new Set(), neg = new Set();
|
||||
let foundSampler = false;
|
||||
for (const n of nodes) {
|
||||
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
|
||||
foundSampler = true;
|
||||
for (const inp of n.inputs) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
const isPos = name === "positive" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit") {
|
||||
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
|
||||
const text = srcNode.widgets_values?.[widgetIdx];
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(originId);
|
||||
if (!text) continue;
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler) return null;
|
||||
// SamplerCustomAdvanced などで positive/negative 直結がない場合、判別不能テキストとして返す
|
||||
if (pos.size === 0 && neg.size === 0) {
|
||||
const allTexts = [...textMap.values()].filter(t => t.trim());
|
||||
if (allTexts.length > 0) return { positives: [], negatives: [], texts: allTexts };
|
||||
return null;
|
||||
}
|
||||
return { positives: [...pos], negatives: [...neg] };
|
||||
}
|
||||
|
||||
// MarkdownNote の **section** → - [name](url) パターンからモデルを抽出
|
||||
// flux/qwen/z-image などのサブグラフ形式ワークフロー向け補完用
|
||||
function extractMarkdownNoteModels(wf) {
|
||||
const allNodes = [];
|
||||
if (Array.isArray(wf.nodes)) allNodes.push(...wf.nodes);
|
||||
for (const sg of wf.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) allNodes.push(...sg.nodes);
|
||||
const result = { checkpoints: [], vaes: [], diffusionModels: [], textEncoders: [], loras: [] };
|
||||
const seen = { checkpoints: new Set(), vaes: new Set(), diffusionModels: new Set(), textEncoders: new Set(), loras: new Set() };
|
||||
function addU(arr, set, name) { if (name && typeof name === "string" && !set.has(name)) { set.add(name); arr.push(name); } }
|
||||
for (const n of allNodes) {
|
||||
if (n.type !== "MarkdownNote") continue;
|
||||
const raw = n.widgets_values;
|
||||
const text = Array.isArray(raw) ? raw[0] : (typeof raw === "string" ? raw : null);
|
||||
if (!text) continue;
|
||||
const sRe = /\*\*([^*\n]+)\*\*/g;
|
||||
let sm;
|
||||
while ((sm = sRe.exec(text)) !== null) {
|
||||
const sec = sm[1].trim().toLowerCase().replace(/\s+/g, "_");
|
||||
if (!["text_encoders", "diffusion_models", "vae", "checkpoints", "loras"].includes(sec)) continue;
|
||||
const rest = text.slice(sm.index + sm[0].length);
|
||||
const end = rest.search(/\n\*\*|\n##/);
|
||||
const content = end >= 0 ? rest.slice(0, end) : rest;
|
||||
const lRe = /^- \[([^\]]+)\]/gm;
|
||||
let lm;
|
||||
while ((lm = lRe.exec(content)) !== null) {
|
||||
const name = lm[1].trim();
|
||||
if (sec === "text_encoders") addU(result.textEncoders, seen.textEncoders, name);
|
||||
else if (sec === "diffusion_models") addU(result.diffusionModels, seen.diffusionModels, name);
|
||||
else if (sec === "vae") addU(result.vaes, seen.vaes, name);
|
||||
else if (sec === "checkpoints") addU(result.checkpoints, seen.checkpoints, name);
|
||||
else if (sec === "loras") addU(result.loras, seen.loras, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasAny = result.checkpoints.length || result.vaes.length || result.diffusionModels.length || result.textEncoders.length || result.loras.length;
|
||||
return hasAny ? result : null;
|
||||
}
|
||||
|
||||
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
|
||||
function resolveLinkedText(wf, srcId, slot) {
|
||||
const src = wf[String(srcId)];
|
||||
if (!src || typeof src !== "object") return null;
|
||||
const ct = src.class_type ?? "";
|
||||
// PromptStyler系: slot 0 = text_positive, slot 1 = text_negative
|
||||
if (isPromptStylerNode(ct)) {
|
||||
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// 汎用テキストキー
|
||||
const keys = slot === 0
|
||||
? ["text_positive", "text", "text_g", "prompt"]
|
||||
: ["text_negative", "text_l"];
|
||||
for (const k of keys) {
|
||||
const v = src.inputs?.[k];
|
||||
if (v && typeof v === "string") return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPromptsAPI(wf) {
|
||||
const metaNodes = Object.values(wf).filter(n => n?.class_type === "ImageMetadataPromptLoader");
|
||||
if (metaNodes.length > 0) {
|
||||
const pos = new Set(), neg = new Set();
|
||||
for (const n of metaNodes) { if (n.inputs?.positive_text) pos.add(n.inputs.positive_text); if (n.inputs?.negative_text) neg.add(n.inputs.negative_text); }
|
||||
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
|
||||
}
|
||||
const textMap = new Map();
|
||||
for (const [id, n] of Object.entries(wf)) {
|
||||
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
|
||||
if (raw && typeof raw === "string") {
|
||||
textMap.set(id, raw);
|
||||
} else if (Array.isArray(raw)) {
|
||||
// リンク参照 [srcNodeId, slot] → 解決
|
||||
const text = resolveLinkedText(wf, raw[0], raw[1] ?? 0);
|
||||
if (text) textMap.set(id, text);
|
||||
}
|
||||
}
|
||||
const pos = new Set(), neg = new Set();
|
||||
let foundSampler = false;
|
||||
for (const n of Object.values(wf)) {
|
||||
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const isPos = key === "positive" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
|
||||
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(String(val[0]));
|
||||
if (!text) continue;
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
|
||||
return { positives: [...pos], negatives: [...neg] };
|
||||
}
|
||||
|
||||
function extractPromptsLiteGraph(wf) {
|
||||
const { nodes, links } = wf;
|
||||
if (!Array.isArray(nodes)) return { positives: [], negatives: [] };
|
||||
|
||||
// 1. ImageMetadataPromptLoader (WFS専用)
|
||||
const metaNodes = nodes.filter(n => n.type === "ImageMetadataPromptLoader");
|
||||
if (metaNodes.length > 0) {
|
||||
const pos = new Set(), neg = new Set();
|
||||
for (const n of metaNodes) { const p = n.widgets_values?.[2], ng = n.widgets_values?.[3]; if (p) pos.add(p); if (ng) neg.add(ng); }
|
||||
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
|
||||
}
|
||||
|
||||
// 2. WFS_PromptText (WFS専用)
|
||||
const wfsNodes = nodes.filter(n => n.type === "WFS_PromptText");
|
||||
if (wfsNodes.length > 0) {
|
||||
const pos = new Set(), neg = new Set();
|
||||
for (const n of wfsNodes) { const p = n.widgets_values?.[0], ng = n.widgets_values?.[1]; if (p) pos.add(p); if (ng) neg.add(ng); }
|
||||
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
|
||||
}
|
||||
|
||||
// 3. トップレベルの CLIPTextEncode + KSampler
|
||||
const topResult = extractPromptsFromNodeSet(nodes, links ?? []);
|
||||
if (topResult) return topResult;
|
||||
|
||||
// 4. PrimitiveStringMultiline(flux2-klein / ernie など)- サブグラフ内も探す
|
||||
const primTexts = [];
|
||||
for (const n of collectAllNodes(wf)) {
|
||||
if (n.type !== "PrimitiveStringMultiline") continue;
|
||||
const t = Array.isArray(n.widgets_values) ? n.widgets_values[0] : n.widgets_values;
|
||||
if (t && typeof t === "string" && t.trim()) primTexts.push(t.trim());
|
||||
}
|
||||
if (primTexts.length > 0) return { positives: [], negatives: [], texts: primTexts };
|
||||
|
||||
// 5. サブグラフ内の CLIPTextEncode + KSampler(z-image / qwen / flux など)
|
||||
for (const sg of wf.definitions?.subgraphs ?? []) {
|
||||
if (!Array.isArray(sg.nodes)) continue;
|
||||
const sgResult = extractPromptsFromNodeSet(sg.nodes, sg.links ?? []);
|
||||
if (sgResult) return sgResult;
|
||||
}
|
||||
|
||||
// 6. PromptStyler フォールバック
|
||||
const stylerPos = new Set(), stylerNeg = new Set();
|
||||
for (const n of nodes) {
|
||||
if (!isPromptStylerNode(n.type ?? "")) continue;
|
||||
const vals = n.widgets_values ?? [];
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
if (typeof vals[i] !== "string" || !vals[i].trim()) continue;
|
||||
if (i % 2 === 0) stylerPos.add(vals[i]);
|
||||
else stylerNeg.add(vals[i]);
|
||||
}
|
||||
}
|
||||
if (stylerPos.size > 0 || stylerNeg.size > 0) return { positives: [...stylerPos], negatives: [...stylerNeg] };
|
||||
|
||||
// 7. テキストエンコーダー全テキスト(最終フォールバック)- サブグラフも含む・判別不能
|
||||
const all = [];
|
||||
for (const n of collectAllNodes(wf)) {
|
||||
if (!isTextEncoderNode(n.type ?? "")) continue;
|
||||
const t = n.widgets_values?.[0];
|
||||
if (t && typeof t === "string" && t.trim()) all.push(t);
|
||||
}
|
||||
return { positives: [], negatives: [], texts: all };
|
||||
}
|
||||
|
||||
// ── SD/Fooocus prompt extraction ──────────────────────────────
|
||||
function parseSDAParameters(raw) {
|
||||
const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const stepsMatch = text.match(/\nSteps:\s+\d/);
|
||||
if (!stepsMatch) return null;
|
||||
const paramsStart = stepsMatch.index + 1;
|
||||
const promptSection = text.slice(0, paramsStart - 1);
|
||||
const paramsLine = text.slice(paramsStart);
|
||||
const negSep = "\nNegative prompt: ";
|
||||
const negIdx = promptSection.indexOf(negSep);
|
||||
let positive = "", negative = "";
|
||||
if (negIdx !== -1) { positive = promptSection.slice(0, negIdx).trim(); negative = promptSection.slice(negIdx + negSep.length).trim(); }
|
||||
else positive = promptSection.trim();
|
||||
const params = {};
|
||||
const re = /,?\s*([A-Za-z][A-Za-z0-9 ]*):\s*("(?:[^"\\]|\\.)*"|[^,]+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(paramsLine)) !== null) params[m[1].trim()] = m[2].trim().replace(/^"|"$/g, "");
|
||||
return { positive, negative, params };
|
||||
}
|
||||
function parseFooocusMetadata(raw) {
|
||||
let obj; try { obj = JSON.parse(raw); } catch { return null; }
|
||||
if (!obj?.base_model) return null;
|
||||
const toArray = v => !v ? [] : Array.isArray(v) ? v.filter(Boolean) : [String(v)];
|
||||
return { checkpoint: obj.base_model, vae: (obj.vae && obj.vae !== "Default") ? obj.vae : null, positives: toArray(obj.full_prompt ?? obj.prompt), negatives: toArray(obj.full_negative_prompt ?? obj.negative_prompt) };
|
||||
}
|
||||
|
||||
// ── Master extractAllMetadata ─────────────────────────────────
|
||||
async function extractAllMetadata(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
const isJSON = file.type === "application/json" || name.endsWith(".json");
|
||||
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
|
||||
|
||||
async function fromWorkflow(originalWf, source) {
|
||||
let wf = originalWf;
|
||||
// サブグラフを持つワークフローは、外側のサブグラフノードに実際に設定された値
|
||||
// (ユーザーが見ている値)が内部ノードのwidgets_valuesへ反映されていないことがある
|
||||
// (テンプレートの古いデフォルト値のまま)。convertUiToApi() はこの注入も含めて
|
||||
// 正しく解決するため、そちらを経由してから抽出する。通常のワークフローには影響しない。
|
||||
if (Array.isArray(wf?.nodes) && wf.definitions?.subgraphs?.length > 0) {
|
||||
try {
|
||||
const apiWf = await comfyWorkflow.convertUiToApi(wf);
|
||||
if (apiWf && Object.keys(apiWf).length > 0) wf = apiWf;
|
||||
} catch { /* 変換失敗時は元のUI形式のまま抽出(既存ロジックにフォールバック) */ }
|
||||
}
|
||||
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
|
||||
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)。
|
||||
// API変換後は nodes 情報が失われるため、常に元のUI形式ワークフローから抽出する。
|
||||
const mdm = extractMarkdownNoteModels(originalWf);
|
||||
if (mdm) {
|
||||
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
|
||||
if (!base.vaes.length) base.vaes = mdm.vaes;
|
||||
if (!base.diffusionModels.length) base.diffusionModels = mdm.diffusionModels;
|
||||
if (!base.textEncoders.length) base.textEncoders = mdm.textEncoders;
|
||||
if (!base.loras.length) base.loras = mdm.loras;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
if (isJSON) {
|
||||
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
|
||||
return wf ? await fromWorkflow(wf, "comfyui") : null;
|
||||
}
|
||||
if (isWebP) {
|
||||
const exif = await readWebPEXIFChunk(file);
|
||||
if (!exif) return null;
|
||||
const wf = extractWorkflowFromEXIF(exif);
|
||||
return wf ? await fromWorkflow(wf, "comfyui") : null;
|
||||
}
|
||||
// PNG
|
||||
const chunks = await readAllPNGTextChunks(file);
|
||||
if (!chunks) return null;
|
||||
|
||||
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
|
||||
|
||||
if (chunks.fooocus_scheme === "fooocus" && chunks.parameters) {
|
||||
const f = parseFooocusMetadata(chunks.parameters);
|
||||
if (!f) return null;
|
||||
return { source: "fooocus", checkpoints: [f.checkpoint], vaes: f.vae ? [f.vae] : [], diffusionModels: [], textEncoders: [], loras: [], positives: f.positives, negatives: f.negatives };
|
||||
}
|
||||
if (chunks.parameters) {
|
||||
const p = parseSDAParameters(chunks.parameters);
|
||||
if (!p) return null;
|
||||
const { positive, negative, params } = p;
|
||||
const modelName = params["Model"];
|
||||
if (!modelName) return null;
|
||||
if (params["Module 2"] != null) {
|
||||
const textEncoders = [];
|
||||
for (let i = 2; i <= 9; i++) { const mod = params[`Module ${i}`]; if (!mod) break; textEncoders.push(mod); }
|
||||
return { source: "sd_forge", checkpoints: [], vaes: params["Module 1"] ? [params["Module 1"]] : [], diffusionModels: [modelName], textEncoders, loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
|
||||
}
|
||||
const vaeValue = params["Module 1"] ?? params["VAE"] ?? null;
|
||||
return { source: "sd", checkpoints: [modelName], vaes: vaeValue ? [vaeValue] : [], diffusionModels: [], textEncoders: [], loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── UI helpers ────────────────────────────────────────────────
|
||||
|
||||
function buildModelItem(label) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-meta-item";
|
||||
el.title = label;
|
||||
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(label)}</span>`;
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildLoRAItem(lora) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-meta-item";
|
||||
el.title = lora.name;
|
||||
const sm = lora.strength_model.toFixed(2);
|
||||
const sc = lora.strength_clip.toFixed(2);
|
||||
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(lora.name)}</span><span class="wfm-meta-item-badge">${sm}/${sc}</span>`;
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-meta-item wfm-meta-item-clickable";
|
||||
const snippet = label.length > 60 ? label.slice(0, 60) + "…" : label;
|
||||
const typeBadge = type === "positive" ? `<span class="wfm-meta-badge-pos">POS</span>`
|
||||
: type === "negative" ? `<span class="wfm-meta-badge-neg">NEG</span>`
|
||||
: ``;
|
||||
el.innerHTML = `${typeBadge}<span class="wfm-meta-item-name">${escapeHtml(snippet)}</span>`;
|
||||
el.addEventListener("click", () => {
|
||||
listEl.querySelectorAll(".wfm-meta-item-clickable").forEach(e => e.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
fullArea.value = full;
|
||||
fullLabel.textContent = type === "positive" ? t("metaPromptPositive")
|
||||
: type === "negative" ? t("metaPromptNegative")
|
||||
: t("metaPromptText") || "Text";
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderSection(sectionEl, listEl, items, buildFn) {
|
||||
listEl.innerHTML = "";
|
||||
if (items.length === 0) { sectionEl.classList.add("wfm-meta-section-empty"); return; }
|
||||
sectionEl.classList.remove("wfm-meta-section-empty");
|
||||
for (const item of items) listEl.appendChild(buildFn(item));
|
||||
}
|
||||
|
||||
// ── External API ──────────────────────────────────────────────
|
||||
let _externalHandleFile = null;
|
||||
|
||||
export async function loadFileIntoMetadataTab(file) {
|
||||
document.querySelector('.wfm-tab[data-tab="metadata"]')?.click();
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
if (_externalHandleFile) await _externalHandleFile(file);
|
||||
}
|
||||
|
||||
// ── Tab initialization ────────────────────────────────────────
|
||||
export function initMetadataTab() {
|
||||
const dropZone = document.getElementById("wfm-meta-drop");
|
||||
const dropLabel = document.getElementById("wfm-meta-drop-label");
|
||||
const previewImg = document.getElementById("wfm-meta-preview-img");
|
||||
const fileInfo = document.getElementById("wfm-meta-file-info");
|
||||
const fileInput = document.getElementById("wfm-meta-file-input");
|
||||
|
||||
const ckptSection = document.getElementById("wfm-meta-ckpt-section");
|
||||
const vaeSection = document.getElementById("wfm-meta-vae-section");
|
||||
const diffSection = document.getElementById("wfm-meta-diff-section");
|
||||
const teSection = document.getElementById("wfm-meta-te-section");
|
||||
const loraSection = document.getElementById("wfm-meta-lora-section");
|
||||
const promptSection = document.getElementById("wfm-meta-prompt-section");
|
||||
|
||||
const ckptList = document.getElementById("wfm-meta-ckpt-list");
|
||||
const vaeList = document.getElementById("wfm-meta-vae-list");
|
||||
const diffList = document.getElementById("wfm-meta-diff-list");
|
||||
const teList = document.getElementById("wfm-meta-te-list");
|
||||
const loraList = document.getElementById("wfm-meta-lora-list");
|
||||
const promptList = document.getElementById("wfm-meta-prompt-list");
|
||||
const promptFull = document.getElementById("wfm-meta-prompt-full");
|
||||
const promptFullLabel = document.getElementById("wfm-meta-prompt-full-label");
|
||||
|
||||
if (!dropZone) return;
|
||||
|
||||
// Apply i18n to section titles
|
||||
const titleMap = {
|
||||
"wfm-meta-ckpt-section": "metaSectionCkpt",
|
||||
"wfm-meta-vae-section": "metaSectionVae",
|
||||
"wfm-meta-diff-section": "metaSectionDiff",
|
||||
"wfm-meta-te-section": "metaSectionTe",
|
||||
"wfm-meta-lora-section": "metaSectionLora",
|
||||
"wfm-meta-prompt-section": "metaSectionPrompt",
|
||||
};
|
||||
for (const [id, key] of Object.entries(titleMap)) {
|
||||
const title = document.querySelector(`#${id} .wfm-meta-section-title`);
|
||||
if (title) { const tx = t(key); if (tx && tx !== key) title.textContent = tx; }
|
||||
}
|
||||
|
||||
// Apply i18n to format note
|
||||
const i18nIds = {
|
||||
"wfm-meta-format-note-title": "metaFormatNoteTitle",
|
||||
"wfm-meta-fmt-comfyui": "metaFmtComfyui",
|
||||
"wfm-meta-fmt-sdwebui": "metaFmtSdwebui",
|
||||
"wfm-meta-fmt-fooocus": "metaFmtFooocus",
|
||||
"wfm-meta-format-todo": "metaFormatTodo",
|
||||
};
|
||||
for (const [id, key] of Object.entries(i18nIds)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
|
||||
}
|
||||
|
||||
// Apply i18n to help card
|
||||
const helpIds = {
|
||||
"wfm-help-metadata-title": "helpMetadataTitle",
|
||||
"wfm-help-metadata-desc": "helpMetadataDesc",
|
||||
"wfm-help-metadata-1": "helpMetadata1",
|
||||
"wfm-help-metadata-2": "helpMetadata2",
|
||||
"wfm-help-metadata-3": "helpMetadata3",
|
||||
"wfm-help-metadata-4": "helpMetadata4",
|
||||
"wfm-help-metadata-5": "helpMetadata5",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIds)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => { if (l) l.innerHTML = ""; });
|
||||
[ckptSection, vaeSection, diffSection, teSection, loraSection, promptSection].forEach(s => { if (s) s.classList.add("wfm-meta-section-empty"); });
|
||||
if (promptFull) promptFull.value = "";
|
||||
if (promptFullLabel) promptFullLabel.textContent = "";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
fileInfo.textContent = t("metaFileTooLarge");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
return;
|
||||
}
|
||||
|
||||
fileInfo.textContent = t("metaParsing");
|
||||
fileInfo.style.color = "var(--wfm-text-secondary)";
|
||||
clearAll();
|
||||
|
||||
// Show preview for images
|
||||
const isImage = file.type.startsWith("image/") || file.name.toLowerCase().match(/\.(png|webp|jpg|jpeg)$/);
|
||||
if (isImage) {
|
||||
const url = URL.createObjectURL(file);
|
||||
previewImg.src = url;
|
||||
previewImg.style.display = "block";
|
||||
dropLabel.style.display = "none";
|
||||
previewImg.onload = () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
previewImg.style.display = "none";
|
||||
dropLabel.style.display = "flex";
|
||||
}
|
||||
|
||||
let meta;
|
||||
try {
|
||||
meta = await extractAllMetadata(file);
|
||||
} catch (err) {
|
||||
console.error("[MetadataTab]", err);
|
||||
fileInfo.textContent = t("metaParseError");
|
||||
fileInfo.style.color = "var(--wfm-danger)";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
fileInfo.textContent = t("metaNoMetadata");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeKB = (file.size / 1024).toFixed(1);
|
||||
const sourceLabel = { comfyui: "ComfyUI", sd: "SD WebUI", sd_forge: "SD Forge", fooocus: "Fooocus" }[meta.source] ?? meta.source;
|
||||
fileInfo.textContent = `${file.name} (${sizeKB} KB · ${sourceLabel})`;
|
||||
fileInfo.style.color = "var(--wfm-text-secondary)";
|
||||
|
||||
renderSection(ckptSection, ckptList, meta.checkpoints, n => buildModelItem(n));
|
||||
renderSection(vaeSection, vaeList, meta.vaes, n => buildModelItem(n));
|
||||
renderSection(diffSection, diffList, meta.diffusionModels, n => buildModelItem(n));
|
||||
renderSection(teSection, teList, meta.textEncoders, n => buildModelItem(n));
|
||||
renderSection(loraSection, loraList, meta.loras, l => buildLoRAItem(l));
|
||||
|
||||
// Prompts
|
||||
promptList.innerHTML = "";
|
||||
const allPrompts = [
|
||||
...meta.positives.map(p => ({ type: "positive", text: p })),
|
||||
...meta.negatives.map(p => ({ type: "negative", text: p })),
|
||||
...(meta.texts ?? []).map(p => ({ type: "text", text: p })),
|
||||
];
|
||||
if (allPrompts.length > 0) {
|
||||
promptSection.classList.remove("wfm-meta-section-empty");
|
||||
for (const { type, text } of allPrompts) {
|
||||
promptList.appendChild(buildPromptItem(text, type, text, promptFull, promptFullLabel, promptList));
|
||||
}
|
||||
// Auto-select first positive
|
||||
const firstPositive = promptList.querySelector(".wfm-meta-item-clickable");
|
||||
if (firstPositive) firstPositive.click();
|
||||
} else {
|
||||
promptSection.classList.add("wfm-meta-section-empty");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt action buttons ─────────────────────────────────
|
||||
function getPromptText() { return promptFull?.value ?? ""; }
|
||||
|
||||
function flashBtn(btn) {
|
||||
btn.classList.add("wfm-meta-btn-flash");
|
||||
setTimeout(() => btn.classList.remove("wfm-meta-btn-flash"), 600);
|
||||
}
|
||||
|
||||
function setTextareaValue(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return false;
|
||||
el.value = text;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
document.getElementById("wfm-meta-copy-btn")?.addEventListener("click", function () {
|
||||
const text = getPromptText();
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(() => flashBtn(this));
|
||||
});
|
||||
|
||||
document.getElementById("wfm-meta-genui-pos-btn")?.addEventListener("click", function () {
|
||||
const text = getPromptText();
|
||||
if (!text) return;
|
||||
if (setTextareaValue("wfm-prompt-pos-text", text)) flashBtn(this);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-meta-genui-neg-btn")?.addEventListener("click", function () {
|
||||
const text = getPromptText();
|
||||
if (!text) return;
|
||||
if (setTextareaValue("wfm-prompt-neg-text", text)) flashBtn(this);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-meta-preset-pos-btn")?.addEventListener("click", function () {
|
||||
const text = getPromptText();
|
||||
if (!text) return;
|
||||
if (setTextareaValue("wfm-preset-pos", text)) flashBtn(this);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-meta-preset-neg-btn")?.addEventListener("click", function () {
|
||||
const text = getPromptText();
|
||||
if (!text) return;
|
||||
if (setTextareaValue("wfm-preset-neg", text)) flashBtn(this);
|
||||
});
|
||||
|
||||
_externalHandleFile = handleFile;
|
||||
|
||||
// Drag & Drop
|
||||
dropZone.addEventListener("dragover", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", e => { e.stopPropagation(); dropZone.classList.remove("drag-over"); });
|
||||
dropZone.addEventListener("drop", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); handleFile(e.dataTransfer.files?.[0]); });
|
||||
dropZone.addEventListener("click", e => { if (e.target === previewImg) return; fileInput.click(); });
|
||||
fileInput.addEventListener("change", () => { handleFile(fileInput.files?.[0]); fileInput.value = ""; });
|
||||
|
||||
// Prevent scroll passthrough in lists
|
||||
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => {
|
||||
if (l) l.addEventListener("wheel", e => e.stopPropagation(), { passive: true });
|
||||
});
|
||||
|
||||
clearAll();
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* Tagger Tab
|
||||
* Handles WD Tagger / DeepDanbooru + Ollama VLM single/batch tagging.
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./app.js";
|
||||
|
||||
// ── 状態 ────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
/** @type {string|null} 現在の画像 base64 (data URI prefix を除く) */
|
||||
imageB64: null,
|
||||
/** @type {string} 元のファイルパス (Galleryから来た場合) */
|
||||
imagePath: "",
|
||||
/** @type {string} 表示名 */
|
||||
imageName: "",
|
||||
/** @type {NodeJS.Timeout|null} バッチポーリングタイマー */
|
||||
batchPollTimer: null,
|
||||
/** @type {number|null} DB編集中の行ID */
|
||||
dbEditId: null,
|
||||
};
|
||||
|
||||
// ── 初期化 ──────────────────────────────────────────────────────
|
||||
|
||||
export function initTaggerTab() {
|
||||
_applyI18n();
|
||||
_setupSubtabs();
|
||||
_setupSliders();
|
||||
_setupOllamaToggle();
|
||||
_setupSingleActions();
|
||||
_setupBatchActions();
|
||||
_setupDbActions();
|
||||
_loadModels();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
/** GalleryタブからTaggerタブへ画像を渡す */
|
||||
export function openImageInTaggerTab(img) {
|
||||
// タブを切り替え
|
||||
document.querySelectorAll(".wfm-tab").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".wfm-tab-content").forEach(s => s.classList.remove("active"));
|
||||
const btn = document.querySelector('.wfm-tab[data-tab="tagger"]');
|
||||
const sec = document.getElementById("wfm-tab-tagger");
|
||||
if (btn) btn.classList.add("active");
|
||||
if (sec) sec.classList.add("active");
|
||||
|
||||
// 画像を読み込む (Gallery APIから取得)
|
||||
if (!img?.path) return;
|
||||
const url = `/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`;
|
||||
fetch(url)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
_setPreviewFromDataUrl(e.target.result, img.filename || img.path.split(/[\\/]/).pop());
|
||||
state.imagePath = img.path;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch(() => showToast(t("taggerNoImage"), "error"));
|
||||
}
|
||||
|
||||
// ── i18n ─────────────────────────────────────────────────────────
|
||||
|
||||
function _applyI18n() {
|
||||
const map = {
|
||||
"wfm-tagger-from-gallery-btn": "taggerFromGallery",
|
||||
"wfm-tagger-upload-label": "taggerUpload",
|
||||
"wfm-tagger-threshold-label": "taggerThreshold",
|
||||
"wfm-tagger-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-generate-btn": "taggerGenerateTags",
|
||||
"wfm-tagger-ollama-header-label": "taggerOllamaEnable",
|
||||
"wfm-tagger-ollama-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-ollama-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-ollama-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-ollama-max-label": "taggerOllamaMaxTags",
|
||||
"wfm-tagger-result-header": "taggerResult",
|
||||
"wfm-tagger-send-genui-btn": "taggerSendToGenUI",
|
||||
"wfm-tagger-send-prompt-btn": "taggerSendToPrompt",
|
||||
"wfm-tagger-save-gallery-btn": "taggerSaveToGallery",
|
||||
"wfm-tagger-write-file-btn": "taggerWriteToFile",
|
||||
"wfm-tagger-save-db-btn": "taggerSaveToDB",
|
||||
"wfm-tagger-batch-folder-header": "taggerBatchFolder",
|
||||
"wfm-tagger-batch-threshold-label": "taggerThreshold",
|
||||
"wfm-tagger-batch-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-batch-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-batch-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-batch-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-batch-output-header": "taggerBatchOutput",
|
||||
"wfm-tagger-batch-save-db-label": "taggerSaveToDB",
|
||||
"wfm-tagger-batch-write-file-label": "taggerWriteToFile",
|
||||
"wfm-tagger-batch-write-txt-label": "taggerBatchWriteTxt",
|
||||
"wfm-tagger-batch-start-btn": "taggerBatchStart",
|
||||
"wfm-tagger-batch-stop-btn": "taggerBatchStop",
|
||||
"wfm-tagger-db-search-btn": "taggerDbSearch",
|
||||
"wfm-tagger-db-show-all-btn": "taggerDbShowAll",
|
||||
"wfm-tagger-db-export-btn": "taggerDbExport",
|
||||
"wfm-tagger-db-th-file": "taggerDbFile",
|
||||
"wfm-tagger-db-th-interrogator": "taggerDbInterrogator",
|
||||
"wfm-tagger-db-th-vlm": "taggerDbVlm",
|
||||
"wfm-tagger-db-th-date": "taggerDbDate",
|
||||
"wfm-tagger-db-edit-interr-label": "taggerDbInterrogator",
|
||||
"wfm-tagger-db-edit-vlm-label": "taggerDbVlm",
|
||||
"wfm-tagger-db-save-btn": "taggerDbSave",
|
||||
"wfm-tagger-db-delete-btn": "taggerDbDelete",
|
||||
};
|
||||
for (const [id, key] of Object.entries(map)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
// サブタブボタン
|
||||
const subtabKeys = ["taggerSingleTab", "taggerBatchTab", "taggerDbTab"];
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach((btn, i) => {
|
||||
btn.textContent = t(subtabKeys[i] || "");
|
||||
});
|
||||
|
||||
// プレースホルダー
|
||||
const ph = document.getElementById("wfm-tagger-preview-placeholder");
|
||||
if (ph) ph.textContent = t("taggerNoImage");
|
||||
}
|
||||
|
||||
// ── サブタブ切り替え ─────────────────────────────────────────────
|
||||
|
||||
function _setupSubtabs() {
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const tab = btn.dataset.taggerTab;
|
||||
document.querySelectorAll(".wfm-tagger-subtab").forEach(s => {
|
||||
s.style.display = s.id === `wfm-tagger-${tab}` ? "" : "none";
|
||||
});
|
||||
if (tab === "db") _dbLoad();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── スライダー ───────────────────────────────────────────────────
|
||||
|
||||
function _setupSliders() {
|
||||
[
|
||||
["wfm-tagger-threshold", "wfm-tagger-threshold-val"],
|
||||
["wfm-tagger-char-threshold", "wfm-tagger-char-threshold-val"],
|
||||
["wfm-tagger-batch-threshold", "wfm-tagger-batch-threshold-val"],
|
||||
["wfm-tagger-batch-char-threshold", "wfm-tagger-batch-char-threshold-val"],
|
||||
].forEach(([sliderId, valId]) => {
|
||||
const slider = document.getElementById(sliderId);
|
||||
const val = document.getElementById(valId);
|
||||
if (!slider || !val) return;
|
||||
val.textContent = slider.value;
|
||||
slider.addEventListener("input", () => { val.textContent = slider.value; });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Ollama トグル ────────────────────────────────────────────────
|
||||
|
||||
function _setupOllamaToggle() {
|
||||
const chk = document.getElementById("wfm-tagger-ollama-enable");
|
||||
const wrap = document.getElementById("wfm-tagger-ollama-settings");
|
||||
if (chk && wrap) {
|
||||
chk.addEventListener("change", () => {
|
||||
wrap.style.display = chk.checked ? "" : "none";
|
||||
});
|
||||
wrap.style.display = chk.checked ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// ── モデル一覧ロード ─────────────────────────────────────────────
|
||||
|
||||
async function _loadModels() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/models");
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
["wfm-tagger-model", "wfm-tagger-batch-model"].forEach(selId => {
|
||||
const sel = document.getElementById(selId);
|
||||
if (!sel) return;
|
||||
sel.innerHTML = `<option value="">${t("taggerNoModel")}</option>` +
|
||||
models.map(m => `<option value="${m.name}">[${m.type}] ${m.name}</option>`).join("");
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("tagger: _loadModels error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ollamaモデル一覧ロード ───────────────────────────────────────
|
||||
|
||||
async function _loadOllamaModels(urlId, selId) {
|
||||
const urlEl = document.getElementById(urlId);
|
||||
const selEl = document.getElementById(selId);
|
||||
if (!urlEl || !selEl) return;
|
||||
const api = urlEl.value || "http://127.0.0.1:11434";
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/ollama/models?api_url=${encodeURIComponent(api)}`);
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
if (models.length === 0) {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
} else {
|
||||
selEl.innerHTML = models.map(m => `<option value="${m}">${m}</option>`).join("");
|
||||
}
|
||||
} catch {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 設定の読み込み/保存 ─────────────────────────────────────────
|
||||
|
||||
async function _loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/settings");
|
||||
const data = await res.json();
|
||||
if (data.ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-ollama-url");
|
||||
if (el) el.value = data.ollama_url;
|
||||
}
|
||||
if (data.batch_ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-batch-ollama-url");
|
||||
if (el) el.value = data.batch_ollama_url;
|
||||
}
|
||||
} catch {
|
||||
// 初回は無視
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveSettings() {
|
||||
try {
|
||||
await fetch("/wfm/tagger/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ollama_url: document.getElementById("wfm-tagger-ollama-url")?.value || "",
|
||||
batch_ollama_url: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "",
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// サイレントに失敗
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single サブタブ ─────────────────────────────────────────────
|
||||
|
||||
function _setupSingleActions() {
|
||||
// ファイルアップロード
|
||||
document.getElementById("wfm-tagger-upload")?.addEventListener("change", e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
_loadImageFile(file);
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
// ドラッグ&ドロップ
|
||||
const dropZone = document.getElementById("wfm-tagger-drop-zone");
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("drag-over");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", e => {
|
||||
if (!dropZone.contains(e.relatedTarget)) {
|
||||
dropZone.classList.remove("drag-over");
|
||||
}
|
||||
});
|
||||
dropZone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
_loadImageFile(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// タグ生成
|
||||
document.getElementById("wfm-tagger-generate-btn")?.addEventListener("click", _generateTags);
|
||||
|
||||
// Ollamaリフレッシュ
|
||||
document.getElementById("wfm-tagger-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-ollama-url", "wfm-tagger-ollama-model");
|
||||
});
|
||||
|
||||
// 結果アクション
|
||||
document.getElementById("wfm-tagger-send-genui-btn")?.addEventListener("click", _sendToGenUI);
|
||||
document.getElementById("wfm-tagger-send-prompt-btn")?.addEventListener("click", _sendToPrompt);
|
||||
document.getElementById("wfm-tagger-save-gallery-btn")?.addEventListener("click", _saveToGallery);
|
||||
document.getElementById("wfm-tagger-write-file-btn")?.addEventListener("click", _writeToFile);
|
||||
document.getElementById("wfm-tagger-save-db-btn")?.addEventListener("click", _saveToDb);
|
||||
}
|
||||
|
||||
function _loadImageFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
_setPreviewFromDataUrl(e.target.result, file.name);
|
||||
state.imagePath = "";
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function _setPreviewFromDataUrl(dataUrl, name) {
|
||||
state.imageB64 = dataUrl.replace(/^data:[^;]+;base64,/, "");
|
||||
state.imageName = name;
|
||||
const img = document.getElementById("wfm-tagger-preview");
|
||||
const ph = document.getElementById("wfm-tagger-preview-placeholder");
|
||||
const nameEl = document.getElementById("wfm-tagger-image-name");
|
||||
if (img) { img.src = dataUrl; img.style.display = ""; }
|
||||
if (ph) ph.style.display = "none";
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
}
|
||||
|
||||
async function _generateTags() {
|
||||
if (!state.imageB64) {
|
||||
showToast(t("taggerNoImage"), "warning");
|
||||
return;
|
||||
}
|
||||
const model = document.getElementById("wfm-tagger-model")?.value || "";
|
||||
const threshold = parseFloat(document.getElementById("wfm-tagger-threshold")?.value || "0.35");
|
||||
const charThreshold = parseFloat(document.getElementById("wfm-tagger-char-threshold")?.value || "0.85");
|
||||
const useOllama = document.getElementById("wfm-tagger-ollama-enable")?.checked || false;
|
||||
const ollamaUrl = document.getElementById("wfm-tagger-ollama-url")?.value || "http://127.0.0.1:11434";
|
||||
const ollamaModel = document.getElementById("wfm-tagger-ollama-model")?.value || "";
|
||||
const ollamaPrompt = document.getElementById("wfm-tagger-ollama-prompt")?.value || "";
|
||||
const maxTags = parseInt(document.getElementById("wfm-tagger-ollama-max-tags")?.value || "40");
|
||||
|
||||
const generateBtn = document.getElementById("wfm-tagger-generate-btn");
|
||||
if (generateBtn) { generateBtn.disabled = true; generateBtn.textContent = "..."; }
|
||||
|
||||
try {
|
||||
let tags = "";
|
||||
|
||||
if (model) {
|
||||
const res = await fetch("/wfm/tagger/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_b64: state.imageB64, model, threshold, char_threshold: charThreshold }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
tags = data.tags || "";
|
||||
}
|
||||
|
||||
if (useOllama && ollamaModel) {
|
||||
const res2 = await fetch("/wfm/tagger/ollama/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
image_b64: state.imageB64, api_url: ollamaUrl,
|
||||
model: ollamaModel, prompt: ollamaPrompt, max_tags: maxTags,
|
||||
}),
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
if (data2.error) throw new Error(data2.error);
|
||||
const vlm = data2.tags || "";
|
||||
tags = [tags, vlm].filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
document.getElementById("wfm-tagger-result").value = tags;
|
||||
showToast(t("taggerDone"), "success");
|
||||
_saveSettings();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
} finally {
|
||||
if (generateBtn) { generateBtn.disabled = false; generateBtn.textContent = t("taggerGenerateTags"); }
|
||||
}
|
||||
}
|
||||
|
||||
function _sendToGenUI() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
const ta = document.getElementById("wfm-prompt-pos-text");
|
||||
if (!ta) { showToast(t("taggerNoGenUI"), "warning"); return; }
|
||||
const cur = ta.value;
|
||||
ta.value = cur ? `${cur}, ${tags}` : tags;
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
document.getElementById("wfm-prompt-pos-apply")?.click();
|
||||
showToast(t("taggerSentToGenUI"), "success");
|
||||
}
|
||||
|
||||
function _sendToPrompt() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
const ta = document.getElementById("wfm-preset-pos");
|
||||
if (!ta) { showToast(t("taggerNoGalleryImage"), "warning"); return; }
|
||||
const cur = ta.value;
|
||||
ta.value = cur ? `${cur}, ${tags}` : tags;
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast(t("taggerSentToPrompt"), "success");
|
||||
}
|
||||
|
||||
async function _saveToGallery() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags || !state.imagePath) {
|
||||
showToast(t("taggerNoGalleryImage"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tagsArray = tags.split(",").map(s => s.trim()).filter(Boolean);
|
||||
const res = await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.imagePath, tags: tagsArray }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerSavedToGallery"), "success");
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _writeToFile() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags || !state.imagePath) {
|
||||
showToast(t("taggerNoGalleryImage"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/write_meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.imagePath, tags }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerWrittenToFile"), "success");
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveToDb() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/db/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: state.imageName, interrogator_tags: tags, vlm_tags: "" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerSavedToDB"), "success");
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch サブタブ ───────────────────────────────────────────────
|
||||
|
||||
function _setupBatchActions() {
|
||||
document.getElementById("wfm-tagger-batch-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-batch-ollama-url", "wfm-tagger-batch-ollama-model");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-tagger-batch-start-btn")?.addEventListener("click", _batchStart);
|
||||
document.getElementById("wfm-tagger-batch-stop-btn")?.addEventListener("click", _batchStop);
|
||||
}
|
||||
|
||||
async function _batchStart() {
|
||||
const folder = document.getElementById("wfm-tagger-batch-folder")?.value || "";
|
||||
if (!folder) { showToast(t("taggerBatchFolder"), "warning"); return; }
|
||||
|
||||
const body = {
|
||||
folder,
|
||||
model: document.getElementById("wfm-tagger-batch-model")?.value || "",
|
||||
threshold: parseFloat(document.getElementById("wfm-tagger-batch-threshold")?.value || "0.35"),
|
||||
char_threshold: parseFloat(document.getElementById("wfm-tagger-batch-char-threshold")?.value || "0.85"),
|
||||
use_ollama: document.getElementById("wfm-tagger-batch-ollama-enable")?.checked || false,
|
||||
ollama_api: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "http://127.0.0.1:11434",
|
||||
ollama_model: document.getElementById("wfm-tagger-batch-ollama-model")?.value || "",
|
||||
ollama_prompt: document.getElementById("wfm-tagger-batch-ollama-prompt")?.value || "",
|
||||
save_db: document.getElementById("wfm-tagger-batch-save-db")?.checked ?? true,
|
||||
write_file: document.getElementById("wfm-tagger-batch-write-file")?.checked || false,
|
||||
write_txt: document.getElementById("wfm-tagger-batch-write-txt")?.checked || false,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/batch/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
document.getElementById("wfm-tagger-batch-start-btn").disabled = true;
|
||||
document.getElementById("wfm-tagger-batch-stop-btn").disabled = false;
|
||||
document.getElementById("wfm-tagger-batch-log").value = "";
|
||||
_startBatchPoll();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _batchStop() {
|
||||
await fetch("/wfm/tagger/batch/stop", { method: "POST" });
|
||||
}
|
||||
|
||||
function _startBatchPoll() {
|
||||
if (state.batchPollTimer) clearInterval(state.batchPollTimer);
|
||||
state.batchPollTimer = setInterval(_pollBatch, 1000);
|
||||
}
|
||||
|
||||
async function _pollBatch() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/batch/status");
|
||||
const data = await res.json();
|
||||
|
||||
const total = data.total || 0;
|
||||
const done = data.done || 0;
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
|
||||
const bar = document.getElementById("wfm-tagger-batch-progress-bar");
|
||||
const txt = document.getElementById("wfm-tagger-batch-progress-text");
|
||||
if (bar) bar.style.width = pct + "%";
|
||||
if (txt) txt.textContent = `${done} / ${total} (${pct}%)`;
|
||||
|
||||
const logEl = document.getElementById("wfm-tagger-batch-log");
|
||||
if (logEl && data.log?.length) {
|
||||
logEl.value = data.log.join("\n");
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
if (!data.running) {
|
||||
clearInterval(state.batchPollTimer);
|
||||
state.batchPollTimer = null;
|
||||
document.getElementById("wfm-tagger-batch-start-btn").disabled = false;
|
||||
document.getElementById("wfm-tagger-batch-stop-btn").disabled = true;
|
||||
showToast(t("taggerDone"), "success");
|
||||
}
|
||||
} catch {
|
||||
// 無視
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB サブタブ ──────────────────────────────────────────────────
|
||||
|
||||
function _setupDbActions() {
|
||||
document.getElementById("wfm-tagger-db-search-btn")?.addEventListener("click", _dbSearch);
|
||||
document.getElementById("wfm-tagger-db-show-all-btn")?.addEventListener("click", _dbLoad);
|
||||
document.getElementById("wfm-tagger-db-export-btn")?.addEventListener("click", _dbExport);
|
||||
document.getElementById("wfm-tagger-db-search-input")?.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") _dbSearch();
|
||||
});
|
||||
document.getElementById("wfm-tagger-db-save-btn")?.addEventListener("click", _dbSave);
|
||||
document.getElementById("wfm-tagger-db-delete-btn")?.addEventListener("click", _dbDelete);
|
||||
}
|
||||
|
||||
async function _dbLoad() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/db/list?limit=200&offset=0");
|
||||
const data = await res.json();
|
||||
_renderDbTable(data.rows || []);
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _dbSearch() {
|
||||
const q = document.getElementById("wfm-tagger-db-search-input")?.value || "";
|
||||
if (!q) { _dbLoad(); return; }
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/db/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
_renderDbTable(data.rows || []);
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDbTable(rows) {
|
||||
const tbody = document.getElementById("wfm-tagger-db-tbody");
|
||||
if (!tbody) return;
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows.map(row => `
|
||||
<tr data-id="${row.id}" title="${_esc(row.all_tags)}">
|
||||
<td>${row.id}</td>
|
||||
<td>${_esc(row.filename)}</td>
|
||||
<td>${_esc(_truncate(row.interrogator_tags, 60))}</td>
|
||||
<td>${_esc(_truncate(row.vlm_tags, 60))}</td>
|
||||
<td>${_esc(row.created_at?.split("T")[0] || row.created_at || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(tr => {
|
||||
tr.addEventListener("click", () => {
|
||||
tbody.querySelectorAll("tr").forEach(r => r.classList.remove("selected"));
|
||||
tr.classList.add("selected");
|
||||
const row = rows.find(r => r.id === parseInt(tr.dataset.id));
|
||||
if (row) _openDbEdit(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _openDbEdit(row) {
|
||||
state.dbEditId = row.id;
|
||||
const panel = document.getElementById("wfm-tagger-db-edit-panel");
|
||||
const header = document.getElementById("wfm-tagger-db-edit-header");
|
||||
const interrEl = document.getElementById("wfm-tagger-db-edit-interrogator");
|
||||
const vlmEl = document.getElementById("wfm-tagger-db-edit-vlm");
|
||||
if (!panel) return;
|
||||
if (header) header.textContent = `ID: ${row.id} ${row.filename}`;
|
||||
if (interrEl) interrEl.value = row.interrogator_tags;
|
||||
if (vlmEl) vlmEl.value = row.vlm_tags;
|
||||
panel.style.display = "";
|
||||
}
|
||||
|
||||
async function _dbSave() {
|
||||
if (state.dbEditId == null) return;
|
||||
const interrogator_tags = document.getElementById("wfm-tagger-db-edit-interrogator")?.value || "";
|
||||
const vlm_tags = document.getElementById("wfm-tagger-db-edit-vlm")?.value || "";
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/db/${state.dbEditId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ interrogator_tags, vlm_tags }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerDbSaved"), "success");
|
||||
_dbLoad();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _dbDelete() {
|
||||
if (state.dbEditId == null) return;
|
||||
if (!confirm(`Delete DB record #${state.dbEditId}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/db/${state.dbEditId}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
document.getElementById("wfm-tagger-db-edit-panel").style.display = "none";
|
||||
state.dbEditId = null;
|
||||
showToast(t("taggerDbDeleted"), "success");
|
||||
_dbLoad();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _dbExport() {
|
||||
const a = document.createElement("a");
|
||||
a.href = "/wfm/tagger/db/export";
|
||||
a.download = "tagger_tags.csv";
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ── ユーティリティ ───────────────────────────────────────────────
|
||||
|
||||
function _esc(str) {
|
||||
return String(str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function _truncate(str, len) {
|
||||
if (!str) return "";
|
||||
return str.length > len ? str.slice(0, len) + "…" : str;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Shared utilities
|
||||
*/
|
||||
|
||||
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// localStorage からJSONを安全に読む(不正JSON・未設定時はfallback)
|
||||
export function readJsonStorage(key, fallback = {}) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// プラグイン共通設定 (wfm_settings)
|
||||
export function getSettings() {
|
||||
return readJsonStorage("wfm_settings");
|
||||
}
|
||||
|
||||
// AIバックエンド(Ollama/LM Studio/Lemonade)ごとのデフォルトURL
|
||||
export const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
};
|
||||
|
||||
export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索inputにオーバーレイXボタンを設定する。
|
||||
* @param {string} inputId - 検索inputのID
|
||||
* @param {string} clearBtnId - クリアボタンのID
|
||||
* @param {Function} onClear - クリア時に呼び出すコールバック(inputは既に空になった後で呼ばれる)
|
||||
*/
|
||||
export function setupSearchClearBtn(inputId, clearBtnId, onClear) {
|
||||
const input = document.getElementById(inputId);
|
||||
const btn = document.getElementById(clearBtnId);
|
||||
if (!input || !btn) return;
|
||||
|
||||
const sync = () => {
|
||||
btn.style.display = input.value ? "flex" : "none";
|
||||
};
|
||||
|
||||
input.addEventListener("input", sync);
|
||||
btn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
btn.style.display = "none";
|
||||
input.focus();
|
||||
onClear();
|
||||
});
|
||||
|
||||
// 初期値が入っている場合に備えて同期
|
||||
sync();
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* WFS_GalleryFeeder – ComfyUI canvas extension
|
||||
* Adds "After Gen", Run, and Stop controls directly on the node widget area.
|
||||
*/
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const FEEDER_GROUP = "__Feeder__";
|
||||
|
||||
// Per-node runtime state (keyed by node.id)
|
||||
const _ns = new Map();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchGroupImages(groupName) {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/groups/${encodeURIComponent(groupName)}/images`);
|
||||
if (!res.ok) return [];
|
||||
return (await res.json()).images || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function getOrCreate(nodeId) {
|
||||
if (!_ns.has(nodeId)) {
|
||||
_ns.set(nodeId, { running: false, stopFlag: false, idx: 0, images: [] });
|
||||
}
|
||||
return _ns.get(nodeId);
|
||||
}
|
||||
|
||||
function getInputWidget(node, name) {
|
||||
return node.widgets?.find(w => w.name === name);
|
||||
}
|
||||
|
||||
function setWidgetValue(node, name, value) {
|
||||
const w = getInputWidget(node, name);
|
||||
if (!w) return;
|
||||
w.value = value;
|
||||
if (w.callback) w.callback(value);
|
||||
app.canvas?.setDirty(true, true);
|
||||
}
|
||||
|
||||
/** Resolves when the current prompt finishes (executing=null, interrupted), rejects on error. */
|
||||
function waitForExecution() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
app.api.removeEventListener("executing", onExecuting);
|
||||
app.api.removeEventListener("execution_error", onError);
|
||||
app.api.removeEventListener("execution_interrupted", onInterrupted);
|
||||
};
|
||||
const onExecuting = ({ detail }) => { if (detail === null) { cleanup(); resolve(); } };
|
||||
const onError = ({ detail }) => { cleanup(); reject(new Error(detail?.exception_message || "Execution error")); };
|
||||
const onInterrupted = () => { cleanup(); resolve(); }; // treat interrupt as "done"
|
||||
|
||||
app.api.addEventListener("executing", onExecuting);
|
||||
app.api.addEventListener("execution_error", onError);
|
||||
app.api.addEventListener("execution_interrupted", onInterrupted);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRunBtn(node) {
|
||||
const st = getOrCreate(node.id);
|
||||
if (node._wfsRunWidget) node._wfsRunWidget.name = st.running ? "⏳ Running..." : "▶ Run";
|
||||
app.canvas?.setDirty(true, true);
|
||||
}
|
||||
|
||||
function wfsToast(msg, severity = "warn") {
|
||||
try {
|
||||
const tm = app.extensionManager?.toast;
|
||||
if (tm?.add) { tm.add({ severity, summary: "Gallery Feeder", detail: msg, life: 4000 }); return; }
|
||||
} catch {}
|
||||
console[severity === "error" ? "error" : "warn"]("[WFS_GalleryFeeder]", msg);
|
||||
}
|
||||
|
||||
// ── Run / Stop ────────────────────────────────────────────────────────
|
||||
|
||||
async function startLoop(node) {
|
||||
const st = getOrCreate(node.id);
|
||||
if (st.running) return;
|
||||
|
||||
// Re-fetch images for the current group each Run
|
||||
const groupName = getInputWidget(node, "group_name")?.value || FEEDER_GROUP;
|
||||
const images = await fetchGroupImages(groupName);
|
||||
if (images.length === 0) {
|
||||
wfsToast(`No images in group "${groupName}"`, "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
st.running = true;
|
||||
st.stopFlag = false;
|
||||
st.images = images;
|
||||
st.idx = Math.max(0, Math.min(
|
||||
parseInt(getInputWidget(node, "index")?.value) || 0,
|
||||
images.length - 1
|
||||
));
|
||||
|
||||
updateRunBtn(node);
|
||||
|
||||
// Queue first generation with current index
|
||||
setWidgetValue(node, "index", st.idx);
|
||||
app.queuePrompt(0, 1);
|
||||
|
||||
while (st.running && !st.stopFlag) {
|
||||
try {
|
||||
await waitForExecution();
|
||||
} catch (err) {
|
||||
if (st.running) wfsToast(err.message, "error");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!st.running || st.stopFlag) break;
|
||||
|
||||
const afterGen = node._wfsAfterGenWidget?.value || "loop";
|
||||
const total = st.images.length;
|
||||
|
||||
// Increment mode: stop after the last image
|
||||
if (afterGen === "increment" && st.idx >= total - 1) break;
|
||||
|
||||
if (afterGen !== "fixed") {
|
||||
st.idx = (st.idx + 1) % total;
|
||||
setWidgetValue(node, "index", st.idx);
|
||||
}
|
||||
|
||||
app.queuePrompt(0, 1);
|
||||
}
|
||||
|
||||
st.running = false;
|
||||
updateRunBtn(node);
|
||||
}
|
||||
|
||||
function stopLoop(node) {
|
||||
const st = getOrCreate(node.id);
|
||||
if (!st.running) return;
|
||||
st.stopFlag = true;
|
||||
st.running = false;
|
||||
try { app.api.interrupt(); } catch {}
|
||||
updateRunBtn(node);
|
||||
}
|
||||
|
||||
// ── Extension registration ────────────────────────────────────────────
|
||||
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.GalleryFeeder",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "WFS_GalleryFeeder") return;
|
||||
|
||||
const origCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
origCreated?.call(this);
|
||||
const node = this;
|
||||
|
||||
// ── After Gen selector ────────────────────────────────────
|
||||
// serialize:false → not included in widgets_values / prompt inputs
|
||||
const afterGenW = node.addWidget("combo", "wfs_after_gen", "loop", () => {}, {
|
||||
values: ["loop", "increment", "fixed"],
|
||||
serialize: false,
|
||||
});
|
||||
node._wfsAfterGenWidget = afterGenW;
|
||||
|
||||
// ── Run button ────────────────────────────────────────────
|
||||
const runW = node.addWidget("button", "▶ Run", null, () => startLoop(node));
|
||||
runW.serialize = false;
|
||||
node._wfsRunWidget = runW;
|
||||
|
||||
// ── Stop button ───────────────────────────────────────────
|
||||
const stopW = node.addWidget("button", "■ Stop", null, () => stopLoop(node));
|
||||
stopW.serialize = false;
|
||||
node._wfsStopWidget = stopW;
|
||||
};
|
||||
|
||||
// Clean up when node is removed from graph
|
||||
const origRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
stopLoop(this);
|
||||
_ns.delete(this.id);
|
||||
origRemoved?.call(this);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* Node Library Sidebar Panel
|
||||
*
|
||||
* Fixed sidebar panel injected into ComfyUI DOM.
|
||||
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
|
||||
* Items are draggable onto the canvas or click-to-place.
|
||||
*/
|
||||
console.log("[WFM] node_sets_menu.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] node_sets_menu.js: app imported");
|
||||
|
||||
export const NODE_SETS_TOOLTIP = "Node Library – Browse & drag nodes onto canvas";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
const state = {
|
||||
visible: false,
|
||||
activeTab: "favorites", // favorites | sets | groups
|
||||
favorites: [], // [{name, display_name}]
|
||||
nodeSets: [],
|
||||
groups: {}, // {groupName: [nodeName, ...]}
|
||||
metadata: {},
|
||||
searchText: "",
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API
|
||||
// ============================================
|
||||
|
||||
const fetchNodeSets = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/groups");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const [metadata, nodeSets, groups] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(),
|
||||
]);
|
||||
state.metadata = metadata;
|
||||
state.nodeSets = nodeSets;
|
||||
state.groups = groups;
|
||||
|
||||
// Extract favorites from metadata
|
||||
state.favorites = [];
|
||||
for (const [name, meta] of Object.entries(metadata)) {
|
||||
if (name === "_groups") continue;
|
||||
if (meta.favorite) {
|
||||
state.favorites.push({ name, display_name: name });
|
||||
}
|
||||
}
|
||||
state.loaded = true;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas placement helpers
|
||||
// ============================================
|
||||
|
||||
const getCanvasDropPos = (e) => {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return [100, 100];
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
|
||||
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const placeSingleNode = (classType, pos) => {
|
||||
const graph = app.graph;
|
||||
if (!graph) return;
|
||||
const node = LiteGraph.createNode(classType);
|
||||
if (!node) {
|
||||
showToast(`Unknown node: ${classType}`, "error");
|
||||
return;
|
||||
}
|
||||
node.pos = pos || [100, 100];
|
||||
graph.add(node);
|
||||
app.canvas.setDirty(true, true);
|
||||
showToast(`Placed: ${node.title || classType}`, "success");
|
||||
};
|
||||
|
||||
const placeNodeSet = (nodeSet, pos) => {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas) return;
|
||||
|
||||
const nodes = nodeSet.nodes || [];
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
|
||||
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
|
||||
|
||||
const positions = nodes.map(n => n.rel_pos || [0, 0]);
|
||||
const minX = Math.min(...positions.map(p => p[0]));
|
||||
const minY = Math.min(...positions.map(p => p[1]));
|
||||
|
||||
const createdNodes = [];
|
||||
for (const nodeDef of nodes) {
|
||||
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
|
||||
const node = LiteGraph.createNode(nodeDef.class_type);
|
||||
if (!node) { createdNodes.push(null); continue; }
|
||||
const rp = nodeDef.rel_pos || [0, 0];
|
||||
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
|
||||
if (nodeDef.title) node.title = nodeDef.title;
|
||||
if (nodeDef.widget_values && node.widgets) {
|
||||
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
|
||||
const w = node.widgets.find(ww => ww.name === k);
|
||||
if (w) w.value = v;
|
||||
}
|
||||
}
|
||||
graph.add(node);
|
||||
createdNodes.push(node);
|
||||
}
|
||||
|
||||
for (const link of (nodeSet.links || [])) {
|
||||
const from = createdNodes[link.from_node];
|
||||
const to = createdNodes[link.to_node];
|
||||
if (from && to) {
|
||||
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setDirty(true, true);
|
||||
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Drag & Drop onto canvas
|
||||
// ============================================
|
||||
|
||||
let dropHandlerInstalled = false;
|
||||
|
||||
const installCanvasDropHandler = () => {
|
||||
if (dropHandlerInstalled) return;
|
||||
dropHandlerInstalled = true;
|
||||
|
||||
const canvasEl = document.getElementById("graph-canvas");
|
||||
if (!canvasEl) return;
|
||||
|
||||
canvasEl.addEventListener("dragover", (e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
canvasEl.addEventListener("drop", (e) => {
|
||||
const raw = e.dataTransfer.getData("application/x-wfm-node");
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
const pos = getCanvasDropPos(e);
|
||||
|
||||
if (data.type === "single") {
|
||||
placeSingleNode(data.classType, pos);
|
||||
} else if (data.type === "set") {
|
||||
const set = state.nodeSets.find(s => s.id === data.setId);
|
||||
if (set) placeNodeSet(set, pos);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Sidebar Panel DOM
|
||||
// ============================================
|
||||
|
||||
let panelEl = null;
|
||||
|
||||
const PANEL_ID = "wfm-node-library-panel";
|
||||
|
||||
const createPanel = () => {
|
||||
if (panelEl) return panelEl;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = PANEL_ID;
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-nlp-header">
|
||||
<span class="wfm-nlp-title">Node Library</span>
|
||||
<button class="wfm-nlp-refresh" title="Refresh">↻</button>
|
||||
<button class="wfm-nlp-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-tabs">
|
||||
<button class="wfm-nlp-tab active" data-tab="favorites">★ Favorites</button>
|
||||
<button class="wfm-nlp-tab" data-tab="sets">☰ Sets</button>
|
||||
<button class="wfm-nlp-tab" data-tab="groups">📁 Groups</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-search">
|
||||
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
|
||||
</div>
|
||||
<div class="wfm-nlp-content"></div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(panel);
|
||||
panelEl = panel;
|
||||
|
||||
// Tab switching
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeTab = btn.dataset.tab;
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
// Close
|
||||
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
|
||||
|
||||
// Search
|
||||
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
|
||||
state.searchText = e.target.value.toLowerCase().trim();
|
||||
renderContent();
|
||||
});
|
||||
|
||||
// Refresh
|
||||
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
|
||||
await loadData();
|
||||
renderContent();
|
||||
showToast("Node Library refreshed", "success");
|
||||
});
|
||||
|
||||
injectStyles();
|
||||
return panel;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render content
|
||||
// ============================================
|
||||
|
||||
const renderContent = () => {
|
||||
const content = panelEl?.querySelector(".wfm-nlp-content");
|
||||
if (!content) return;
|
||||
|
||||
if (!state.loaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state.activeTab) {
|
||||
case "favorites": renderFavorites(content); break;
|
||||
case "sets": renderSets(content); break;
|
||||
case "groups": renderGroups(content); break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFavorites = (container) => {
|
||||
let items = state.favorites;
|
||||
if (state.searchText) {
|
||||
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const node of items) {
|
||||
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeSingleNode(node.name);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSets = (container) => {
|
||||
let items = state.nodeSets;
|
||||
if (state.searchText) {
|
||||
items = items.filter(s =>
|
||||
s.name.toLowerCase().includes(state.searchText) ||
|
||||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const set of items) {
|
||||
const count = (set.nodes || []).length;
|
||||
const el = createDraggableItem(
|
||||
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
|
||||
"set",
|
||||
{ setId: set.id },
|
||||
set.description
|
||||
);
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeNodeSet(set);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = (container) => {
|
||||
const groupNames = Object.keys(state.groups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let filtered = groupNames;
|
||||
if (state.searchText) {
|
||||
filtered = groupNames.filter(g =>
|
||||
g.toLowerCase().includes(state.searchText) ||
|
||||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const groupName of filtered) {
|
||||
const nodes = state.groups[groupName] || [];
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.length}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
const list = section.querySelector(".wfm-nlp-group-list");
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
section.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "wfm-nlp-group-list";
|
||||
for (const nodeName of nodes) {
|
||||
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
|
||||
list.appendChild(el);
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Draggable item factory
|
||||
// ============================================
|
||||
|
||||
const createDraggableItem = (label, type, data, subtitle) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item";
|
||||
el.draggable = true;
|
||||
el.innerHTML = `
|
||||
<div class="wfm-nlp-item-label">${label}</div>
|
||||
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
|
||||
`;
|
||||
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
|
||||
el.classList.add("dragging");
|
||||
});
|
||||
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("dragging");
|
||||
});
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Toggle & exports
|
||||
// ============================================
|
||||
|
||||
export const togglePanel = async () => {
|
||||
const panel = createPanel();
|
||||
state.visible = !state.visible;
|
||||
panel.style.display = state.visible ? "flex" : "none";
|
||||
|
||||
if (state.visible && !state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
|
||||
installCanvasDropHandler();
|
||||
};
|
||||
|
||||
export const getNodeSetsIcon = () => `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<path d="M17.5 14v7M14 17.5h7"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// ============================================
|
||||
// Toast
|
||||
// ============================================
|
||||
|
||||
const showToast = (message, type = "info") => {
|
||||
const existing = document.getElementById("wfm-ns-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-ns-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)" },
|
||||
error: { bg: "rgba(255,71,87,0.95)" },
|
||||
info: { bg: "rgba(74,158,255,0.95)" },
|
||||
};
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
|
||||
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
|
||||
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
};
|
||||
|
||||
const esc = (s) => s ? String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """) : "";
|
||||
|
||||
// ============================================
|
||||
// Save selected nodes as Node Set (context menu)
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for name
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
// Build node list with relative positions
|
||||
// Use the top-left of the bounding box as origin
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
// Extract links between selected nodes
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
// Resolve link data
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
|
||||
// or object with .origin_id, .origin_slot, etc.
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
// Only include if both source and target are in the selection
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save via API
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
// Refresh sidebar if open
|
||||
if (state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Register context menu item on all nodes
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.NodeSetContextMenu",
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
|
||||
// Add separator + menu item
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Styles
|
||||
// ============================================
|
||||
|
||||
const injectStyles = () => {
|
||||
if (document.getElementById("wfm-nlp-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wfm-nlp-styles";
|
||||
style.textContent = `
|
||||
#${PANEL_ID} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--comfy-menu-bg, #1e1e1e);
|
||||
border-left: 1px solid var(--border-color, #4e4e4e);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--input-text, #ddd);
|
||||
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.wfm-nlp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.wfm-nlp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wfm-nlp-close:hover { opacity: 1; }
|
||||
.wfm-nlp-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-nlp-tab:hover { opacity: 0.9; }
|
||||
.wfm-nlp-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #4e4e4e);
|
||||
border-radius: 4px;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wfm-nlp-search-input:focus {
|
||||
border-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.wfm-nlp-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--descrip-text, #999);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wfm-nlp-item {
|
||||
padding: 7px 12px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
.wfm-nlp-item:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item-label {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wfm-nlp-item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--descrip-text, #888);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wfm-nlp-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--comfy-input-bg, #444);
|
||||
border-radius: 3px;
|
||||
color: var(--descrip-text, #aaa);
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.wfm-nlp-group-section {
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
}
|
||||
.wfm-nlp-group-header {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.wfm-nlp-group-header:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-group-header::before {
|
||||
content: "\\25BC";
|
||||
font-size: 8px;
|
||||
margin-right: 6px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wfm-nlp-group-header.collapsed::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.wfm-nlp-group-list {
|
||||
padding-left: 8px;
|
||||
}
|
||||
.wfm-nlp-group-list .wfm-nlp-item {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wfm-nlp-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wfm-nlp-refresh:hover { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Context Menu — Save selected nodes as Node Set
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
if (state.loaded) { await loadData(); renderContent(); }
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
export { saveSelectedAsNodeSet };
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
@@ -167,8 +168,101 @@ const captureCanvasSnapshot = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const showSaveDialog = (defaultName) => {
|
||||
return new Promise((resolve) => {
|
||||
// Remove existing dialog if any
|
||||
const existing = document.getElementById("wfm-save-dialog-overlay");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "wfm-save-dialog-overlay";
|
||||
Object.assign(overlay.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: "99999",
|
||||
});
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
Object.assign(dialog.style, {
|
||||
background: "var(--comfy-menu-bg, #2a2a2a)",
|
||||
border: "1px solid var(--border-color, #4e4e4e)",
|
||||
borderRadius: "8px",
|
||||
padding: "16px 20px",
|
||||
minWidth: "360px",
|
||||
maxWidth: "480px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
||||
color: "var(--input-text, #ddd)",
|
||||
fontFamily: "sans-serif",
|
||||
});
|
||||
|
||||
const stem = defaultName.replace(/\.json$/, "");
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div style="font-size:14px;font-weight:bold;margin-bottom:12px;">Save Workflow</div>
|
||||
<label style="font-size:12px;color:var(--descrip-text,#999);display:block;margin-bottom:4px;">Filename</label>
|
||||
<input type="text" id="wfm-save-dialog-input" value="${stem}" style="
|
||||
width:100%;padding:8px 10px;font-size:13px;
|
||||
background:var(--comfy-input-bg,#1a1a1a);
|
||||
border:1px solid var(--border-color,#4e4e4e);
|
||||
border-radius:4px;color:var(--input-text,#ddd);
|
||||
outline:none;box-sizing:border-box;
|
||||
" />
|
||||
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
|
||||
<button id="wfm-save-dialog-cancel" style="
|
||||
padding:6px 16px;font-size:12px;border-radius:4px;cursor:pointer;
|
||||
background:transparent;border:1px solid var(--border-color,#4e4e4e);
|
||||
color:var(--input-text,#ddd);
|
||||
">Cancel</button>
|
||||
<button id="wfm-save-dialog-ok" style="
|
||||
padding:6px 16px;font-size:12px;border-radius:4px;cursor:pointer;
|
||||
background:var(--p-button-background,#4a9eff);border:none;
|
||||
color:#fff;font-weight:bold;
|
||||
">Save</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = dialog.querySelector("#wfm-save-dialog-input");
|
||||
const okBtn = dialog.querySelector("#wfm-save-dialog-ok");
|
||||
const cancelBtn = dialog.querySelector("#wfm-save-dialog-cancel");
|
||||
|
||||
const cleanup = () => overlay.remove();
|
||||
|
||||
const confirm = () => {
|
||||
const val = input.value.trim();
|
||||
cleanup();
|
||||
if (val) {
|
||||
resolve(val.endsWith(".json") ? val : val + ".json");
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => { cleanup(); resolve(null); };
|
||||
|
||||
okBtn.addEventListener("click", confirm);
|
||||
cancelBtn.addEventListener("click", cancel);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) cancel(); });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); confirm(); }
|
||||
if (e.key === "Escape") cancel();
|
||||
});
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
};
|
||||
|
||||
const saveCanvasToWorkflowStudio = async () => {
|
||||
const filename = getWorkflowFilename();
|
||||
const defaultFilename = getWorkflowFilename();
|
||||
const filename = await showSaveDialog(defaultFilename);
|
||||
if (!filename) return; // cancelled
|
||||
|
||||
try {
|
||||
showNotification("Capturing workflow image...", "info");
|
||||
@@ -383,15 +477,47 @@ const createSnapshotButton = async () => {
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
if (!settingsGroup?.element?.parentElement) {
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI settings button group.");
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,109 +527,139 @@ const attachTopMenuButton = async (attempt = 0) => {
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton);
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
};
|
||||
|
||||
const createExtensionObject = (useActionBar) => {
|
||||
const extensionObj = {
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
async setup() {
|
||||
if (!useActionBar) {
|
||||
console.log("Workflow Studio: using legacy button attachment (frontend version < 1.33.9)");
|
||||
await attachTopMenuButton();
|
||||
} else {
|
||||
console.log("Workflow Studio: using actionBarButtons API (frontend version >= 1.33.9)");
|
||||
}
|
||||
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
// WFM launch button
|
||||
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
wfmButtons.forEach((button) => {
|
||||
button.classList.add("wfm-top-menu-button");
|
||||
button.innerHTML = getWfmIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
button.style.backgroundColor = "var(--primary-bg)";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
// Snapshot button
|
||||
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
|
||||
snapButtons.forEach((button) => {
|
||||
button.classList.add("wfm-snapshot-button");
|
||||
button.innerHTML = getSnapshotIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
};
|
||||
|
||||
if (useActionBar) {
|
||||
extensionObj.actionBarButtons = [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
];
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
|
||||
return extensionObj;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const useActionBar = await supportsActionBarButtons();
|
||||
const extensionObj = createExtensionObject(useActionBar);
|
||||
app.registerExtension(extensionObj);
|
||||
})();
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--view-grid-plus] size-4",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
onClick: () => togglePanel(),
|
||||
},
|
||||
],
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const buttonConfigs = [
|
||||
{ tooltip: BUTTON_TOOLTIP, className: "wfm-top-menu-button", getIcon: getWfmIcon, styles: { backgroundColor: "var(--primary-bg)" } },
|
||||
{ tooltip: SNAPSHOT_TOOLTIP, className: "wfm-snapshot-button", getIcon: getSnapshotIcon, styles: null },
|
||||
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
|
||||
];
|
||||
|
||||
const applyIconToButton = (btn, cfg) => {
|
||||
// Skip if the SVG icon is already in place
|
||||
if (btn.querySelector("svg")) return;
|
||||
btn.classList.add(cfg.className);
|
||||
btn.innerHTML = cfg.getIcon();
|
||||
btn.style.borderRadius = "4px";
|
||||
btn.style.padding = "6px";
|
||||
if (cfg.styles) Object.assign(btn.style, cfg.styles);
|
||||
const svg = btn.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
};
|
||||
|
||||
// Initial application — retry until buttons are rendered by Vue
|
||||
const pollUntilFound = () => {
|
||||
let foundAny = false;
|
||||
for (const cfg of buttonConfigs) {
|
||||
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
|
||||
foundAny = true;
|
||||
applyIconToButton(btn, cfg);
|
||||
});
|
||||
}
|
||||
if (!foundAny) {
|
||||
requestAnimationFrame(pollUntilFound);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(pollUntilFound);
|
||||
|
||||
// MutationObserver: re-apply icons after Vue re-renders the action bar
|
||||
// (e.g. when the Properties Panel is toggled inside a subgraph)
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const cfg of buttonConfigs) {
|
||||
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
|
||||
// Only re-apply when Vue has reset innerHTML back to <i>
|
||||
if (btn.querySelector("i")) {
|
||||
applyIconToButton(btn, cfg);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const watchNode =
|
||||
document.querySelector("[data-testid='action-bar-buttons']") ||
|
||||
document.querySelector(".actionbar-container") ||
|
||||
document.body;
|
||||
observer.observe(watchNode, { childList: true, subtree: true });
|
||||
window.__wfmIconObserver = observer;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
console.log("[WFM] top_menu_extension.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] app imported:", !!app);
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
console.log("[WFM] node_sets_menu imports loaded");
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
const WFM_PATH = "/wfm";
|
||||
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
|
||||
const MAX_ATTACH_ATTEMPTS = 120;
|
||||
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
|
||||
|
||||
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
|
||||
|
||||
const openWorkflowStudio = (event) => {
|
||||
const url = `${window.location.origin}${WFM_PATH}`;
|
||||
|
||||
if (event.shiftKey) {
|
||||
window.open(url, "_blank", NEW_WINDOW_FEATURES);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas Snapshot - Export workflow as PNG thumbnail
|
||||
// ============================================
|
||||
|
||||
const getWorkflowFilename = () => {
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
return `wf_${stamp}.json`;
|
||||
};
|
||||
|
||||
// ---- PNG tEXt chunk embedding helpers ----
|
||||
|
||||
const n2b = (n) => new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
||||
|
||||
const joinArrayBuffer = (...bufs) => {
|
||||
const result = new Uint8Array(bufs.reduce((total, buf) => total + buf.byteLength, 0));
|
||||
bufs.reduce((offset, buf) => {
|
||||
result.set(buf, offset);
|
||||
return offset + buf.byteLength;
|
||||
}, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
let _crcTable = null;
|
||||
const crc32 = (data) => {
|
||||
if (!_crcTable) {
|
||||
_crcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
_crcTable[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < data.byteLength; i++) {
|
||||
crc = (crc >>> 8) ^ _crcTable[(crc ^ data[i]) & 0xff];
|
||||
}
|
||||
return (crc ^ -1) >>> 0;
|
||||
};
|
||||
|
||||
const embedWorkflowInPng = async (blob, workflowJson) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const typedArr = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// Create tEXt chunk: "tEXtworkflow\0{json}"
|
||||
const data = new TextEncoder().encode(`tEXtworkflow\0${workflowJson}`);
|
||||
const chunk = joinArrayBuffer(n2b(data.byteLength - 4), data, n2b(crc32(data)));
|
||||
|
||||
// Insert after IHDR chunk (8-byte signature + IHDR length field(4) + IHDR type(4) + IHDR data + CRC(4))
|
||||
const ihdrDataLen = view.getUint32(8);
|
||||
const sz = ihdrDataLen + 20; // 8(sig) + 4(len) + 4(type) + ihdrDataLen + 4(crc)
|
||||
const result = joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
|
||||
|
||||
return new Blob([result], { type: "image/png" });
|
||||
};
|
||||
|
||||
// ---- Canvas capture ----
|
||||
|
||||
const captureCanvasSnapshot = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas || !graph._nodes?.length) {
|
||||
reject(new Error("No workflow loaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize workflow data before changing canvas state
|
||||
const workflowJson = JSON.stringify(graph.serialize());
|
||||
|
||||
// Calculate bounds of all nodes
|
||||
const bounds = graph._nodes.reduce(
|
||||
(p, n) => {
|
||||
if (n.pos[0] < p[0]) p[0] = n.pos[0];
|
||||
if (n.pos[1] < p[1]) p[1] = n.pos[1];
|
||||
const b = n.getBounding();
|
||||
const r = n.pos[0] + b[2];
|
||||
const bot = n.pos[1] + b[3];
|
||||
if (r > p[2]) p[2] = r;
|
||||
if (bot > p[3]) p[3] = bot;
|
||||
return p;
|
||||
},
|
||||
[99999, 99999, -99999, -99999]
|
||||
);
|
||||
|
||||
// Add padding
|
||||
const padding = 80;
|
||||
bounds[0] -= padding;
|
||||
bounds[1] -= padding;
|
||||
bounds[2] += padding;
|
||||
bounds[3] += padding;
|
||||
|
||||
// Save current canvas state
|
||||
const savedState = {
|
||||
scale: canvas.ds.scale,
|
||||
width: canvas.canvas.width,
|
||||
height: canvas.canvas.height,
|
||||
offset: [...canvas.ds.offset],
|
||||
transform: canvas.canvas.getContext("2d").getTransform(),
|
||||
};
|
||||
|
||||
// Set canvas to render full workflow
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.ds.scale = 1;
|
||||
canvas.canvas.width = (bounds[2] - bounds[0]) * dpr;
|
||||
canvas.canvas.height = (bounds[3] - bounds[1]) * dpr;
|
||||
canvas.ds.offset = [-bounds[0], -bounds[1]];
|
||||
canvas.canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Render
|
||||
canvas.draw(true, true);
|
||||
|
||||
// Capture to blob
|
||||
canvas.canvas.toBlob(async (rawBlob) => {
|
||||
// Restore canvas state
|
||||
canvas.ds.scale = savedState.scale;
|
||||
canvas.canvas.width = savedState.width;
|
||||
canvas.canvas.height = savedState.height;
|
||||
canvas.ds.offset = savedState.offset;
|
||||
canvas.canvas.getContext("2d").setTransform(savedState.transform);
|
||||
canvas.draw(true, true);
|
||||
|
||||
if (!rawBlob) {
|
||||
reject(new Error("Failed to generate canvas image"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Embed workflow JSON into PNG tEXt chunk
|
||||
const blob = await embedWorkflowInPng(rawBlob, workflowJson);
|
||||
resolve(blob);
|
||||
} catch (embedErr) {
|
||||
console.warn("Workflow Studio: failed to embed workflow metadata, saving without it:", embedErr);
|
||||
resolve(rawBlob);
|
||||
}
|
||||
}, "image/png");
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveCanvasToWorkflowStudio = async () => {
|
||||
const filename = getWorkflowFilename();
|
||||
|
||||
try {
|
||||
showNotification("Capturing workflow image...", "info");
|
||||
const blob = await captureCanvasSnapshot();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("filename", filename);
|
||||
fd.append("image", blob, "canvas.png");
|
||||
|
||||
const res = await fetch("/api/wfm/workflows/save-canvas-image", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
showNotification(`Workflow saved: ${filename.replace(/\.json$/, "")}`, "success");
|
||||
} catch (err) {
|
||||
showNotification("Error: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const showNotification = (message, type = "info") => {
|
||||
// Try using ComfyUI's built-in toast if available
|
||||
try {
|
||||
if (app.ui?.dialog) {
|
||||
// Fallback: use a simple temporary notification
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Simple toast notification
|
||||
const existing = document.getElementById("wfm-snapshot-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-snapshot-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)", color: "#fff" },
|
||||
error: { bg: "rgba(255,71,87,0.95)", color: "#fff" },
|
||||
info: { bg: "rgba(74,158,255,0.95)", color: "#fff" },
|
||||
};
|
||||
const c = colors[type] || colors.info;
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed",
|
||||
bottom: "24px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
padding: "10px 24px",
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
|
||||
transition: "opacity 0.3s ease",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
if (type !== "info") {
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
} else {
|
||||
// Info messages will be replaced by success/error
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Version Detection & UI
|
||||
// ============================================
|
||||
|
||||
const getComfyUIFrontendVersion = async () => {
|
||||
try {
|
||||
if (window["__COMFYUI_FRONTEND_VERSION__"]) {
|
||||
return window["__COMFYUI_FRONTEND_VERSION__"];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/system_stats");
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.system?.comfyui_frontend_version) {
|
||||
return data.system.comfyui_frontend_version;
|
||||
}
|
||||
|
||||
if (data?.system?.required_frontend_version) {
|
||||
return data.system.required_frontend_version;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to fetch system_stats:", error);
|
||||
}
|
||||
|
||||
return "0.0.0";
|
||||
};
|
||||
|
||||
const parseVersion = (versionStr) => {
|
||||
if (!versionStr || typeof versionStr !== "string") {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const cleanVersion = versionStr.replace(/^[vV]/, "").split("-")[0];
|
||||
const parts = cleanVersion.split(".").map((part) => parseInt(part, 10) || 0);
|
||||
|
||||
while (parts.length < 3) {
|
||||
parts.push(0);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const compareVersions = (version1, version2) => {
|
||||
const v1 = typeof version1 === "string" ? parseVersion(version1) : version1;
|
||||
const v2 = typeof version2 === "string" ? parseVersion(version2) : version2;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1[i] > v2[i]) return 1;
|
||||
if (v1[i] < v2[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const supportsActionBarButtons = async () => {
|
||||
const version = await getComfyUIFrontendVersion();
|
||||
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
|
||||
};
|
||||
|
||||
const getWfmIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 5 L7 17 L12 7 L17 17 L21 5" stroke-width="2.3"/>
|
||||
<path d="M5 20 Q9 17 12 20 Q15 23 19 20" stroke-width="1.8"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
const getSnapshotIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M8 5 L9 3 L15 3 L16 5"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Button Creation
|
||||
// ============================================
|
||||
|
||||
const createTopMenuButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "wfm",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
|
||||
button.element.title = BUTTON_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getWfmIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", openWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createSnapshotButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "snapshot",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
|
||||
button.element.title = SNAPSHOT_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getSnapshotIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--view-grid-plus] size-4",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
onClick: () => togglePanel(),
|
||||
},
|
||||
],
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
wfmButtons.forEach((button) => {
|
||||
button.classList.add("wfm-top-menu-button");
|
||||
button.innerHTML = getWfmIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
button.style.backgroundColor = "var(--primary-bg)";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
|
||||
snapButtons.forEach((button) => {
|
||||
button.classList.add("wfm-snapshot-button");
|
||||
button.innerHTML = getSnapshotIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
|
||||
nsButtons.forEach((button) => {
|
||||
button.classList.add("wfm-node-sets-btn");
|
||||
button.innerHTML = getNodeSetsIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 758 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 316 KiB |
@@ -0,0 +1,665 @@
|
||||
{
|
||||
"id": "6d392e45-b20b-4dee-a422-0b4a18e645cd",
|
||||
"revision": 0,
|
||||
"last_node_id": 35,
|
||||
"last_link_id": 102,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1351.4570000000006,
|
||||
197.9979999999999
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 42
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 84
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
22
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
-35,
|
||||
161
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
80
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
81,
|
||||
82
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
84,
|
||||
90
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"anime\\merge\\hayochamixIDXL_v11+_blueberryPlus_v10-amanatsuIllustrious_v11_x0.1_trainDifference.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
330,
|
||||
369
|
||||
],
|
||||
"size": [
|
||||
412.2799987792969,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 82
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"w\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-20.402179424849777,
|
||||
348.01722821269084
|
||||
],
|
||||
"size": [
|
||||
282.798828125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "choose file to upload",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
101
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "MASK",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
102
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.8"
|
||||
},
|
||||
"image": "clipspace/clipspace-painted-masked-1784801792090.png [input]"
|
||||
},
|
||||
"widgets_values": [
|
||||
"clipspace/clipspace-painted-masked-1784801792090.png [input]",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
997,
|
||||
174
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 80
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 72
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "steps",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "sampler_name",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "scheduler",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "denoise",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
42
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
1008854893628621,
|
||||
"randomize",
|
||||
30,
|
||||
4,
|
||||
"ddim",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "VAEEncodeForInpaint",
|
||||
"pos": [
|
||||
759,
|
||||
169
|
||||
],
|
||||
"size": [
|
||||
226.8000030517578,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 101
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 90
|
||||
},
|
||||
{
|
||||
"localized_name": "mask",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 102
|
||||
},
|
||||
{
|
||||
"localized_name": "grow_mask_by",
|
||||
"name": "grow_mask_by",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "grow_mask_by"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
72
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncodeForInpaint",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1362.0710000000008,
|
||||
369.32099999999986
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
326.97,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
422.8500061035156,
|
||||
164.30999755859375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 81
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"smile"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
42,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
72,
|
||||
26,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
80,
|
||||
29,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
81,
|
||||
29,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
82,
|
||||
29,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
84,
|
||||
29,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
90,
|
||||
29,
|
||||
2,
|
||||
26,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
101,
|
||||
32,
|
||||
0,
|
||||
26,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
102,
|
||||
32,
|
||||
1,
|
||||
26,
|
||||
2,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9229599817706418,
|
||||
"offset": [
|
||||
365.6203139679832,
|
||||
118.10373545614928
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": []
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 134 KiB |