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 }}
|
||||
@@ -185,3 +185,18 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
5. `INPUT_TYPES` 変更時は `widgets_values` も同時更新
|
||||
6. 削除した依存への参照が残っていないか grep で確認
|
||||
7. ComfyUI完全再起動 → ワークフロー読み込み → エンドツーエンド実行確認
|
||||
|
||||
---
|
||||
|
||||
## リリース手順
|
||||
|
||||
コミット・プッシュ・GitHub リリース(`gh release create`)を行う際は、**必ず以下の順序**で実施する。
|
||||
|
||||
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
|
||||
2. `README.md` のバージョンバッジを更新(``)
|
||||
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 に反映されない。
|
||||
|
||||
@@ -9,15 +9,16 @@ WorkflowStudio.add_routes()
|
||||
|
||||
# Register custom nodes (isolated loading)
|
||||
_NODE_MODULES = {
|
||||
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText"),
|
||||
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText", "Prompt Text (WFS)"),
|
||||
"WFS_GalleryFeeder": (".py.nodes.gallery_feeder_node", "WFS_GalleryFeeder", "Gallery Feeder (WFS)"),
|
||||
}
|
||||
|
||||
for _name, (_mod_path, _cls_name) in _NODE_MODULES.items():
|
||||
for _name, (_mod_path, _cls_name, _display_name) in _NODE_MODULES.items():
|
||||
try:
|
||||
import importlib
|
||||
_mod = importlib.import_module(_mod_path, package=__name__)
|
||||
NODE_CLASS_MAPPINGS[_name] = getattr(_mod, _cls_name)
|
||||
NODE_DISPLAY_NAME_MAPPINGS[_name] = "Prompt Text (WFS)"
|
||||
NODE_DISPLAY_NAME_MAPPINGS[_name] = _display_name
|
||||
except Exception as _e:
|
||||
print(f"[WARNING] Workflow Studio: Failed to load '{_name}': {_e}")
|
||||
|
||||
|
||||
@@ -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: 116 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 975 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
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():
|
||||
@@ -59,3 +68,11 @@ 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"
|
||||
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
|
||||
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
|
||||
|
||||
# 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"
|
||||
@@ -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,71 @@
|
||||
"""Lab tab plan file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import LAB_PLAN_DIR
|
||||
from ..services.lab_plan_service import LabPlanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = LabPlanService(LAB_PLAN_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/lab/plans", handle_list)
|
||||
app.router.add_get("/api/wfm/lab/plans/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/lab/plans/save", handle_save)
|
||||
app.router.add_post("/api/wfm/lab/plans/delete", handle_delete)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing lab plans: %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:
|
||||
data = await asyncio.to_thread(_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting lab plan 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", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab plan: %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_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -27,6 +27,13 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
|
||||
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
|
||||
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
|
||||
app.router.add_get("/api/wfm/models/disabled", handle_get_disabled)
|
||||
app.router.add_post("/api/wfm/models/toggle", handle_toggle_model)
|
||||
app.router.add_post("/api/wfm/models/group-toggle", handle_toggle_group)
|
||||
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
|
||||
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 ─────────────────────────────────────────
|
||||
@@ -61,9 +68,10 @@ async def handle_save_metadata(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def handle_get_groups(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/groups"""
|
||||
"""GET /api/wfm/models/groups?type=checkpoint"""
|
||||
model_type = request.query.get("type", "")
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_model_groups)
|
||||
result = await asyncio.to_thread(_service.get_model_groups, model_type or None)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading model groups: %s", e)
|
||||
@@ -71,10 +79,17 @@ async def handle_get_groups(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def handle_save_groups(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/groups"""
|
||||
"""POST /api/wfm/models/groups
|
||||
|
||||
Body: { "model_type": "checkpoint", "groups": { "groupName": [...] } }
|
||||
"""
|
||||
try:
|
||||
groups = await request.json()
|
||||
result = await asyncio.to_thread(_service.save_model_groups, groups)
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
groups = body.get("groups", {})
|
||||
if not model_type:
|
||||
return web.json_response({"error": "model_type required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.save_model_groups, groups, model_type)
|
||||
return web.json_response({"status": "ok", "groups": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving model groups: %s", e)
|
||||
@@ -138,10 +153,24 @@ async def handle_civitai_fetch(request: web.Request) -> web.Response:
|
||||
"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)
|
||||
@@ -257,6 +286,28 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
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),
|
||||
@@ -264,6 +315,7 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
"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()
|
||||
@@ -295,7 +347,7 @@ async def handle_get_preview(request: web.Request) -> web.Response:
|
||||
return web.FileResponse(
|
||||
preview_path,
|
||||
headers={"Content-Type": content_type or "image/png",
|
||||
"Cache-Control": "public, max-age=3600"},
|
||||
"Cache-Control": "no-cache"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error serving model preview: %s", e)
|
||||
@@ -334,7 +386,7 @@ async def handle_change_preview(request: web.Request) -> web.Response:
|
||||
if image_data is None:
|
||||
return web.json_response({"error": "file required"}, status=400)
|
||||
|
||||
# Resolve model file path
|
||||
# Resolve model file path (also check .disabled variants)
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
model_path = None
|
||||
@@ -343,6 +395,11 @@ async def handle_change_preview(request: web.Request) -> web.Response:
|
||||
if candidate.is_file():
|
||||
model_path = candidate
|
||||
break
|
||||
# disabled モデルも対象にする
|
||||
disabled_candidate = d / (model_name + ".disabled")
|
||||
if disabled_candidate.is_file():
|
||||
model_path = disabled_candidate
|
||||
break
|
||||
|
||||
if not model_path:
|
||||
return web.json_response({"error": "Model file not found"}, status=404)
|
||||
@@ -386,3 +443,197 @@ async def handle_get_filepath(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error resolving model filepath: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Model Enable / Disable ────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_disabled(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/disabled?type=checkpoint
|
||||
|
||||
Returns list of disabled model names (normalized, without .disabled suffix).
|
||||
"""
|
||||
model_type = request.query.get("type", "")
|
||||
if not model_type:
|
||||
return web.json_response({"error": "type required"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.scan_disabled_models, model_type)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error scanning disabled models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_toggle_model(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/toggle
|
||||
|
||||
Body: { "model_type": "checkpoint", "model_name": "v1-5.safetensors", "enabled": false }
|
||||
Renames the model file to add/remove .disabled suffix.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
model_name = body.get("model_name", "")
|
||||
enabled = body.get("enabled", True)
|
||||
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "model_type and model_name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
|
||||
if enabled:
|
||||
await asyncio.to_thread(_service.enable_model, model_type, model_name)
|
||||
else:
|
||||
await asyncio.to_thread(_service.disable_model, model_type, model_name)
|
||||
|
||||
return web.json_response({"status": "ok", "enabled": enabled})
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except OSError as e:
|
||||
logger.error("OS error toggling model: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
logger.error("Error toggling model: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_toggle_group(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/group-toggle
|
||||
|
||||
Body: { "model_type": "checkpoint", "group_name": "MyGroup", "enabled": true }
|
||||
Enables or disables all models belonging to the specified group.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
group_name = body.get("group_name", "")
|
||||
enabled = body.get("enabled", True)
|
||||
|
||||
if not model_type or not group_name:
|
||||
return web.json_response({"error": "model_type and group_name required"}, status=400)
|
||||
|
||||
groups = await asyncio.to_thread(_service.get_model_groups, model_type)
|
||||
members = groups.get(group_name, [])
|
||||
|
||||
ok_list = []
|
||||
error_list = []
|
||||
for model_name in members:
|
||||
if ".." in model_name:
|
||||
continue
|
||||
try:
|
||||
if enabled:
|
||||
await asyncio.to_thread(_service.enable_model, model_type, model_name)
|
||||
else:
|
||||
await asyncio.to_thread(_service.disable_model, model_type, model_name)
|
||||
ok_list.append(model_name)
|
||||
except Exception as e:
|
||||
error_list.append({"model": model_name, "error": str(e)})
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"enabled": enabled,
|
||||
"ok": ok_list,
|
||||
"errors": error_list,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error toggling group: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_models(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/delete
|
||||
|
||||
Body: { "model_type": "checkpoint", "model_names": ["a.safetensors", "b.safetensors"] }
|
||||
Deletes each model file and associated preview/sidecar files, then removes metadata.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("model_type", "")
|
||||
model_names = body.get("model_names", [])
|
||||
|
||||
if not model_type or not isinstance(model_names, list) or not model_names:
|
||||
return web.json_response({"error": "model_type and model_names[] required"}, status=400)
|
||||
|
||||
ok_list = []
|
||||
error_list = []
|
||||
for model_name in model_names:
|
||||
if ".." in str(model_name):
|
||||
error_list.append({"model": model_name, "error": "Invalid model name"})
|
||||
continue
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.delete_model, model_type, model_name)
|
||||
ok_list.append({"model": model_name, "deleted": result["deleted"]})
|
||||
logger.info("Deleted model: %s (%s files)", model_name, len(result["deleted"]))
|
||||
except FileNotFoundError as e:
|
||||
error_list.append({"model": model_name, "error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting model %s: %s", model_name, e)
|
||||
error_list.append({"model": model_name, "error": str(e)})
|
||||
|
||||
return web.json_response({"status": "ok", "ok": ok_list, "errors": error_list})
|
||||
except Exception as e:
|
||||
logger.error("Error in delete_models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,64 @@
|
||||
"""AI skill (.md system prompt) file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import AI_SKILLS_DIR
|
||||
from ..services.skill_service import SkillService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = SkillService(AI_SKILLS_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/skills", handle_list)
|
||||
app.router.add_get("/api/wfm/skills/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/skills/save", handle_save)
|
||||
app.router.add_post("/api/wfm/skills/delete", handle_delete)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_skills)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing skills: %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 skill 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 skill: %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 skill: %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)
|
||||
@@ -3,6 +3,8 @@
|
||||
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
|
||||
@@ -11,9 +13,48 @@ 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."""
|
||||
@@ -21,6 +62,8 @@ class CivitaiService:
|
||||
def __init__(self):
|
||||
self._cache = None
|
||||
|
||||
# ── キャッシュ管理 ────────────────────────────────────────
|
||||
|
||||
def _load_cache(self):
|
||||
if self._cache is not None:
|
||||
return self._cache
|
||||
@@ -42,12 +85,37 @@ class CivitaiService:
|
||||
def get_cached(self, sha256_hash):
|
||||
"""Return cached CivitAI data for a given hash, or None."""
|
||||
cache = self._load_cache()
|
||||
return cache.get(sha256_hash)
|
||||
return cache.get(sha256_hash.lower())
|
||||
|
||||
def get_all_cached(self):
|
||||
"""Return the full cache dict."""
|
||||
return self._load_cache()
|
||||
|
||||
# ── APIキー / ヘッダー ────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _get_api_key():
|
||||
"""Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over settings.json."""
|
||||
import os
|
||||
env_key = os.environ.get("CIVITAI_API_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
try:
|
||||
from ..services.settings_service import SettingsService
|
||||
return SettingsService().load().get("civitai_api_key", "").strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_headers(self):
|
||||
"""Build request headers, optionally including Bearer token."""
|
||||
headers = {"User-Agent": "ComfyUI-Workflow-Studio/1.0"}
|
||||
api_key = self._get_api_key()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
# ── ハッシュ計算 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def calculate_sha256(file_path, chunk_size=65536):
|
||||
"""Calculate SHA256 hash of a file."""
|
||||
@@ -61,43 +129,130 @@ class CivitaiService:
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
return h.hexdigest() # 小文字16進数
|
||||
|
||||
# ── 単体フェッチ (GET) ────────────────────────────────────
|
||||
|
||||
def fetch_by_hash(self, sha256_hash):
|
||||
"""Fetch model version info from CivitAI by SHA256 hash.
|
||||
"""Fetch model version info from CivitAI by SHA256 hash (GET).
|
||||
|
||||
429/5xx は指数バックオフでリトライ。
|
||||
Returns dict with model info, or None if not found.
|
||||
Caches successful results.
|
||||
"""
|
||||
# Check cache first
|
||||
sha256_lower = sha256_hash.lower()
|
||||
cache = self._load_cache()
|
||||
if sha256_hash in cache:
|
||||
return cache[sha256_hash]
|
||||
if sha256_lower in cache:
|
||||
return cache[sha256_lower]
|
||||
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash}"
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash.upper()}"
|
||||
|
||||
if not data or "id" not in data:
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
try:
|
||||
req = Request(url, headers=self._build_headers())
|
||||
with urlopen(req, timeout=15, context=_get_ssl_context()) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if not data or "id" not in data:
|
||||
return None
|
||||
|
||||
info = self._extract_info(data)
|
||||
cache[sha256_lower] = info
|
||||
self._save_cache()
|
||||
return info
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 404:
|
||||
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
|
||||
return None
|
||||
if e.code in _RETRY_CODES and attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI %s: retrying in %ss (attempt %d/%d)",
|
||||
e.code, wait, attempt + 1, _MAX_RETRIES)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
|
||||
return None
|
||||
except (URLError, Exception) as e:
|
||||
if attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI request failed: %s — retrying in %ss", e, wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI request failed: %s", e)
|
||||
return None
|
||||
|
||||
# Extract useful fields
|
||||
info = self._extract_info(data)
|
||||
cache[sha256_hash] = info
|
||||
self._save_cache()
|
||||
return info
|
||||
return None
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 404:
|
||||
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
|
||||
return None
|
||||
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
|
||||
return None
|
||||
except (URLError, Exception) as e:
|
||||
logger.warning("CivitAI request failed: %s", e)
|
||||
return None
|
||||
# ── 一括フェッチ (POST) ───────────────────────────────────
|
||||
|
||||
def _batch_fetch_post(self, sha256_hashes):
|
||||
"""POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。
|
||||
|
||||
レスポンスの files[].hashes.SHA256 でリクエストのハッシュと照合する。
|
||||
Returns: { sha256_lower: info_or_none }
|
||||
"""
|
||||
if not sha256_hashes:
|
||||
return {}
|
||||
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash"
|
||||
results = {h.lower(): None for h in sha256_hashes}
|
||||
cache = self._load_cache()
|
||||
|
||||
for chunk_start in range(0, len(sha256_hashes), _BATCH_CHUNK_SIZE):
|
||||
chunk = sha256_hashes[chunk_start:chunk_start + _BATCH_CHUNK_SIZE]
|
||||
chunk_lower = {h.lower() for h in chunk}
|
||||
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
try:
|
||||
body = json.dumps([h.upper() for h in chunk]).encode("utf-8")
|
||||
req = Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={**self._build_headers(), "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=30, context=_get_ssl_context()) as resp:
|
||||
versions = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
# 各バージョンを files[].hashes.SHA256 でリクエストのハッシュと照合
|
||||
for version_data in versions:
|
||||
info = self._extract_info(version_data)
|
||||
matched = False
|
||||
for f in version_data.get("files", []):
|
||||
file_sha256 = f.get("hashes", {}).get("SHA256", "").lower()
|
||||
if file_sha256 in chunk_lower:
|
||||
cache[file_sha256] = info
|
||||
results[file_sha256] = info
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
# files にハッシュがない場合は versionId で照合を試みる
|
||||
logger.debug("CivitAI: could not match version %s to a requested hash",
|
||||
version_data.get("id"))
|
||||
break # チャンク成功
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code in _RETRY_CODES and attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI batch POST %s: retrying in %ss", e.code, wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI batch POST error: %s %s", e.code, e.reason)
|
||||
break
|
||||
except (URLError, Exception) as e:
|
||||
if attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("CivitAI batch POST failed: %s — retrying in %ss", e, wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI batch POST failed: %s", e)
|
||||
break
|
||||
|
||||
self._save_cache()
|
||||
return results
|
||||
|
||||
# ── 情報抽出 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_info(data):
|
||||
@@ -106,101 +261,170 @@ class CivitaiService:
|
||||
images = data.get("images", [])
|
||||
files = data.get("files", [])
|
||||
|
||||
# Get primary file info
|
||||
primary_file = None
|
||||
for f in files:
|
||||
if f.get("primary"):
|
||||
primary_file = f
|
||||
break
|
||||
# プライマリファイルを特定
|
||||
primary_file = next((f for f in files if f.get("primary")), None)
|
||||
if not primary_file and files:
|
||||
primary_file = files[0]
|
||||
|
||||
# Get image URLs (first 5)
|
||||
image_urls = []
|
||||
# 画像情報(URL・寸法・NSFWレベル)を最大5件取得
|
||||
image_list = []
|
||||
for img in images[:5]:
|
||||
url = img.get("url", "")
|
||||
if url:
|
||||
# Optimize for thumbnail
|
||||
optimized = url.replace("/original=true", "/width=450,optimized=true")
|
||||
image_urls.append(optimized)
|
||||
img_url = img.get("url", "")
|
||||
if not img_url:
|
||||
continue
|
||||
image_list.append({
|
||||
"url": img_url,
|
||||
"width": img.get("width"),
|
||||
"height": img.get("height"),
|
||||
"nsfwLevel": img.get("nsfwLevel", 0),
|
||||
})
|
||||
|
||||
# プライマリファイルのメタ情報(精度・フォーマット)
|
||||
file_meta = {}
|
||||
file_hashes = {}
|
||||
if primary_file:
|
||||
pm = primary_file.get("metadata", {})
|
||||
file_meta = {
|
||||
"fp": pm.get("fp"),
|
||||
"size": pm.get("size"),
|
||||
"format": pm.get("format"),
|
||||
}
|
||||
# BLAKE3, SHA256, AutoV2 等のハッシュ
|
||||
file_hashes = primary_file.get("hashes", {})
|
||||
|
||||
stats = data.get("stats", {})
|
||||
|
||||
# バッチ POST API は model オブジェクト内に id を含まない場合がある
|
||||
# トップレベルの modelId をフォールバックとして使用する
|
||||
model_id = model.get("id") or data.get("modelId")
|
||||
version_id = data.get("id", "")
|
||||
|
||||
return {
|
||||
"versionId": data.get("id"),
|
||||
"modelId": model.get("id"),
|
||||
"versionId": version_id,
|
||||
"modelId": model_id,
|
||||
"modelName": model.get("name", ""),
|
||||
"versionName": data.get("name", ""),
|
||||
"type": model.get("type", ""),
|
||||
"description": data.get("description") or model.get("description", ""),
|
||||
"tags": model.get("tags", []),
|
||||
"nsfw": model.get("nsfw", False),
|
||||
"nsfwLevel": data.get("nsfwLevel", 0),
|
||||
"air": data.get("air", ""),
|
||||
"creator": data.get("creator", {}).get("username", ""),
|
||||
"images": image_urls,
|
||||
# 後方互換のため URLリストを維持しつつ詳細情報も保存
|
||||
"images": [img["url"] for img in image_list],
|
||||
"imageDetails": image_list,
|
||||
"trainedWords": data.get("trainedWords", []),
|
||||
"baseModel": data.get("baseModel", ""),
|
||||
"fileSize": primary_file.get("sizeKB", 0) if primary_file else 0,
|
||||
"fileMeta": file_meta,
|
||||
"fileHashes": file_hashes,
|
||||
"downloadUrl": data.get("downloadUrl", ""),
|
||||
"modelUrl": f"https://civitai.com/models/{model.get('id', '')}?modelVersionId={data.get('id', '')}",
|
||||
"modelUrl": (
|
||||
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
|
||||
if model_id else (
|
||||
f"https://civitai.com/model-versions/{version_id}"
|
||||
if version_id else ""
|
||||
)
|
||||
),
|
||||
"stats": {
|
||||
"downloadCount": stats.get("downloadCount", 0),
|
||||
"thumbsUpCount": stats.get("thumbsUpCount", 0),
|
||||
"thumbsDownCount": stats.get("thumbsDownCount", 0),
|
||||
},
|
||||
"updatedAt": data.get("updatedAt", ""),
|
||||
"publishedAt": data.get("publishedAt", ""),
|
||||
}
|
||||
|
||||
# ── バッチフェッチ ────────────────────────────────────────
|
||||
|
||||
def batch_fetch(self, model_files, progress_callback=None):
|
||||
"""Batch fetch CivitAI info for multiple model files.
|
||||
|
||||
Phase 1: SHA256 計算("hashing")
|
||||
Phase 2: POST で一括取得("fetching")— キャッシュ済みはスキップ
|
||||
|
||||
Args:
|
||||
model_files: list of (model_name, file_path) tuples
|
||||
progress_callback: fn(current, total, model_name, status) called per model
|
||||
|
||||
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
|
||||
"""
|
||||
import time
|
||||
|
||||
results = {}
|
||||
total = len(model_files)
|
||||
cache = self._load_cache()
|
||||
|
||||
# Phase 1: ハッシュ計算
|
||||
hashes_needed = [] # [(model_name, sha256_lower, original_index)]
|
||||
|
||||
for i, (model_name, file_path) in enumerate(model_files):
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "hashing")
|
||||
|
||||
# Calculate hash
|
||||
sha256 = self.calculate_sha256(file_path)
|
||||
if not sha256:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
continue
|
||||
|
||||
results[model_name] = {"sha256": sha256, "civitai": None}
|
||||
|
||||
# Check cache
|
||||
if sha256 in cache:
|
||||
results[model_name]["civitai"] = cache[sha256]
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "cached")
|
||||
progress_callback(i + 1, total, model_name, "not_found")
|
||||
continue
|
||||
|
||||
# Fetch from API
|
||||
sha256_lower = sha256.lower()
|
||||
results[model_name] = {"sha256": sha256_lower, "civitai": None}
|
||||
|
||||
if sha256_lower in cache:
|
||||
results[model_name]["civitai"] = cache[sha256_lower]
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total, model_name, "cached")
|
||||
else:
|
||||
hashes_needed.append((model_name, sha256_lower, i))
|
||||
|
||||
# Phase 2: POST で一括取得(未キャッシュ分)
|
||||
if hashes_needed:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "fetching")
|
||||
progress_callback(len(results), total, "", "fetching")
|
||||
|
||||
info = self.fetch_by_hash(sha256)
|
||||
if info:
|
||||
results[model_name]["civitai"] = info
|
||||
# 同一ハッシュが複数モデルに対応する場合を考慮
|
||||
sha256_to_entries: dict[str, list] = {}
|
||||
for name, sha256_lower, idx in hashes_needed:
|
||||
sha256_to_entries.setdefault(sha256_lower, []).append((name, idx))
|
||||
|
||||
if progress_callback:
|
||||
status = "found" if info else "not_found"
|
||||
progress_callback(i, total, model_name, status)
|
||||
batch_results = self._batch_fetch_post(list(sha256_to_entries.keys()))
|
||||
|
||||
# Rate limit: small delay between API calls
|
||||
time.sleep(0.5)
|
||||
for sha256_lower, info in batch_results.items():
|
||||
for name, idx in sha256_to_entries.get(sha256_lower, []):
|
||||
results[name]["civitai"] = info
|
||||
if progress_callback:
|
||||
status = "found" if info else "not_found"
|
||||
progress_callback(idx + 1, total, name, status)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "", "done")
|
||||
|
||||
return results
|
||||
|
||||
# ── 画像ダウンロード ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def download_image(url, save_path, timeout=15):
|
||||
"""Download an image from URL and save to save_path. Returns True on success."""
|
||||
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, None)
|
||||
cache.pop(sha256_hash.lower(), None)
|
||||
else:
|
||||
cache.clear()
|
||||
self._save_cache()
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
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}
|
||||
]
|
||||
}
|
||||
|
||||
self._data(特に "images" 辞書)は、バックグラウンドのフォルダインデックススレッド
|
||||
(gallery_service.pyの_bg_index_folder)と、リクエストハンドラを処理するメインスレッドの
|
||||
双方から並行に読み書きされる。ロックなしで辞書を反復(.items()/.values()/for key in ...や
|
||||
json.dumpによるシリアライズ)している最中に別スレッドがキーを追加/削除すると
|
||||
"dictionary changed size during iteration" が発生するため、_data へアクセスする
|
||||
全メソッドを self._lock で保護する(re-entrant: ensure_group()がcreate_group()を
|
||||
内部呼び出しするため、通常のLockではなくRLockを使う)。
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._lock = threading.RLock()
|
||||
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):
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド
|
||||
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)
|
||||
with self._lock:
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
with self._lock:
|
||||
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]:
|
||||
with self._lock:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
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フィールドも更新する"""
|
||||
with self._lock:
|
||||
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:
|
||||
with self._lock:
|
||||
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]:
|
||||
with self._lock:
|
||||
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回の保存で完結)"""
|
||||
with self._lock:
|
||||
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で返す(高速フィルタ用)"""
|
||||
with self._lock:
|
||||
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:
|
||||
"""グループ内の全画像を除外する(グループ自体は残す)"""
|
||||
with self._lock:
|
||||
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:
|
||||
"""グループが存在しない場合のみ作成する"""
|
||||
with self._lock:
|
||||
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)
|
||||
with self._lock:
|
||||
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)
|
||||
with self._lock:
|
||||
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 += "/"
|
||||
with self._lock:
|
||||
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,121 @@
|
||||
"""Lab tab plan file management service (JSON plan + optional PNG index thumbnail)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable lab plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/lab_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"batch_count": data.get("batch_count", 1),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def delete_plan(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()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -9,10 +11,23 @@ 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",
|
||||
@@ -59,6 +74,7 @@ class ModelsService:
|
||||
|
||||
def __init__(self):
|
||||
self.metadata_file = MODEL_METADATA_FILE
|
||||
self._scan_cache: dict = {} # model_type -> (timestamp, frozenset)
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -100,15 +116,180 @@ class ModelsService:
|
||||
self._save_metadata(data)
|
||||
return entry
|
||||
|
||||
def get_model_groups(self):
|
||||
data = self._load_metadata()
|
||||
return data.get("_groups", {})
|
||||
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
|
||||
|
||||
def save_model_groups(self, groups):
|
||||
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()
|
||||
data["_groups"] = groups
|
||||
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)
|
||||
return groups
|
||||
# Also invalidate scan cache so next get_model_groups sees the fresh data
|
||||
self._scan_cache.pop(model_type, None)
|
||||
return normalized
|
||||
|
||||
def find_model_file(self, model_type, model_name):
|
||||
"""Find a model file, checking both enabled and disabled states.
|
||||
|
||||
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.
|
||||
@@ -138,7 +319,7 @@ class ModelsService:
|
||||
|
||||
for ext in _PREVIEW_EXTENSIONS:
|
||||
preview = parent / (stem + ext)
|
||||
if preview.is_file():
|
||||
if preview.is_file() and preview.stat().st_size >= 100:
|
||||
logger.debug("Preview found: %s", preview)
|
||||
return preview
|
||||
|
||||
@@ -148,3 +329,189 @@ class ModelsService:
|
||||
|
||||
logger.debug("Preview: model file not found in any dir: %s", model_name)
|
||||
return None
|
||||
|
||||
def delete_model(self, model_type, model_name):
|
||||
"""Delete a model file and all associated preview/sidecar files.
|
||||
|
||||
Also removes the model's metadata entry.
|
||||
Returns dict with deleted file paths.
|
||||
"""
|
||||
if ".." in model_name:
|
||||
raise ValueError(f"Invalid model name: {model_name}")
|
||||
|
||||
path, _ = self.find_model_file(model_type, model_name)
|
||||
if path is None:
|
||||
raise FileNotFoundError(f"Model not found: {model_name}")
|
||||
|
||||
deleted = []
|
||||
parent = path.parent
|
||||
|
||||
# Compute stem from the original (non-disabled) filename.
|
||||
# path.name for a disabled file is e.g. "model.safetensors.disabled"
|
||||
# so we strip the .disabled suffix first, then take Path.stem.
|
||||
orig_name = path.name
|
||||
if orig_name.endswith(_DISABLED_SUFFIX):
|
||||
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
|
||||
stem = Path(orig_name).stem # "model.safetensors" -> "model"
|
||||
|
||||
# Delete the model file itself
|
||||
path.unlink()
|
||||
deleted.append(str(path))
|
||||
logger.info("Deleted model file: %s", path)
|
||||
|
||||
# Delete all sidecar files (previews, metadata, info)
|
||||
for ext in _SIDECAR_EXTENSIONS:
|
||||
sidecar = parent / (stem + ext)
|
||||
if sidecar.is_file():
|
||||
sidecar.unlink()
|
||||
deleted.append(str(sidecar))
|
||||
logger.info("Deleted sidecar: %s", sidecar)
|
||||
|
||||
# Remove metadata entry
|
||||
data = self._load_metadata()
|
||||
if model_name in data:
|
||||
del data[model_name]
|
||||
self._save_metadata(data)
|
||||
|
||||
return {"deleted": deleted}
|
||||
|
||||
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,228 @@
|
||||
"""AI skill (.md system prompt) file management service."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_EXTS = {"md"}
|
||||
|
||||
DEFAULT_SKILL_FILENAME = "svg-icon.md"
|
||||
|
||||
DEFAULT_SKILL_CONTENT = """---
|
||||
name: SVG Icon Generator
|
||||
description: Produces clean, minimal, valid SVG icons and simple illustrations.
|
||||
---
|
||||
|
||||
You are an expert SVG icon and illustration designer. When the user asks for an icon, logo, pictogram, diagram, character, animal, or any vector graphic, respond with valid, clean SVG markup.
|
||||
|
||||
## Composition process (do this before writing markup - skipping this step is the main cause of unrecognizable output)
|
||||
- Break the subject down into 3-8 distinct parts that make it recognizable (e.g. a bird: body, head, beak, eye, wing, tail; a house: wall, roof, door, window). Drop parts that are not essential.
|
||||
- Decide each part's approximate position, size, and draw order within the viewBox (elements listed later in the markup are drawn on top).
|
||||
- Pick the simplest shape that captures each part's silhouette: `<circle>`/`<ellipse>` for round parts, `<rect>` for rectangular parts, `<polygon>` for angular parts (beaks, ears, leaves, roofs). For a rounded organic outline that no primitive can approximate well (a body silhouette, a wing, a leaf edge), use a short `<path>` with 2-4 smooth curve commands (`Q` or `C`) instead of a plain circle - a few deliberate curve points beat a distorted primitive.
|
||||
- Never fall back to drawing an unrelated blob plus a few disconnected decorative lines when a subject is hard to render - always decompose it into the named parts above instead, even if each part is crude.
|
||||
|
||||
## Output rules
|
||||
- Always wrap the SVG markup in a fenced code block labeled `svg`, e.g. ```svg ... ```.
|
||||
- Keep any explanation brief and place it OUTSIDE the code block, never mixed inside the SVG.
|
||||
- Output exactly one `<svg>...</svg>` element per code block.
|
||||
|
||||
## Structure rules
|
||||
- Always include a `viewBox` attribute (e.g. `viewBox="0 0 24 24"`). Do not hardcode large pixel `width`/`height`; if you set them, make them match the viewBox aspect ratio.
|
||||
- Use a small, round coordinate system (0-24 or 0-100).
|
||||
- Round coordinate values to at most 2 decimal places.
|
||||
- Keep every `<path>` short (at most about 6 commands) - long, intricate paths are where quality breaks down most often.
|
||||
- Group related elements with `<g>` when it clarifies structure, but do not over-nest.
|
||||
|
||||
## Color rules
|
||||
- For single-color icons, use `fill="currentColor"` (or `stroke="currentColor"` for line icons) so the icon inherits the surrounding text color.
|
||||
- For multi-color illustrations, use explicit hex colors (e.g. `#4A90D9`) - never named CSS colors like `"blue"`.
|
||||
- Avoid gradients, filters, and blend modes unless the user explicitly asks for a glossy/gradient style - they rarely render correctly from generated coordinates.
|
||||
|
||||
## Accessibility
|
||||
- Include a `<title>` element as the first child of `<svg>` describing what the icon depicts.
|
||||
|
||||
## Security (mandatory - do not violate)
|
||||
- Never include `<script>`, `<foreignObject>`, event handler attributes (`onload`, `onclick`, etc.), or `<a>`/`href` links to external URLs.
|
||||
- Never reference external resources (no `xlink:href` to remote URLs, no `<image>` with external `src`).
|
||||
|
||||
## Example 1 - simple geometric icon (single color, primitives only)
|
||||
|
||||
Request: "Sun icon"
|
||||
|
||||
Parts: disc (circle), 8 rays (lines).
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Sun</title>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="12" y1="1" x2="12" y2="4"/>
|
||||
<line x1="12" y1="20" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="4" y2="12"/>
|
||||
<line x1="20" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
|
||||
</g>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Example 2 - organic subject (multi-color, decomposed into parts)
|
||||
|
||||
Request: "Bird icon"
|
||||
|
||||
Parts: body (rounded outline via a short curved path), head (circle), beak (small triangle), eye (dot), wing (small curved path) - each drawn as its own element, layered head/beak/eye on top of the body.
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Bird</title>
|
||||
<path d="M4 16 Q4 10 11 9 Q18 8 20 13 Q20 17 14 18 Q7 19 4 16 Z" fill="#4A90D9"/>
|
||||
<path d="M8 14 Q5 12 3 14 Q6 16 9 16 Z" fill="#3576B8"/>
|
||||
<circle cx="17" cy="10" r="3.2" fill="#4A90D9"/>
|
||||
<polygon points="20,10 23,9.3 20,11.4" fill="#F2B807"/>
|
||||
<circle cx="18" cy="9" r="0.6" fill="#1A1A1A"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Follow this pattern for every request: list the parts first, represent each with the simplest adequate shape, then assemble - never skip straight to unstructured shapes.
|
||||
"""
|
||||
|
||||
SKILL_CREATOR_FILENAME = "skill-creator.md"
|
||||
|
||||
SKILL_CREATOR_CONTENT = """---
|
||||
name: Skill Creator
|
||||
description: Interactively interviews you and drafts a new WFS chat skill (.md system prompt) ready to save.
|
||||
---
|
||||
|
||||
You are a skill-design assistant embedded in ComfyUI Workflow Studio's AI TOOL Chat pane. Your job is to interactively help the user design a NEW "skill" - a markdown file with frontmatter (`name`, `description`) followed by system-prompt instructions. Once saved, a skill can be selected from a dropdown and is silently prepended to future chat messages as a system prompt.
|
||||
|
||||
## How to conduct the interview
|
||||
- Ask ONE focused question at a time, never a bulleted list of questions. Wait for the user's answer before asking the next one.
|
||||
- Cover, in this order, only what is not already clear from context:
|
||||
1. What task or domain should the skill specialize in (what should the model do differently once this skill is active)?
|
||||
2. What output format is expected - plain prose, a specific fenced code block language, a fixed structure, a length limit?
|
||||
3. Any hard constraints or things to avoid (tone, forbidden content, security concerns, things past attempts got wrong)?
|
||||
4. Would a short worked example help smaller/local models follow the pattern more reliably? If yes, ask what a good example input/output looks like, or offer to draft one.
|
||||
- Keep it short - 3 to 5 questions is usually enough. If the user says "just generate it", "that's enough", or similar at any point, stop asking and produce the best skill you can from what you already have.
|
||||
|
||||
## Output format for the finished skill
|
||||
- Once you have enough information, output the complete skill file in a single fenced code block labeled `skill` (```skill ... ```). Never mix prose inside that block - explanation goes outside it.
|
||||
- The block must start with frontmatter exactly in this shape:
|
||||
---
|
||||
name: <short display name, shown in the skill dropdown>
|
||||
description: <one sentence, shown as a tooltip in the dropdown>
|
||||
---
|
||||
- After the frontmatter, write clear, imperative system-prompt instructions for the target skill: what role the model should take, concrete output-format rules, hard constraints, and - if useful - one short worked example following a "request -> ideal output" shape.
|
||||
- After the code block, tell the user in one line that they can click the "Save as new skill" button below the message to add it, or copy the block manually into a new skill.
|
||||
"""
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
"""Extract a shallow `key: value` frontmatter block, if present."""
|
||||
m = _FRONTMATTER_RE.match(content)
|
||||
if not m:
|
||||
return {}
|
||||
fields: dict = {}
|
||||
for line in m.group(1).splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key:
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
class SkillService:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills_dir = skills_dir
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_default_skill()
|
||||
|
||||
def _seed_default_skill(self) -> None:
|
||||
"""Seed a starter skill file the first time the directory is empty."""
|
||||
try:
|
||||
has_any = any(
|
||||
p.is_file() and p.suffix.lstrip(".").lower() in ALLOWED_EXTS
|
||||
for p in self.skills_dir.iterdir()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
has_any = False
|
||||
if not has_any:
|
||||
(self.skills_dir / DEFAULT_SKILL_FILENAME).write_text(
|
||||
DEFAULT_SKILL_CONTENT, encoding="utf-8"
|
||||
)
|
||||
(self.skills_dir / SKILL_CREATOR_FILENAME).write_text(
|
||||
SKILL_CREATOR_CONTENT, encoding="utf-8"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(self) -> list[dict]:
|
||||
"""Return sorted list of skill files with parsed frontmatter metadata."""
|
||||
if not self.skills_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for path in sorted(self.skills_dir.glob("*")):
|
||||
if not (path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS):
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
content = ""
|
||||
meta = _parse_frontmatter(content)
|
||||
files.append({
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
})
|
||||
return files
|
||||
|
||||
def get_content(self, filename: str) -> str:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def save_file(self, filename: str, content: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
meta = _parse_frontmatter(content)
|
||||
return {
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
|
||||
def delete_file(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
|
||||
def _safe_path(self, filename: str) -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
# Skills are a flat directory - reject any path separator (no subfolders).
|
||||
name = filename.strip()
|
||||
if "/" in name or "\\" in name or name in (".", ".."):
|
||||
return None
|
||||
ext = Path(name).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.skills_dir / name
|
||||
@@ -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,6 +3,35 @@
|
||||
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)."""
|
||||
@@ -11,16 +40,65 @@ def _model_name_from_ui_node(node):
|
||||
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()
|
||||
@@ -36,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 = []
|
||||
@@ -62,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")
|
||||
@@ -98,26 +185,34 @@ 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: UI-based structure with definitions (subgraphs) or linearMode
|
||||
# App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
# A workflow that merely *contains* subgraphs (definitions.subgraphs) is still a
|
||||
# normal node graph (comfyui-workflow.js's convertUiToApi flattens subgraphs before
|
||||
# conversion), so it's treated as "ui" here too — must match detectFormat() in
|
||||
# static/js/comfyui-workflow.js or the App/UI badge shown to the user disagrees
|
||||
# with what GenerateUI actually does with the file.
|
||||
extra = workflow_data.get("extra", {})
|
||||
if (
|
||||
"definitions" in workflow_data
|
||||
or extra.get("linearMode") is True
|
||||
):
|
||||
if extra.get("linearMode") is True:
|
||||
wf_format = "app"
|
||||
elif filename and filename.lower().endswith(".app.json"):
|
||||
wf_format = "app"
|
||||
|
||||
@@ -112,6 +112,7 @@ class WorkflowService:
|
||||
"summary": meta.get("summary", ""),
|
||||
"modelTypesOverride": override_types,
|
||||
"favorite": bool(meta.get("favorite", False)),
|
||||
"badges": meta.get("badges", []),
|
||||
},
|
||||
"mtime": stat.st_mtime,
|
||||
"thumbnail": thumbnail,
|
||||
@@ -132,7 +133,7 @@ class WorkflowService:
|
||||
"""Save metadata (tags, memo, summary, etc.) for a workflow."""
|
||||
metadata = self._load_metadata()
|
||||
entry = metadata.get(filename, {})
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite"):
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges"):
|
||||
if key in updates:
|
||||
entry[key] = updates[key]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
|
||||
@@ -4,7 +4,7 @@ import jinja2
|
||||
from aiohttp import web
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +41,22 @@ async def serve_workflow_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Lab plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = LAB_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowStudio:
|
||||
"""Main entry point for Workflow Studio plugin."""
|
||||
|
||||
@@ -57,11 +73,14 @@ class WorkflowStudio:
|
||||
# Dynamic workflow file serving (thumbnails etc.)
|
||||
app.router.add_get("/wfm_data/workflows/{filename}", serve_workflow_file)
|
||||
|
||||
# Dynamic Lab plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
|
||||
|
||||
# Main page
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
@@ -70,5 +89,11 @@ class WorkflowStudio:
|
||||
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)
|
||||
skill_routes.setup_routes(app)
|
||||
lab_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.87"
|
||||
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,285 @@
|
||||
/* Lab tab — experimental I2I batch generation with per-column keyframes */
|
||||
|
||||
/* .wfm-gen-subtab-content.active defaults to flex-direction:row (main.css);
|
||||
Lab needs its Setting/Results nav stacked above the panel body. */
|
||||
#wfm-gen-subtab-lab.active {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-lab-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: calc(100% - 33px);
|
||||
}
|
||||
|
||||
.wfm-lab-panel.active { display: flex; }
|
||||
|
||||
.wfm-lab-pane-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 4px 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------------- Setting layout: left | center (4 columns) | right ---------------- */
|
||||
|
||||
.wfm-lab-setting-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-pane:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-left { width: 220px; flex-shrink: 0; }
|
||||
.wfm-lab-center { flex: 1; padding: 0; }
|
||||
.wfm-lab-right { width: 260px; flex-shrink: 0; overflow-y: auto; }
|
||||
|
||||
.wfm-lab-side-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-note {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------- Keyframe columns ---------------- */
|
||||
|
||||
.wfm-lab-columns {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-col-btns { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-col-cells {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell {
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-cell:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-cell-index {
|
||||
font-weight: 600;
|
||||
color: var(--wfm-primary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-value {
|
||||
color: var(--wfm-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-revert .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-applied {
|
||||
margin-top: 3px;
|
||||
padding-top: 3px;
|
||||
border-top: 1px dashed var(--wfm-border);
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-cell-prompt-line {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- Modal form (checkpoint/vae/prompt/ksampler edit) ---------------- */
|
||||
|
||||
.wfm-lab-modal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------------- Results layout ---------------- */
|
||||
|
||||
.wfm-lab-results-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-results-left {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-results-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-lab-results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
border: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-result-item { position: relative; }
|
||||
|
||||
.wfm-lab-result-index {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { initI18n, t } from "./i18n.js";
|
||||
import { getSettings } from "./util.js";
|
||||
|
||||
// Initialize i18n before anything else
|
||||
initI18n();
|
||||
@@ -90,7 +91,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -188,6 +189,39 @@ function applyI18nToHtml() {
|
||||
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
|
||||
});
|
||||
|
||||
// Gallery tab
|
||||
const gallerySideTabBtns = document.querySelectorAll(".wfm-gallery-detail-tab-btn");
|
||||
gallerySideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
|
||||
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
|
||||
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
});
|
||||
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");
|
||||
@@ -216,6 +250,90 @@ function applyI18nToHtml() {
|
||||
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");
|
||||
const aiSkillSel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
if (aiSkillSel) {
|
||||
const firstOpt = aiSkillSel.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = `-- ${t("aiSkillNone")} --`;
|
||||
}
|
||||
const aiSkillManageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
|
||||
if (aiSkillManageBtn) aiSkillManageBtn.title = t("aiSkillManageBtnTitle");
|
||||
const aiSkillPanelTitle = document.querySelector(".wfm-ai-skill-panel-title");
|
||||
if (aiSkillPanelTitle) aiSkillPanelTitle.textContent = t("aiSkillPanelTitle");
|
||||
const aiSkillNewBtn = document.getElementById("wfm-ai-skill-new-btn");
|
||||
if (aiSkillNewBtn) aiSkillNewBtn.textContent = t("aiSkillNewBtn");
|
||||
const aiSkillContentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
if (aiSkillContentTA) aiSkillContentTA.placeholder = t("aiSkillContentPlaceholder");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
if (propTitle) propTitle.textContent = t("properties");
|
||||
@@ -257,45 +375,268 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
|
||||
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
|
||||
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
|
||||
"wfm-help-gen-11": "helpGen11", "wfm-help-gen-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-lab-title": "helpLabTitle",
|
||||
"wfm-help-lab-desc": "helpLabDesc",
|
||||
"wfm-help-lab-card-columns-title": "helpLabCardColumnsTitle",
|
||||
"wfm-help-lab-1": "helpLab1", "wfm-help-lab-2": "helpLab2",
|
||||
"wfm-help-lab-3": "helpLab3", "wfm-help-lab-4": "helpLab4",
|
||||
"wfm-help-lab-5": "helpLab5", "wfm-help-lab-6": "helpLab6",
|
||||
"wfm-help-lab-7": "helpLab7",
|
||||
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
|
||||
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
|
||||
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
|
||||
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
|
||||
"wfm-help-lab-plan-7": "helpLabPlan7",
|
||||
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
|
||||
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
|
||||
"wfm-help-lab-results-3": "helpLabResults3",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
"wfm-help-prompt-7": "helpPrompt7",
|
||||
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
|
||||
"wfm-help-prompt-9": "helpPrompt9",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-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-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-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-6": "helpAi6",
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"wfm-help-ai-8": "helpAi8",
|
||||
"wfm-help-ai-9": "helpAi9",
|
||||
"wfm-help-ai-card-layout-title": "helpAiCardLayout",
|
||||
"wfm-help-ai-card-translate-title": "helpAiCardTranslate",
|
||||
"wfm-help-ai-card-chat-title": "helpAiCardChat",
|
||||
"wfm-help-ai-card-svg-title": "helpAiCardSvg",
|
||||
"wfm-help-ai-card-skills-title": "helpAiCardSkills",
|
||||
"wfm-help-ai-card-skilltips-title": "helpAiCardSkillTips",
|
||||
"wfm-help-ai-card-tools-title": "helpAiCardTools",
|
||||
"wfm-help-ai-card-settings-title": "helpAiCardSettings",
|
||||
"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-trouble-9": "helpTrouble9",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
"wfm-help-kofi-desc": "helpKofiDesc",
|
||||
"wfm-help-thanks": "helpThanks",
|
||||
// -- Help card headings (card-based redesign) --
|
||||
"wfm-help-about-card-getting-started-title": "helpAboutCardGettingStarted",
|
||||
"wfm-help-wf-title": "helpWfTitle",
|
||||
"wfm-help-wf-card-browse-title": "helpWfCardBrowse",
|
||||
"wfm-help-wf-card-groups-title": "helpWfCardGroups",
|
||||
"wfm-help-wf-card-panel-title": "helpWfCardPanel",
|
||||
"wfm-help-gen-title": "helpGenTitle",
|
||||
"wfm-help-gen-card-connect-title": "helpGenCardConnect",
|
||||
"wfm-help-gen-card-tabs-title": "helpGenCardTabs",
|
||||
"wfm-help-gen-card-seed-title": "helpGenCardSeed",
|
||||
"wfm-help-gen-card-batchsetup-title": "helpGenCardBatchSetup",
|
||||
"wfm-help-gen-card-batchtypes-title": "helpGenCardBatchTypes",
|
||||
"wfm-help-prompt-title": "helpPromptTitle",
|
||||
"wfm-help-prompt-card-ai-title": "helpPromptCardAi",
|
||||
"wfm-help-prompt-card-presets-title": "helpPromptCardPresets",
|
||||
"wfm-help-prompt-card-wildcards-title": "helpPromptCardWildcards",
|
||||
"wfm-help-metadata-card-overview-title": "helpMetadataCardOverview",
|
||||
"wfm-help-metadata-card-extracted-title": "helpMetadataCardExtracted",
|
||||
"wfm-help-metadata-card-formats-title": "helpMetadataCardFormats",
|
||||
"wfm-help-settings-title": "helpSettingsTitle",
|
||||
"wfm-help-settings-card-general-title": "helpSettingsCardGeneral",
|
||||
"wfm-help-settings-card-connections-title": "helpSettingsCardConnections",
|
||||
"wfm-help-settings-card-workflow-title": "helpSettingsCardWorkflow",
|
||||
"wfm-help-settings-card-editor-title": "helpSettingsCardEditor",
|
||||
"wfm-help-settings-card-misc-title": "helpSettingsCardMisc",
|
||||
"wfm-help-nodes-title": "helpNodesTitle",
|
||||
"wfm-help-nodes-card-browse-title": "helpNodesCardBrowse",
|
||||
"wfm-help-nodes-card-groups-title": "helpNodesCardGroups",
|
||||
"wfm-help-nodes-card-panel-title": "helpNodesCardPanel",
|
||||
"wfm-help-gallery-card-browse-title": "helpGalleryCardBrowse",
|
||||
"wfm-help-gallery-card-folder-title": "helpGalleryCardFolder",
|
||||
"wfm-help-gallery-card-detail-title": "helpGalleryCardDetail",
|
||||
"wfm-help-gallery-card-actions-title": "helpGalleryCardActions",
|
||||
"wfm-help-gallery-card-formats-title": "helpGalleryCardFormats",
|
||||
"wfm-help-models-title": "helpModelsTitle",
|
||||
"wfm-help-models-card-browse-title": "helpModelsCardBrowse",
|
||||
"wfm-help-models-card-civitai-title": "helpModelsCardCivitai",
|
||||
"wfm-help-models-card-apply-title": "helpModelsCardApply",
|
||||
"wfm-help-models-card-groups-title": "helpModelsCardGroups",
|
||||
"wfm-help-models-card-bulk-title": "helpModelsCardBulk",
|
||||
"wfm-help-sidepanel-card-tabs-title": "helpSidepanelCardTabs",
|
||||
"wfm-help-sidepanel-card-dnd-title": "helpSidepanelCardDnd",
|
||||
"wfm-help-sidepanel-card-info-title": "helpSidepanelCardInfo",
|
||||
"wfm-help-sidepanel-card-ai-title": "helpSidepanelCardAi",
|
||||
"wfm-help-sidepanel-card-nav-title": "helpSidepanelCardNav",
|
||||
"wfm-help-tagger-card-overview-title": "helpTaggerCardOverview",
|
||||
"wfm-help-tagger-card-single-title": "helpTaggerCardSingle",
|
||||
"wfm-help-tagger-card-sending-title": "helpTaggerCardSending",
|
||||
"wfm-help-tagger-card-batch-title": "helpTaggerCardBatch",
|
||||
"wfm-help-imageedit-card-overview-title": "helpImageEditCardOverview",
|
||||
"wfm-help-shortcuts-card-general-title": "helpShortcutsCardGeneral",
|
||||
"wfm-help-trouble-card-connection-title": "helpTroubleCardConnection",
|
||||
"wfm-help-trouble-card-workflow-title": "helpTroubleCardWorkflow",
|
||||
"wfm-help-trouble-card-tagger-title": "helpTroubleCardTagger",
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
// Jump-to-tab buttons
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.textContent = t("helpJumpToTab");
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -333,22 +674,59 @@ 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, applyTheme, getSavedTheme } 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");
|
||||
});
|
||||
});
|
||||
|
||||
// Jump-to-tab buttons: switch to the real feature tab this help page documents
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelector(`.wfm-tab[data-tab="${btn.dataset.jumpTab}"]`)?.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -88,7 +88,18 @@ export const comfyUI = {
|
||||
try {
|
||||
const info = await this.fetchObjectInfo(cls);
|
||||
if (info?.[cls]?.input?.required?.[inputKey]) {
|
||||
return info[cls].input.required[inputKey][0] || [];
|
||||
const inputDef = info[cls].input.required[inputKey];
|
||||
const first = inputDef[0];
|
||||
// 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 {}
|
||||
}
|
||||
@@ -121,17 +132,19 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
async fetchDiffusionModels() {
|
||||
return this._fetchModelList(
|
||||
["UNETLoader", "UnetLoaderGGUF"],
|
||||
"unet_name"
|
||||
);
|
||||
const [standard, gguf] = await Promise.all([
|
||||
this._fetchModelList(["UNETLoader", "UnetLoaderGGUF"], "unet_name"),
|
||||
this._fetchModelList(["LoaderGGUF", "LoaderGGUFAdvanced"], "gguf_name"),
|
||||
]);
|
||||
return [...new Set([...standard, ...gguf])];
|
||||
},
|
||||
|
||||
async fetchTextEncoders() {
|
||||
return this._fetchModelList(
|
||||
["DualCLIPLoader", "CLIPLoader"],
|
||||
"clip_name1"
|
||||
);
|
||||
const [dual, single] = await Promise.all([
|
||||
this._fetchModelList(["DualCLIPLoader", "DualClipLoaderGGUF"], "clip_name1"),
|
||||
this._fetchModelList(["CLIPLoader", "ClipLoaderGGUF"], "clip_name"),
|
||||
]);
|
||||
return [...new Set([...dual, ...single])];
|
||||
},
|
||||
|
||||
async fetchHypernetworks() {
|
||||
@@ -142,14 +155,17 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
async fetchEmbeddings() {
|
||||
// ComfyUI exposes embeddings via /embeddings endpoint
|
||||
// Use WFS backend API (reliable, goes through same aiohttp server)
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/embeddings`);
|
||||
const res = await fetch("/api/wfm/models/files?type=embedding");
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
} catch {}
|
||||
console.warn("[WFS] /api/wfm/models/files?type=embedding returned", res.status);
|
||||
} catch (e) {
|
||||
console.error("[WFS] fetchEmbeddings error:", e);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
@@ -162,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(() => ({}));
|
||||
@@ -222,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) {
|
||||
@@ -269,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
|
||||
@@ -282,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) {
|
||||
@@ -289,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;
|
||||
|
||||
@@ -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,785 @@
|
||||
/**
|
||||
* Lab Tab - Experimental I2I batch generation.
|
||||
*
|
||||
* Runs the currently loaded workflow N times, letting Checkpoint / VAE / Prompt /
|
||||
* KSampler each be overridden independently starting at a chosen iteration
|
||||
* ("keyframe"). Never mutates comfyUI.currentWorkflow — everything is applied to a
|
||||
* fresh deep clone per iteration so it can't interfere with the Input/Model/Settings/
|
||||
* Batch subtabs.
|
||||
*/
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { showToast, openModal, closeModal } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
|
||||
|
||||
const COLUMN_KEYS = ["checkpoint", "vae", "prompt", "ksampler"];
|
||||
const MAX_RESULTS = 9;
|
||||
const RESULTS_GRID_COLS = 3;
|
||||
|
||||
function _defaultValueFor(col) {
|
||||
if (col === "prompt") return { positive: "", negative: "" };
|
||||
if (col === "ksampler") return { steps: null, cfg: null, sampler_name: "", scheduler: "", denoise: null, seed: null };
|
||||
return "";
|
||||
}
|
||||
|
||||
function _emptyColumns() {
|
||||
return {
|
||||
checkpoint: [{ atIteration: 1, value: "", revertToBase: false }],
|
||||
vae: [{ atIteration: 1, value: "", revertToBase: false }],
|
||||
prompt: [{ atIteration: 1, value: _defaultValueFor("prompt"), revertToBase: false }],
|
||||
ksampler: [{ atIteration: 1, value: _defaultValueFor("ksampler"), revertToBase: false }],
|
||||
};
|
||||
}
|
||||
|
||||
function _emptyLabState() {
|
||||
return {
|
||||
planFilename: null,
|
||||
note: "",
|
||||
batchCount: 4,
|
||||
chainImage: false,
|
||||
sourceImageFilename: null,
|
||||
columns: _emptyColumns(),
|
||||
results: { images: [] },
|
||||
};
|
||||
}
|
||||
|
||||
let _lab = _emptyLabState();
|
||||
let _paused = false;
|
||||
let _aborted = false;
|
||||
let _resumeResolve = null;
|
||||
let _lastGeneratedImageRef = null; // ComfyUI "name [type]" annotated ref of the previous iteration's first output
|
||||
|
||||
// ============================================
|
||||
// Init
|
||||
// ============================================
|
||||
|
||||
export function initLabTab() {
|
||||
_initDropZone();
|
||||
_initPlanDropZone();
|
||||
_initSubtabToggle();
|
||||
_initColumnButtons();
|
||||
_initRunControls();
|
||||
_initPlanButtons();
|
||||
_renderAllColumns();
|
||||
_renderResultsGrid();
|
||||
}
|
||||
|
||||
function _initDropZone() {
|
||||
const dropZone = document.getElementById("wfm-lab-drop-zone");
|
||||
const fileInput = document.getElementById("wfm-lab-file-input");
|
||||
const previewWrap = document.getElementById("wfm-lab-preview-wrap");
|
||||
const previewImg = document.getElementById("wfm-lab-preview-img");
|
||||
|
||||
const applyFile = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
if (previewImg) previewImg.src = url;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
_lab.sourceImageFilename = result.name;
|
||||
_updateResultsSourceImage();
|
||||
showToast(t("labImageUploaded", result.name), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labUploadFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Plan Load — same drag&drop + click-to-browse UX as the Image drop zone.
|
||||
// Accepts a .json plan file (read directly) or a .png index-image thumbnail
|
||||
// (same basename as its .json on the server, fetched by filename).
|
||||
function _initPlanDropZone() {
|
||||
const dropZone = document.getElementById("wfm-lab-plan-drop-zone");
|
||||
const fileInput = document.getElementById("wfm-lab-plan-file-input");
|
||||
|
||||
const applyFile = (file) => {
|
||||
if (!file) return;
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith(".json")) {
|
||||
_loadPlanFromFile(file);
|
||||
} else if (name.endsWith(".png") || file.type.startsWith("image/")) {
|
||||
_loadPlanFromIndexImage(file);
|
||||
} else {
|
||||
showToast(t("labPlanInvalidFile"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
fileInput.value = ""; // allow re-selecting the same file next time
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _initSubtabToggle() {
|
||||
document.querySelectorAll(".wfm-lab-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.labSubtab;
|
||||
document.querySelectorAll(".wfm-lab-subtab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.getElementById("wfm-lab-panel-setting")?.classList.toggle("active", target === "setting");
|
||||
document.getElementById("wfm-lab-panel-results")?.classList.toggle("active", target === "results");
|
||||
if (target === "results") _updateResultsSourceImage();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _initRunControls() {
|
||||
document.getElementById("wfm-lab-batch-count")?.addEventListener("change", (e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
_lab.batchCount = (isNaN(v) || v < 1) ? 1 : v;
|
||||
e.target.value = _lab.batchCount;
|
||||
});
|
||||
document.getElementById("wfm-lab-note")?.addEventListener("input", (e) => { _lab.note = e.target.value; });
|
||||
document.getElementById("wfm-lab-chain-image")?.addEventListener("change", (e) => { _lab.chainImage = e.target.checked; });
|
||||
|
||||
document.getElementById("wfm-lab-run-btn")?.addEventListener("click", () => _runLabBatch());
|
||||
document.getElementById("wfm-lab-cancel-btn")?.addEventListener("click", async () => {
|
||||
_aborted = true;
|
||||
_paused = false;
|
||||
if (_resumeResolve) { _resumeResolve(); _resumeResolve = null; }
|
||||
await comfyUI.interrupt();
|
||||
showToast(t("interrupted"), "info");
|
||||
});
|
||||
document.getElementById("wfm-lab-pause-btn")?.addEventListener("click", (e) => {
|
||||
_paused = !_paused;
|
||||
e.target.textContent = _paused ? t("labResume") : t("labPause");
|
||||
if (!_paused && _resumeResolve) { _resumeResolve(); _resumeResolve = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function _initPlanButtons() {
|
||||
document.getElementById("wfm-lab-plan-save-btn")?.addEventListener("click", () => _savePlan());
|
||||
document.getElementById("wfm-lab-plan-saveas-btn")?.addEventListener("click", () => _savePlan(null, true));
|
||||
document.getElementById("wfm-lab-plan-clear-btn")?.addEventListener("click", () => _clearPlan());
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Column keyframes: render / add / remove / edit
|
||||
// ============================================
|
||||
|
||||
function _initColumnButtons() {
|
||||
document.querySelectorAll(".wfm-lab-col-add").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const kfs = _lab.columns[col];
|
||||
const lastIter = kfs[kfs.length - 1].atIteration;
|
||||
if (lastIter >= _lab.batchCount) {
|
||||
showToast(t("labBatchCountTooSmall"), "error");
|
||||
return;
|
||||
}
|
||||
kfs.push({ atIteration: lastIter + 1, value: _defaultValueFor(col), revertToBase: false });
|
||||
_renderColumn(col);
|
||||
_openCellModal(col, kfs.length - 1);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".wfm-lab-col-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const kfs = _lab.columns[col];
|
||||
if (kfs.length <= 1) return; // Row 1 (baseline) cannot be removed
|
||||
kfs.pop();
|
||||
_renderColumn(col);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _renderAllColumns() {
|
||||
COLUMN_KEYS.forEach(_renderColumn);
|
||||
}
|
||||
|
||||
function _renderColumn(col) {
|
||||
const container = document.getElementById(`wfm-lab-col-cells-${col}`);
|
||||
if (!container) return;
|
||||
const keyframes = _lab.columns[col];
|
||||
container.innerHTML = keyframes.map((kf, idx) => _cellHtml(col, kf, idx)).join("");
|
||||
container.querySelectorAll(".wfm-lab-cell").forEach((el) => {
|
||||
el.addEventListener("click", () => _openCellModal(col, parseInt(el.dataset.idx, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
function _cellHtml(col, kf, idx) {
|
||||
const revertClass = kf.revertToBase ? " wfm-lab-cell-revert" : "";
|
||||
if (idx === 0) {
|
||||
return `<div class="wfm-lab-cell${revertClass}" data-idx="${idx}">
|
||||
<span class="wfm-lab-cell-index">${kf.atIteration}:</span>
|
||||
<span class="wfm-lab-cell-value">${_cellLabel(col, kf)}</span>
|
||||
</div>`;
|
||||
}
|
||||
// Iteration #2 and beyond: value on its own line, applied-at iteration below it
|
||||
return `<div class="wfm-lab-cell${revertClass}" data-idx="${idx}">
|
||||
<div class="wfm-lab-cell-value">${_cellLabel(col, kf)}</div>
|
||||
<div class="wfm-lab-cell-applied">${t("labAppliedAt", kf.atIteration)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _cellLabel(col, kf) {
|
||||
if (kf.revertToBase) return t("labRevertToBase");
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
return kf.value ? escapeHtml(kf.value) : t("labUseWorkflowDefault");
|
||||
}
|
||||
if (col === "prompt") {
|
||||
const p = kf.value?.positive || "";
|
||||
const n = kf.value?.negative || "";
|
||||
if (!p && !n) return t("labUseWorkflowDefault");
|
||||
return `<span class="wfm-lab-cell-prompt-line">P: ${escapeHtml(p)}</span><span class="wfm-lab-cell-prompt-line">N: ${escapeHtml(n)}</span>`;
|
||||
}
|
||||
if (col === "ksampler") {
|
||||
const v = kf.value || {};
|
||||
if (v.steps == null && v.cfg == null && v.denoise == null) return t("labUseWorkflowDefault");
|
||||
const parts = [];
|
||||
if (v.steps != null) parts.push(`Step${v.steps}`);
|
||||
if (v.cfg != null) parts.push(`CFG${v.cfg}`);
|
||||
if (v.denoise != null) parts.push(`Denoise${v.denoise}`);
|
||||
return escapeHtml(parts.join(" / "));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function _isEmptyValue(col, value) {
|
||||
if (col === "checkpoint" || col === "vae") return !value;
|
||||
if (col === "prompt") return !value || (!value.positive && !value.negative);
|
||||
if (col === "ksampler") {
|
||||
return !value || (value.steps == null && value.cfg == null && value.denoise == null &&
|
||||
!value.sampler_name && !value.scheduler && value.seed == null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _openCellModal(col, idx) {
|
||||
const kf = _lab.columns[col][idx];
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === _lab.columns[col].length - 1;
|
||||
|
||||
let valueFieldsHtml = "";
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
const options = comfyEditor.models[col === "checkpoint" ? "checkpoints" : "vaes"] || [];
|
||||
valueFieldsHtml = `
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>${col === "checkpoint" ? "Checkpoint" : "VAE"}</label>
|
||||
<select id="wfm-lab-modal-value" class="wfm-select">
|
||||
<option value="">${t("labUseWorkflowDefault")}</option>
|
||||
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === kf.value ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
|
||||
</select>
|
||||
</div>`;
|
||||
} else if (col === "prompt") {
|
||||
const v = kf.value || {};
|
||||
valueFieldsHtml = `
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>Positive</label>
|
||||
<textarea id="wfm-lab-modal-positive" class="wfm-input" rows="3">${escapeHtml(v.positive || "")}</textarea>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>Negative</label>
|
||||
<textarea id="wfm-lab-modal-negative" class="wfm-input" rows="3">${escapeHtml(v.negative || "")}</textarea>
|
||||
</div>`;
|
||||
} else if (col === "ksampler") {
|
||||
const v = kf.value || {};
|
||||
const samplers = comfyEditor.models.samplers || [];
|
||||
const schedulers = comfyEditor.models.schedulers || [];
|
||||
valueFieldsHtml = `
|
||||
<div class="wfm-lab-modal-inline">
|
||||
<div><label>Steps</label><input type="number" id="wfm-lab-modal-steps" class="wfm-input" value="${v.steps ?? ""}"></div>
|
||||
<div><label>CFG</label><input type="number" step="0.1" id="wfm-lab-modal-cfg" class="wfm-input" value="${v.cfg ?? ""}"></div>
|
||||
<div><label>Denoise</label><input type="number" step="0.01" min="0" max="1" id="wfm-lab-modal-denoise" class="wfm-input" value="${v.denoise ?? ""}"></div>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-inline">
|
||||
<div><label>Sampler</label>
|
||||
<select id="wfm-lab-modal-sampler" class="wfm-select">
|
||||
<option value="">${t("labUseWorkflowDefault")}</option>
|
||||
${samplers.map((s) => `<option value="${escapeHtml(s)}" ${s === v.sampler_name ? "selected" : ""}>${escapeHtml(s)}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Scheduler</label>
|
||||
<select id="wfm-lab-modal-scheduler" class="wfm-select">
|
||||
<option value="">${t("labUseWorkflowDefault")}</option>
|
||||
${schedulers.map((s) => `<option value="${escapeHtml(s)}" ${s === v.scheduler ? "selected" : ""}>${escapeHtml(s)}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>${t("labSeedEmptyRandom")}</label>
|
||||
<input type="number" id="wfm-lab-modal-seed" class="wfm-input" value="${v.seed ?? ""}">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const atIterRow = isFirst ? "" : `
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>${t("labApplyFromIteration")}</label>
|
||||
<input type="number" id="wfm-lab-modal-iter" class="wfm-input" value="${kf.atIteration}" min="2" max="${_lab.batchCount}">
|
||||
</div>`;
|
||||
|
||||
const revertRow = isFirst ? "" : `
|
||||
<div class="wfm-lab-modal-checkbox">
|
||||
<input type="checkbox" id="wfm-lab-modal-revert" ${kf.revertToBase ? "checked" : ""}>
|
||||
<label for="wfm-lab-modal-revert">${t("labRevertCheckboxLabel")}</label>
|
||||
</div>`;
|
||||
|
||||
const deleteBtn = (!isFirst && isLast)
|
||||
? `<button id="wfm-lab-modal-delete" class="wfm-btn wfm-btn-danger">${t("delete")}</button>` : "";
|
||||
|
||||
const html = `
|
||||
<div id="wfm-lab-modal-fields">${valueFieldsHtml}</div>
|
||||
${atIterRow}
|
||||
${revertRow}
|
||||
<div style="display:flex;gap:8px;margin-top:12px;justify-content:flex-end;">
|
||||
${deleteBtn}
|
||||
<button id="wfm-lab-modal-save" class="wfm-btn wfm-btn-primary">${t("save")}</button>
|
||||
</div>`;
|
||||
openModal(`${col} #${idx + 1}`, html);
|
||||
|
||||
const revertCb = document.getElementById("wfm-lab-modal-revert");
|
||||
const fieldsWrap = document.getElementById("wfm-lab-modal-fields");
|
||||
const syncDisabled = () => {
|
||||
const disabled = !!revertCb?.checked;
|
||||
if (fieldsWrap) fieldsWrap.style.opacity = disabled ? "0.4" : "1";
|
||||
fieldsWrap?.querySelectorAll("input,select,textarea").forEach((el) => { el.disabled = disabled; });
|
||||
};
|
||||
revertCb?.addEventListener("change", syncDisabled);
|
||||
syncDisabled();
|
||||
|
||||
document.getElementById("wfm-lab-modal-delete")?.addEventListener("click", () => {
|
||||
_lab.columns[col].splice(idx, 1);
|
||||
_renderColumn(col);
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-lab-modal-save")?.addEventListener("click", () => {
|
||||
const revert = !!document.getElementById("wfm-lab-modal-revert")?.checked;
|
||||
let atIteration = kf.atIteration;
|
||||
if (!isFirst) {
|
||||
const parsed = parseInt(document.getElementById("wfm-lab-modal-iter")?.value, 10);
|
||||
if (!isNaN(parsed)) atIteration = Math.min(Math.max(parsed, 2), _lab.batchCount);
|
||||
}
|
||||
|
||||
let value = kf.value;
|
||||
if (!revert) {
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
value = document.getElementById("wfm-lab-modal-value")?.value || "";
|
||||
} else if (col === "prompt") {
|
||||
value = {
|
||||
positive: document.getElementById("wfm-lab-modal-positive")?.value || "",
|
||||
negative: document.getElementById("wfm-lab-modal-negative")?.value || "",
|
||||
};
|
||||
} else if (col === "ksampler") {
|
||||
const numOrNull = (id) => {
|
||||
const raw = document.getElementById(id)?.value;
|
||||
return (raw === "" || raw == null) ? null : Number(raw);
|
||||
};
|
||||
value = {
|
||||
steps: numOrNull("wfm-lab-modal-steps"),
|
||||
cfg: numOrNull("wfm-lab-modal-cfg"),
|
||||
denoise: numOrNull("wfm-lab-modal-denoise"),
|
||||
sampler_name: document.getElementById("wfm-lab-modal-sampler")?.value || "",
|
||||
scheduler: document.getElementById("wfm-lab-modal-scheduler")?.value || "",
|
||||
seed: numOrNull("wfm-lab-modal-seed"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_lab.columns[col][idx] = { atIteration, value, revertToBase: revert };
|
||||
_lab.columns[col].sort((a, b) => a.atIteration - b.atIteration);
|
||||
_renderColumn(col);
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Effective-value resolution & workflow build
|
||||
// ============================================
|
||||
|
||||
function _resolveKeyframe(col, iteration) {
|
||||
const kfs = _lab.columns[col];
|
||||
let applicable = null;
|
||||
for (const kf of kfs) {
|
||||
if (kf.atIteration <= iteration) applicable = kf;
|
||||
else break;
|
||||
}
|
||||
if (!applicable) return null;
|
||||
if (applicable.revertToBase) {
|
||||
const row1 = kfs.find((k) => k.atIteration === 1);
|
||||
return (row1 && !_isEmptyValue(col, row1.value)) ? row1.value : null;
|
||||
}
|
||||
return _isEmptyValue(col, applicable.value) ? null : applicable.value;
|
||||
}
|
||||
|
||||
function _buildWorkflowForIteration(iteration, chainImageRef) {
|
||||
const wf = JSON.parse(JSON.stringify(comfyUI.currentWorkflow || {}));
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!analysis) return { workflow: wf, seed: null };
|
||||
|
||||
const write = (nodeId, key, val) => {
|
||||
if (nodeId != null && wf[nodeId]) wf[nodeId].inputs[key] = val;
|
||||
};
|
||||
|
||||
const ckpt = _resolveKeyframe("checkpoint", iteration);
|
||||
if (ckpt && analysis.checkpoint_nodes?.[0]) {
|
||||
write(analysis.checkpoint_nodes[0].id, "ckpt_name", ckpt);
|
||||
}
|
||||
|
||||
const vae = _resolveKeyframe("vae", iteration);
|
||||
if (vae && analysis.vae_nodes?.[0]) {
|
||||
write(analysis.vae_nodes[0].id, "vae_name", vae);
|
||||
}
|
||||
|
||||
const prompt = _resolveKeyframe("prompt", iteration);
|
||||
if (prompt) {
|
||||
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
|
||||
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
|
||||
if (posNode && prompt.positive) write(posNode.id, posNode.textKey || "text", prompt.positive);
|
||||
if (negNode && prompt.negative) write(negNode.id, negNode.textKey || "text", prompt.negative);
|
||||
}
|
||||
|
||||
let seed = null;
|
||||
const ks = _resolveKeyframe("ksampler", iteration);
|
||||
const sampler = analysis.sampler_nodes?.[0];
|
||||
if (ks && sampler) {
|
||||
if (ks.steps != null) write(sampler.stepsNodeId ?? sampler.id, "steps", ks.steps);
|
||||
if (ks.cfg != null) write(sampler.cfgNodeId ?? sampler.id, "cfg", ks.cfg);
|
||||
if (ks.sampler_name) write(sampler.samplerNodeId ?? sampler.id, "sampler_name", ks.sampler_name);
|
||||
if (ks.scheduler && sampler.schedulerNodeId) write(sampler.schedulerNodeId, "scheduler", ks.scheduler);
|
||||
if (ks.denoise != null && sampler.denoiseNodeId) write(sampler.denoiseNodeId, "denoise", ks.denoise);
|
||||
if (ks.seed != null) seed = ks.seed;
|
||||
}
|
||||
|
||||
const imageRef = chainImageRef || _lab.sourceImageFilename;
|
||||
if (imageRef && analysis.load_image_nodes?.[0]) {
|
||||
write(analysis.load_image_nodes[0].id, "image", imageRef);
|
||||
}
|
||||
|
||||
return { workflow: wf, seed };
|
||||
}
|
||||
|
||||
// ComfyUI's LoadImage accepts "name [type]" (optionally "subfolder/name [type]") to
|
||||
// reference a previously generated output/temp image without re-uploading it.
|
||||
function _annotatedImageRef(img) {
|
||||
const rel = img.subfolder ? `${img.subfolder}/${img.filename}` : img.filename;
|
||||
return `${rel} [${img.type || "output"}]`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Batch execution
|
||||
// ============================================
|
||||
|
||||
function _waitIfPaused() {
|
||||
if (!_paused) return Promise.resolve();
|
||||
return new Promise((resolve) => { _resumeResolve = resolve; });
|
||||
}
|
||||
|
||||
function _setRunUiState(running) {
|
||||
const runBtn = document.getElementById("wfm-lab-run-btn");
|
||||
const cancelBtn = document.getElementById("wfm-lab-cancel-btn");
|
||||
const pauseBtn = document.getElementById("wfm-lab-pause-btn");
|
||||
if (runBtn) runBtn.style.display = running ? "none" : "";
|
||||
if (cancelBtn) cancelBtn.style.display = running ? "" : "none";
|
||||
if (pauseBtn) {
|
||||
pauseBtn.disabled = !running;
|
||||
pauseBtn.textContent = t("labPause");
|
||||
}
|
||||
}
|
||||
|
||||
async function _runLabBatch() {
|
||||
if (!comfyUI.currentWorkflow || !comfyUI.currentAnalysis) {
|
||||
showToast(t("labNoWorkflowLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
if (!_lab.sourceImageFilename) {
|
||||
showToast(t("labNoSourceImage"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
_paused = false;
|
||||
_aborted = false;
|
||||
_lastGeneratedImageRef = null;
|
||||
_lab.results.images = [];
|
||||
_renderResultsGrid();
|
||||
_setRunUiState(true);
|
||||
|
||||
const progressBar = document.getElementById("wfm-lab-progress-bar");
|
||||
const progressText = document.getElementById("wfm-lab-progress-text");
|
||||
const eagleAutoSave = getEagleSettings().autoSave;
|
||||
|
||||
let completed = 0, failed = 0;
|
||||
try {
|
||||
for (let i = 1; i <= _lab.batchCount; i++) {
|
||||
if (_aborted) break;
|
||||
await _waitIfPaused();
|
||||
if (_aborted) break;
|
||||
|
||||
if (progressText) progressText.textContent = `[${i}/${_lab.batchCount}] ...`;
|
||||
const chainRef = (_lab.chainImage && i > 1) ? _lastGeneratedImageRef : null;
|
||||
const { workflow, seed } = _buildWorkflowForIteration(i, chainRef);
|
||||
|
||||
try {
|
||||
const { images } = await comfyUI.generate(workflow, {
|
||||
seedMode: seed != null ? "fixed" : "random",
|
||||
seedValue: seed ?? -1,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `[${i}/${_lab.batchCount}] ${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
});
|
||||
const outputImages = images.filter((img) => img.type !== "temp");
|
||||
if (outputImages.length > 0) {
|
||||
_lastGeneratedImageRef = _annotatedImageRef(outputImages[0]);
|
||||
}
|
||||
for (const img of outputImages) {
|
||||
if (_lab.results.images.length < MAX_RESULTS) {
|
||||
_lab.results.images.push({ ...img, iteration: i });
|
||||
}
|
||||
if (eagleAutoSave) {
|
||||
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
saveToEagle(viewUrl, img.filename, [], img);
|
||||
}
|
||||
}
|
||||
_renderResultsGrid();
|
||||
completed++;
|
||||
} catch (err) {
|
||||
if (_aborted) break;
|
||||
failed++;
|
||||
showToast(t("batchItemFailed", i, _lab.batchCount, err.message), "error");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_paused = false;
|
||||
if (_resumeResolve) { _resumeResolve(); _resumeResolve = null; }
|
||||
_setRunUiState(false);
|
||||
}
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
if (_aborted) {
|
||||
showToast(t("batchStopped", completed, failed), "info");
|
||||
} else {
|
||||
showToast(t("batchComplete", completed, _lab.batchCount, failed), failed > 0 ? "error" : "success");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Results grid
|
||||
// ============================================
|
||||
|
||||
function _resultImageUrl(img) {
|
||||
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
}
|
||||
|
||||
function _renderResultsGrid() {
|
||||
const grid = document.getElementById("wfm-lab-results-grid");
|
||||
if (!grid) return;
|
||||
if (_lab.results.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("labNoResultsYet")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = _lab.results.images.map((img, i) => `
|
||||
<div class="wfm-lab-result-item">
|
||||
<span class="wfm-lab-result-index">${img.iteration}</span>
|
||||
<img class="wfm-lab-result-thumb" data-idx="${i}" src="${_resultImageUrl(img)}">
|
||||
</div>`).join("");
|
||||
grid.querySelectorAll(".wfm-lab-result-thumb").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
openModal(t("labResultPreview"), `<img src="${el.src}" style="max-width:100%;max-height:80vh;display:block;margin:0 auto;">`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _updateResultsSourceImage() {
|
||||
const img = document.getElementById("wfm-lab-results-source-img");
|
||||
if (!img) return;
|
||||
if (_lab.sourceImageFilename) {
|
||||
img.src = `/view?filename=${encodeURIComponent(_lab.sourceImageFilename)}&type=input`;
|
||||
img.style.display = "";
|
||||
} else {
|
||||
img.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Index (contact-sheet) thumbnail generation
|
||||
// ============================================
|
||||
|
||||
function _loadImageEl(src) {
|
||||
return new Promise((resolve) => {
|
||||
const im = new Image();
|
||||
im.onload = () => resolve(im);
|
||||
im.onerror = () => resolve(null);
|
||||
im.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function _buildIndexImageDataUrl() {
|
||||
const imgs = _lab.results.images.slice(0, MAX_RESULTS);
|
||||
if (imgs.length === 0) return null;
|
||||
|
||||
const cols = Math.min(RESULTS_GRID_COLS, imgs.length);
|
||||
const rows = Math.ceil(imgs.length / cols);
|
||||
const cellSize = 128;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cols * cellSize;
|
||||
canvas.height = rows * cellSize;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let i = 0; i < imgs.length; i++) {
|
||||
const im = await _loadImageEl(_resultImageUrl(imgs[i]));
|
||||
if (!im) continue;
|
||||
const col = i % cols, row = Math.floor(i / cols);
|
||||
const scale = Math.max(cellSize / im.width, cellSize / im.height);
|
||||
const w = im.width * scale, h = im.height * scale;
|
||||
ctx.drawImage(im, col * cellSize - (w - cellSize) / 2, row * cellSize - (h - cellSize) / 2, w, h);
|
||||
}
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Plan save / load / clear
|
||||
// ============================================
|
||||
|
||||
async function _savePlan(filenameOverride, forceNewName = false) {
|
||||
let filename = filenameOverride;
|
||||
if (!filename) {
|
||||
if (forceNewName || !_lab.planFilename) {
|
||||
filename = window.prompt(t("labEnterPlanName"), forceNewName ? "" : (_lab.planFilename || ""));
|
||||
if (!filename) return;
|
||||
} else {
|
||||
filename = _lab.planFilename;
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: filename.replace(/\.json$/i, ""),
|
||||
note: _lab.note,
|
||||
batch_count: _lab.batchCount,
|
||||
chain_image: _lab.chainImage,
|
||||
source_image: _lab.sourceImageFilename,
|
||||
columns: _lab.columns,
|
||||
results: { images: _lab.results.images },
|
||||
};
|
||||
|
||||
let indexImageBase64 = null;
|
||||
try {
|
||||
indexImageBase64 = await _buildIndexImageDataUrl();
|
||||
} catch { /* thumbnail generation is best-effort */ }
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/lab/plans/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, data, index_image_base64: indexImageBase64 }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
_lab.planFilename = json.filename;
|
||||
showToast(t("labPlanSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labPlanSaveFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _applyPlanData(filename, data) {
|
||||
_lab.planFilename = filename;
|
||||
_lab.note = data.note || "";
|
||||
_lab.batchCount = data.batch_count || 1;
|
||||
_lab.chainImage = !!data.chain_image;
|
||||
_lab.sourceImageFilename = data.source_image || null;
|
||||
_lab.columns = data.columns || _emptyColumns();
|
||||
_lab.results = { images: (data.results?.images || []).slice(0, MAX_RESULTS) };
|
||||
|
||||
const noteEl = document.getElementById("wfm-lab-note");
|
||||
const batchEl = document.getElementById("wfm-lab-batch-count");
|
||||
const chainEl = document.getElementById("wfm-lab-chain-image");
|
||||
if (noteEl) noteEl.value = _lab.note;
|
||||
if (batchEl) batchEl.value = _lab.batchCount;
|
||||
if (chainEl) chainEl.checked = _lab.chainImage;
|
||||
|
||||
const previewWrap = document.getElementById("wfm-lab-preview-wrap");
|
||||
const previewImg = document.getElementById("wfm-lab-preview-img");
|
||||
if (_lab.sourceImageFilename && previewImg && previewWrap) {
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(_lab.sourceImageFilename)}&type=input`;
|
||||
previewWrap.style.display = "";
|
||||
} else if (previewWrap) {
|
||||
previewWrap.style.display = "none";
|
||||
}
|
||||
|
||||
_renderAllColumns();
|
||||
_renderResultsGrid();
|
||||
_updateResultsSourceImage();
|
||||
}
|
||||
|
||||
// Plan Load reads a dropped/picked .json plan file directly in the browser (no
|
||||
// server round-trip needed — the browser's native file picker can't be preset to
|
||||
// open in .../Workflow-Studio/lab_plan, but most browsers remember the folder
|
||||
// after the user browses there once).
|
||||
async function _loadPlanFromFile(file) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const filename = file.name.toLowerCase().endsWith(".json") ? file.name : `${file.name}.json`;
|
||||
_applyPlanData(filename, data);
|
||||
showToast(t("labPlanLoaded"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labPlanLoadFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Dropping the index-image thumbnail (same basename as its .json, saved
|
||||
// alongside it on the server) fetches the actual plan JSON by that filename.
|
||||
async function _loadPlanFromIndexImage(file) {
|
||||
const stem = file.name.replace(/\.[^.]+$/, "");
|
||||
await _loadPlanFromServer(`${stem}.json`);
|
||||
}
|
||||
|
||||
async function _loadPlanFromServer(filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/lab/plans/content?filename=${encodeURIComponent(filename)}`);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
_applyPlanData(filename, json.data || {});
|
||||
showToast(t("labPlanLoaded"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labPlanLoadFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _clearPlan() {
|
||||
if (!window.confirm(t("labConfirmClear"))) return;
|
||||
_lab = _emptyLabState();
|
||||
|
||||
const noteEl = document.getElementById("wfm-lab-note");
|
||||
const batchEl = document.getElementById("wfm-lab-batch-count");
|
||||
const chainEl = document.getElementById("wfm-lab-chain-image");
|
||||
if (noteEl) noteEl.value = "";
|
||||
if (batchEl) batchEl.value = _lab.batchCount;
|
||||
if (chainEl) chainEl.checked = false;
|
||||
|
||||
const previewWrap = document.getElementById("wfm-lab-preview-wrap");
|
||||
if (previewWrap) previewWrap.style.display = "none";
|
||||
|
||||
_renderAllColumns();
|
||||
_renderResultsGrid();
|
||||
_updateResultsSourceImage();
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
|
||||
function resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const srcNode = nodeMap.get(srcId);
|
||||
if (!srcNode) return null;
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[slot];
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
|
||||
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
|
||||
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
|
||||
for (const name of ["on_false", "on_true"]) {
|
||||
const inp = srcNode.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) continue;
|
||||
const text = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// WFS_PromptText, PrimitiveStringMultiline 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") return v;
|
||||
// さらにリンクされている場合(StringReplace等の中継ノード)は追跡する
|
||||
if (Array.isArray(srcNode.inputs)) {
|
||||
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string"].includes(i.name));
|
||||
if (nextInput?.link != null) {
|
||||
const originId = linkOrigin.get(nextInput.link);
|
||||
const originSlot = linkSlot.get(nextInput.link) ?? 0;
|
||||
if (originId != null) return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role slots.
|
||||
let inputsToScan = n.inputs;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 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" || srcNode?.type === "TextEncodeBooguEdit") {
|
||||
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;
|
||||
}
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
let resolvedOriginId = originId;
|
||||
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
|
||||
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
|
||||
if (innerInput?.link != null) {
|
||||
const innerId = linkOrigin.get(innerInput.link);
|
||||
if (innerId != null) resolvedOriginId = innerId;
|
||||
}
|
||||
}
|
||||
const text = textMap.get(resolvedOriginId);
|
||||
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, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
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;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
|
||||
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
|
||||
// リテラルへ解決できた方を採用する。
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const v = src.inputs?.[key];
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
} else if (typeof v === "string" && v) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 汎用テキストキー("value" は PrimitiveString/PrimitiveStringMultiline)
|
||||
const keys = slot === 0
|
||||
? ["text_positive", "text", "text_g", "prompt", "value"]
|
||||
: ["text_negative", "text_l"];
|
||||
for (const k of keys) {
|
||||
const v = src.inputs?.[k];
|
||||
if (typeof v === "string" && v) return v;
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
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;
|
||||
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
|
||||
// model text encoders use this key instead of "text"/"text_g".
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? 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;
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 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" || srcNode?.class_type === "TextEncodeBooguEdit") {
|
||||
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;
|
||||
}
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
|
||||
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
|
||||
: String(val[0]);
|
||||
const text = textMap.get(resolvedId);
|
||||
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();
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
|
||||
const NODES_PER_PAGE = 50;
|
||||
|
||||
const state = {
|
||||
allNodes: [],
|
||||
@@ -26,6 +26,8 @@ const state = {
|
||||
activeSubView: "browser",
|
||||
loaded: false,
|
||||
currentPage: 0,
|
||||
selectedNodes: new Set(),
|
||||
lastSelectionIndex: -1,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
@@ -51,9 +53,6 @@ function categoryBadgeHtml(cat) {
|
||||
return `<span class="wfm-badge wfm-badge-category">${cat}</span>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────────────
|
||||
|
||||
@@ -257,91 +256,78 @@ function renderNodeGrid() {
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
|
||||
clearPagination();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderNodeTableView(grid, filtered);
|
||||
clearPagination();
|
||||
return;
|
||||
}
|
||||
|
||||
// Card view with pagination
|
||||
const totalPages = Math.ceil(filtered.length / NODES_PER_PAGE);
|
||||
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
|
||||
if (state.currentPage < 0) state.currentPage = 0;
|
||||
|
||||
const start = state.currentPage * NODES_PER_PAGE;
|
||||
const pageItems = filtered.slice(start, start + NODES_PER_PAGE);
|
||||
|
||||
grid.innerHTML = "";
|
||||
pageItems.forEach(node => {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card wfm-node-card";
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
card.classList.add("wfm-card-selected");
|
||||
}
|
||||
card.dataset.nodeName = node.name;
|
||||
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
|
||||
filtered.forEach(node => grid.appendChild(createNodeCard(node)));
|
||||
}
|
||||
|
||||
const inputCount = Object.keys(node.input.required || {}).length +
|
||||
Object.keys(node.input.optional || {}).length;
|
||||
const outputCount = (node.output || []).length;
|
||||
const tags = (meta.tags || []).map(tg => `<span class="wfm-tag">#${escapeHtml(tg)}</span>`).join("");
|
||||
const favStar = meta.favorite ? "\u2605" : "\u2606";
|
||||
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
function createNodeCard(node) {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card wfm-node-card";
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
card.classList.add("wfm-card-selected");
|
||||
}
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
card.classList.add("multi-selected");
|
||||
}
|
||||
card.dataset.nodeName = node.name;
|
||||
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
|
||||
<div class="wfm-card-meta">
|
||||
${categoryBadgeHtml(node.category)}
|
||||
</div>
|
||||
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
|
||||
const tags = (meta.tags || []).map(tg => `<span class="wfm-tag">#${escapeHtml(tg)}</span>`).join("");
|
||||
const favStar = meta.favorite ? "\u2605" : "\u2606";
|
||||
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
|
||||
<div class="wfm-card-meta">
|
||||
${categoryBadgeHtml(node.category)}
|
||||
</div>
|
||||
<button class="${favClass}" title="Favorite">${favStar}</button>`;
|
||||
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
|
||||
</div>
|
||||
<button class="${favClass}" title="Favorite">${favStar}</button>`;
|
||||
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
card.addEventListener("click", () => showNodeSidePanel(node));
|
||||
grid.appendChild(card);
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
|
||||
renderPagination(filtered.length, totalPages);
|
||||
}
|
||||
|
||||
function renderPagination(totalItems, totalPages) {
|
||||
const container = document.getElementById("wfm-nodes-pagination");
|
||||
if (!container) return;
|
||||
if (totalPages <= 1) { container.innerHTML = ""; return; }
|
||||
|
||||
const start = state.currentPage * NODES_PER_PAGE + 1;
|
||||
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
|
||||
container.innerHTML = `
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">«</button>
|
||||
<span>${start}-${end} / ${totalItems}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
|
||||
card.addEventListener("click", (e) => {
|
||||
const filtered = filterNodes();
|
||||
const idx = filtered.findIndex(n => n.name === node.name);
|
||||
if (e.shiftKey && state.lastSelectionIndex !== -1) {
|
||||
const from = Math.min(state.lastSelectionIndex, idx);
|
||||
const to = Math.max(state.lastSelectionIndex, idx);
|
||||
for (let i = from; i <= to; i++) {
|
||||
state.selectedNodes.add(filtered[i].name);
|
||||
}
|
||||
_applyNodeSelectionToDOM();
|
||||
updateNodeBulkBar();
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
state.selectedNodes.delete(node.name);
|
||||
card.classList.remove("multi-selected");
|
||||
} else {
|
||||
state.selectedNodes.add(node.name);
|
||||
card.classList.add("multi-selected");
|
||||
}
|
||||
state.lastSelectionIndex = idx;
|
||||
updateNodeBulkBar();
|
||||
} else {
|
||||
state.lastSelectionIndex = idx;
|
||||
showNodeSidePanel(node);
|
||||
}
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function clearPagination() {
|
||||
const container = document.getElementById("wfm-nodes-pagination");
|
||||
if (container) container.innerHTML = "";
|
||||
}
|
||||
|
||||
function scrollGridToTop() {
|
||||
document.getElementById("wfm-nodes-grid")?.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function renderNodeTableView(grid, filtered) {
|
||||
grid.innerHTML = "";
|
||||
@@ -363,6 +349,9 @@ function renderNodeTableView(grid, filtered) {
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
tr.classList.add("wfm-card-selected");
|
||||
}
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
tr.classList.add("multi-selected");
|
||||
}
|
||||
const inputCount = Object.keys(node.input.required || {}).length +
|
||||
Object.keys(node.input.optional || {}).length;
|
||||
const outputCount = (node.output || []).length;
|
||||
@@ -376,7 +365,31 @@ function renderNodeTableView(grid, filtered) {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
tr.addEventListener("click", () => showNodeSidePanel(node));
|
||||
tr.addEventListener("click", (e) => {
|
||||
const idx = filtered.findIndex(n => n.name === node.name);
|
||||
if (e.shiftKey && state.lastSelectionIndex !== -1) {
|
||||
const from = Math.min(state.lastSelectionIndex, idx);
|
||||
const to = Math.max(state.lastSelectionIndex, idx);
|
||||
for (let i = from; i <= to; i++) {
|
||||
state.selectedNodes.add(filtered[i].name);
|
||||
}
|
||||
_applyNodeSelectionToDOM();
|
||||
updateNodeBulkBar();
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
state.selectedNodes.delete(node.name);
|
||||
tr.classList.remove("multi-selected");
|
||||
} else {
|
||||
state.selectedNodes.add(node.name);
|
||||
tr.classList.add("multi-selected");
|
||||
}
|
||||
state.lastSelectionIndex = idx;
|
||||
updateNodeBulkBar();
|
||||
} else {
|
||||
state.lastSelectionIndex = idx;
|
||||
showNodeSidePanel(node);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
@@ -395,13 +408,104 @@ async function toggleFavorite(nodeName, btnEl) {
|
||||
btnEl.classList.toggle("active", newFav);
|
||||
}
|
||||
|
||||
// ── 複数選択ユーティリティ ─────────────────────────────────
|
||||
|
||||
function _applyNodeSelectionToDOM() {
|
||||
const grid = document.getElementById("wfm-nodes-grid");
|
||||
if (!grid) return;
|
||||
grid.querySelectorAll("[data-node-name]").forEach(el => {
|
||||
el.classList.toggle("multi-selected", state.selectedNodes.has(el.dataset.nodeName));
|
||||
});
|
||||
}
|
||||
|
||||
function updateNodeBulkBar() {
|
||||
const bar = document.getElementById("wfm-nodes-bulk-bar");
|
||||
const countEl = document.getElementById("wfm-nodes-bulk-count");
|
||||
if (!bar) return;
|
||||
const count = state.selectedNodes.size;
|
||||
if (count > 0) {
|
||||
bar.style.display = "";
|
||||
if (countEl) countEl.textContent = `${count} ${t("nodesBulkSelected")}`;
|
||||
} else {
|
||||
bar.style.display = "none";
|
||||
}
|
||||
const groupSel = document.getElementById("wfm-nodes-bulk-group-select");
|
||||
if (groupSel) {
|
||||
const current = groupSel.value;
|
||||
const grpNames = Object.keys(state.nodeGroups).sort();
|
||||
groupSel.innerHTML = `<option value="">${t("nodesBulkAddToGroup")}</option>` +
|
||||
grpNames.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
|
||||
if (grpNames.includes(current)) groupSel.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 一括操作 ──────────────────────────────────────────────
|
||||
|
||||
async function bulkNodeAddToGroup(groupName) {
|
||||
if (!groupName || state.selectedNodes.size === 0) return;
|
||||
const nodeNames = [...state.selectedNodes];
|
||||
if (!state.nodeGroups[groupName]) state.nodeGroups[groupName] = [];
|
||||
let added = 0;
|
||||
nodeNames.forEach(name => {
|
||||
if (!state.nodeGroups[groupName].includes(name)) {
|
||||
state.nodeGroups[groupName].push(name);
|
||||
added++;
|
||||
}
|
||||
});
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
renderFilters();
|
||||
if (state.selectedNode) renderSideGroups(state.selectedNode);
|
||||
showToast(t("addedNImagesToGroup", added, groupName), "success");
|
||||
}
|
||||
|
||||
async function bulkNodeRemoveFromGroup(groupName) {
|
||||
if (!groupName || state.selectedNodes.size === 0) return;
|
||||
if (!state.nodeGroups[groupName]) return;
|
||||
const nodeNames = new Set(state.selectedNodes);
|
||||
const before = state.nodeGroups[groupName].length;
|
||||
state.nodeGroups[groupName] = state.nodeGroups[groupName].filter(n => !nodeNames.has(n));
|
||||
const removed = before - state.nodeGroups[groupName].length;
|
||||
if (state.nodeGroups[groupName].length === 0) delete state.nodeGroups[groupName];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
renderFilters();
|
||||
if (state.selectedNode) renderSideGroups(state.selectedNode);
|
||||
showToast(t("removedNImagesFromGroup", removed, groupName), "success");
|
||||
}
|
||||
|
||||
async function bulkNodeCreateAndAddToGroup(name) {
|
||||
if (!name || state.selectedNodes.size === 0) return;
|
||||
if (state.nodeGroups[name] !== undefined) {
|
||||
showToast(t("groupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[name] = [...state.selectedNodes];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
renderFilters();
|
||||
if (state.selectedNode) renderSideGroups(state.selectedNode);
|
||||
updateNodeBulkBar();
|
||||
showToast(t("groupCreated", name), "success");
|
||||
}
|
||||
|
||||
async function bulkNodeSetFavorite(value) {
|
||||
if (state.selectedNodes.size === 0) return;
|
||||
const nodeNames = [...state.selectedNodes].filter(name => {
|
||||
const meta = state.nodeMetadata[name];
|
||||
return (meta?.favorite ?? false) !== value;
|
||||
});
|
||||
if (nodeNames.length === 0) return;
|
||||
await Promise.all(nodeNames.map(name => saveNodeMetadata(name, { favorite: value })));
|
||||
nodeNames.forEach(name => {
|
||||
if (!state.nodeMetadata[name]) state.nodeMetadata[name] = {};
|
||||
state.nodeMetadata[name].favorite = value;
|
||||
});
|
||||
renderNodeGrid();
|
||||
showToast(value ? t("favoritedNImages", nodeNames.length) : t("unfavoritedNImages", nodeNames.length), "success");
|
||||
}
|
||||
|
||||
// ── Side Panel ────────────────────────────────────────────
|
||||
|
||||
function showNodeSidePanel(node) {
|
||||
state.selectedNode = node;
|
||||
const panel = document.getElementById("wfm-nodes-side-panel");
|
||||
if (!panel) return;
|
||||
panel.style.display = "flex";
|
||||
|
||||
// Highlight selected card
|
||||
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
|
||||
@@ -545,42 +649,61 @@ function renderSideGroups(node) {
|
||||
const el = document.getElementById("wfm-nodes-side-group");
|
||||
if (!el) return;
|
||||
|
||||
const groupNames = Object.keys(state.nodeGroups).sort();
|
||||
const memberOf = groupNames.filter(g => (state.nodeGroups[g] || []).includes(node.name));
|
||||
const allGroups = Object.keys(state.nodeGroups).sort();
|
||||
const memberOf = allGroups.filter(g => (state.nodeGroups[g] || []).includes(node.name));
|
||||
const available = allGroups.filter(g => !memberOf.includes(g));
|
||||
|
||||
let html = `<h4 style="margin:0 0 8px;">${t("nodesGroups")}</h4>`;
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map(g =>
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`
|
||||
).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${available.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
|
||||
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-nodes-new-group-input" class="wfm-input"
|
||||
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
|
||||
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-nodes-manage-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-nodes-rename-group-btn" class="wfm-btn wfm-btn-sm"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
<button id="wfm-nodes-delete-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Current groups
|
||||
if (memberOf.length) {
|
||||
html += memberOf.map(g =>
|
||||
`<div class="wfm-node-group-item">
|
||||
<span>${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}">${t("nodesRemoveGroup")}</button>
|
||||
</div>`
|
||||
).join("");
|
||||
} else {
|
||||
html += `<p style="color:var(--wfm-text-secondary);font-size:12px;">No groups assigned</p>`;
|
||||
}
|
||||
|
||||
// Assign to group
|
||||
const available = groupNames.filter(g => !memberOf.includes(g));
|
||||
html += `<div style="margin-top:12px;display:flex;gap:4px;">
|
||||
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">${t("nodesAssignGroup")}</option>
|
||||
${available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+</button>
|
||||
</div>`;
|
||||
|
||||
// Create new group
|
||||
html += `<div style="margin-top:8px;display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-nodes-new-group-input" class="wfm-input" style="flex:1;" placeholder="${t("nodesGroupName")}">
|
||||
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm">${t("nodesCreateGroup")}</button>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Event listeners
|
||||
// グループから除外
|
||||
el.querySelectorAll(".wfm-node-remove-group").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const group = btn.dataset.group;
|
||||
@@ -592,8 +715,9 @@ function renderSideGroups(node) {
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = document.getElementById("wfm-nodes-assign-group-select");
|
||||
// グループに追加
|
||||
el.querySelector("#wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-nodes-assign-group-select");
|
||||
const group = sel?.value;
|
||||
if (!group) return;
|
||||
if (!state.nodeGroups[group]) state.nodeGroups[group] = [];
|
||||
@@ -605,25 +729,61 @@ function renderSideGroups(node) {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
|
||||
const input = document.getElementById("wfm-nodes-new-group-input");
|
||||
// グループ作成(作成後に現在のノードに追加)
|
||||
el.querySelector("#wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
|
||||
const input = el.querySelector("#wfm-nodes-new-group-input");
|
||||
const name = input?.value?.trim();
|
||||
if (!name) return;
|
||||
if (!state.nodeGroups[name]) state.nodeGroups[name] = [];
|
||||
if (!state.nodeGroups[name].includes(node.name)) {
|
||||
state.nodeGroups[name].push(node.name);
|
||||
if (state.nodeGroups[name]) {
|
||||
showToast(t("groupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[name] = [node.name];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
input.value = "";
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
});
|
||||
|
||||
// グループ名変更
|
||||
el.querySelector("#wfm-nodes-rename-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-nodes-manage-group-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (state.nodeGroups[newName] !== undefined) {
|
||||
showToast(t("groupNameExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[newName] = state.nodeGroups[oldName];
|
||||
delete state.nodeGroups[oldName];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(t("renamedTo", newName), "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
el.querySelector("#wfm-nodes-delete-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-nodes-manage-group-select");
|
||||
const name = sel?.value;
|
||||
if (!name) return;
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
delete state.nodeGroups[name];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
if (state.groupFilter === name) state.groupFilter = "";
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(t("groupDeleted", name), "success");
|
||||
});
|
||||
}
|
||||
|
||||
function closeNodeSidePanel() {
|
||||
const panel = document.getElementById("wfm-nodes-side-panel");
|
||||
if (panel) panel.style.display = "none";
|
||||
state.selectedNode = null;
|
||||
const titleEl = document.getElementById("wfm-nodes-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
|
||||
el.classList.remove("wfm-card-selected");
|
||||
});
|
||||
@@ -1035,6 +1195,11 @@ export function initNodesTab() {
|
||||
renderNodeGrid();
|
||||
});
|
||||
}
|
||||
setupSearchClearBtn("wfm-nodes-search", "wfm-nodes-search-clear-btn", () => {
|
||||
state.searchText = "";
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
|
||||
// Filters
|
||||
document.getElementById("wfm-nodes-category-filter")?.addEventListener("change", e => {
|
||||
@@ -1083,8 +1248,6 @@ export function initNodesTab() {
|
||||
loadNodesData();
|
||||
});
|
||||
|
||||
// Side panel close
|
||||
document.getElementById("wfm-nodes-panel-close")?.addEventListener("click", closeNodeSidePanel);
|
||||
|
||||
// Side panel tab switching
|
||||
document.querySelectorAll(".wfm-nodes-side-tab-btn").forEach(btn => {
|
||||
@@ -1102,9 +1265,88 @@ export function initNodesTab() {
|
||||
});
|
||||
});
|
||||
|
||||
// 一括操作バー: テキストをi18nで設定
|
||||
const bulkDeselBtn = document.getElementById("wfm-nodes-bulk-deselect");
|
||||
if (bulkDeselBtn) bulkDeselBtn.textContent = t("nodesBulkDeselect");
|
||||
const bulkGroupAddBtn = document.getElementById("wfm-nodes-bulk-group-add");
|
||||
if (bulkGroupAddBtn) bulkGroupAddBtn.textContent = t("nodesBulkGroupAdd");
|
||||
const bulkGroupRemBtn = document.getElementById("wfm-nodes-bulk-group-remove");
|
||||
if (bulkGroupRemBtn) bulkGroupRemBtn.textContent = t("nodesBulkGroupRemove");
|
||||
const bulkCreateAddBtn = document.getElementById("wfm-nodes-bulk-create-add");
|
||||
if (bulkCreateAddBtn) bulkCreateAddBtn.textContent = t("nodesBulkCreateAdd");
|
||||
const bulkFavBtn = document.getElementById("wfm-nodes-bulk-fav");
|
||||
if (bulkFavBtn) bulkFavBtn.textContent = t("nodesBulkFavAll");
|
||||
const bulkUnfavBtn = document.getElementById("wfm-nodes-bulk-unfav");
|
||||
if (bulkUnfavBtn) bulkUnfavBtn.textContent = t("nodesBulkUnfavAll");
|
||||
const bulkNewGroupInput = document.getElementById("wfm-nodes-bulk-new-group");
|
||||
if (bulkNewGroupInput) bulkNewGroupInput.placeholder = t("nodesBulkNewGroup");
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-deselect")?.addEventListener("click", () => {
|
||||
state.selectedNodes.clear();
|
||||
state.lastSelectionIndex = -1;
|
||||
updateNodeBulkBar();
|
||||
document.querySelectorAll("#wfm-nodes-grid .multi-selected").forEach(el => el.classList.remove("multi-selected"));
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-group-add")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-nodes-bulk-group-select");
|
||||
if (sel?.value) {
|
||||
bulkNodeAddToGroup(sel.value);
|
||||
} else {
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-group-remove")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-nodes-bulk-group-select");
|
||||
if (sel?.value) {
|
||||
bulkNodeRemoveFromGroup(sel.value);
|
||||
} else {
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-create-add")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-nodes-bulk-new-group");
|
||||
const name = input?.value?.trim();
|
||||
if (!name) return;
|
||||
bulkNodeCreateAndAddToGroup(name).then(() => { if (input) input.value = ""; });
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-fav")?.addEventListener("click", () => bulkNodeSetFavorite(true));
|
||||
document.getElementById("wfm-nodes-bulk-unfav")?.addEventListener("click", () => bulkNodeSetFavorite(false));
|
||||
|
||||
// Create node set button
|
||||
document.getElementById("wfm-node-set-create-btn")?.addEventListener("click", showCreateSetModal);
|
||||
|
||||
// Clear filters button
|
||||
const nodesClearBtn = document.getElementById("wfm-nodes-clear-filters-btn");
|
||||
if (nodesClearBtn) {
|
||||
nodesClearBtn.textContent = t("clearFilters");
|
||||
nodesClearBtn.addEventListener("click", () => {
|
||||
state.searchText = "";
|
||||
state.categoryFilter = "";
|
||||
state.packageFilter = "";
|
||||
state.tagFilter = "";
|
||||
state.groupFilter = "";
|
||||
state.showFavoritesOnly = false;
|
||||
state.currentPage = 0;
|
||||
const searchInput = document.getElementById("wfm-nodes-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
const catFilter = document.getElementById("wfm-nodes-category-filter");
|
||||
if (catFilter) catFilter.value = "";
|
||||
const pkgFilter = document.getElementById("wfm-nodes-package-filter");
|
||||
if (pkgFilter) pkgFilter.value = "";
|
||||
const tagFilter = document.getElementById("wfm-nodes-tag-filter");
|
||||
if (tagFilter) tagFilter.value = "";
|
||||
const grpFilter = document.getElementById("wfm-nodes-group-filter");
|
||||
if (grpFilter) grpFilter.value = "";
|
||||
const favBtn = document.getElementById("wfm-nodes-fav-btn");
|
||||
if (favBtn) favBtn.classList.remove("active");
|
||||
renderNodeGrid();
|
||||
});
|
||||
}
|
||||
|
||||
// Lazy load on first tab click
|
||||
document.querySelector('[data-tab="nodes"]')?.addEventListener("click", () => {
|
||||
if (!state.loaded) loadNodesData();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -21,6 +22,7 @@ let pmSearchText = "";
|
||||
let pmSelectedId = null; // currently selected preset id
|
||||
let pmGroups = {}; // { groupName: [presetId, ...] }
|
||||
const PM_GROUPS_KEY = "wfm_prompt_preset_groups";
|
||||
const PROMPT_RESERVED_GROUPS = ["Batch"];
|
||||
|
||||
// ============================================
|
||||
// Preset API helpers
|
||||
@@ -91,15 +93,11 @@ async function migrateLocalStoragePresets() {
|
||||
await apiCreatePreset({
|
||||
name: p.name || "Untitled",
|
||||
text: p.posText || p.text || "",
|
||||
negText: p.negText || "",
|
||||
category: "",
|
||||
tags: [],
|
||||
favorite: false,
|
||||
});
|
||||
// Note: negText is stored separately - we combine it
|
||||
if (p.negText) {
|
||||
// Create a separate entry for negative prompt reference
|
||||
// or store in the text field with marker
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(PRESETS_KEY);
|
||||
} catch { /* ignore migration errors */ }
|
||||
@@ -114,15 +112,17 @@ async function loadAllPresets() {
|
||||
promptPresets = await fetchPresets();
|
||||
|
||||
// Load groups from localStorage
|
||||
try {
|
||||
pmGroups = JSON.parse(localStorage.getItem(PM_GROUPS_KEY) || "{}");
|
||||
} catch { pmGroups = {}; }
|
||||
pmGroups = readJsonStorage(PM_GROUPS_KEY);
|
||||
|
||||
// Clean stale entries from groups
|
||||
// Clean stale entries from groups (preserve reserved groups even if empty)
|
||||
const validIds = new Set(promptPresets.map(p => p.id));
|
||||
for (const g of Object.keys(pmGroups)) {
|
||||
pmGroups[g] = (pmGroups[g] || []).filter(id => validIds.has(id));
|
||||
if (pmGroups[g].length === 0) delete pmGroups[g];
|
||||
if (pmGroups[g].length === 0 && !PROMPT_RESERVED_GROUPS.includes(g)) delete pmGroups[g];
|
||||
}
|
||||
// Ensure reserved groups always exist
|
||||
for (const g of PROMPT_RESERVED_GROUPS) {
|
||||
if (!pmGroups[g]) pmGroups[g] = [];
|
||||
}
|
||||
saveGroups();
|
||||
|
||||
@@ -213,6 +213,25 @@ function matchesSearch(p) {
|
||||
(p.tags || []).some(t => t.toLowerCase().includes(s));
|
||||
}
|
||||
|
||||
function isInBatchPreset(id) {
|
||||
return (pmGroups["Batch"] || []).includes(id);
|
||||
}
|
||||
|
||||
function toggleBatchPreset(id) {
|
||||
const batch = pmGroups["Batch"] || [];
|
||||
const idx = batch.indexOf(id);
|
||||
if (idx >= 0) { batch.splice(idx, 1); } else { batch.push(id); }
|
||||
pmGroups["Batch"] = batch;
|
||||
saveGroups();
|
||||
}
|
||||
|
||||
function clearBatchPresets() {
|
||||
pmGroups["Batch"] = [];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(t("promptBatchClear"), "success");
|
||||
}
|
||||
|
||||
function createPmItem(preset) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-pm-item" + (pmSelectedId === preset.id ? " active" : "");
|
||||
@@ -225,12 +244,15 @@ function createPmItem(preset) {
|
||||
? `<span style="font-size:9px;color:var(--wfm-primary);margin-left:4px;">[${esc(preset.category)}]</span>`
|
||||
: "";
|
||||
|
||||
const inBatch = isInBatchPreset(preset.id);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-pm-item-body">
|
||||
<div class="wfm-pm-item-name">${preset.favorite ? '<span style="color:#ffd700;">★</span> ' : ""}${esc(preset.name)}${catBadge}</div>
|
||||
<div class="wfm-pm-item-sub">${esc(previewText)}</div>
|
||||
</div>
|
||||
<div class="wfm-pm-item-actions">
|
||||
<button class="wfm-pm-action-btn pm-batch-btn${inBatch ? " batch-active" : ""}" title="Batch">B</button>
|
||||
<button class="wfm-pm-action-btn pm-fav-btn${preset.favorite ? " fav-active" : ""}" title="Favorite">★</button>
|
||||
<button class="wfm-pm-action-btn pm-del-btn" title="Delete" style="color:var(--wfm-danger);">✕</button>
|
||||
</div>
|
||||
@@ -242,6 +264,13 @@ function createPmItem(preset) {
|
||||
selectPresetInEditor(preset);
|
||||
});
|
||||
|
||||
// Batch toggle
|
||||
el.querySelector(".pm-batch-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleBatchPreset(preset.id);
|
||||
renderPresetManager();
|
||||
});
|
||||
|
||||
// Favorite toggle
|
||||
el.querySelector(".pm-fav-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -331,7 +360,8 @@ function renderPmGroups(container) {
|
||||
removeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
|
||||
if (pmGroups[groupName].length === 0) delete pmGroups[groupName];
|
||||
// 予約グループ (Batch) は空になってもキーを保持する
|
||||
if (pmGroups[groupName].length === 0 && !PROMPT_RESERVED_GROUPS.includes(groupName)) delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
});
|
||||
@@ -345,26 +375,70 @@ function renderPmGroups(container) {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Ollama API helpers
|
||||
// AI Assistant helpers (Ollama / LM Studio)
|
||||
// ============================================
|
||||
|
||||
async function ollamaModels() {
|
||||
const res = await fetch("/api/wfm/ollama/models");
|
||||
return await res.json();
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
|
||||
function loadPromptAiSettings() {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
}
|
||||
|
||||
async function ollamaTest() {
|
||||
const res = await fetch("/api/wfm/ollama/test", { method: "POST" });
|
||||
return await res.json();
|
||||
// バックエンド変更検知用: モデル一覧を取得した時点のバックエンド名を記録
|
||||
let _promptAiBackendForModels = null;
|
||||
|
||||
function getPromptAiConfig() {
|
||||
const s = loadPromptAiSettings();
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return { backend, url };
|
||||
}
|
||||
|
||||
async function ollamaChat(model, messages) {
|
||||
const res = await fetch("/api/wfm/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages }),
|
||||
});
|
||||
return await res.json();
|
||||
async function fetchAiModels() {
|
||||
const { backend, url } = getPromptAiConfig();
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function chatWithAi(model, messages) {
|
||||
const { backend, url } = getPromptAiConfig();
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
} else {
|
||||
// LM Studio / Lemonade (OpenAI-compatible): convert Ollama-style messages (with .images) to OpenAI content arrays
|
||||
const openAiMessages = messages.map(msg => {
|
||||
if (msg.images?.length) {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: [
|
||||
{ type: "text", text: msg.content },
|
||||
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { role: msg.role, content: msg.content };
|
||||
});
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages: openAiMessages, stream: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -429,7 +503,7 @@ function clearChat() {
|
||||
if (chat) {
|
||||
chat.innerHTML = `
|
||||
<div class="wfm-ollama-welcome">
|
||||
<p>AI Assistant (Ollama)</p>
|
||||
<p>AI Assistant</p>
|
||||
<p>${t("assistantWelcome")}</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -493,6 +567,24 @@ async function sendMessage() {
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
// バックエンドが変更されていたらモデル一覧を自動更新
|
||||
const { backend } = getPromptAiConfig();
|
||||
if (backend !== _promptAiBackendForModels) {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = `Switching to ${backend}...`; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
modelSelect.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) { status.textContent = `${models.length} models [${backend}]`; status.className = "wfm-ollama-status connected"; }
|
||||
} catch {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
}
|
||||
|
||||
const model = modelSelect.value;
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
@@ -512,16 +604,11 @@ async function sendMessage() {
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await ollamaChat(model, ollamaState.chatHistory);
|
||||
if (data.status === "success" && data.message) {
|
||||
const reply = data.message.content;
|
||||
addChatMessage("assistant", reply);
|
||||
ollamaState.chatHistory.push({ role: "assistant", content: reply });
|
||||
ollamaState.attachedImage = null;
|
||||
updateAttachmentDisplay();
|
||||
} else {
|
||||
showToast(t("error") + ": " + (data.error || data.message || "No response"), "error");
|
||||
}
|
||||
const reply = await chatWithAi(model, ollamaState.chatHistory);
|
||||
addChatMessage("assistant", reply);
|
||||
ollamaState.chatHistory.push({ role: "assistant", content: reply });
|
||||
ollamaState.attachedImage = null;
|
||||
updateAttachmentDisplay();
|
||||
} catch (err) {
|
||||
showToast(t("error") + ": " + err.message, "error");
|
||||
} finally {
|
||||
@@ -564,12 +651,8 @@ async function sendTranslate(direction) {
|
||||
};
|
||||
const prompt = prompts[direction];
|
||||
|
||||
const data = await ollamaChat(model, [{ role: "user", content: prompt }]);
|
||||
if (data.status === "success" && data.message) {
|
||||
addChatMessage("assistant", data.message.content);
|
||||
} else {
|
||||
showToast(t("error") + ": No response", "error");
|
||||
}
|
||||
const reply = await chatWithAi(model, [{ role: "user", content: prompt }]);
|
||||
addChatMessage("assistant", reply);
|
||||
} catch (err) {
|
||||
showToast(t("error") + ": " + err.message, "error");
|
||||
} finally {
|
||||
@@ -593,25 +676,255 @@ function applyToGenerateUI(text) {
|
||||
showToast(t("appliedToGenerateUI"), "success");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard API helpers
|
||||
// ============================================
|
||||
|
||||
async function wcFetchFiles() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function wcFetchContent(filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(filename)}`);
|
||||
const data = await res.json();
|
||||
return data.content ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function wcSaveFile(filename, content) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, content }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.status === "ok" ? data.file : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function wcDeleteFile(filename) {
|
||||
try {
|
||||
await fetch("/api/wfm/wildcards/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard state & helpers
|
||||
// ============================================
|
||||
|
||||
let wcFiles = [];
|
||||
let wcEditingFilename = null; // null = new file
|
||||
|
||||
// Insert text at cursor; if open+close provided wraps selection
|
||||
function wcInsertAtCursor(textarea, open, close = "") {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = textarea.value.substring(start, end);
|
||||
let inserted, cursorStart, cursorEnd;
|
||||
|
||||
if (selected && close) {
|
||||
inserted = open + selected + close;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart + selected.length;
|
||||
} else if (close) {
|
||||
inserted = open + close;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart;
|
||||
} else {
|
||||
inserted = open;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart;
|
||||
}
|
||||
|
||||
textarea.value =
|
||||
textarea.value.substring(0, start) +
|
||||
inserted +
|
||||
textarea.value.substring(end);
|
||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file list rendering
|
||||
// ============================================
|
||||
|
||||
function wcRenderFileList() {
|
||||
const list = document.getElementById("wfm-wc-file-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
if (wcFiles.length === 0) {
|
||||
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">No wildcard files yet.<br><small>Click "+ New" to create one.</small></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by directory; root ("") first, then alphabetical
|
||||
const grouped = {};
|
||||
for (const f of wcFiles) {
|
||||
const dir = f.dir || "";
|
||||
if (!grouped[dir]) grouped[dir] = [];
|
||||
grouped[dir].push(f);
|
||||
}
|
||||
const dirs = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === "") return -1;
|
||||
if (b === "") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
function addFileItem(f, indented) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item" + (indented ? " wfm-wc-file-item--sub" : "");
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span class="wfm-wc-file-name">${esc(f.name)}</span>
|
||||
<span class="wfm-wc-file-ext">.${esc(f.ext)}</span>
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-wc-use-btn" title="Insert __${esc(f.wc_name)}__ at cursor">Use</button>
|
||||
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">✎</button>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
|
||||
});
|
||||
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const content = await wcFetchContent(f.filename);
|
||||
wcOpenEditor(f.wc_name, f.ext, content ?? "", f.filename);
|
||||
});
|
||||
item.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (dir) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-wc-dir-header";
|
||||
header.textContent = dir + "/";
|
||||
list.appendChild(header);
|
||||
}
|
||||
for (const f of grouped[dir]) {
|
||||
addFileItem(f, dir !== "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file editor
|
||||
// ============================================
|
||||
|
||||
function wcOpenEditor(name, ext, content, existingFilename) {
|
||||
wcEditingFilename = existingFilename || null;
|
||||
|
||||
const editor = document.getElementById("wfm-wc-editor");
|
||||
const nameInput = document.getElementById("wfm-wc-editor-name");
|
||||
const extSelect = document.getElementById("wfm-wc-editor-ext");
|
||||
const contentTA = document.getElementById("wfm-wc-editor-content");
|
||||
const deleteBtn = document.getElementById("wfm-wc-editor-delete-btn");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = name || "";
|
||||
extSelect.value = ext || "txt";
|
||||
contentTA.value = content || "";
|
||||
deleteBtn.style.display = existingFilename ? "" : "none";
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function wcCloseEditor() {
|
||||
wcEditingFilename = null;
|
||||
const editor = document.getElementById("wfm-wc-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
async function wcRefreshFiles() {
|
||||
wcFiles = await wcFetchFiles();
|
||||
wcRenderFileList();
|
||||
wcUpdateFilePicker();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file picker popup
|
||||
// ============================================
|
||||
|
||||
function wcUpdateFilePicker() {
|
||||
const picker = document.getElementById("wfm-wc-file-picker");
|
||||
if (!picker) return;
|
||||
picker.innerHTML = "";
|
||||
if (wcFiles.length === 0) {
|
||||
picker.innerHTML = `<div style="padding:8px;font-size:11px;color:var(--wfm-text-secondary);">No wildcard files</div>`;
|
||||
return;
|
||||
}
|
||||
// Group by directory same as file list
|
||||
const grouped = {};
|
||||
for (const f of wcFiles) {
|
||||
const dir = f.dir || "";
|
||||
if (!grouped[dir]) grouped[dir] = [];
|
||||
grouped[dir].push(f);
|
||||
}
|
||||
const dirs = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === "") return -1;
|
||||
if (b === "") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
for (const dir of dirs) {
|
||||
if (dir) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "wfm-wc-picker-dir";
|
||||
sep.textContent = dir + "/";
|
||||
picker.appendChild(sep);
|
||||
}
|
||||
for (const f of grouped[dir]) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-wc-picker-item" + (dir ? " wfm-wc-picker-item--sub" : "");
|
||||
btn.textContent = `__${f.wc_name}__`;
|
||||
btn.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
|
||||
picker.style.display = "none";
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialize
|
||||
// ============================================
|
||||
|
||||
export function initPromptTab() {
|
||||
// Ollama model refresh
|
||||
// AI model refresh
|
||||
async function refreshModels() {
|
||||
const select = document.getElementById("wfm-ollama-model");
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
const { backend } = getPromptAiConfig();
|
||||
try {
|
||||
const data = await ollamaModels();
|
||||
if (data.status === "success" && data.models) {
|
||||
select.innerHTML = data.models
|
||||
.map((m) => `<option value="${m.name}">${m.name}</option>`)
|
||||
.join("");
|
||||
if (status) {
|
||||
status.textContent = `${data.models.length} models`;
|
||||
status.className = "wfm-ollama-status connected";
|
||||
}
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
if (select) {
|
||||
select.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
}
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) {
|
||||
status.textContent = `${models.length} models [${backend}]`;
|
||||
status.className = "wfm-ollama-status connected";
|
||||
}
|
||||
} catch {
|
||||
if (status) {
|
||||
@@ -631,15 +944,11 @@ export function initPromptTab() {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = "Testing..."; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
const data = await ollamaTest();
|
||||
if (data.connected) {
|
||||
if (status) { status.textContent = t("connected"); status.className = "wfm-ollama-status connected"; }
|
||||
await refreshModels();
|
||||
} else {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
await fetchAiModels();
|
||||
if (status) { status.textContent = t("connectedCheck"); status.className = "wfm-ollama-status connected"; }
|
||||
await refreshModels();
|
||||
} catch {
|
||||
if (status) { status.textContent = t("error"); status.className = "wfm-ollama-status error"; }
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -750,18 +1059,14 @@ export function initPromptTab() {
|
||||
});
|
||||
if (created) {
|
||||
promptPresets.push(created);
|
||||
pmSelectedId = created.id;
|
||||
showToast(t("presetSaved"), "success");
|
||||
}
|
||||
}
|
||||
|
||||
pmSelectedId = null;
|
||||
renderPresetSelect();
|
||||
renderPresetManager();
|
||||
|
||||
// Re-select
|
||||
if (pmSelectedId && presetSelect) {
|
||||
presetSelect.value = pmSelectedId;
|
||||
}
|
||||
if (presetSelect) presetSelect.value = "";
|
||||
});
|
||||
|
||||
// Apply preset to GenerateUI
|
||||
@@ -793,50 +1098,54 @@ export function initPromptTab() {
|
||||
if (!name || !name.trim()) return;
|
||||
const key = name.trim();
|
||||
if (pmGroups[key]) {
|
||||
showToast("Group already exists", "error");
|
||||
showToast(t("groupExists"), "error");
|
||||
return;
|
||||
}
|
||||
pmGroups[key] = [];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(`Group "${key}" created`, "success");
|
||||
showToast(t("groupCreated", key), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-preset-add-to-group-btn")?.addEventListener("click", () => {
|
||||
const groupSelect = document.getElementById("wfm-preset-group-select");
|
||||
const groupName = groupSelect?.value;
|
||||
if (!groupName) {
|
||||
showToast("Select a group first", "error");
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
return;
|
||||
}
|
||||
const id = presetSelect?.value;
|
||||
if (!id) {
|
||||
showToast("Select a preset first", "error");
|
||||
showToast(t("selectPresetFirst"), "error");
|
||||
return;
|
||||
}
|
||||
if (!pmGroups[groupName]) pmGroups[groupName] = [];
|
||||
if (pmGroups[groupName].includes(id)) {
|
||||
showToast("Already in this group", "info");
|
||||
showToast(t("alreadyInGroup"), "info");
|
||||
return;
|
||||
}
|
||||
pmGroups[groupName].push(id);
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast("Added to group", "success");
|
||||
showToast(t("addedToGroup"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-preset-del-group-btn")?.addEventListener("click", () => {
|
||||
const groupSelect = document.getElementById("wfm-preset-group-select");
|
||||
const groupName = groupSelect?.value;
|
||||
if (!groupName) {
|
||||
showToast("Select a group first", "error");
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
return;
|
||||
}
|
||||
if (PROMPT_RESERVED_GROUPS.includes(groupName)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete group "${groupName}"?`)) return;
|
||||
delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(`Group "${groupName}" deleted`, "success");
|
||||
showToast(t("groupDeleted", groupName), "success");
|
||||
});
|
||||
|
||||
// --- Preset Manager ---
|
||||
@@ -853,4 +1162,161 @@ export function initPromptTab() {
|
||||
pmSearchText = e.target.value.trim();
|
||||
renderPresetManager();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-pm-batch-clear-btn")?.addEventListener("click", () => {
|
||||
clearBatchPresets();
|
||||
});
|
||||
|
||||
// ── Center column tab switching ──────────────────────────
|
||||
document.querySelectorAll(".wfm-prompt-center-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.ptab;
|
||||
document.querySelectorAll(".wfm-prompt-center-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".wfm-prompt-center-pane").forEach(p => {
|
||||
p.style.display = p.id === `wfm-ptab-${target}` ? "" : "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Wildcard syntax buttons ──────────────────────────────
|
||||
document.querySelectorAll(".wfm-wc-btn[data-wc-open]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
const open = btn.dataset.wcOpen || "";
|
||||
const close = btn.dataset.wcClose || "";
|
||||
wcInsertAtCursor(ta, open, close);
|
||||
});
|
||||
});
|
||||
|
||||
// LoRA LBW template button
|
||||
document.getElementById("wfm-wc-lora-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
wcInsertAtCursor(ta, "<lora::1:LBW=;>");
|
||||
// Move cursor to after "lora:" for the name
|
||||
const pos = ta.selectionStart - "<lora::1:LBW=;>".length + 6;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
ta.focus();
|
||||
});
|
||||
|
||||
// n$${ } button — asks for n then inserts multi-pick template
|
||||
document.getElementById("wfm-wc-nset-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
const n = prompt("Number of items to pick (n):", "2");
|
||||
if (n === null) return;
|
||||
const num = parseInt(n, 10);
|
||||
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
|
||||
const template = "{" + num + "$$|}";
|
||||
const start = ta.selectionStart;
|
||||
wcInsertAtCursor(ta, template);
|
||||
// Move cursor between $$ and |
|
||||
const inner = start + ("{" + num + "$$").length;
|
||||
ta.setSelectionRange(inner, inner);
|
||||
ta.focus();
|
||||
});
|
||||
|
||||
// File picker toggle
|
||||
const filePickBtn = document.getElementById("wfm-wc-filepick-btn");
|
||||
const filePicker = document.getElementById("wfm-wc-file-picker");
|
||||
filePickBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!filePicker) return;
|
||||
const isOpen = filePicker.style.display !== "none";
|
||||
filePicker.style.display = isOpen ? "none" : "";
|
||||
if (!isOpen) wcUpdateFilePicker();
|
||||
});
|
||||
document.addEventListener("click", () => {
|
||||
if (filePicker) filePicker.style.display = "none";
|
||||
});
|
||||
|
||||
// ── Wildcard prompt actions ──────────────────────────────
|
||||
document.getElementById("wfm-wc-copy-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
const text = ta?.value || "";
|
||||
if (!text.trim()) { showToast(t("noTextToCopy"), "error"); return; }
|
||||
navigator.clipboard.writeText(text).then(() => showToast(t("copiedToClipboard"), "success"));
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-pos-btn")?.addEventListener("click", () => {
|
||||
const src = document.getElementById("wfm-wc-prompt");
|
||||
const dst = document.getElementById("wfm-preset-pos");
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast(t("sentToPositive"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
|
||||
const src = document.getElementById("wfm-wc-prompt");
|
||||
const dst = document.getElementById("wfm-preset-neg");
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast(t("sentToNegative"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) { ta.value = ""; ta.focus(); }
|
||||
});
|
||||
|
||||
// ── Wildcard file manager ────────────────────────────────
|
||||
document.getElementById("wfm-wc-new-file-btn")?.addEventListener("click", () => {
|
||||
wcOpenEditor("", "txt", "", null);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-files-refresh-btn")?.addEventListener("click", () => {
|
||||
wcRefreshFiles();
|
||||
});
|
||||
|
||||
// Editor: save
|
||||
document.getElementById("wfm-wc-editor-save-btn")?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-wc-editor-name");
|
||||
const extSelect = document.getElementById("wfm-wc-editor-ext");
|
||||
const contentTA = document.getElementById("wfm-wc-editor-content");
|
||||
|
||||
// Normalize separators and strip leading/trailing slashes
|
||||
const rawName = (nameInput?.value || "").trim().replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
|
||||
const ext = extSelect?.value || "txt";
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!rawName) { showToast(t("pleaseEnterFilename"), "error"); return; }
|
||||
// Validate each path component (allow letters, numbers, -, _, ., space)
|
||||
const nameParts = rawName.split("/");
|
||||
if (nameParts.some(p => !p || !/^[\w\-. ]+$/.test(p))) {
|
||||
showToast(t("invalidPathFormat"), "error");
|
||||
return;
|
||||
}
|
||||
const name = rawName;
|
||||
const filename = `${name}.${ext}`;
|
||||
const saved = await wcSaveFile(filename, content);
|
||||
if (saved) {
|
||||
showToast(t("savedAs", filename), "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
} else {
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: delete
|
||||
document.getElementById("wfm-wc-editor-delete-btn")?.addEventListener("click", async () => {
|
||||
if (!wcEditingFilename) return;
|
||||
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
|
||||
await wcDeleteFile(wcEditingFilename);
|
||||
showToast(t("deletedName", wcEditingFilename), "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
});
|
||||
|
||||
// Editor: cancel
|
||||
document.getElementById("wfm-wc-editor-cancel-btn")?.addEventListener("click", () => {
|
||||
wcCloseEditor();
|
||||
});
|
||||
|
||||
// Initial wildcard file load
|
||||
wcRefreshFiles();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
|
||||
|
||||
import { getSettings, readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
@@ -15,11 +17,7 @@ function rgbToHex(rgb) {
|
||||
}
|
||||
|
||||
function loadLocalSettings() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return readJsonStorage(SETTINGS_KEY);
|
||||
}
|
||||
|
||||
function saveLocalSettings(patch) {
|
||||
@@ -314,6 +312,60 @@ function clearCustomOverrides() {
|
||||
if (fontEl) fontEl.remove();
|
||||
}
|
||||
|
||||
const TEXTAREA_FONT_SIZE_DEFAULT = 13;
|
||||
const TEXTAREA_FONT_SIZE_STYLE_ID = "wfm-ta-font-size-style";
|
||||
const TEXTAREA_FONT_SIZE_TARGETS = [
|
||||
"#wfm-prompt-pos-text",
|
||||
"#wfm-prompt-neg-text",
|
||||
"#wfm-ollama-input",
|
||||
"#wfm-preset-pos",
|
||||
"#wfm-preset-neg",
|
||||
"#wfm-wc-prompt",
|
||||
"#wfm-wc-editor-content",
|
||||
"#wfm-meta-prompt-full",
|
||||
].join(",\n ");
|
||||
|
||||
export function applyTextareaFontSize(size) {
|
||||
const px = Math.max(10, Math.min(28, parseInt(size) || TEXTAREA_FONT_SIZE_DEFAULT));
|
||||
let el = document.getElementById(TEXTAREA_FONT_SIZE_STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = TEXTAREA_FONT_SIZE_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `${TEXTAREA_FONT_SIZE_TARGETS} { font-size: ${px}px !important; }`;
|
||||
}
|
||||
|
||||
// JSON highlight color settings
|
||||
const JSON_COLOR_STYLE_ID = "wfm-json-color-style";
|
||||
|
||||
const JSON_COLOR_DEFS = [
|
||||
{ id: "base", label: () => t("jsonColorBase") || "通常テキスト", def: "#abb2bf" },
|
||||
{ id: "yellow", label: () => t("jsonColorYellow") || "名前 / スケジューラ", def: "#e5c07b" },
|
||||
{ id: "pink", label: () => t("jsonColorPink") || "タイトル", def: "#c678dd" },
|
||||
{ id: "green", label: () => t("jsonColorGreen") || "Width / Height", def: "#98c379" },
|
||||
{ id: "cyan", label: () => t("jsonColorCyan") || "プロンプト / テキスト", def: "#61afef" },
|
||||
{ id: "red", label: () => t("jsonColorRed") || "画像 / ファイル", def: "#e06c75" },
|
||||
];
|
||||
|
||||
export function applyJsonColors(colors) {
|
||||
const vals = Object.fromEntries(JSON_COLOR_DEFS.map(d => [d.id, colors?.[d.id] || d.def]));
|
||||
let el = document.getElementById(JSON_COLOR_STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = JSON_COLOR_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = [
|
||||
`.wfm-json-highlight { color: ${vals.base}; }`,
|
||||
`.json-key-yellow, .json-val-yellow { color: ${vals.yellow}; }`,
|
||||
`.json-key-pink, .json-val-pink { color: ${vals.pink}; }`,
|
||||
`.json-key-green, .json-val-green { color: ${vals.green}; }`,
|
||||
`.json-key-cyan, .json-val-cyan { color: ${vals.cyan}; }`,
|
||||
`.json-key-red, .json-val-red { color: ${vals.red}; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
if (themeId) {
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
@@ -339,6 +391,11 @@ export async function initSettingsTab() {
|
||||
const settings = loadLocalSettings();
|
||||
const serverSettings = await loadServerSettings();
|
||||
|
||||
// Sync civitai_host to localStorage so other tabs can read it without an extra fetch
|
||||
if (serverSettings.civitai_host) {
|
||||
localStorage.setItem("wfm_civitai_host", serverSettings.civitai_host);
|
||||
}
|
||||
|
||||
// Load current workflows dir info
|
||||
let workflowsDirInfo = { current: "", default: "" };
|
||||
try {
|
||||
@@ -346,6 +403,12 @@ export async function initSettingsTab() {
|
||||
workflowsDirInfo = await res.json();
|
||||
} catch {}
|
||||
|
||||
// Load gallery output dir info
|
||||
let outputDirInfo = { current: "", default: "", saved: "" };
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
outputDirInfo = await res.json();
|
||||
} catch {}
|
||||
|
||||
const uiLang = getLang();
|
||||
const summaryLang = getSummaryLang();
|
||||
@@ -353,26 +416,10 @@ export async function initSettingsTab() {
|
||||
container.innerHTML = `
|
||||
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
|
||||
|
||||
<!-- Language Settings -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("langLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-ui-lang">
|
||||
${buildLangOptions(getLanguageOptions(), uiLang)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("summaryLangLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-summary-lang">
|
||||
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
|
||||
</select>
|
||||
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
|
||||
⚠ ${t("summaryLangNote")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
<div class="wfm-settings-layout">
|
||||
|
||||
<!-- Right column: Theme (floated right) -->
|
||||
<div class="wfm-settings-right-col">
|
||||
<!-- Theme -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("themeLabel")}</summary>
|
||||
@@ -471,6 +518,65 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div><!-- /wfm-settings-right-col -->
|
||||
|
||||
<!-- Left column: all settings (Language + rest), flows left of floated Theme -->
|
||||
<div class="wfm-settings-left-col">
|
||||
<!-- Language Settings -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("langLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-ui-lang">
|
||||
${buildLangOptions(getLanguageOptions(), uiLang)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("summaryLangLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-summary-lang">
|
||||
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
|
||||
</select>
|
||||
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
|
||||
⚠ ${t("summaryLangNote")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Text Size -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("textSizeLabel") || "Text Size"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>${t("textSizeTextarea") || "Prompt / Chat Textarea"}</span>
|
||||
<span id="wfm-ta-font-size-val" style="font-size:12px;color:var(--wfm-text-secondary);">${settings.textareaFontSize || TEXTAREA_FONT_SIZE_DEFAULT}px</span>
|
||||
</label>
|
||||
<input type="range" class="wfm-range" id="wfm-ta-font-size"
|
||||
min="10" max="28" step="1"
|
||||
value="${settings.textareaFontSize || TEXTAREA_FONT_SIZE_DEFAULT}"
|
||||
style="width:100%;margin-top:4px;">
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("textSizeHint") || "Applies to: Generate UI prompts, AI Assistant chat, Preset prompts, Wildcard textarea, Metadata prompt preview"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- RAW JSON Colors -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("jsonColorsSection") || "RAW JSON Colors"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<div class="wfm-color-grid" id="wfm-json-color-grid">
|
||||
${JSON_COLOR_DEFS.map(d => {
|
||||
const saved = settings.jsonColors?.[d.id] || d.def;
|
||||
return `<div class="wfm-color-item">
|
||||
<label>${d.label()}</label>
|
||||
<input type="color" data-json-color="${d.id}" value="${saved}">
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-json-color-reset" style="margin-top:8px;">${t("jsonColorReset") || "デフォルトに戻す"}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Workflow Data Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
|
||||
@@ -492,6 +598,27 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Gallery Output Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("galleryOutputDirLabel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-output-dir"
|
||||
value="${serverSettings.gallery_output_dir || ""}"
|
||||
placeholder="${outputDirInfo.default}">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-output-dir-apply">${t("workflowsDirApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-output-dir-reset">${t("workflowsDirDefault")}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("galleryOutputDirHint")}
|
||||
</small>
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;">
|
||||
${t("workflowsDirCurrent")}: <code id="wfm-settings-output-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${outputDirInfo.current}</code>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ComfyUI Settings -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
|
||||
@@ -510,28 +637,95 @@ export async function initSettingsTab() {
|
||||
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- Ollama Settings -->
|
||||
<!-- Default Checkpoint -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("defaultCheckpointSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-settings-default-ckpt-enabled" ${serverSettings.default_checkpoint_enabled ? "checked" : ""}>
|
||||
<span id="wfm-settings-default-ckpt-enable-label">${t("defaultCheckpointEnable")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label id="wfm-settings-default-ckpt-model-label">${t("defaultCheckpointModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-default-ckpt-select" style="flex:1;">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-default-ckpt-refresh">${t("refresh")}</button>
|
||||
</div>
|
||||
<small id="wfm-settings-default-ckpt-hint" style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("defaultCheckpointHint")}
|
||||
</small>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-default-ckpt-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- AI Assistant Settings (Prompt Tab) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("aiSettingsBackend")}</label>
|
||||
<div class="wfm-ai-backend-row">
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="ollama"> Ollama
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaUrl")}</label>
|
||||
<input type="text" class="wfm-input" id="wfm-settings-ollama-url"
|
||||
value="${serverSettings.ollama_url || "http://localhost:11434"}"
|
||||
<input type="text" class="wfm-input" id="wfm-settings-prompt-ai-url"
|
||||
placeholder="http://localhost:11434">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaDefaultModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-ollama-model" style="flex:1;">
|
||||
<select class="wfm-select" id="wfm-settings-prompt-ai-model" style="flex:1;">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-refresh">${t("refresh")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-refresh">${t("refresh")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-ollama-save">${t("saveOllama")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-test">${t("testConnection")}</button>
|
||||
<span id="wfm-settings-ollama-status" style="font-size:12px;line-height:28px;"></span>
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-prompt-ai-save">${t("saveOllama")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-test">${t("testConnection")}</button>
|
||||
<span id="wfm-settings-prompt-ai-status" style="font-size:12px;line-height:28px;"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- CivitAI Settings -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("civitaiApiKeySetting")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;gap:4px;">
|
||||
${t("civitaiHostSetting")}
|
||||
<span title="${t("civitaiHostHint")}" style="cursor:help;color:var(--wfm-text-secondary);font-size:13px;">ℹ</span>
|
||||
</label>
|
||||
<select class="wfm-select" id="wfm-settings-civitai-host" style="width:100%;">
|
||||
<option value="civitai.com" ${(serverSettings.civitai_host || "civitai.com") === "civitai.com" ? "selected" : ""}>${t("civitaiHostCom")}</option>
|
||||
<option value="civitai.red" ${serverSettings.civitai_host === "civitai.red" ? "selected" : ""}>${t("civitaiHostRed")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-bottom:8px;">
|
||||
${t("civitaiApiKeyHint")}
|
||||
</small>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="password" class="wfm-input" id="wfm-settings-civitai-api-key"
|
||||
value="${serverSettings.civitai_api_key || ""}"
|
||||
placeholder="${t("civitaiApiKeyPlaceholder")}"
|
||||
style="flex:1;">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-civitai-api-key-save">
|
||||
${t("civitaiApiKeySave")}
|
||||
</button>
|
||||
</div>
|
||||
<div id="wfm-settings-civitai-api-key-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -571,8 +765,51 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- G'MIC Integration -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">G'MIC-Qt Integration</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>G'MIC-Qt Executable Path</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-gmic-path"
|
||||
value="${serverSettings.gmic_qt_path || ""}"
|
||||
placeholder="C:\\path\\to\\gmic_qt.exe"
|
||||
style="flex:1;">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-gmic-path-save">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div id="wfm-settings-gmic-path-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Data Management -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
|
||||
<p style="font-size:12px;color:var(--wfm-text-secondary);margin-bottom:12px;">${t("dataManagementHint")}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export">${t("exportData")}</button>
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
|
||||
${t("importData")}
|
||||
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- Wildcard Integration -->
|
||||
<details class="wfm-settings-section" id="wfm-wildcard-integration-section">
|
||||
<summary class="wfm-settings-summary">Wildcard Integration</summary>
|
||||
<div id="wfm-wildcard-link-status" style="font-size:13px;color:var(--wfm-text-secondary);">Loading...</div>
|
||||
</details>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
|
||||
|
||||
</div><!-- /wfm-settings-left-col -->
|
||||
|
||||
<div style="clear:both;"></div>
|
||||
</div><!-- /wfm-settings-layout -->
|
||||
`;
|
||||
|
||||
// --- Theme change handler ---
|
||||
@@ -776,7 +1013,7 @@ export async function initSettingsTab() {
|
||||
setLang(lang);
|
||||
saveLocalSettings({ ...loadLocalSettings(), uiLang: lang });
|
||||
// Re-render the entire page to apply new language
|
||||
showToast("Language changed. Reloading...", "success");
|
||||
showToast(t("languageChanged"), "success");
|
||||
setTimeout(() => location.reload(), 500);
|
||||
});
|
||||
|
||||
@@ -828,56 +1065,166 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Ollama model list loader ---
|
||||
async function loadOllamaModels() {
|
||||
const select = document.getElementById("wfm-settings-ollama-model");
|
||||
// --- Gallery output dir handlers ---
|
||||
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
const currentEl = document.getElementById("wfm-settings-output-dir-current");
|
||||
const newDir = dirInput.value.trim();
|
||||
try {
|
||||
const res = await fetch("/api/wfm/ollama/models");
|
||||
const res = await fetch("/api/wfm/settings/output-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gallery_output_dir: newDir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
const savedModel = serverSettings.ollama_model || "llava";
|
||||
if (data.error) {
|
||||
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
|
||||
} else {
|
||||
if (currentEl) currentEl.textContent = data.current;
|
||||
showToast(t("workflowsDirChanged"), "success");
|
||||
// gallery-tab に通知して再ロード
|
||||
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-settings-output-dir-reset")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
const currentEl = document.getElementById("wfm-settings-output-dir-current");
|
||||
dirInput.value = "";
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gallery_output_dir: "" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (currentEl) currentEl.textContent = data.current;
|
||||
showToast(t("workflowsDirChanged"), "success");
|
||||
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// --- AI Assistant (Prompt Tab) settings ---
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
const loadPromptAiCfg = () => {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
};
|
||||
const savePromptAiCfg = (patch) => {
|
||||
const data = { ...loadPromptAiCfg(), ...patch };
|
||||
localStorage.setItem(PROMPT_AI_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Restore saved values into UI
|
||||
const promptAiSaved = loadPromptAiCfg();
|
||||
const promptAiBackendSaved = promptAiSaved.backend || "ollama";
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.checked = r.value === promptAiBackendSaved;
|
||||
});
|
||||
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
|
||||
if (promptAiUrlEl) {
|
||||
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
|
||||
promptAiUrlEl.placeholder = getAiBackendDefaultUrl(promptAiBackendSaved);
|
||||
}
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
|
||||
});
|
||||
});
|
||||
|
||||
// Load models directly from the selected backend
|
||||
async function loadPromptAiModels() {
|
||||
const select = document.getElementById("wfm-settings-prompt-ai-model");
|
||||
if (!select) return;
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
try {
|
||||
let models = [];
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
models = (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
models = ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
const saved = loadPromptAiCfg().model || "";
|
||||
select.innerHTML = models.length
|
||||
? models.map((m) => `<option value="${m.name}" ${m.name === savedModel ? "selected" : ""}>${m.name}</option>`).join("")
|
||||
? models.map(name => `<option value="${name}" ${name === saved ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
} catch {
|
||||
select.innerHTML = `<option value="">${t("failedLoadModels")}</option>`;
|
||||
}
|
||||
}
|
||||
await loadOllamaModels();
|
||||
await loadPromptAiModels();
|
||||
|
||||
// Refresh Ollama models
|
||||
document.getElementById("wfm-settings-ollama-refresh")?.addEventListener("click", loadOllamaModels);
|
||||
document.getElementById("wfm-settings-prompt-ai-refresh")?.addEventListener("click", loadPromptAiModels);
|
||||
|
||||
// Test Ollama connection
|
||||
document.getElementById("wfm-settings-ollama-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-ollama-status");
|
||||
statusEl.textContent = "Testing...";
|
||||
statusEl.style.color = "var(--wfm-text-secondary)";
|
||||
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
|
||||
try {
|
||||
const res = await fetch("/api/wfm/ollama/test", { method: "POST" });
|
||||
const data = await res.json();
|
||||
statusEl.textContent = data.connected ? t("ollamaConnected") : `${t("ollamaFailed")}: ${data.message}`;
|
||||
statusEl.style.color = data.connected ? "var(--wfm-success)" : "var(--wfm-danger)";
|
||||
let count = 0;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
count = ((await res.json()).models || []).length;
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
count = ((await res.json()).data || []).length;
|
||||
}
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaConnected")} (${count} models)`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
await loadPromptAiModels();
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("error")}: ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaFailed")}: ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
});
|
||||
|
||||
// Save Ollama settings (to server)
|
||||
document.getElementById("wfm-settings-ollama-save")?.addEventListener("click", async () => {
|
||||
const ollamaUrl = document.getElementById("wfm-settings-ollama-url")?.value.trim() || "http://localhost:11434";
|
||||
const ollamaModel = document.getElementById("wfm-settings-ollama-model")?.value || "";
|
||||
document.getElementById("wfm-settings-prompt-ai-save")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = promptAiUrlEl?.value.trim() || "";
|
||||
const model = document.getElementById("wfm-settings-prompt-ai-model")?.value || "";
|
||||
savePromptAiCfg({ backend, backendUrl: url, model });
|
||||
showToast(t("ollamaSaved"), "success");
|
||||
});
|
||||
|
||||
// CivitAI host select
|
||||
document.getElementById("wfm-settings-civitai-host")?.addEventListener("change", async (e) => {
|
||||
const host = e.target.value;
|
||||
try {
|
||||
await saveServerSettings({ ollama_url: ollamaUrl, ollama_model: ollamaModel });
|
||||
serverSettings.ollama_url = ollamaUrl;
|
||||
serverSettings.ollama_model = ollamaModel;
|
||||
showToast(t("ollamaSaved"), "success");
|
||||
await saveServerSettings({ civitai_host: host });
|
||||
serverSettings.civitai_host = host;
|
||||
localStorage.setItem("wfm_civitai_host", host);
|
||||
showToast(t("civitaiHostSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("saveError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Save CivitAI API key
|
||||
document.getElementById("wfm-settings-civitai-api-key-save")?.addEventListener("click", async () => {
|
||||
const apiKey = document.getElementById("wfm-settings-civitai-api-key")?.value.trim() || "";
|
||||
const statusEl = document.getElementById("wfm-settings-civitai-api-key-status");
|
||||
try {
|
||||
await saveServerSettings({ civitai_api_key: apiKey });
|
||||
serverSettings.civitai_api_key = apiKey;
|
||||
showToast(t("civitaiApiKeySaved"), "success");
|
||||
if (statusEl) { statusEl.textContent = ""; }
|
||||
} catch (err) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `${t("saveError")}: ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test ComfyUI URL
|
||||
document.getElementById("wfm-settings-test-url")?.addEventListener("click", async () => {
|
||||
const urlInput = document.getElementById("wfm-settings-comfyui-url");
|
||||
@@ -890,6 +1237,35 @@ export async function initSettingsTab() {
|
||||
statusEl.style.color = ok ? "var(--wfm-success)" : "var(--wfm-danger)";
|
||||
});
|
||||
|
||||
// Default Checkpoint
|
||||
const defaultCkptSelect = document.getElementById("wfm-settings-default-ckpt-select");
|
||||
|
||||
async function loadDefaultCkptOptions() {
|
||||
if (!defaultCkptSelect) return;
|
||||
try {
|
||||
const list = await comfyUI.fetchCheckpoints();
|
||||
const saved = serverSettings.default_checkpoint_name || "";
|
||||
defaultCkptSelect.innerHTML = `<option value="">${t("selectModel")}</option>` +
|
||||
(list || []).map((m) => `<option value="${m}" ${m === saved ? "selected" : ""}>${m}</option>`).join("");
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadDefaultCkptOptions();
|
||||
|
||||
document.getElementById("wfm-settings-default-ckpt-refresh")?.addEventListener("click", loadDefaultCkptOptions);
|
||||
|
||||
document.getElementById("wfm-settings-default-ckpt-save")?.addEventListener("click", async () => {
|
||||
const enabled = !!document.getElementById("wfm-settings-default-ckpt-enabled")?.checked;
|
||||
const ckptName = defaultCkptSelect?.value || "";
|
||||
try {
|
||||
await saveServerSettings({ default_checkpoint_enabled: enabled, default_checkpoint_name: ckptName });
|
||||
serverSettings.default_checkpoint_enabled = enabled;
|
||||
serverSettings.default_checkpoint_name = ckptName;
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("saveError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Test Eagle URL (via server proxy to avoid CORS)
|
||||
document.getElementById("wfm-settings-test-eagle")?.addEventListener("click", async () => {
|
||||
const urlInput = document.getElementById("wfm-settings-eagle-url");
|
||||
@@ -918,7 +1294,7 @@ export async function initSettingsTab() {
|
||||
|
||||
// Clear default workflow
|
||||
document.getElementById("wfm-settings-clear-wf")?.addEventListener("click", () => {
|
||||
const s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
const s = getSettings();
|
||||
delete s.defaultWorkflow;
|
||||
delete s.defaultWorkflowData;
|
||||
localStorage.setItem("wfm_settings", JSON.stringify(s));
|
||||
@@ -928,8 +1304,38 @@ export async function initSettingsTab() {
|
||||
showToast(t("defaultWorkflowCleared"), "success");
|
||||
});
|
||||
|
||||
// --- Textarea font size slider ---
|
||||
document.getElementById("wfm-ta-font-size")?.addEventListener("input", (e) => {
|
||||
const px = parseInt(e.target.value);
|
||||
const valEl = document.getElementById("wfm-ta-font-size-val");
|
||||
if (valEl) valEl.textContent = `${px}px`;
|
||||
applyTextareaFontSize(px);
|
||||
});
|
||||
|
||||
// --- JSON highlight color pickers ---
|
||||
document.getElementById("wfm-json-color-grid")?.addEventListener("input", (e) => {
|
||||
if (e.target.type !== "color") return;
|
||||
const id = e.target.dataset.jsonColor;
|
||||
const jsonColors = { ...(loadLocalSettings().jsonColors || {}), [id]: e.target.value };
|
||||
saveLocalSettings({ jsonColors });
|
||||
applyJsonColors(jsonColors);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-json-color-reset")?.addEventListener("click", () => {
|
||||
const cur = loadLocalSettings();
|
||||
delete cur.jsonColors;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
|
||||
applyJsonColors(undefined);
|
||||
document.querySelectorAll("#wfm-json-color-grid input[type=color]").forEach(inp => {
|
||||
const def = JSON_COLOR_DEFS.find(d => d.id === inp.dataset.jsonColor);
|
||||
if (def) inp.value = def.def;
|
||||
});
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// Save (local settings)
|
||||
document.getElementById("wfm-settings-save")?.addEventListener("click", () => {
|
||||
const taFontSize = parseInt(document.getElementById("wfm-ta-font-size")?.value) || TEXTAREA_FONT_SIZE_DEFAULT;
|
||||
const patch = {
|
||||
comfyuiUrl: document.getElementById("wfm-settings-comfyui-url")?.value.trim() || "",
|
||||
eagleUrl: document.getElementById("wfm-settings-eagle-url")?.value.trim() || "http://localhost:41595",
|
||||
@@ -937,6 +1343,7 @@ export async function initSettingsTab() {
|
||||
uiLang: getLang(),
|
||||
summaryLang: getSummaryLang(),
|
||||
theme: document.documentElement.getAttribute("data-theme") || "",
|
||||
textareaFontSize: taFontSize,
|
||||
};
|
||||
|
||||
saveLocalSettings(patch);
|
||||
@@ -952,4 +1359,158 @@ export async function initSettingsTab() {
|
||||
if (savedUrl) {
|
||||
comfyUI.updateUrl(savedUrl);
|
||||
}
|
||||
|
||||
// --- Wildcard Integration ---
|
||||
async function wcLoadLinkStatus() {
|
||||
const el = document.getElementById("wfm-wildcard-link-status");
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards/link-status");
|
||||
const data = await res.json();
|
||||
wcRenderLinkStatus(el, data);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<span style="color:var(--wfm-error,#f44336)">Error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function wcRenderLinkStatus(el, data) {
|
||||
if (!data.impact_pack_installed) {
|
||||
el.innerHTML = `
|
||||
<p style="margin:0 0 8px;">ComfyUI-Impact-Pack がインストールされていません。</p>
|
||||
<p style="margin:0;font-size:12px;color:var(--wfm-text-secondary);">
|
||||
ワイルドカードを Impact Pack と共有するには、先に
|
||||
<a href="https://github.com/ltdrdata/ComfyUI-Impact-Pack" target="_blank" rel="noopener"
|
||||
style="color:var(--wfm-accent);">ComfyUI-Impact-Pack</a>
|
||||
をインストールしてください。
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.is_linked) {
|
||||
el.innerHTML = `
|
||||
<p style="margin:0 0 4px;color:var(--wfm-success,#4caf50);">✓ リンク済み</p>
|
||||
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
|
||||
WFS wildcard → ${data.link_target || data.impact_pack_wildcards_dir}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-wc-remove-link">リンクを解除</button>
|
||||
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
|
||||
document.getElementById("wfm-wc-remove-link")?.addEventListener("click", async () => {
|
||||
const msg = document.getElementById("wfm-wc-link-msg");
|
||||
try {
|
||||
const r = await fetch("/api/wfm/wildcards/remove-link", { method: "POST" });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || r.statusText);
|
||||
msg.textContent = "リンクを解除しました。";
|
||||
msg.style.color = "var(--wfm-success,#4caf50)";
|
||||
setTimeout(wcLoadLinkStatus, 800);
|
||||
} catch (e) {
|
||||
msg.textContent = "エラー: " + e.message;
|
||||
msg.style.color = "var(--wfm-error,#f44336)";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<p style="margin:0 0 4px;">Impact Pack wildcards ディレクトリ:</p>
|
||||
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">${data.impact_pack_wildcards_dir}</p>
|
||||
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
|
||||
WFS の wildcard ディレクトリを Impact Pack の wildcards ディレクトリへのリンクに置き換えます。
|
||||
既存の WFS ワイルドカードファイルは Impact Pack ディレクトリへ移行されます。
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-wc-create-link">リンクを作成</button>
|
||||
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
|
||||
document.getElementById("wfm-wc-create-link")?.addEventListener("click", async () => {
|
||||
const msg = document.getElementById("wfm-wc-link-msg");
|
||||
try {
|
||||
const r = await fetch("/api/wfm/wildcards/create-link", { method: "POST" });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || r.statusText);
|
||||
const migrated = d.migrated_files?.length
|
||||
? ` (移行: ${d.migrated_files.join(", ")})`
|
||||
: "";
|
||||
msg.textContent = "リンクを作成しました。" + migrated;
|
||||
msg.style.color = "var(--wfm-success,#4caf50)";
|
||||
setTimeout(wcLoadLinkStatus, 800);
|
||||
} catch (e) {
|
||||
msg.textContent = "エラー: " + e.message;
|
||||
msg.style.color = "var(--wfm-error,#f44336)";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load wildcard link status when section is opened
|
||||
document.getElementById("wfm-wildcard-integration-section")?.addEventListener("toggle", (e) => {
|
||||
if (e.target.open) wcLoadLinkStatus();
|
||||
});
|
||||
|
||||
// --- Data Export ---
|
||||
document.getElementById("wfm-settings-export")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/export");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "wfm-data-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
statusEl.textContent = t("exportSuccess");
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("exportError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Data Import ---
|
||||
document.getElementById("wfm-settings-import-file")?.addEventListener("change", async (e) => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const bundle = JSON.parse(text);
|
||||
const res = await fetch("/api/wfm/settings/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || res.statusText);
|
||||
const imported = result.imported?.join(", ") || "-";
|
||||
statusEl.textContent = t("importSuccess") + ": " + imported;
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("importError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
// Reset file input so same file can be re-selected
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
// Save G'MIC path setting
|
||||
document.getElementById("wfm-settings-gmic-path-save")?.addEventListener("click", async () => {
|
||||
const path = document.getElementById("wfm-settings-gmic-path")?.value.trim() || "";
|
||||
const statusEl = document.getElementById("wfm-settings-gmic-path-status");
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Saving...";
|
||||
statusEl.style.color = "var(--wfm-text-secondary)";
|
||||
}
|
||||
try {
|
||||
await saveServerSettings({ gmic_qt_path: path });
|
||||
serverSettings.gmic_qt_path = path;
|
||||
showToast("G'MIC path saved successfully", "success");
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Saved successfully!";
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Error: " + err.message;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,109 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
export function getEagleSettings() {
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/eagle/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
eagleUrl: eagle.url,
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
|
||||
filename: fileInfo?.filename || "",
|
||||
subfolder: fileInfo?.subfolder || "",
|
||||
type: fileInfo?.type || "output",
|
||||
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
|
||||
localPath: fileInfo?.localPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === "success") {
|
||||
console.log("[Eagle] Saved:", name);
|
||||
} else {
|
||||
console.warn("[Eagle] Save failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Eagle] Save error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索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();
|
||||
}
|
||||
@@ -10,6 +10,12 @@ import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { t, getSummaryPrompt } from "./i18n.js";
|
||||
import { highlightJSON } from "./json-highlight.js";
|
||||
import { openBadgeEditModal } from "./models-tab.js";
|
||||
import { escapeHtml, getSettings, readJsonStorage, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
const WF_RESERVED_GROUPS = ["Batch"];
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -19,35 +25,48 @@ const state = {
|
||||
workflows: [],
|
||||
activeBadge: "ALL",
|
||||
searchText: "",
|
||||
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
|
||||
viewMode: localStorage.getItem("wfm_default_view") === "table" ? "table" : "thumb",
|
||||
selectedWf: null,
|
||||
groupFilter: "", // empty = all groups
|
||||
currentPage: 0,
|
||||
showBatchOnly: false,
|
||||
};
|
||||
|
||||
const WF_PER_PAGE = 24;
|
||||
|
||||
function getBadgePalette() {
|
||||
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
|
||||
catch { return {}; }
|
||||
return readJsonStorage("wfm_models_badge_palette");
|
||||
}
|
||||
|
||||
function saveBadgePalette(palette) {
|
||||
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Open in ComfyUI
|
||||
// Open in ComfyUI / Send to Canvas
|
||||
// ============================================
|
||||
|
||||
function sendToCanvas(workflowData) {
|
||||
try {
|
||||
// window.opener経由でComfyUIキャンバスに直接ロード(推奨)
|
||||
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
|
||||
window.opener.wfmReceiveWorkflow(workflowData);
|
||||
showToast(t("workflowSentToCanvasDirect"), "success");
|
||||
return;
|
||||
}
|
||||
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
|
||||
const fmt = comfyWorkflow.detectFormat(workflowData);
|
||||
if (fmt === "api") {
|
||||
showToast(t("apiFormatCanvasNoOpener"), "error");
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("wfm_pending_workflow", JSON.stringify(workflowData));
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function openInComfyUI(workflowData) {
|
||||
// Open ComfyUI frontend in a new tab and load the workflow.
|
||||
// Same-origin, so we can access the child window's app object.
|
||||
@@ -89,10 +108,15 @@ const groups = {
|
||||
|
||||
load() {
|
||||
try {
|
||||
this._data = JSON.parse(localStorage.getItem("wfm_groups") || "{}");
|
||||
this._data = readJsonStorage("wfm_groups");
|
||||
} catch {
|
||||
this._data = {};
|
||||
}
|
||||
let needsSave = false;
|
||||
for (const g of WF_RESERVED_GROUPS) {
|
||||
if (!this._data[g]) { this._data[g] = []; needsSave = true; }
|
||||
}
|
||||
if (needsSave) this.save();
|
||||
return this._data;
|
||||
},
|
||||
save() {
|
||||
@@ -118,11 +142,14 @@ const groups = {
|
||||
},
|
||||
|
||||
deleteGroup(name) {
|
||||
if (WF_RESERVED_GROUPS.includes(name)) return false;
|
||||
delete this.data[name];
|
||||
this.save();
|
||||
return true;
|
||||
},
|
||||
|
||||
renameGroup(oldName, newName) {
|
||||
if (WF_RESERVED_GROUPS.includes(oldName)) return false;
|
||||
if (!newName || this.data[newName] || !this.data[oldName]) return false;
|
||||
this.data[newName] = this.data[oldName];
|
||||
delete this.data[oldName];
|
||||
@@ -155,6 +182,32 @@ const groups = {
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Batch helpers
|
||||
// ============================================
|
||||
|
||||
function isInBatch(filename) {
|
||||
return (groups.data["Batch"] || []).includes(filename);
|
||||
}
|
||||
|
||||
function toggleBatch(filename) {
|
||||
const batch = groups.data["Batch"] || [];
|
||||
const idx = batch.indexOf(filename);
|
||||
if (idx >= 0) { batch.splice(idx, 1); } else { batch.push(filename); }
|
||||
groups.data["Batch"] = batch;
|
||||
groups.save();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function clearBatch() {
|
||||
groups.data["Batch"] = [];
|
||||
groups.save();
|
||||
state.showBatchOnly = false;
|
||||
renderModelFilters();
|
||||
renderGrid();
|
||||
showToast(t("wfBatchClear"), "success");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API
|
||||
// ============================================
|
||||
@@ -217,6 +270,85 @@ async function getRawWorkflow(filename) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Summary — structured extraction (not raw JSON dump)
|
||||
// ============================================
|
||||
|
||||
// analyzeWorkflow() の結果をコンパクトなテキストに整形する。
|
||||
// 生JSONをそのままLLMに渡すと、MarkdownNoteの長文やUI座標情報などのノイズで
|
||||
// 文字数制限に達し、肝心のノード構成情報(サブグラフ内含む)が切り捨てられやすい。
|
||||
function _buildStructuredSummaryText(analysis) {
|
||||
const lines = [];
|
||||
|
||||
if (analysis.checkpoint_nodes.length) {
|
||||
lines.push(`Checkpoint: ${analysis.checkpoint_nodes.map((n) => n.ckpt_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.diffusion_model_nodes.length) {
|
||||
lines.push(`Diffusion Model (UNET): ${analysis.diffusion_model_nodes.map((n) => n.unet_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.text_encoder_nodes.length) {
|
||||
lines.push(`Text Encoder (CLIP): ${analysis.text_encoder_nodes.map((n) => [n.clip_name1, n.clip_name2].filter(Boolean).join("+")).join(", ")}`);
|
||||
}
|
||||
if (analysis.vae_nodes.length) {
|
||||
lines.push(`VAE: ${analysis.vae_nodes.map((n) => n.vae_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.lora_nodes.length) {
|
||||
lines.push(`LoRA: ${analysis.lora_nodes.map((n) => {
|
||||
if (n.is_lora_manager) return "(LoraManager)";
|
||||
const strengths = n.strength_clip !== undefined
|
||||
? `model:${n.strength_model}, clip:${n.strength_clip}`
|
||||
: `model:${n.strength_model}`;
|
||||
return `${n.lora_name} (${strengths})`;
|
||||
}).join(", ")}`);
|
||||
}
|
||||
if (analysis.controlnet_nodes.length) {
|
||||
lines.push(`ControlNet: ${analysis.controlnet_nodes.map((n) => n.control_net_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.hypernetwork_nodes.length) {
|
||||
lines.push(`Hypernetwork: ${analysis.hypernetwork_nodes.map((n) => n.hypernetwork_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
|
||||
const positives = analysis.prompt_nodes.filter((n) => n.role === "positive").map((n) => n.text).filter(Boolean);
|
||||
const negatives = analysis.prompt_nodes.filter((n) => n.role === "negative").map((n) => n.text).filter(Boolean);
|
||||
if (positives.length) lines.push(`Positive Prompt: ${positives.join(" / ")}`);
|
||||
if (negatives.length) lines.push(`Negative Prompt: ${negatives.join(" / ")}`);
|
||||
|
||||
if (analysis.sampler_nodes.length) {
|
||||
const s = analysis.sampler_nodes[0];
|
||||
lines.push(`Sampler: steps=${s.steps}, cfg=${s.cfg}, sampler=${s.sampler_name}, scheduler=${s.scheduler}, denoise=${s.denoise}`);
|
||||
}
|
||||
if (analysis.latent_nodes.length) {
|
||||
const l = analysis.latent_nodes[0];
|
||||
lines.push(`Latent size: ${l.width ?? "?"}x${l.height ?? "?"}, batch=${l.batch_size ?? "?"}`);
|
||||
}
|
||||
|
||||
if (analysis.all_nodes.length) {
|
||||
const nodeTypes = [...new Set(analysis.all_nodes.map((n) => n.type))];
|
||||
lines.push(`Node types used (${analysis.all_nodes.length} nodes total): ${nodeTypes.join(", ")}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ワークフロー(UI/API/サブグラフ入りいずれの形式でも)から要約用テキストを組み立てる。
|
||||
// 変換・解析に失敗した場合は、従来通り生JSON先頭部分にフォールバックする。
|
||||
async function _buildSummarySourceText(wfJson, filename) {
|
||||
try {
|
||||
const fmt = comfyWorkflow.detectFormat(wfJson, filename);
|
||||
let apiWf = wfJson;
|
||||
if (fmt === "ui") {
|
||||
apiWf = await comfyWorkflow.convertUiToApi(wfJson);
|
||||
} else if (fmt !== "api") {
|
||||
return JSON.stringify(wfJson).substring(0, 4000);
|
||||
}
|
||||
const analysis = comfyWorkflow.analyzeWorkflow(apiWf);
|
||||
const text = _buildStructuredSummaryText(analysis);
|
||||
return text || JSON.stringify(wfJson).substring(0, 4000);
|
||||
} catch {
|
||||
return JSON.stringify(wfJson).substring(0, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rendering Helpers
|
||||
// ============================================
|
||||
@@ -245,16 +377,29 @@ function renderModelFilters() {
|
||||
const container = document.getElementById("wfm-model-filters");
|
||||
if (!container) return;
|
||||
|
||||
const badges = getAllBadges();
|
||||
const palette = getBadgePalette();
|
||||
const badges = Object.keys(palette).sort();
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="wfm-filter-btn ${state.activeBadge === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
|
||||
<button class="wfm-filter-btn ${state.activeBadge === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">★</button>
|
||||
${badges.map((b) => `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}">${escapeHtml(b)}</button>`).join("")}
|
||||
<button class="wfm-filter-btn wfm-wf-batch-filter-btn${state.showBatchOnly ? " active" : ""}">B</button>
|
||||
${badges.map((b) => {
|
||||
const color = palette[b] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
return `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}"${style}>${escapeHtml(b)}</button>`;
|
||||
}).join("")}
|
||||
`;
|
||||
|
||||
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
if (btn.classList.contains("wfm-wf-batch-filter-btn")) {
|
||||
state.showBatchOnly = !state.showBatchOnly;
|
||||
state.currentPage = 0;
|
||||
renderModelFilters();
|
||||
renderGrid();
|
||||
return;
|
||||
}
|
||||
state.activeBadge = btn.dataset.filter;
|
||||
state.currentPage = 0;
|
||||
renderModelFilters();
|
||||
@@ -265,6 +410,8 @@ function renderModelFilters() {
|
||||
|
||||
function filterWorkflows() {
|
||||
return state.workflows.filter((wf) => {
|
||||
if (wf.filename === ".index.json") return false;
|
||||
|
||||
if (state.activeBadge === "FAVORITE") {
|
||||
if (!wf.metadata.favorite) return false;
|
||||
} else if (state.activeBadge !== "ALL") {
|
||||
@@ -272,6 +419,11 @@ function filterWorkflows() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Batch filter
|
||||
if (state.showBatchOnly) {
|
||||
if (!(groups.data["Batch"] || []).includes(wf.filename)) return false;
|
||||
}
|
||||
|
||||
// Group filter
|
||||
if (state.groupFilter) {
|
||||
const grpFiles = groups.data[state.groupFilter] || [];
|
||||
@@ -312,56 +464,39 @@ function renderGrid() {
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML =
|
||||
'<p class="wfm-placeholder">No workflows found</p>';
|
||||
updateWfPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderTableView(grid, filtered);
|
||||
updateWfPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Thumbnail / Card view with pagination
|
||||
const totalPages = Math.ceil(filtered.length / WF_PER_PAGE);
|
||||
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
|
||||
if (state.currentPage < 0) state.currentPage = 0;
|
||||
const start = state.currentPage * WF_PER_PAGE;
|
||||
const pageItems = filtered.slice(start, start + WF_PER_PAGE);
|
||||
|
||||
updateWfPagination(totalPages);
|
||||
|
||||
grid.innerHTML = "";
|
||||
pageItems.forEach((wf) => {
|
||||
filtered.forEach((wf) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card";
|
||||
card.dataset.filename = wf.filename;
|
||||
const hasThumb = !!wf.thumbnail;
|
||||
const badges = wfBadgesHtml(wf);
|
||||
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
|
||||
const favStar = wf.metadata.favorite ? "\u2605" : "\u2606";
|
||||
const favClass = wf.metadata.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
|
||||
if (state.viewMode === "thumb") {
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-thumb">${hasThumb ? `<img src="${wf.thumbnail}" loading="lazy" />` : `<span class="wfm-card-thumb-placeholder">${t("noImage")}</span>`}</div>
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-thumb">
|
||||
${hasThumb ? `<img src="${wf.thumbnail}" loading="lazy" />` : `<span class="wfm-card-thumb-placeholder">${t("noImage")}</span>`}
|
||||
<button class="wfm-batch-btn${isInBatch(wf.filename) ? ' active' : ''}" title="Batch">B</button>
|
||||
</div>
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${wf.filename}">${wf.filename.replace(/\.json$/, "")}</div>
|
||||
<div class="wfm-card-meta">${badges}</div>
|
||||
</div>
|
||||
<button class="${favClass}" title="${t("favorite")}">${favStar}</button>`;
|
||||
} else {
|
||||
// Card view: no thumbnail, more meta info
|
||||
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${wf.filename}">${wf.filename.replace(/\.json$/, "")}</div>
|
||||
<div class="wfm-card-meta">${badges}</div>
|
||||
<div class="wfm-card-io">${io}</div>
|
||||
<div class="wfm-card-tags">${tags}</div>
|
||||
</div>
|
||||
<button class="${favClass}" title="${t("favorite")}">${favStar}</button>`;
|
||||
}
|
||||
|
||||
card.querySelector(".wfm-batch-btn")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleBatch(wf.filename);
|
||||
});
|
||||
|
||||
card.querySelector(".wfm-fav-btn")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -382,22 +517,6 @@ function renderGrid() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateWfPagination(totalPages) {
|
||||
const container = document.getElementById("wfm-workflow-pagination");
|
||||
if (!container) return;
|
||||
if (totalPages <= 1) { container.innerHTML = ""; return; }
|
||||
container.innerHTML = `
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">«</button>
|
||||
<span>${state.currentPage + 1} / ${totalPages}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderTableView(grid, filtered) {
|
||||
const table = document.createElement("table");
|
||||
@@ -411,6 +530,7 @@ function renderTableView(grid, filtered) {
|
||||
<th style="width:60px;">${t("tags")}</th>
|
||||
<th class="wfm-table-th-memo">${t("memo")}</th>
|
||||
<th class="wfm-table-th-summary">${t("summary")}</th>
|
||||
<th style="width:30px;"></th>
|
||||
</tr></thead>`;
|
||||
const tbody = document.createElement("tbody");
|
||||
|
||||
@@ -435,7 +555,13 @@ function renderTableView(grid, filtered) {
|
||||
<td class="wfm-table-td-io">${io}</td>
|
||||
<td>${tags}</td>
|
||||
<td class="wfm-table-td-memo" title="${(wf.metadata.memo || "").replace(/"/g, """)}">${wf.metadata.memo || ""}</td>
|
||||
<td class="wfm-table-td-summary" title="${(wf.metadata.summary || "").replace(/"/g, """)}">${wf.metadata.summary || ""}</td>`;
|
||||
<td class="wfm-table-td-summary" title="${(wf.metadata.summary || "").replace(/"/g, """)}">${wf.metadata.summary || ""}</td>
|
||||
<td class="wfm-table-td-batch"><button class="wfm-batch-btn${isInBatch(wf.filename) ? ' active' : ''}" title="Batch">B</button></td>`;
|
||||
|
||||
tr.querySelector(".wfm-batch-btn")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleBatch(wf.filename);
|
||||
});
|
||||
|
||||
tr.querySelector(".wfm-fav-btn")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -468,7 +594,7 @@ async function toggleFavorite(wf, btnEl) {
|
||||
btnEl.textContent = newVal ? "\u2605" : "\u2606";
|
||||
btnEl.classList.toggle("active", newVal);
|
||||
} catch (err) {
|
||||
showToast("Error: " + err.message, "error");
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +610,6 @@ async function showSidePanel(wf, cardEl) {
|
||||
|
||||
titleEl.textContent = wf.filename.replace(/\.json$/, "");
|
||||
contentEl.textContent = t("loading");
|
||||
panel.style.display = "flex";
|
||||
|
||||
state.selectedWf = wf;
|
||||
|
||||
@@ -504,7 +629,7 @@ async function showSidePanel(wf, cardEl) {
|
||||
sidePanelThumbUpdate(wf);
|
||||
|
||||
// Update group tab
|
||||
sidePanelGroupUpdate();
|
||||
renderSideGroup(wf);
|
||||
|
||||
const badgeEl = document.getElementById("wfm-json-format-badge");
|
||||
try {
|
||||
@@ -528,17 +653,17 @@ async function showSidePanel(wf, cardEl) {
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
const panel = document.getElementById("wfm-side-panel");
|
||||
if (panel) panel.style.display = "none";
|
||||
document.querySelectorAll(".wfm-card, .wfm-table-row").forEach((c) =>
|
||||
c.classList.remove("wfm-card-selected")
|
||||
);
|
||||
state.selectedWf = null;
|
||||
const titleEl = document.getElementById("wfm-side-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
const listLoadBtn = document.getElementById("wfm-list-load-btn");
|
||||
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
|
||||
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
|
||||
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
|
||||
sidePanelGroupUpdate();
|
||||
renderSideGroup(null);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -573,49 +698,164 @@ function sidePanelThumbUpdate(wf) {
|
||||
// Group Tab in Side Panel
|
||||
// ============================================
|
||||
|
||||
function sidePanelGroupUpdate() {
|
||||
const nameEl = document.getElementById("wfm-grp-prop-name");
|
||||
const groupEl = document.getElementById("wfm-grp-prop-group");
|
||||
const assignBtn = document.getElementById("wfm-assign-group-btn");
|
||||
const removeBtn = document.getElementById("wfm-remove-group-btn");
|
||||
function renderSideGroup(wf) {
|
||||
const el = document.getElementById("wfm-side-tab-group");
|
||||
if (!el) return;
|
||||
|
||||
if (!state.selectedWf) {
|
||||
if (nameEl) nameEl.textContent = t("noSelection");
|
||||
if (groupEl) groupEl.textContent = "";
|
||||
if (assignBtn) assignBtn.disabled = true;
|
||||
if (removeBtn) removeBtn.disabled = true;
|
||||
const allGroups = groups.groupNames();
|
||||
|
||||
if (!wf) {
|
||||
el.innerHTML = `<p style="padding:12px;color:var(--wfm-text-secondary);font-size:12px;">${t("noSelection")}</p>`;
|
||||
_refreshGroupFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = state.selectedWf.filename;
|
||||
if (nameEl) nameEl.textContent = filename.replace(/\.json$/, "");
|
||||
const grps = groups.groupsOf(filename);
|
||||
if (groupEl) groupEl.textContent = grps.length > 0 ? `${t("groupsLabel")}: ${grps.join(", ")}` : `${t("groupsLabel")}: ${t("groupsNone")}`;
|
||||
if (assignBtn) assignBtn.disabled = false;
|
||||
const filename = wf.filename;
|
||||
const memberOf = groups.groupsOf(filename);
|
||||
const available = allGroups.filter(g => !memberOf.includes(g));
|
||||
|
||||
const selGroup = document.getElementById("wfm-group-select")?.value;
|
||||
if (removeBtn) removeBtn.disabled = !selGroup || !grps.includes(selGroup);
|
||||
el.innerHTML = `
|
||||
<div style="padding:8px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map(g =>
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-wf-grp-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`
|
||||
).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-grp-assign-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${available.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-grp-assign-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
|
||||
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-grp-new-input" class="wfm-input"
|
||||
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
|
||||
<button id="wfm-grp-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-grp-manage-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-grp-rename-btn" class="wfm-btn wfm-btn-sm"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
<button id="wfm-grp-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// グループから除外
|
||||
el.querySelectorAll(".wfm-wf-grp-remove").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
groups.remove(filename, btn.dataset.group);
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(t("removedFromGroupName", btn.dataset.group), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// グループに追加
|
||||
el.querySelector("#wfm-grp-assign-btn")?.addEventListener("click", () => {
|
||||
const g = el.querySelector("#wfm-grp-assign-sel")?.value;
|
||||
if (!g) return;
|
||||
groups.assign(filename, g);
|
||||
renderSideGroup(wf);
|
||||
showToast(t("addedToGroupName", g), "success");
|
||||
});
|
||||
|
||||
// グループ作成(作成後に現在のWFに追加)
|
||||
el.querySelector("#wfm-grp-create-btn")?.addEventListener("click", () => {
|
||||
const input = el.querySelector("#wfm-grp-new-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(t("nameAlreadyExists", name), "warning");
|
||||
return;
|
||||
}
|
||||
groups.assign(filename, name);
|
||||
input.value = "";
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(t("groupCreated", name), "success");
|
||||
});
|
||||
|
||||
// グループ名変更
|
||||
el.querySelector("#wfm-grp-rename-btn")?.addEventListener("click", () => {
|
||||
const oldName = el.querySelector("#wfm-grp-manage-sel")?.value;
|
||||
if (!oldName) return;
|
||||
if (WF_RESERVED_GROUPS.includes(oldName)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (!groups.renameGroup(oldName, newName)) {
|
||||
showToast(t("renameFailed"), "error");
|
||||
return;
|
||||
}
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(t("renamedTo", newName), "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
el.querySelector("#wfm-grp-delete-btn")?.addEventListener("click", () => {
|
||||
const name = el.querySelector("#wfm-grp-manage-sel")?.value;
|
||||
if (!name) return;
|
||||
if (WF_RESERVED_GROUPS.includes(name)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
groups.deleteGroup(name);
|
||||
if (state.groupFilter === name) {
|
||||
state.groupFilter = "";
|
||||
const filterSel = document.getElementById("wfm-group-filter");
|
||||
if (filterSel) filterSel.value = "";
|
||||
}
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(t("groupDeleted", name), "success");
|
||||
});
|
||||
|
||||
_refreshGroupFilter();
|
||||
}
|
||||
|
||||
function refreshGroupSelects() {
|
||||
const sideSel = document.getElementById("wfm-group-select");
|
||||
/** ツールバーのグループフィルタセレクトのみ更新 */
|
||||
function _refreshGroupFilter() {
|
||||
const filterSel = document.getElementById("wfm-group-filter");
|
||||
if (!filterSel) return;
|
||||
const prevVal = filterSel.value;
|
||||
const grpNames = groups.groupNames();
|
||||
|
||||
[filterSel, sideSel].forEach((sel, i) => {
|
||||
if (!sel) return;
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = i === 0
|
||||
? `<option value="">${t("allGroups")}</option>`
|
||||
: `<option value="">${t("selectGroup")}</option>`;
|
||||
grpNames.forEach((g) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (grpNames.includes(prevVal)) sel.value = prevVal;
|
||||
filterSel.innerHTML = `<option value="">${t("allGroups")}</option>`;
|
||||
grpNames.forEach(g => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
filterSel.appendChild(opt);
|
||||
});
|
||||
if (grpNames.includes(prevVal)) filterSel.value = prevVal;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -718,7 +958,7 @@ function openDetailModal(wf) {
|
||||
</section>
|
||||
<div class="wfm-modal-actions">
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-detail-load">${t("loadInGenerate")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-open-comfyui">${t("openInComfyUI")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-open-comfyui">${t("sendToCanvas")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-set-default">${t("setAsDefault")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-change-thumb">${t("changeThumbnail")}</button>
|
||||
<input type="file" id="wfm-detail-thumb-file" accept="image/png,image/webp,image/jpeg" style="display:none">
|
||||
@@ -756,7 +996,7 @@ function openDetailModal(wf) {
|
||||
favBtn.classList.toggle("active", newVal);
|
||||
renderGrid();
|
||||
} catch (err) {
|
||||
showToast("Error: " + err.message, "error");
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
titleEl.parentNode.insertBefore(favBtn, titleEl);
|
||||
@@ -799,6 +1039,8 @@ function openDetailModal(wf) {
|
||||
showToast(t("renameError") + ": " + err.message, "error");
|
||||
}
|
||||
};
|
||||
if (titleInput._commitRename) titleInput.removeEventListener("blur", titleInput._commitRename);
|
||||
titleInput._commitRename = commitRename;
|
||||
titleInput.addEventListener("blur", commitRename);
|
||||
titleInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); titleInput.blur(); }
|
||||
@@ -866,11 +1108,11 @@ function openDetailModal(wf) {
|
||||
}
|
||||
});
|
||||
|
||||
// Open in ComfyUI
|
||||
// Send to Canvas
|
||||
document.getElementById("wfm-detail-open-comfyui")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const wfData = await getRawWorkflow(wf.filename);
|
||||
openInComfyUI(wfData);
|
||||
sendToCanvas(wfData);
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
@@ -887,7 +1129,7 @@ function openDetailModal(wf) {
|
||||
apiData = await comfyWorkflow.convertUiToApi(wfData);
|
||||
}
|
||||
// Save to localStorage
|
||||
const settings = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
const settings = getSettings();
|
||||
settings.defaultWorkflow = wf.filename;
|
||||
settings.defaultWorkflowData = apiData;
|
||||
localStorage.setItem("wfm_settings", JSON.stringify(settings));
|
||||
@@ -965,11 +1207,20 @@ function openDetailModal(wf) {
|
||||
btn.textContent = t("summarizing");
|
||||
try {
|
||||
const wfJson = await getRawWorkflow(wf.filename);
|
||||
const promptText = getSummaryPrompt() + JSON.stringify(wfJson).substring(0, 4000);
|
||||
const sourceText = await _buildSummarySourceText(wfJson, wf.filename);
|
||||
const promptText = getSummaryPrompt() + sourceText;
|
||||
const aiCfg = readJsonStorage("wfm_prompt_ai_settings", {});
|
||||
if (aiCfg.backend && aiCfg.backend !== "ollama") {
|
||||
throw new Error(t("summarizeOllamaOnly"));
|
||||
}
|
||||
const chatRes = await fetch("/api/wfm/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: promptText }] }),
|
||||
body: JSON.stringify({
|
||||
url: aiCfg.backendUrl || undefined,
|
||||
model: aiCfg.model || undefined,
|
||||
messages: [{ role: "user", content: promptText }],
|
||||
}),
|
||||
});
|
||||
const chatData = await chatRes.json();
|
||||
if (chatData.error) throw new Error(chatData.error);
|
||||
@@ -1037,7 +1288,7 @@ export function initWorkflowTab() {
|
||||
showToast(msg, ng.length ? "error" : "success");
|
||||
await loadWorkflows();
|
||||
} catch (err) {
|
||||
showToast("Import error: " + err.message, "error");
|
||||
showToast(t("importErrorMsg", err.message), "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
});
|
||||
@@ -1064,6 +1315,11 @@ export function initWorkflowTab() {
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
setupSearchClearBtn("wfm-search", "wfm-search-clear-btn", () => {
|
||||
state.searchText = "";
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
// Drag & drop on workflow grid
|
||||
const grid = document.getElementById("wfm-workflow-grid");
|
||||
@@ -1080,17 +1336,14 @@ export function initWorkflowTab() {
|
||||
const data = await importFiles(files);
|
||||
const ok =
|
||||
data.results?.filter((r) => r.status === "success").length ?? 0;
|
||||
showToast(`${ok} file(s) imported via drag & drop`, "success");
|
||||
showToast(t("importedViaDrop", ok), "success");
|
||||
await loadWorkflows();
|
||||
} catch (err) {
|
||||
showToast("Import error: " + err.message, "error");
|
||||
showToast(t("importErrorMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Side panel: close button
|
||||
document.getElementById("wfm-side-panel-close")?.addEventListener("click", closeSidePanel);
|
||||
|
||||
// Side panel: tab switching
|
||||
document.querySelectorAll(".wfm-side-tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -1124,74 +1377,7 @@ export function initWorkflowTab() {
|
||||
|
||||
// Group management
|
||||
groups.load();
|
||||
refreshGroupSelects();
|
||||
|
||||
// Add group
|
||||
document.getElementById("wfm-group-add-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-group-name-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(`"${name}" already exists`, "error");
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
refreshGroupSelects();
|
||||
showToast(`Group "${name}" created`, "success");
|
||||
});
|
||||
|
||||
// Rename group
|
||||
document.getElementById("wfm-group-rename-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
const newName = prompt("New group name:", oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (!groups.renameGroup(oldName, newName)) {
|
||||
showToast("Rename failed", "error");
|
||||
return;
|
||||
}
|
||||
refreshGroupSelects();
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Group renamed to "${newName}"`, "success");
|
||||
});
|
||||
|
||||
// Delete group
|
||||
document.getElementById("wfm-group-delete-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const name = sel?.value;
|
||||
if (!name) return;
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
groups.deleteGroup(name);
|
||||
refreshGroupSelects();
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
});
|
||||
|
||||
// Assign to group
|
||||
document.getElementById("wfm-assign-group-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const groupName = sel?.value;
|
||||
if (!groupName || !state.selectedWf) return;
|
||||
groups.assign(state.selectedWf.filename, groupName);
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Added to "${groupName}"`, "success");
|
||||
});
|
||||
|
||||
// Remove from group
|
||||
document.getElementById("wfm-remove-group-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const groupName = sel?.value;
|
||||
if (!groupName || !state.selectedWf) return;
|
||||
groups.remove(state.selectedWf.filename, groupName);
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Removed from "${groupName}"`, "success");
|
||||
});
|
||||
|
||||
// Update remove button state when group select changes
|
||||
document.getElementById("wfm-group-select")?.addEventListener("change", () => {
|
||||
sidePanelGroupUpdate();
|
||||
});
|
||||
_refreshGroupFilter();
|
||||
|
||||
// Toolbar: group filter dropdown
|
||||
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
|
||||
@@ -1213,12 +1399,12 @@ export function initWorkflowTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar: Open in ComfyUI button
|
||||
// Toolbar: Send to Canvas button
|
||||
document.getElementById("wfm-list-open-comfyui-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
try {
|
||||
const wfData = await getRawWorkflow(state.selectedWf.filename);
|
||||
openInComfyUI(wfData);
|
||||
sendToCanvas(wfData);
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
@@ -1227,7 +1413,7 @@ export function initWorkflowTab() {
|
||||
// Toolbar: Refresh button
|
||||
document.getElementById("wfm-refresh-btn")?.addEventListener("click", async () => {
|
||||
await loadWorkflows();
|
||||
showToast("Refreshed", "success");
|
||||
showToast(t("refreshed"), "success");
|
||||
});
|
||||
|
||||
// Toolbar: ⚙ settings button (view settings panel)
|
||||
@@ -1256,6 +1442,30 @@ export function initWorkflowTab() {
|
||||
openBadgeEditModal(() => renderGrid());
|
||||
});
|
||||
|
||||
// Toolbar: BC (batch clear) button
|
||||
document.getElementById("wfm-wf-batch-clear-btn")?.addEventListener("click", () => {
|
||||
clearBatch();
|
||||
});
|
||||
|
||||
// Toolbar: Clear filters button
|
||||
const wfClearBtn = document.getElementById("wfm-wf-clear-filters-btn");
|
||||
if (wfClearBtn) {
|
||||
wfClearBtn.textContent = t("clearFilters");
|
||||
wfClearBtn.addEventListener("click", () => {
|
||||
state.activeBadge = "ALL";
|
||||
state.showBatchOnly = false;
|
||||
state.searchText = "";
|
||||
state.groupFilter = "";
|
||||
state.currentPage = 0;
|
||||
const searchInput = document.getElementById("wfm-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
const groupFilter = document.getElementById("wfm-group-filter");
|
||||
if (groupFilter) groupFilter.value = "";
|
||||
renderModelFilters();
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadWorkflows();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -605,50 +605,61 @@ app.registerExtension({
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const applyButtonIcon = (button, className, getIconFn, extraStyles) => {
|
||||
if (button.querySelector("svg")) return;
|
||||
button.classList.add(className);
|
||||
button.innerHTML = getIconFn();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
if (extraStyles) Object.assign(button.style, extraStyles);
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
};
|
||||
|
||||
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 replaceAllIcons = () => {
|
||||
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) => {
|
||||
applyButtonIcon(btn, cfg.className, cfg.getIcon, cfg.styles);
|
||||
foundAny = true;
|
||||
applyIconToButton(btn, cfg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const waitAndObserve = () => {
|
||||
const firstBtn = document.querySelector(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
if (!firstBtn) {
|
||||
requestAnimationFrame(waitAndObserve);
|
||||
return;
|
||||
}
|
||||
replaceAllIcons();
|
||||
|
||||
const container = firstBtn.closest(".actionbar-container") || firstBtn.parentElement;
|
||||
if (container) {
|
||||
new MutationObserver(() => replaceAllIcons()).observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
if (!foundAny) {
|
||||
requestAnimationFrame(pollUntilFound);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(waitAndObserve);
|
||||
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 |