Compare commits
@@ -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}")
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -69,3 +69,8 @@ NODE_SETS_FILE = DATA_DIR / "node_sets.json"
|
||||
PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
TAGGER_SETTINGS_FILE = DATA_DIR / "tagger_settings.json"
|
||||
TAGGER_MODELS_DIR = _COMFYUI_ROOT / "models" / "tagger"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""WFS_GalleryFeeder – Feeds images from a gallery group into a workflow."""
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feeder専用グループ名(gallery_metadata.json 内で予約される)
|
||||
FEEDER_GROUP = "__Feeder__"
|
||||
|
||||
|
||||
class WFS_GalleryFeeder:
|
||||
"""Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"group_name": ("STRING", {"default": FEEDER_GROUP}),
|
||||
"index": ("INT", {"default": 0, "min": 0, "max": 99999}),
|
||||
"sort_mode": (["filename_asc", "filename_desc", "random"],),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "Workflow Studio"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# 毎回実行させる(インデックスが変わるため)
|
||||
return float("NaN")
|
||||
|
||||
def execute(self, group_name: str, index: int, sort_mode: str, seed: int):
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
paths = self._get_sorted_paths(group_name, sort_mode, seed)
|
||||
if not paths:
|
||||
logger.warning("WFS_GalleryFeeder: group '%s' is empty or not found", group_name)
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
img_path = paths[index % len(paths)]
|
||||
try:
|
||||
img = PILImage.open(img_path).convert("RGB")
|
||||
except Exception as e:
|
||||
logger.error("WFS_GalleryFeeder: failed to load '%s': %s", img_path, e)
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
tensor = torch.from_numpy(arr).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
# ── 内部ヘルパー ──────────────────────────────────────────────
|
||||
|
||||
def _get_sorted_paths(self, group_name: str, sort_mode: str, seed: int) -> list[str]:
|
||||
metadata_file = self._find_metadata_file()
|
||||
if not metadata_file.exists():
|
||||
return []
|
||||
try:
|
||||
with open(metadata_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error("WFS_GalleryFeeder: metadata read error: %s", e)
|
||||
return []
|
||||
|
||||
paths = [
|
||||
key for key, meta in data.get("images", {}).items()
|
||||
if group_name in meta.get("groups", []) and Path(key).exists()
|
||||
]
|
||||
|
||||
if sort_mode == "filename_asc":
|
||||
paths.sort(key=lambda p: Path(p).name.lower())
|
||||
elif sort_mode == "filename_desc":
|
||||
paths.sort(key=lambda p: Path(p).name.lower(), reverse=True)
|
||||
elif sort_mode == "random":
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(paths)
|
||||
|
||||
return paths
|
||||
|
||||
@staticmethod
|
||||
def _find_metadata_file() -> Path:
|
||||
# py/nodes/ -> py/ -> plugin root -> ComfyUI/custom_nodes/ -> ComfyUI root
|
||||
plugin_root = Path(__file__).resolve().parent.parent.parent
|
||||
comfyui_root = plugin_root.parent.parent
|
||||
user_default = comfyui_root / "user" / "default"
|
||||
if user_default.is_dir():
|
||||
return user_default / "Workflow-Studio" / "gallery_metadata.json"
|
||||
return plugin_root / "data" / "gallery_metadata.json"
|
||||
@@ -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)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
@@ -15,26 +18,63 @@ 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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -69,7 +109,8 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
|
||||
try:
|
||||
images = _service.list_images(
|
||||
images = await asyncio.to_thread(
|
||||
_service.list_images,
|
||||
folder,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
@@ -77,6 +118,9 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
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)
|
||||
@@ -127,6 +171,70 @@ async def serve_image(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
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()
|
||||
@@ -152,6 +260,56 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -174,10 +332,15 @@ async def create_group(request: web.Request) -> web.Response:
|
||||
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:
|
||||
@@ -192,6 +355,8 @@ async def rename_group(request: web.Request) -> web.Response:
|
||||
|
||||
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})
|
||||
|
||||
@@ -224,6 +389,28 @@ async def list_group_images(request: web.Request) -> web.Response:
|
||||
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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダ・ファイル操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -280,3 +467,37 @@ async def move_images_route(request: web.Request) -> web.Response:
|
||||
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)
|
||||
@@ -31,6 +31,9 @@ def setup_routes(app: web.Application):
|
||||
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 ─────────────────────────────────────────
|
||||
@@ -344,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)
|
||||
@@ -383,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
|
||||
@@ -392,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)
|
||||
@@ -566,3 +574,66 @@ async def handle_delete_models(request: web.Request) -> web.Response:
|
||||
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,
|
||||
|
||||
@@ -23,6 +23,7 @@ _DATA_FILES = [
|
||||
"prompts.json",
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
"tagger_settings.json",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,6 +37,7 @@ def setup_routes(app: web.Application):
|
||||
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:
|
||||
@@ -179,13 +181,20 @@ def _load_data_file(filename: str):
|
||||
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 not None:
|
||||
bundle[filename] = data
|
||||
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
|
||||
|
||||
|
||||
@@ -234,3 +243,30 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error importing data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory."""
|
||||
style_dir = DATA_DIR / "style"
|
||||
styles = []
|
||||
if not style_dir.is_dir():
|
||||
return styles
|
||||
for json_file in sorted(style_dir.glob("*.json")):
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
styles.extend(data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
return styles
|
||||
|
||||
|
||||
async def handle_get_styles(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/styles - Get all styles from style directory."""
|
||||
try:
|
||||
styles = await asyncio.to_thread(_load_styles)
|
||||
return web.json_response(styles)
|
||||
except Exception as e:
|
||||
logger.error("Error loading styles: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tagger API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import TAGGER_DB_FILE, TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE
|
||||
from ..services.tagger_db_service import TaggerDbService
|
||||
from ..services.tagger_service import TaggerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_svc = TaggerService(TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE)
|
||||
_db = TaggerDbService(TAGGER_DB_FILE)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/tagger/models", handle_models)
|
||||
app.router.add_post("/wfm/tagger/predict", handle_predict)
|
||||
app.router.add_get("/wfm/tagger/ollama/models", handle_ollama_models)
|
||||
app.router.add_post("/wfm/tagger/ollama/predict", handle_ollama_predict)
|
||||
app.router.add_post("/wfm/tagger/batch/start", handle_batch_start)
|
||||
app.router.add_get("/wfm/tagger/batch/status", handle_batch_status)
|
||||
app.router.add_post("/wfm/tagger/batch/stop", handle_batch_stop)
|
||||
app.router.add_get("/wfm/tagger/db/list", handle_db_list)
|
||||
app.router.add_get("/wfm/tagger/db/search", handle_db_search)
|
||||
app.router.add_put("/wfm/tagger/db/{id}", handle_db_update)
|
||||
app.router.add_delete("/wfm/tagger/db/{id}", handle_db_delete)
|
||||
app.router.add_get("/wfm/tagger/db/export", handle_db_export)
|
||||
app.router.add_post("/wfm/tagger/db/save", handle_db_save)
|
||||
app.router.add_post("/wfm/tagger/write_meta", handle_write_meta)
|
||||
app.router.add_get("/wfm/tagger/settings", handle_settings_get)
|
||||
app.router.add_post("/wfm/tagger/settings", handle_settings_save)
|
||||
|
||||
|
||||
async def handle_models(request: web.Request) -> web.Response:
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.list_models)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
logger.error("handle_models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
b64 = body.get("image_b64", "")
|
||||
model = body.get("model", "")
|
||||
if not b64 or not model:
|
||||
return web.json_response({"error": "image_b64 and model required"}, status=400)
|
||||
result = await asyncio.to_thread(
|
||||
_svc.predict, b64, model,
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("handle_predict: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_models(request: web.Request) -> web.Response:
|
||||
api_url = request.rel_url.query.get("api_url", "http://127.0.0.1:11434")
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.ollama_models, api_url)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.ollama_predict,
|
||||
body.get("image_b64", ""),
|
||||
body.get("api_url", "http://127.0.0.1:11434"),
|
||||
body.get("model", ""),
|
||||
body.get("prompt", ""),
|
||||
int(body.get("max_tags", 40)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.batch_start,
|
||||
body.get("folder", ""),
|
||||
body.get("model", ""),
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
bool(body.get("use_ollama", False)),
|
||||
body.get("ollama_api", "http://127.0.0.1:11434"),
|
||||
body.get("ollama_model", ""),
|
||||
body.get("ollama_prompt", ""),
|
||||
int(body.get("ollama_max_tags", 40)),
|
||||
bool(body.get("save_db", True)),
|
||||
bool(body.get("write_file", False)),
|
||||
bool(body.get("write_txt", False)),
|
||||
_db,
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_batch_status(request: web.Request) -> web.Response:
|
||||
return web.json_response(_svc.batch_status())
|
||||
|
||||
|
||||
async def handle_batch_stop(request: web.Request) -> web.Response:
|
||||
_svc.batch_stop()
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def handle_db_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.rel_url.query.get("limit", 100))
|
||||
offset = int(request.rel_url.query.get("offset", 0))
|
||||
rows = await asyncio.to_thread(_db.list, limit, offset)
|
||||
total = await asyncio.to_thread(_db.total)
|
||||
return web.json_response({"rows": rows, "total": total})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_search(request: web.Request) -> web.Response:
|
||||
try:
|
||||
q = request.rel_url.query.get("q", "")
|
||||
rows = await asyncio.to_thread(_db.search, q)
|
||||
return web.json_response({"rows": rows})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_update(request: web.Request) -> web.Response:
|
||||
try:
|
||||
row_id = int(request.match_info["id"])
|
||||
body = await request.json()
|
||||
await asyncio.to_thread(
|
||||
_db.update, row_id,
|
||||
body.get("interrogator_tags", ""),
|
||||
body.get("vlm_tags", ""),
|
||||
)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
row_id = int(request.match_info["id"])
|
||||
await asyncio.to_thread(_db.delete, row_id)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_export(request: web.Request) -> web.Response:
|
||||
try:
|
||||
csv_data = await asyncio.to_thread(_db.export_csv)
|
||||
return web.Response(
|
||||
body=csv_data.encode("utf-8"),
|
||||
content_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=tagger_tags.csv"},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
row_id = await asyncio.to_thread(
|
||||
_db.save,
|
||||
body.get("filename", ""),
|
||||
body.get("interrogator_tags", ""),
|
||||
body.get("vlm_tags", ""),
|
||||
)
|
||||
return web.json_response({"ok": True, "id": row_id})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_write_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.write_meta_to_file,
|
||||
body.get("path", ""),
|
||||
body.get("tags", ""),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_settings_get(request: web.Request) -> web.Response:
|
||||
return web.json_response(_svc.load_settings())
|
||||
|
||||
|
||||
async def handle_settings_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
await asyncio.to_thread(_svc.save_settings, body)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -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,102 +261,155 @@ 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) as resp:
|
||||
with urlopen(req, timeout=timeout, context=_get_ssl_context()) as resp:
|
||||
data = resp.read()
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
@@ -210,11 +418,13 @@ class CivitaiService:
|
||||
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()
|
||||
|
||||
@@ -117,6 +117,18 @@ class GalleryMetadataStore:
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
|
||||
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
@@ -124,6 +136,19 @@ class GalleryMetadataStore:
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
"""グループ内の全画像を除外する(グループ自体は残す)"""
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
"""グループが存在しない場合のみ作成する"""
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
|
||||
def delete(self, image_path: str) -> bool:
|
||||
"""画像のメタデータエントリを削除する"""
|
||||
key = self._normalize_path(image_path)
|
||||
@@ -140,3 +165,22 @@ class GalleryMetadataStore:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
|
||||
"""folder_path 配下の存在しないファイルのメタデータを削除する。
|
||||
existing_paths: そのフォルダの現在のファイルパス集合(_normalize_path 済み)
|
||||
"""
|
||||
folder_key = self._normalize_path(folder_path)
|
||||
if not folder_key.endswith("/"):
|
||||
folder_key += "/"
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if key.startswith(folder_key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
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
|
||||
@@ -14,7 +16,7 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
@@ -26,7 +28,7 @@ _PNG_CHUNK_MAX = 32 * 1024 * 1024
|
||||
_JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 30.0
|
||||
_CACHE_TTL = 60.0
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
@@ -72,17 +74,82 @@ class GalleryService:
|
||||
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:
|
||||
resolved = path.resolve()
|
||||
return resolved == path.resolve()
|
||||
return False
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
@@ -188,6 +255,10 @@ class GalleryService:
|
||||
# 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:
|
||||
# グループフィルタ(サーバーサイド)
|
||||
@@ -218,13 +289,14 @@ class GalleryService:
|
||||
"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
|
||||
|
||||
@@ -263,6 +335,14 @@ class GalleryService:
|
||||
|
||||
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,
|
||||
@@ -385,14 +465,42 @@ class GalleryService:
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
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}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -410,6 +518,8 @@ class GalleryService:
|
||||
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:
|
||||
@@ -418,12 +528,26 @@ class GalleryService:
|
||||
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]:
|
||||
return self.metadata_store.list_images_in_group(group_name)
|
||||
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)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像配信
|
||||
@@ -540,3 +664,51 @@ class GalleryService:
|
||||
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 # フォールバック: 元ファイル
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -9,6 +11,8 @@ 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"]
|
||||
@@ -17,11 +21,13 @@ _PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".previe
|
||||
_SIDECAR_EXTENSIONS = [
|
||||
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
|
||||
".png", ".jpg", ".jpeg", ".webp",
|
||||
".json", ".civitai.info", ".info",
|
||||
".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",
|
||||
@@ -68,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")
|
||||
@@ -109,6 +116,60 @@ class ModelsService:
|
||||
self._save_metadata(data)
|
||||
return entry
|
||||
|
||||
def _scan_model_names(self, model_type: str) -> set:
|
||||
"""モデルタイプの全ファイル名をスキャンして返す(相対パス、/区切り、.disabled除く)。
|
||||
結果は30秒キャッシュされ、起動時の多重スキャンによる高負荷を防ぐ。"""
|
||||
now = time.monotonic()
|
||||
cached = self._scan_cache.get(model_type)
|
||||
if cached is not None:
|
||||
ts, names = cached
|
||||
if now - ts < _SCAN_CACHE_TTL:
|
||||
return names
|
||||
|
||||
dirs = _get_model_dirs(model_type)
|
||||
names = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
rel = rel[: -len(_DISABLED_SUFFIX)]
|
||||
names.add(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self._scan_cache[model_type] = (now, names)
|
||||
return names
|
||||
|
||||
def list_model_files(self, model_type: str) -> list:
|
||||
"""モデルタイプのモデルファイル名をソートして返す(拡張子付き、/区切り)。
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
seen = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
name = f.name
|
||||
if name.endswith(_DISABLED_SUFFIX):
|
||||
name = name[: -len(_DISABLED_SUFFIX)]
|
||||
if Path(name).suffix.lower() not in _MODEL_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
rel = rel[: -len(_DISABLED_SUFFIX)]
|
||||
seen.add(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
return sorted(seen)
|
||||
|
||||
def get_model_groups(self, model_type=None):
|
||||
data = self._load_metadata()
|
||||
raw = data.get("_groups", {})
|
||||
@@ -122,16 +183,55 @@ class ModelsService:
|
||||
raw = {}
|
||||
|
||||
if model_type:
|
||||
return raw.get(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] = 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.
|
||||
@@ -219,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
|
||||
|
||||
@@ -274,3 +374,144 @@ class ModelsService:
|
||||
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,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)))
|
||||
@@ -3,6 +3,17 @@
|
||||
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",
|
||||
@@ -18,6 +29,7 @@ _CLIP_TYPE_TO_MODEL = {
|
||||
"wan": "Wan",
|
||||
"cosmos": "Cosmos",
|
||||
"lumina": "Lumina",
|
||||
"lumina2": "Z-IMAGE",
|
||||
}
|
||||
|
||||
|
||||
@@ -180,25 +192,27 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
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"
|
||||
|
||||
@@ -61,7 +61,7 @@ class WorkflowStudio:
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
@@ -72,5 +72,7 @@ class WorkflowStudio:
|
||||
models_routes.setup_routes(app)
|
||||
gallery_routes.setup_routes(app)
|
||||
wildcard_routes.setup_routes(app)
|
||||
tagger_routes.setup_routes(app)
|
||||
gmic_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
|
||||
version = "0.3.7"
|
||||
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
|
||||
version = "0.3.78"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -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
|
||||
@@ -219,6 +219,43 @@
|
||||
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;
|
||||
@@ -378,6 +415,15 @@
|
||||
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;
|
||||
@@ -385,6 +431,43 @@
|
||||
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;
|
||||
@@ -602,6 +685,109 @@
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
/* =============================================
|
||||
Image Edit Tab
|
||||
============================================= */
|
||||
|
||||
#wfm-tab-image-edit {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-editor {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Left Toolbar
|
||||
============================================= */
|
||||
|
||||
.ie-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 5px;
|
||||
background: var(--wfm-surface);
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-tool-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ie-tool-btn:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
border-color: var(--wfm-border);
|
||||
}
|
||||
|
||||
.ie-tool-btn.active {
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ie-tool-placeholder {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.ie-tool-placeholder::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--wfm-text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Center column
|
||||
============================================= */
|
||||
|
||||
.ie-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Action bar */
|
||||
.ie-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tool options bar */
|
||||
.ie-tool-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
min-height: 36px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ie-opt-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ie-opt-group label {
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-opt-select {
|
||||
font-size: 12px;
|
||||
padding: 2px 5px;
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ie-opt-input {
|
||||
font-size: 12px;
|
||||
padding: 2px 5px;
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ie-opt-active {
|
||||
background: var(--wfm-primary) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--wfm-primary) !important;
|
||||
}
|
||||
|
||||
/* Canvas wrap */
|
||||
.ie-canvas-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
background-image:
|
||||
linear-gradient(45deg, #222 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #222 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #222 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #222 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
}
|
||||
|
||||
.ie-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: top left;
|
||||
line-height: 0;
|
||||
box-shadow: 0 2px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.ie-canvas-container canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#ie-canvas-bg {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#ie-canvas-draw {
|
||||
z-index: 2;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
#ie-canvas-overlay {
|
||||
z-index: 3;
|
||||
cursor: default;
|
||||
pointer-events: none; /* SelectTool.activate()でautoに変更 */
|
||||
}
|
||||
|
||||
.ie-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ie-placeholder-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Zoom bar */
|
||||
.ie-zoom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
#ie-zoom-label {
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Properties Pane (left of Layers panel)
|
||||
============================================= */
|
||||
|
||||
.ie-props-pane {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-props-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-props-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ie-props-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ie-props-row label {
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-props-row input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ie-props-row span {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Mask layer item highlight */
|
||||
.ie-layer-item[data-type="mask"] .ie-layer-type-icon {
|
||||
color: #e25252;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Right Panel (Layers)
|
||||
============================================= */
|
||||
|
||||
.ie-right-panel {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-layers-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-layers-header > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ie-layer-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.ie-layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ie-layer-item:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.ie-layer-item.active {
|
||||
background: rgba(90, 110, 220, 0.22);
|
||||
outline: 1px solid rgba(90, 110, 220, 0.4);
|
||||
}
|
||||
|
||||
.ie-layer-thumb {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
background:
|
||||
repeating-conic-gradient(#888 0% 25%, transparent 0% 50%)
|
||||
0 0 / 8px 8px;
|
||||
}
|
||||
|
||||
.ie-layer-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-layer-vis-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--wfm-text);
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.ie-layer-vis-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ie-opacity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.ie-opacity-row input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#ie-layer-opacity-label {
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Text Tool Overlay
|
||||
============================================= */
|
||||
|
||||
.ie-text-overlay {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ie-text-textarea {
|
||||
width: 200px;
|
||||
resize: vertical;
|
||||
background: var(--wfm-bg);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.ie-text-btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Scrollbar
|
||||
============================================= */
|
||||
|
||||
.ie-layer-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.ie-layer-list::-webkit-scrollbar-thumb {
|
||||
background: var(--wfm-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/* ============================================================
|
||||
Tagger Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── サブタブナビ ────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-subtab-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn:hover {
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn.active {
|
||||
color: var(--wfm-primary);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
/* ── サブタブ共通 ────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-subtab {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ── セクション ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-section {
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-tagger-section-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── フォームレイアウト ─────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-tagger-form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── スライダー ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
accent-color: var(--wfm-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-tagger-slider-val {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Single サブタブ ─────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-single-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-image-pane {
|
||||
width: 300px;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-image-sources {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-preview-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--wfm-surface);
|
||||
border: 2px dashed var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-tagger-preview-wrap.drag-over {
|
||||
border-color: var(--wfm-primary);
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
.wfm-tagger-image-name {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.wfm-tagger-controls-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-result-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Batch サブタブ ──────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-batch-layout {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.wfm-tagger-batch-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wfm-tagger-progress-bar-wrap {
|
||||
height: 6px;
|
||||
background: var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-tagger-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--wfm-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── DB サブタブ ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-db-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table th,
|
||||
.wfm-tagger-db-table td {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table th {
|
||||
background: var(--wfm-bg-secondary);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr.selected {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-edit-panel {
|
||||
flex-shrink: 0;
|
||||
margin-top: 10px;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* ── ユーティリティ ─────────────────────────────────────────── */
|
||||
|
||||
#wfm-tab-tagger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--wfm-header-height, 56px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#wfm-tab-tagger.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wfm-placeholder {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* AI Tab - Translation, VLM, Settings
|
||||
* Supports Ollama and LM Studio as backends
|
||||
* Supports Ollama, LM Studio, and Lemonade as backends
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_ai_settings";
|
||||
|
||||
@@ -19,11 +23,7 @@ const LANG_NAMES = {
|
||||
// ============================================
|
||||
|
||||
function loadAiSettings() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return readJsonStorage(SETTINGS_KEY);
|
||||
}
|
||||
|
||||
function saveAiSettings(patch) {
|
||||
@@ -199,7 +199,7 @@ function initTranslateTab() {
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -288,12 +288,11 @@ function initSettingsTab() {
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
// Update URL placeholder when backend changes
|
||||
// Switch URL to the new backend's default when the user changes backend
|
||||
backendRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
if (!urlInput) return;
|
||||
const defaultUrl = r.value === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
|
||||
if (!saved.backendUrl) urlInput.value = defaultUrl;
|
||||
urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,6 +343,47 @@ function initSettingsTab() {
|
||||
if (freeSrcInput && saved.freeSrcLang) freeSrcInput.value = saved.freeSrcLang;
|
||||
if (freeDstInput && saved.freeDstLang) freeDstInput.value = saved.freeDstLang;
|
||||
|
||||
// Chat image generation — dedicated workflow selection (T2I)
|
||||
const chatGenCheckbox = document.getElementById("wfm-ai-chatgen-dedicated-checkbox");
|
||||
const chatGenRow = document.getElementById("wfm-ai-chatgen-dedicated-row");
|
||||
const chatGenSelect = document.getElementById("wfm-ai-chatgen-dedicated-select");
|
||||
|
||||
if (chatGenCheckbox) chatGenCheckbox.checked = !!saved.chatGenDedicatedEnabled;
|
||||
if (chatGenRow) chatGenRow.style.display = saved.chatGenDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenCheckbox?.addEventListener("change", () => {
|
||||
if (chatGenRow) chatGenRow.style.display = chatGenCheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
// Chat I2I generation — dedicated workflow selection
|
||||
const chatGenI2ICheckbox = document.getElementById("wfm-ai-chatgen-i2i-dedicated-checkbox");
|
||||
const chatGenI2IRow = document.getElementById("wfm-ai-chatgen-i2i-dedicated-row");
|
||||
const chatGenI2ISelect = document.getElementById("wfm-ai-chatgen-i2i-dedicated-select");
|
||||
|
||||
if (chatGenI2ICheckbox) chatGenI2ICheckbox.checked = !!saved.chatGenI2IDedicatedEnabled;
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = saved.chatGenI2IDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenI2ICheckbox?.addEventListener("change", () => {
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = chatGenI2ICheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
if (chatGenSelect) {
|
||||
fetch(`${comfyUI.baseUrl}/api/wfm/workflows`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list) => {
|
||||
const filenames = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
const optionsHtml = `<option value="">-- select workflow --</option>` +
|
||||
filenames.map((f) => `<option value="${f}">${f}</option>`).join("");
|
||||
chatGenSelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenDedicatedFilename) chatGenSelect.value = saved.chatGenDedicatedFilename;
|
||||
if (chatGenI2ISelect) {
|
||||
chatGenI2ISelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenI2IDedicatedFilename) chatGenI2ISelect.value = saved.chatGenI2IDedicatedFilename;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Save settings
|
||||
document.getElementById("wfm-ai-settings-save-btn")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
@@ -351,13 +391,21 @@ function initSettingsTab() {
|
||||
const model = document.getElementById("wfm-ai-model-select")?.value || "";
|
||||
const freeSrcLang = freeSrcInput?.value?.trim() || "";
|
||||
const freeDstLang = freeDstInput?.value?.trim() || "";
|
||||
const chatGenDedicatedEnabled = !!chatGenCheckbox?.checked;
|
||||
const chatGenDedicatedFilename = chatGenSelect?.value || "";
|
||||
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
|
||||
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
saveAiSettings({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
});
|
||||
|
||||
@@ -367,31 +415,374 @@ function initSettingsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chat tab
|
||||
// ============================================
|
||||
|
||||
const IMAGE_GEN_TOOLS = [{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_image",
|
||||
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prompt: { type: "string", description: "Positive prompt describing the desired image." },
|
||||
negative_prompt: { type: "string", description: "What to avoid in the image. Optional." },
|
||||
},
|
||||
required: ["prompt"],
|
||||
},
|
||||
},
|
||||
}];
|
||||
|
||||
// chatHistory の内部共通形式 {role, content, images?:[{base64,mimeType}]} を
|
||||
// バックエンドごとのワイヤーフォーマットへ変換する(callVLM() と同じ規約)
|
||||
function _formatMessagesForBackend(messages, backend) {
|
||||
return messages.map((m) => {
|
||||
if (!m.images?.length) return { role: m.role, content: m.content };
|
||||
if (backend === "ollama") {
|
||||
return { role: m.role, content: m.content, images: m.images.map((i) => i.base64) };
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content: [
|
||||
{ type: "text", text: m.content },
|
||||
...m.images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mimeType};base64,${i.base64}` } })),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function callChat(url, backend, model, messages, tools) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) body.tools = tools;
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).choices?.[0]?.message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
}
|
||||
}
|
||||
|
||||
function _parseToolArgs(toolCall) {
|
||||
const raw = toolCall.function?.arguments ?? toolCall.arguments;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw); } catch { return {}; }
|
||||
}
|
||||
return raw || {};
|
||||
}
|
||||
|
||||
async function _loadDedicatedChatGenWorkflow(filename) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let workflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(workflow, filename);
|
||||
if (format === "ui") {
|
||||
workflow = await comfyWorkflow.convertUiToApi(workflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
|
||||
}
|
||||
|
||||
async function generateImageFromChat(prompt, negativePrompt) {
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const isI2I = !!_toolsImage;
|
||||
let workflow, analysis;
|
||||
|
||||
if (isI2I && settings.chatGenI2IDedicatedEnabled && settings.chatGenI2IDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenI2IDedicatedFilename));
|
||||
} else if (!isI2I && settings.chatGenDedicatedEnabled && settings.chatGenDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenDedicatedFilename));
|
||||
} else {
|
||||
if (!comfyUI.currentWorkflow || !comfyUI.currentAnalysis) throw new Error(t("aiChatNoWorkflowLoaded"));
|
||||
workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
|
||||
analysis = comfyUI.currentAnalysis;
|
||||
}
|
||||
|
||||
if (isI2I) {
|
||||
if (!(analysis.load_image_nodes?.length > 0)) throw new Error(t("aiChatNoLoadImageNode"));
|
||||
await comfyEditor.applyImageToSlot(_toolsImage.file, 0, { workflow, analysis });
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
|
||||
|
||||
const result = await window._wfmGenerateTab.generate(workflow);
|
||||
const images = result?.images || [];
|
||||
if (images.length === 0) throw new Error(t("aiChatNoImageProduced"));
|
||||
|
||||
const img = images[0];
|
||||
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
}
|
||||
|
||||
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
|
||||
function _extractSvgCode(text) {
|
||||
if (!text) return null;
|
||||
const fenced = text.match(/```(?:svg|xml)?\s*([\s\S]*?<svg[\s\S]*?<\/svg>)[\s\S]*?```/i);
|
||||
if (fenced) return fenced[1].trim();
|
||||
const bare = text.match(/<svg[\s\S]*?<\/svg>/i);
|
||||
if (bare) return bare[0].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
// SVGコードをプレビュー表示し、Galleryへ保存するボタンを追加する。
|
||||
// <img src="data:..."> でのレンダリングはブラウザがスクリプト実行を無効化するため安全。
|
||||
function _appendSvgPreview(container, svgCode) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "wfm-ai-chat-svg-wrapper";
|
||||
|
||||
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgCode)))}`;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = dataUrl;
|
||||
img.className = "wfm-ai-chat-svg-img";
|
||||
img.alt = "Generated SVG";
|
||||
wrapper.appendChild(img);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "wfm-btn wfm-btn-sm";
|
||||
saveBtn.textContent = t("aiChatSaveSvgToGallery");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: `chat_svg_${Date.now()}.svg`, imageData: dataUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
showToast(t("aiChatSvgSaved"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("aiChatSvgSaveFailed") + e.message, "error");
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
wrapper.appendChild(saveBtn);
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
|
||||
if (content) div.textContent = content;
|
||||
if (opts.imageUrl) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.imageUrl;
|
||||
img.className = "wfm-ai-chat-img";
|
||||
div.appendChild(img);
|
||||
}
|
||||
if (opts.attachThumb) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.attachThumb;
|
||||
img.className = "wfm-ai-chat-attach-thumb";
|
||||
div.appendChild(img);
|
||||
}
|
||||
if (role === "assistant" && content) {
|
||||
const svgCode = _extractSvgCode(content);
|
||||
if (svgCode) _appendSvgPreview(div, svgCode);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function initChatTab() {
|
||||
const messagesEl = document.getElementById("wfm-ai-chat-messages");
|
||||
const inputEl = document.getElementById("wfm-ai-chat-input");
|
||||
const sendBtn = document.getElementById("wfm-ai-chat-send-btn");
|
||||
const clearBtn = document.getElementById("wfm-ai-chat-clear-btn");
|
||||
const statusEl = document.getElementById("wfm-ai-chat-status");
|
||||
const imgGenToggle = document.getElementById("wfm-ai-chat-imggen-toggle");
|
||||
|
||||
if (!messagesEl || !inputEl || !sendBtn) return;
|
||||
|
||||
if (imgGenToggle) {
|
||||
const savedToggle = loadAiSettings();
|
||||
imgGenToggle.checked = savedToggle.chatImageGenEnabled !== false;
|
||||
imgGenToggle.addEventListener("change", () => {
|
||||
saveAiSettings({ chatImageGenEnabled: imgGenToggle.checked });
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("wfm-ai-chat-attachment-clear")?.addEventListener("click", () => {
|
||||
_clearToolsImage();
|
||||
});
|
||||
|
||||
let chatHistory = [];
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
if (!model) {
|
||||
showToast(t("aiToastNoModel"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
inputEl.value = "";
|
||||
inputEl.style.height = "";
|
||||
const userEntry = { role: "user", content: text };
|
||||
if (_toolsImage) userEntry.images = [{ base64: _toolsImage.base64, mimeType: _toolsImage.mimeType }];
|
||||
chatHistory.push(userEntry);
|
||||
appendChatBubble(messagesEl, "user", text, _toolsImage ? { attachThumb: `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}` } : {});
|
||||
|
||||
sendBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusChatting");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
|
||||
try {
|
||||
const tools = imgGenToggle?.checked !== false ? IMAGE_GEN_TOOLS : undefined;
|
||||
const reply = await callChat(url, backend, model, chatHistory, tools);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
|
||||
if (toolCall) {
|
||||
chatHistory.push({ role: "assistant", content: reply.content || "" });
|
||||
if (reply.content) appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
|
||||
statusEl.textContent = t("aiStatusGeneratingImage");
|
||||
const args = _parseToolArgs(toolCall);
|
||||
try {
|
||||
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
|
||||
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
|
||||
} catch (genErr) {
|
||||
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
|
||||
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
|
||||
}
|
||||
} else {
|
||||
chatHistory.push({ role: "assistant", content: reply.content });
|
||||
appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
}
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wfm-ai-trans-status";
|
||||
} catch (err) {
|
||||
chatHistory.pop();
|
||||
if (messagesEl.lastChild) messagesEl.removeChild(messagesEl.lastChild);
|
||||
inputEl.value = text;
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastChatFailed") + err.message, "error");
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener("click", sendMessage);
|
||||
|
||||
inputEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
chatHistory = [];
|
||||
messagesEl.innerHTML = "";
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wfm-ai-trans-status";
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TOOLS ↔ Chat shared image attachment
|
||||
// ============================================
|
||||
|
||||
let _toolsImage = null; // { file: File, base64: string, mimeType: string }
|
||||
|
||||
function _syncAttachmentUI() {
|
||||
const vlmPreview = document.getElementById("wfm-ai-vlm-preview");
|
||||
const vlmLabel = document.getElementById("wfm-ai-vlm-label");
|
||||
const vlmClear = document.getElementById("wfm-ai-vlm-clear");
|
||||
const chatAttach = document.getElementById("wfm-ai-chat-attachment");
|
||||
const chatThumb = document.getElementById("wfm-ai-chat-attachment-thumb");
|
||||
const chatLabel = document.getElementById("wfm-ai-chat-attachment-label");
|
||||
|
||||
if (_toolsImage) {
|
||||
const dataUrl = `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}`;
|
||||
if (vlmPreview) { vlmPreview.src = dataUrl; vlmPreview.style.display = "block"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "none";
|
||||
if (vlmClear) vlmClear.style.display = "flex";
|
||||
if (chatAttach) chatAttach.style.display = "flex";
|
||||
if (chatThumb) chatThumb.src = dataUrl;
|
||||
if (chatLabel) chatLabel.textContent = t("aiChatAttachmentLabel");
|
||||
} else {
|
||||
if (vlmPreview) { vlmPreview.src = ""; vlmPreview.style.display = "none"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "";
|
||||
if (vlmClear) vlmClear.style.display = "none";
|
||||
if (chatAttach) chatAttach.style.display = "none";
|
||||
if (chatThumb) chatThumb.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _clearToolsImage() {
|
||||
_toolsImage = null;
|
||||
_syncAttachmentUI();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VLM tab
|
||||
// ============================================
|
||||
|
||||
function initVlmTab() {
|
||||
const dropEl = document.getElementById("wfm-ai-vlm-drop");
|
||||
const previewEl = document.getElementById("wfm-ai-vlm-preview");
|
||||
const labelEl = document.getElementById("wfm-ai-vlm-label");
|
||||
const clearEl = document.getElementById("wfm-ai-vlm-clear");
|
||||
const fileInput = document.getElementById("wfm-ai-vlm-file");
|
||||
const taskSel = document.getElementById("wfm-ai-vlm-task");
|
||||
const runBtn = document.getElementById("wfm-ai-vlm-run");
|
||||
const statusEl = document.getElementById("wfm-ai-vlm-status");
|
||||
const resultEl = document.getElementById("wfm-ai-vlm-result");
|
||||
const copyBtn = document.getElementById("wfm-ai-vlm-copy");
|
||||
const wcInputs = document.getElementById("wfm-ai-wc-inputs");
|
||||
const wcNameEl = document.getElementById("wfm-ai-wc-name");
|
||||
const wcCountEl = document.getElementById("wfm-ai-wc-count");
|
||||
|
||||
if (!dropEl) return;
|
||||
|
||||
let vlmImage = null; // { base64, mimeType }
|
||||
function updateTaskUI() {
|
||||
const isWildcard = taskSel?.value === "wildcard";
|
||||
dropEl.style.display = isWildcard ? "none" : "";
|
||||
if (wcInputs) wcInputs.style.display = isWildcard ? "" : "none";
|
||||
}
|
||||
taskSel?.addEventListener("change", updateTaskUI);
|
||||
updateTaskUI();
|
||||
|
||||
const loadImage = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
vlmImage = await fileToBase64(file);
|
||||
previewEl.src = `data:${vlmImage.mimeType};base64,${vlmImage.base64}`;
|
||||
previewEl.style.display = "block";
|
||||
labelEl.style.display = "none";
|
||||
const { base64, mimeType } = await fileToBase64(file);
|
||||
_toolsImage = { file, base64, mimeType };
|
||||
_syncAttachmentUI();
|
||||
};
|
||||
|
||||
dropEl.addEventListener("click", () => fileInput.click());
|
||||
@@ -403,28 +794,61 @@ function initVlmTab() {
|
||||
dropEl.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]);
|
||||
});
|
||||
clearEl?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_clearToolsImage();
|
||||
if (fileInput) fileInput.value = "";
|
||||
});
|
||||
|
||||
runBtn.addEventListener("click", async () => {
|
||||
if (!vlmImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
|
||||
const task = taskSel?.value || "describe";
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
if (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
|
||||
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
|
||||
|
||||
const task = taskSel?.value || "describe";
|
||||
if (task === "wildcard") {
|
||||
const name = wcNameEl?.value.trim() || "";
|
||||
const count = Math.max(1, parseInt(wcCountEl?.value) || 20);
|
||||
if (!name) { showToast(t("aiToastWcNoName"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
resultEl.value = "";
|
||||
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
|
||||
try {
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
resultEl.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
.filter(l => l.length > 0)
|
||||
.join("\n");
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastVlmFailed") + err.message, "error");
|
||||
} finally {
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_toolsImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
resultEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType);
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
@@ -450,6 +874,7 @@ function initVlmTab() {
|
||||
|
||||
export function initAiTab() {
|
||||
initTranslateTab();
|
||||
initChatTab();
|
||||
initVlmTab();
|
||||
initSettingsTab();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { initI18n, t } from "./i18n.js";
|
||||
import { getSettings } from "./util.js";
|
||||
|
||||
// Initialize i18n before anything else
|
||||
initI18n();
|
||||
@@ -90,7 +91,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp", ai: "tabAi" };
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -195,6 +196,31 @@ function applyI18nToHtml() {
|
||||
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");
|
||||
@@ -284,6 +310,16 @@ function applyI18nToHtml() {
|
||||
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
|
||||
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
|
||||
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
|
||||
const aiChatImgGenLabel = document.getElementById("wfm-ai-chat-imggen-label-text");
|
||||
if (aiChatImgGenLabel) aiChatImgGenLabel.textContent = t("aiChatImgGenToggle");
|
||||
const aiChatGenTitle = document.getElementById("wfm-ai-settings-chatgen-title");
|
||||
if (aiChatGenTitle) aiChatGenTitle.textContent = t("aiSettingsChatGenTitle");
|
||||
const aiChatGenDedicatedLabel = document.getElementById("wfm-ai-chatgen-dedicated-label");
|
||||
if (aiChatGenDedicatedLabel) aiChatGenDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
const aiChatGenI2ITitle = document.getElementById("wfm-ai-settings-chatgen-i2i-title");
|
||||
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
|
||||
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
|
||||
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
@@ -326,14 +362,25 @@ 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-11": "helpGen11", "wfm-help-gen-12": "helpGen12",
|
||||
"wfm-help-gen-13": "helpGen13", "wfm-help-gen-14": "helpGen14",
|
||||
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
|
||||
"wfm-help-gen-17": "helpGen17", "wfm-help-gen-18": "helpGen18",
|
||||
"wfm-help-gen-19": "helpGen19",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
"wfm-help-feeder-imgloop-desc": "helpFeederImgloopDesc",
|
||||
"wfm-help-feeder-1": "helpFeeder1", "wfm-help-feeder-2": "helpFeeder2",
|
||||
"wfm-help-feeder-3": "helpFeeder3", "wfm-help-feeder-4": "helpFeeder4",
|
||||
"wfm-help-feeder-5": "helpFeeder5", "wfm-help-feeder-6": "helpFeeder6",
|
||||
"wfm-help-feeder-7": "helpFeeder7", "wfm-help-feeder-8": "helpFeeder8",
|
||||
"wfm-help-feeder-9": "helpFeeder9",
|
||||
"wfm-help-feeder-gal-title": "helpFeederGalTitle",
|
||||
"wfm-help-feeder-gal-desc": "helpFeederGalDesc",
|
||||
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
|
||||
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
|
||||
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
@@ -344,22 +391,34 @@ function applyI18nToHtml() {
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13",
|
||||
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
|
||||
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
|
||||
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
|
||||
"wfm-help-nodes-7": "helpNodes7",
|
||||
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
|
||||
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
|
||||
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
|
||||
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
|
||||
"wfm-help-models-9": "helpModels9", "wfm-help-models-10": "helpModels10",
|
||||
"wfm-help-models-11": "helpModels11",
|
||||
"wfm-help-models-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-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",
|
||||
@@ -370,15 +429,71 @@ function applyI18nToHtml() {
|
||||
"wfm-help-sidepanel-13": "helpSidepanel13",
|
||||
"wfm-help-sidepanel-14": "helpSidepanel14", "wfm-help-sidepanel-15": "helpSidepanel15",
|
||||
"wfm-help-sidepanel-16": "helpSidepanel16",
|
||||
"wfm-help-sidepanel-17": "helpSidepanel17",
|
||||
"wfm-help-ai-title": "helpAiTitle",
|
||||
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-5": "helpAi5", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"wfm-help-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
"wfm-help-imageedit-text-title": "helpImageEditTextTitle",
|
||||
"wfm-help-imageedit-export-title": "helpImageEditExportTitle",
|
||||
"wfm-help-imageedit-kbd-title": "helpImageEditKbdTitle",
|
||||
"wfm-help-imageedit-1": "helpImageEdit1", "wfm-help-imageedit-2": "helpImageEdit2", "wfm-help-imageedit-3": "helpImageEdit3",
|
||||
"wfm-help-imageedit-4": "helpImageEdit4", "wfm-help-imageedit-5": "helpImageEdit5",
|
||||
"wfm-help-imageedit-6": "helpImageEdit6", "wfm-help-imageedit-7": "helpImageEdit7",
|
||||
"wfm-help-imageedit-8": "helpImageEdit8", "wfm-help-imageedit-9": "helpImageEdit9",
|
||||
"wfm-help-imageedit-10": "helpImageEdit10", "wfm-help-imageedit-11": "helpImageEdit11",
|
||||
"wfm-help-imageedit-12": "helpImageEdit12", "wfm-help-imageedit-13": "helpImageEdit13",
|
||||
"wfm-help-imageedit-14": "helpImageEdit14", "wfm-help-imageedit-15": "helpImageEdit15",
|
||||
"wfm-help-imageedit-16": "helpImageEdit16", "wfm-help-imageedit-17": "helpImageEdit17",
|
||||
"wfm-help-imageedit-18": "helpImageEdit18", "wfm-help-imageedit-19": "helpImageEdit19",
|
||||
"wfm-help-imageedit-20": "helpImageEdit20", "wfm-help-imageedit-21": "helpImageEdit21",
|
||||
"wfm-help-imageedit-22": "helpImageEdit22", "wfm-help-imageedit-23": "helpImageEdit23",
|
||||
"wfm-help-imageedit-24": "helpImageEdit24", "wfm-help-imageedit-25": "helpImageEdit25",
|
||||
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
|
||||
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
|
||||
"wfm-help-imageedit-30": "helpImageEdit30",
|
||||
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
|
||||
"wfm-help-imageedit-8b": "helpImageEdit8b",
|
||||
"wfm-help-imageedit-blur-title": "helpImageEditBlurTitle",
|
||||
"wfm-help-imageedit-blur-1": "helpImageEditBlur1", "wfm-help-imageedit-blur-2": "helpImageEditBlur2",
|
||||
"wfm-help-imageedit-blur-3": "helpImageEditBlur3", "wfm-help-imageedit-blur-4": "helpImageEditBlur4",
|
||||
"wfm-help-imageedit-blur-5": "helpImageEditBlur5", "wfm-help-imageedit-blur-6": "helpImageEditBlur6",
|
||||
"wfm-help-imageedit-bgremove-title": "helpImageEditBgRemoveTitle",
|
||||
"wfm-help-imageedit-bgremove-1": "helpImageEditBgRemove1", "wfm-help-imageedit-bgremove-2": "helpImageEditBgRemove2",
|
||||
"wfm-help-imageedit-bgremove-3": "helpImageEditBgRemove3", "wfm-help-imageedit-bgremove-4": "helpImageEditBgRemove4",
|
||||
"wfm-help-imageedit-bgremove-5": "helpImageEditBgRemove5",
|
||||
"wfm-help-imageedit-mask-title": "helpImageEditMaskTitle",
|
||||
"wfm-help-imageedit-mask-1": "helpImageEditMask1", "wfm-help-imageedit-mask-2": "helpImageEditMask2",
|
||||
"wfm-help-imageedit-mask-3": "helpImageEditMask3", "wfm-help-imageedit-mask-4": "helpImageEditMask4",
|
||||
"wfm-help-imageedit-mask-5": "helpImageEditMask5", "wfm-help-imageedit-mask-6": "helpImageEditMask6",
|
||||
"wfm-help-imageedit-mask-7": "helpImageEditMask7", "wfm-help-imageedit-mask-8": "helpImageEditMask8",
|
||||
"wfm-help-imageedit-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
|
||||
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
|
||||
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
|
||||
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
|
||||
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
|
||||
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
|
||||
"wfm-help-imageedit-gmic-install-title": "helpImageEditGmicInstallTitle",
|
||||
"wfm-help-imageedit-gmic-install-1": "helpImageEditGmicInstall1",
|
||||
"wfm-help-imageedit-gmic-install-2": "helpImageEditGmicInstall2",
|
||||
"wfm-help-imageedit-gmic-install-3": "helpImageEditGmicInstall3",
|
||||
"wfm-help-imageedit-tabkbd-title": "helpImageEditTabKbdTitle",
|
||||
"wfm-help-imageedit-tabkbd-1": "helpImageEdit22", "wfm-help-imageedit-tabkbd-2": "helpImageEdit23",
|
||||
"wfm-help-imageedit-tabkbd-3": "helpImageEdit24", "wfm-help-imageedit-tabkbd-4": "helpImageEdit25",
|
||||
"wfm-help-imageedit-tabkbd-5": "helpImageEdit26", "wfm-help-imageedit-tabkbd-6": "helpImageEdit27",
|
||||
"wfm-help-imageedit-tabkbd-7": "helpImageEdit28", "wfm-help-imageedit-tabkbd-8": "helpImageEdit29",
|
||||
"wfm-help-shortcuts-title": "helpShortcutsTitle",
|
||||
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
|
||||
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
|
||||
"wfm-help-trouble-title": "helpTroubleTitle",
|
||||
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
|
||||
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
|
||||
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
|
||||
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
@@ -389,6 +504,41 @@ function applyI18nToHtml() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
// Search box placeholder
|
||||
const helpSearch = document.getElementById("wfm-help-search");
|
||||
if (helpSearch) {
|
||||
helpSearch.placeholder = t("helpSearchPlaceholder");
|
||||
helpSearch.addEventListener("input", _onHelpSearch);
|
||||
}
|
||||
}
|
||||
|
||||
function _onHelpSearch(e) {
|
||||
const q = e.target.value.trim().toLowerCase();
|
||||
const navItems = document.querySelectorAll("[data-help-page]");
|
||||
if (!q) {
|
||||
navItems.forEach(btn => btn.classList.remove("search-hidden"));
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedPages = new Set();
|
||||
document.querySelectorAll(".wfm-help-page").forEach(page => {
|
||||
const pageId = page.id.replace("wfm-help-page-", "");
|
||||
if (page.textContent.toLowerCase().includes(q)) matchedPages.add(pageId);
|
||||
});
|
||||
|
||||
let firstBtn = null;
|
||||
navItems.forEach(btn => {
|
||||
const pageId = btn.dataset.helpPage;
|
||||
if (matchedPages.has(pageId)) {
|
||||
btn.classList.remove("search-hidden");
|
||||
if (!firstBtn) firstBtn = btn;
|
||||
} else {
|
||||
btn.classList.add("search-hidden");
|
||||
}
|
||||
});
|
||||
|
||||
if (firstBtn && !firstBtn.classList.contains("active")) firstBtn.click();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -431,26 +581,36 @@ 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
|
||||
// Apply saved textarea font size & JSON highlight colors
|
||||
try {
|
||||
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
} catch {}
|
||||
|
||||
// Apply saved JSON highlight colors
|
||||
try {
|
||||
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
applyJsonColors(_s.jsonColors);
|
||||
} catch {}
|
||||
|
||||
function initHelpTab() {
|
||||
document.querySelectorAll(".wfm-help-nav-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const page = btn.dataset.helpPage;
|
||||
document.querySelectorAll(".wfm-help-nav-item").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".wfm-help-page").forEach((p) => p.classList.remove("active"));
|
||||
document.getElementById(`wfm-help-page-${page}`)?.classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
applyI18nToHtml();
|
||||
initTabs();
|
||||
initModal();
|
||||
initHelpTab();
|
||||
initSettingsTab(); // Settings first (applies saved URL)
|
||||
initWorkflowTab();
|
||||
initNodesTab();
|
||||
@@ -460,6 +620,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initMetadataTab();
|
||||
initGalleryTab();
|
||||
initAiTab();
|
||||
initTaggerTab();
|
||||
imageEditTab.init();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -90,11 +90,16 @@ export const comfyUI = {
|
||||
if (info?.[cls]?.input?.required?.[inputKey]) {
|
||||
const inputDef = info[cls].input.required[inputKey];
|
||||
const first = inputDef[0];
|
||||
// V1: [["model1", "model2"], {}]
|
||||
if (Array.isArray(first)) return first;
|
||||
// Newer ComfyUI format: ["COMBO", { "values": [...] }]
|
||||
// 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 {}
|
||||
}
|
||||
@@ -127,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() {
|
||||
@@ -148,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 [];
|
||||
},
|
||||
|
||||
@@ -168,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(() => ({}));
|
||||
@@ -228,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) {
|
||||
@@ -275,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
|
||||
@@ -288,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) {
|
||||
@@ -295,10 +316,23 @@ export const comfyUI = {
|
||||
images.push(img);
|
||||
}
|
||||
}
|
||||
if (nodeOutput.saved_svg) {
|
||||
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
|
||||
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
|
||||
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
|
||||
const joinIfCharArray = (v) =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
|
||||
? v.join("")
|
||||
: v;
|
||||
svgOutputs.push({
|
||||
filename: joinIfCharArray(nodeOutput.saved_svg),
|
||||
path: joinIfCharArray(nodeOutput.path) || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(images, seed);
|
||||
return { images, seed };
|
||||
return { images, seed, svgOutputs };
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
throw err;
|
||||
|
||||
@@ -5,6 +5,50 @@
|
||||
// Cache for /object_info (loaded once, shared)
|
||||
let _objectInfoCache = null;
|
||||
|
||||
// convertUiToApi() の COMBO フォールバック(未一致時に選択肢の先頭へ無警告で差し替え)が
|
||||
// ckpt_name に対して発生した場合の記録。呼び出し元が読み込み直後に確認して警告表示できるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastCheckpointSubstitutions = [];
|
||||
|
||||
// convertUiToApi() で除外された Bypass(mode:4) / Mute(mode:2) ノードの記録。
|
||||
// API形式には mode 情報が残らないため、呼び出し元がRaw JSON表示時に
|
||||
// 「このワークフローにはバイパス/ミュート中のノードがあった」と伝えられるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastBypassedNodes = [];
|
||||
let _lastMutedNodes = [];
|
||||
|
||||
/**
|
||||
* Trace a link's source through any Bypass-mode (mode 4) nodes, matching each bypassed
|
||||
* node's output slot to an input slot of the same type and following the connection
|
||||
* upstream (recursively, for chained bypasses). Mirrors ComfyUI's own Bypass behavior,
|
||||
* where a bypassed node is removed but its wires are reconnected end-to-end.
|
||||
* Returns null when no pass-through source exists (the matching input is unconnected),
|
||||
* in which case the caller should treat the input as unlinked.
|
||||
*/
|
||||
function _resolveBypassSource(nodeById, linkMap, nodeId, slot, visited) {
|
||||
const idStr = String(nodeId);
|
||||
const node = nodeById[idStr];
|
||||
if (!node || node.mode !== 4) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass chain guard
|
||||
|
||||
visited.add(idStr);
|
||||
const outputs = node.outputs || [];
|
||||
const inputs = node.inputs || [];
|
||||
const outType = outputs[slot]?.type;
|
||||
|
||||
let inIdx = -1;
|
||||
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
|
||||
inIdx = slot;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
}
|
||||
if (inIdx === -1) return null;
|
||||
|
||||
const upstream = linkMap[idStr][inIdx];
|
||||
if (!upstream) return null;
|
||||
return _resolveBypassSource(nodeById, linkMap, upstream[0], upstream[1], visited);
|
||||
}
|
||||
|
||||
async function _loadObjectInfo() {
|
||||
if (_objectInfoCache) return _objectInfoCache;
|
||||
try {
|
||||
@@ -37,7 +81,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
|
||||
@@ -51,7 +95,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +106,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
|
||||
/**
|
||||
* Get ordered widget input types for a node class (parallel to _getWidgetInputNames).
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", or "COMBO".
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", or "COMFY_DYNAMICCOMBO_V3".
|
||||
*/
|
||||
function _getWidgetInputTypes(objectInfo, classType) {
|
||||
const info = objectInfo[classType];
|
||||
@@ -77,7 +121,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +132,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +140,19 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
|
||||
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
|
||||
*/
|
||||
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
|
||||
const info = objectInfo[classType];
|
||||
const spec = info?.input?.required?.[widgetName] || info?.input?.optional?.[widgetName];
|
||||
const options = spec?.[1]?.options;
|
||||
if (!Array.isArray(options)) return [];
|
||||
const opt = options.find((o) => o.key === selectedKey) || options[0];
|
||||
return Object.keys(opt?.inputs?.required || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a widget value is a frontend-only extra (like "control_after_generate").
|
||||
* These values appear in widgets_values but are not in object_info inputs.
|
||||
@@ -123,10 +180,17 @@ function _isExtraWidgetValue(val, expectedType) {
|
||||
*
|
||||
* Strategy: expand internal nodes into the parent, redirect parent links through
|
||||
* the boundary ports, and convert internal links from object to array format.
|
||||
* Widget values in internal nodes are preserved as-is (they are synced at save time
|
||||
* by ComfyUI frontend). convertUiToApi handles widget mapping via object_info.
|
||||
*
|
||||
* Widget values: the outer subgraph node's own widgets_values hold the values actually
|
||||
* set via the subgraph's collapsed form (what the user sees/edits). The internal nodes'
|
||||
* widgets_values, stored in the subgraph template (definitions.subgraphs[].nodes), are
|
||||
* only stale defaults from when the template was authored — ComfyUI does NOT keep them
|
||||
* synced in saved workflow JSON. Un-linked outer widget inputs are therefore copied onto
|
||||
* the matching internal node's widgets_values (via objectInfo to get the correct index,
|
||||
* since the internal node's real class type is known). Linked outer inputs are left to
|
||||
* the "Redirect parent links" step above, which takes priority.
|
||||
*/
|
||||
function _flattenSubgraphs(workflow) {
|
||||
function _flattenSubgraphs(workflow, objectInfo) {
|
||||
const defs = workflow.definitions?.subgraphs;
|
||||
if (!defs || defs.length === 0) return workflow;
|
||||
|
||||
@@ -192,6 +256,12 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Redirect parent links ===
|
||||
// Track which {internal node, input slot} pairs end up resolved by a real outer
|
||||
// link after redirection — needed below to tell apart "boundary-only" links
|
||||
// (prompt/model-name ports that are actually widget values, see injection step)
|
||||
// from ports that truly carry a link end-to-end (e.g. width/height wired to a
|
||||
// ResolutionSelector node).
|
||||
const redirectedTargets = new Set();
|
||||
for (const link of wf.links) {
|
||||
if (link[3] === node.id) {
|
||||
const dstSlot = link[4];
|
||||
@@ -204,6 +274,7 @@ function _flattenSubgraphs(workflow) {
|
||||
if (target) {
|
||||
link[3] = target.targetNodeId;
|
||||
link[4] = target.targetSlot;
|
||||
redirectedTargets.add(`${target.targetNodeId}:${target.targetSlot}`);
|
||||
}
|
||||
}
|
||||
if (link[1] === node.id) {
|
||||
@@ -217,12 +288,82 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Add internal nodes (remapped IDs) ===
|
||||
const remappedByOrigId = {};
|
||||
const normalizedNodeIds = new Set();
|
||||
for (const iNode of sgDef.nodes) {
|
||||
const remapped = JSON.parse(JSON.stringify(iNode));
|
||||
remapped.id = nodeIdRemap[iNode.id];
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format
|
||||
// (one entry per widget input, including linked ones), while convertUiToApi's
|
||||
// widget-mapping loop assumes the "modern" format (linked widget inputs have no
|
||||
// entry at all). Detect the full format by comparing lengths and strip the
|
||||
// entries belonging to inputs that are *actually* linked end-to-end (per
|
||||
// redirectedTargets above) so downstream mapping stays index-aligned. Boundary
|
||||
// links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// stripped — their value lives in the outer node's widgets_values instead and
|
||||
// is injected below.
|
||||
const allWidgetInputs = (remapped.inputs || []).filter((i) => i.widget);
|
||||
if (remapped.widgets_values && remapped.widgets_values.length === allWidgetInputs.length) {
|
||||
const linkedIdx = new Set();
|
||||
allWidgetInputs.forEach((inp, i) => {
|
||||
const slotIdx = remapped.inputs.indexOf(inp);
|
||||
if (redirectedTargets.has(`${remapped.id}:${slotIdx}`)) linkedIdx.add(i);
|
||||
});
|
||||
if (linkedIdx.size > 0) {
|
||||
remapped.widgets_values = remapped.widgets_values.filter((_, i) => !linkedIdx.has(i));
|
||||
normalizedNodeIds.add(remapped.id);
|
||||
}
|
||||
}
|
||||
|
||||
addedNodes.push(remapped);
|
||||
remappedByOrigId[iNode.id] = remapped;
|
||||
}
|
||||
|
||||
// === Inject outer subgraph-node widget values into internal nodes ===
|
||||
// Only un-linked widget inputs — linked ones are already redirected above.
|
||||
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
|
||||
outerWidgetInputs.forEach((inp, widgetIdx) => {
|
||||
if (inp.link != null) return;
|
||||
if (widgetIdx >= (node.widgets_values || []).length) return;
|
||||
const outerValue = node.widgets_values[widgetIdx];
|
||||
|
||||
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
|
||||
if (portIdx === -1) return;
|
||||
const target = inputPortTargets[portIdx];
|
||||
if (!target) return;
|
||||
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) return;
|
||||
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) return;
|
||||
|
||||
// When the internal node's widgets_values was normalized above (stripped of
|
||||
// linked-slot entries), the index must be computed against that same reduced
|
||||
// set of widget names — object_info's full name order (which still includes
|
||||
// e.g. width/height) would be off by however many entries were stripped.
|
||||
let iWidgetIdx;
|
||||
if (normalizedNodeIds.has(remappedNode.id)) {
|
||||
const remainingNames = (remappedNode.inputs || [])
|
||||
.filter((i) => i.widget)
|
||||
.filter((i) => {
|
||||
const slotIdx = remappedNode.inputs.indexOf(i);
|
||||
return !redirectedTargets.has(`${remappedNode.id}:${slotIdx}`);
|
||||
})
|
||||
.map((i) => i.name);
|
||||
iWidgetIdx = remainingNames.indexOf(targetInputDef.name);
|
||||
} else {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
iWidgetIdx = widgetNames.indexOf(targetInputDef.name);
|
||||
}
|
||||
if (iWidgetIdx === -1) return;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
});
|
||||
|
||||
// === Add internal links (object format → array format) ===
|
||||
for (const iLink of (sgDef.links || [])) {
|
||||
if (iLink.origin_id === -10 || iLink.target_id === -20) continue;
|
||||
@@ -253,8 +394,11 @@ export const comfyWorkflow = {
|
||||
detectFormat(workflow, filename) {
|
||||
if (!workflow || typeof workflow !== "object") return "unknown";
|
||||
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) {
|
||||
// App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
if (workflow.definitions || workflow.extra?.linearMode === true) return "app";
|
||||
// App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
// A workflow that merely *contains* subgraphs (definitions.subgraphs) is still
|
||||
// a normal node graph — convertUiToApi() flattens subgraphs before conversion,
|
||||
// so it's treated as "ui" rather than blocked as unsupported "app" format.
|
||||
if (workflow.extra?.linearMode === true) return "app";
|
||||
if (filename && /\.app\.json$/i.test(filename)) return "app";
|
||||
return "ui";
|
||||
}
|
||||
@@ -267,14 +411,30 @@ export const comfyWorkflow = {
|
||||
/**
|
||||
* Convert UI-format workflow to API-format using /object_info for accurate widget mapping.
|
||||
*/
|
||||
getLastCheckpointSubstitutions() {
|
||||
return _lastCheckpointSubstitutions;
|
||||
},
|
||||
|
||||
getLastBypassedNodes() {
|
||||
return _lastBypassedNodes;
|
||||
},
|
||||
|
||||
getLastMutedNodes() {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
_lastMutedNodes = [];
|
||||
if (!workflow.nodes || !workflow.links) return {};
|
||||
|
||||
// Flatten subgraphs before conversion
|
||||
const flatWorkflow = _flattenSubgraphs(workflow);
|
||||
|
||||
const objectInfo = await _loadObjectInfo();
|
||||
|
||||
// Flatten subgraphs before conversion (needs objectInfo to correctly map outer
|
||||
// subgraph-node widget values onto internal nodes' widgets_values indices)
|
||||
const flatWorkflow = _flattenSubgraphs(workflow, objectInfo);
|
||||
|
||||
// Build link map: dstNode -> { dstSlot -> [srcNodeId, srcSlot] }
|
||||
const linkMap = {};
|
||||
for (const link of flatWorkflow.links) {
|
||||
@@ -282,10 +442,23 @@ export const comfyWorkflow = {
|
||||
if (!linkMap[dstNode]) linkMap[dstNode] = {};
|
||||
linkMap[dstNode][dstSlot] = [String(srcNode), srcSlot];
|
||||
}
|
||||
const nodeById = {};
|
||||
for (const n of flatWorkflow.nodes) nodeById[String(n.id)] = n;
|
||||
|
||||
const api = {};
|
||||
for (const node of flatWorkflow.nodes) {
|
||||
if (node.mode === 4) continue; // muted/bypassed
|
||||
// Mute (mode 2): excluded, no pass-through — matches ComfyUI itself, where
|
||||
// muting a node with required downstream dependents is expected to error.
|
||||
// Bypass (mode 4): excluded here too, but its wires are rerouted below via
|
||||
// _resolveBypassSource() so downstream nodes reconnect to its upstream source.
|
||||
if (node.mode === 2) {
|
||||
_lastMutedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
if (node.mode === 4) {
|
||||
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
// Skip note/display-only nodes that are unknown to ComfyUI backend
|
||||
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
|
||||
const nodeId = String(node.id);
|
||||
@@ -295,16 +468,38 @@ export const comfyWorkflow = {
|
||||
const nodeLinks = linkMap[node.id] || {};
|
||||
const inputDefs = node.inputs || [];
|
||||
const linkedInputNames = new Set();
|
||||
// UI slot inputs with an actual link don't have a widget value entry in
|
||||
// widgets_values (modern ComfyUI format). Track these separately so the
|
||||
// widget-mapping loop won't advance wIdx for them.
|
||||
const linkedSlotNames = new Set();
|
||||
inputDefs.forEach((inp, idx) => {
|
||||
if (nodeLinks[idx]) {
|
||||
inputs[inp.name] = nodeLinks[idx];
|
||||
const resolved = _resolveBypassSource(nodeById, linkMap, nodeLinks[idx][0], nodeLinks[idx][1], new Set());
|
||||
if (!resolved) return; // Bypass chain with no upstream source — leave unlinked
|
||||
inputs[inp.name] = resolved;
|
||||
linkedInputNames.add(inp.name);
|
||||
linkedSlotNames.add(inp.name);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Map widgets_values to widget input names using object_info
|
||||
const widgets = node.widgets_values || [];
|
||||
if (widgets.length > 0) {
|
||||
// Lora Loader (LoraManager): use __lm_widget_ids from node properties
|
||||
// widgets_values: [autocomplete_meta_obj, text_str, loras_array]
|
||||
// API format: {__lm_autocomplete_meta_text: obj, text: str, loras: {__value__: arr}}
|
||||
const lmWidgetIds = node.properties?.__lm_widget_ids;
|
||||
if (lmWidgetIds && Array.isArray(lmWidgetIds)) {
|
||||
lmWidgetIds.forEach((name, idx) => {
|
||||
if (idx >= widgets.length || linkedInputNames.has(name)) return;
|
||||
let val = widgets[idx];
|
||||
if (name === "loras" && Array.isArray(val)) {
|
||||
val = { "__value__": val };
|
||||
}
|
||||
inputs[name] = val;
|
||||
});
|
||||
} else {
|
||||
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, node.type);
|
||||
|
||||
if (widgetNames.length > 0) {
|
||||
@@ -321,13 +516,15 @@ export const comfyWorkflow = {
|
||||
const expectedType = widgetTypes[nIdx];
|
||||
// Skip if this input is already linked
|
||||
if (linkedInputNames.has(name)) {
|
||||
// Still consume widget value(s) since widgets_values
|
||||
// includes entries for linked inputs too
|
||||
wIdx++;
|
||||
// Also skip control_after_generate if present
|
||||
if (wIdx < widgets.length && _isExtraWidgetValue(widgets[wIdx], expectedType)) {
|
||||
if (!linkedSlotNames.has(name)) {
|
||||
// Not a UI slot input — old-style linked input that still
|
||||
// occupies a slot in widgets_values; consume it.
|
||||
wIdx++;
|
||||
if (wIdx < widgets.length && _isExtraWidgetValue(widgets[wIdx], expectedType)) {
|
||||
wIdx++;
|
||||
}
|
||||
}
|
||||
// UI slot inputs with a real link have no widget value entry — don't advance wIdx.
|
||||
continue;
|
||||
}
|
||||
// Check if current widget value matches expected type
|
||||
@@ -343,6 +540,22 @@ export const comfyWorkflow = {
|
||||
wIdx++;
|
||||
}
|
||||
}
|
||||
// DynamicCombo (e.g. SaveImageAdvanced's "format"): val is the selected
|
||||
// option's key (e.g. "png"). Per comfy_api/latest/_io.py
|
||||
// DynamicCombo._expand_schema_for_dynamic, the backend looks up flat
|
||||
// dot-prefixed keys ("format", "format.bit_depth", ...) in the raw
|
||||
// inputs dict and assembles them into a single dict itself at execution
|
||||
// time — it does NOT accept a pre-built dict under the "format" key.
|
||||
if (expectedType === "COMFY_DYNAMICCOMBO_V3") {
|
||||
inputs[name] = val;
|
||||
const subNames = _getDynamicComboSubNames(objectInfo, node.type, name, val);
|
||||
for (const subName of subNames) {
|
||||
if (wIdx >= widgets.length) break;
|
||||
inputs[`${name}.${subName}`] = widgets[wIdx];
|
||||
wIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// For COMBO inputs, validate the value against the available choices.
|
||||
// Dynamic COMBO options (e.g. Impact Pack's "Select to add Wildcard") may
|
||||
// differ between the saved workflow and the current ComfyUI instance
|
||||
@@ -357,6 +570,14 @@ export const comfyWorkflow = {
|
||||
if (spec) {
|
||||
const choices = Array.isArray(spec[0]) ? spec[0] : null;
|
||||
if (choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (name === "ckpt_name") {
|
||||
_lastCheckpointSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
title: node.title || node.type,
|
||||
original: val,
|
||||
replaced: choices[0],
|
||||
});
|
||||
}
|
||||
val = choices[0];
|
||||
}
|
||||
}
|
||||
@@ -374,6 +595,15 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
}
|
||||
} // end else (non-LoraManager path)
|
||||
}
|
||||
|
||||
// ImpactWildcardEncode / ImpactWildcardProcessor: widgets[0] is always wildcard_text.
|
||||
// Always overwrite: object_info ordering may misalign when widget order differs between
|
||||
// impact-pack versions; widgets[0] is stable across all known versions.
|
||||
if ((node.type === "ImpactWildcardEncode" || node.type === "ImpactWildcardProcessor")
|
||||
&& widgets.length > 0) {
|
||||
inputs["wildcard_text"] = widgets[0];
|
||||
}
|
||||
|
||||
api[nodeId] = {
|
||||
@@ -456,9 +686,12 @@ export const comfyWorkflow = {
|
||||
vae_nodes: [],
|
||||
save_nodes: [],
|
||||
load_image_nodes: [],
|
||||
inpaint_encode_nodes: [],
|
||||
mask_editor_one_nodes: [],
|
||||
text_encoder_nodes: [],
|
||||
diffusion_model_nodes: [],
|
||||
controlnet_nodes: [],
|
||||
hypernetwork_nodes: [],
|
||||
all_nodes: [],
|
||||
};
|
||||
|
||||
@@ -497,7 +730,10 @@ export const comfyWorkflow = {
|
||||
// Nodes that pass CONDITIONING through without changing positive/negative role
|
||||
const COND_PASSTHROUGH = new Set([
|
||||
"ConditioningCombine", "ConditioningConcat", "ConditioningAverage",
|
||||
"ConditioningZeroOut", "ConditioningSetTimestepRange",
|
||||
"ConditioningSetTimestepRange",
|
||||
// ConditioningZeroOut is intentionally excluded: it discards upstream text entirely
|
||||
// (used as a no-text negative in ZIT/Lumina2 workflows). Propagating the negative
|
||||
// role upstream would incorrectly mark the shared positive CLIPTextEncode as "unknown".
|
||||
"ControlNetApply", "ControlNetApplyAdvanced",
|
||||
"IPAdapterApply", "IPAdapterApplyFaceID",
|
||||
"StyleModelApply",
|
||||
@@ -545,6 +781,16 @@ export const comfyWorkflow = {
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
// CLIPTextEncodeEditPlus — propagate role to its STRING inputs (text1, text2)
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
for (const key of ["text1", "text2"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +821,23 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// ImageMetadataPromptLoader — checkpoint + positive/negative prompts in one node
|
||||
if (ct === "ImageMetadataPromptLoader") {
|
||||
result.checkpoint_nodes.push({ id, type: ct, title, ckpt_name: inputs.ckpt_name });
|
||||
if (typeof inputs.positive_text === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.positive_text, textKey: "positive_text",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_text === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_text, textKey: "negative_text",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt nodes ---
|
||||
|
||||
if (ct === "CLIPTextEncode") {
|
||||
@@ -602,6 +865,19 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// CLIPTextEncodeEditPlus — text_edit is the locally editable override; text1 is always a link.
|
||||
// Skip when text_edit is empty: it means upstream nodes (ImpactWildcardEncode etc.) supply
|
||||
// the actual prompt text and the edit field is just an optional local override.
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
const textVal = inputs.text_edit;
|
||||
if (typeof textVal === "string" && textVal !== "") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "text_edit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen-based image+text encoder
|
||||
if (ct === "TextEncodeQwenImageEditPlus") {
|
||||
result.prompt_nodes.push({
|
||||
@@ -611,6 +887,23 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
|
||||
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
|
||||
if (inputs.text_positive !== undefined) {
|
||||
@@ -638,6 +931,22 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// WFS_PromptText — 2出力ノード。output[0]=positive / output[1]=negative
|
||||
if (ct === "WFS_PromptText") {
|
||||
if (typeof inputs.positive === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.positive, textKey: "positive",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative, textKey: "negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PrimitiveStringMultiline / PrimitiveString — when feeding a prompt node
|
||||
if ((ct === "PrimitiveStringMultiline" || ct === "PrimitiveString") && (isPos || isNeg)) {
|
||||
result.prompt_nodes.push({
|
||||
@@ -656,6 +965,20 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// TextEncodeMageFlowEdit also carries width/height/batch_size directly
|
||||
// (it builds the latent internally instead of a separate EmptyLatentImage node).
|
||||
// width/height are commonly wired to a ResolutionSelector-style helper node
|
||||
// (array link ref) rather than a direct number — leave those as undefined so
|
||||
// the settings-tab UI falls back to its default instead of rendering a link ref.
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
});
|
||||
}
|
||||
|
||||
// --- LoRA nodes ---
|
||||
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
|
||||
result.lora_nodes.push({
|
||||
@@ -680,6 +1003,26 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// Lora Loader (LoraManager) — loras stored in inputs.loras.__value__
|
||||
if (ct === "Lora Loader (LoraManager)") {
|
||||
result.lora_nodes.push({ id, type: ct, title, is_lora_manager: true });
|
||||
}
|
||||
|
||||
// ImageMetadataLoRALoader — up to 3 LoRA slots; skip "None" entries
|
||||
if (ct === "ImageMetadataLoRALoader") {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const loraName = inputs[`lora_${i}`];
|
||||
if (typeof loraName === "string" && loraName !== "None") {
|
||||
result.lora_nodes.push({
|
||||
id, type: ct, title,
|
||||
lora_name: loraName,
|
||||
strength_model: inputs[`strength_model_${i}`] ?? 1.0,
|
||||
strength_clip: inputs[`strength_clip_${i}`] ?? 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- VAE nodes ---
|
||||
if (ct === "VAELoader") {
|
||||
result.vae_nodes.push({ id, type: ct, title, vae_name: inputs.vae_name });
|
||||
@@ -698,19 +1041,42 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// --- VAE Encode (for Inpainting) nodes ---
|
||||
if (ct === "VAEEncodeForInpaint") {
|
||||
result.inpaint_encode_nodes.push({
|
||||
id, type: ct, title,
|
||||
grow_mask_by: inputs.grow_mask_by,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mask Editor One nodes (comfyui-mask-editor-one) ---
|
||||
// 画像とマスクをノード自身が内包する(LoadImageを介さない)インペイント画像ソース。
|
||||
// image出力はサーバー側キャッシュ(bg_image_b64、node_idキー)から、mask出力は
|
||||
// layer_data(JSON文字列)ウィジェットの各レイヤーのアルファチャンネルから合成される。
|
||||
if (ct === "MaskEditorOne") {
|
||||
result.mask_editor_one_nodes.push({ id, type: ct, title });
|
||||
}
|
||||
|
||||
// --- Text encoder (CLIP) nodes ---
|
||||
if (ct === "DualCLIPLoader" || ct === "CLIPLoader") {
|
||||
if (ct === "DualCLIPLoader" || ct === "CLIPLoader" || ct === "ClipLoaderGGUF" || ct === "DualClipLoaderGGUF") {
|
||||
result.text_encoder_nodes.push({
|
||||
id, type: ct, title,
|
||||
clip_name1: inputs.clip_name1 || inputs.clip_name,
|
||||
clip_name2: inputs.clip_name2,
|
||||
clip_type: inputs.type,
|
||||
device: inputs.device,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Diffusion model nodes ---
|
||||
if (ct === "UNETLoader" || ct === "UnetLoaderGGUF") {
|
||||
result.diffusion_model_nodes.push({
|
||||
id, type: ct, title, unet_name: inputs.unet_name,
|
||||
id, type: ct, title, unet_name: inputs.unet_name, inputKey: "unet_name",
|
||||
});
|
||||
}
|
||||
if (ct === "LoaderGGUF" || ct === "LoaderGGUFAdvanced") {
|
||||
result.diffusion_model_nodes.push({
|
||||
id, type: ct, title, unet_name: inputs.gguf_name, inputKey: "gguf_name",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -721,6 +1087,33 @@ export const comfyWorkflow = {
|
||||
control_net_name: inputs.control_net_name,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hypernetwork loader nodes ---
|
||||
if (ct === "HypernetworkLoader") {
|
||||
result.hypernetwork_nodes.push({
|
||||
id, type: ct, title,
|
||||
hypernetwork_name: inputs.hypernetwork_name,
|
||||
strength: inputs.strength ?? 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Post-pass: flag LoadImage nodes whose MASK output (slot 1) is wired to something
|
||||
// downstream (directly, or via a pass-through node like MaskEditorNode). These are the
|
||||
// "inpaint-capable" slots — image + mask both come from the same node's two outputs, so
|
||||
// painting a mask for them only requires re-uploading that one node's image with the mask
|
||||
// baked into the alpha channel (matches ComfyUI's native LoadImage alpha→MASK extraction). ---
|
||||
const loadImageIds = new Set(result.load_image_nodes.filter(n => n.type === "LoadImage").map(n => n.id));
|
||||
if (loadImageIds.size > 0) {
|
||||
for (const node of Object.values(workflow)) {
|
||||
if (!node?.inputs) continue;
|
||||
for (const v of Object.values(node.inputs)) {
|
||||
if (Array.isArray(v) && v.length === 2 && Number(v[1]) === 1 && loadImageIds.has(String(v[0]))) {
|
||||
const target = result.load_image_nodes.find(n => n.id === String(v[0]));
|
||||
if (target) target.mask_used = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.all_nodes.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
@@ -765,17 +1158,30 @@ function _getWidgetMapping(nodeType) {
|
||||
CheckpointLoaderSimple: ["ckpt_name"],
|
||||
KSampler: ["seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
|
||||
CLIPTextEncode: ["text"],
|
||||
CLIPTextEncodeEditPlus: ["text_edit", "mode"],
|
||||
ImageMetadataCheckpointLoader: ["ckpt_name", "vae_name", "_metadata_json"],
|
||||
ImageMetadataPromptLoader: ["ckpt_name", "vae_name", "positive_text", "negative_text", "_metadata_json"],
|
||||
ImageMetadataLoRALoader: ["lora_1", "strength_model_1", "strength_clip_1", "lora_2", "strength_model_2", "strength_clip_2", "lora_3", "strength_model_3", "strength_clip_3"],
|
||||
EmptyLatentImage: ["width", "height", "batch_size"],
|
||||
LoraLoader: ["lora_name", "strength_model", "strength_clip"],
|
||||
VAELoader: ["vae_name"],
|
||||
SaveImage: ["filename_prefix"],
|
||||
LoadImage: ["image", "upload"],
|
||||
UNETLoader: ["unet_name", "weight_dtype"],
|
||||
LoaderGGUF: ["gguf_name"],
|
||||
LoaderGGUFAdvanced: ["gguf_name", "dequant_dtype", "patch_dtype", "patch_on_device"],
|
||||
ResolutionSelector: ["aspect_ratio", "megapixels"],
|
||||
EmptySD3LatentImage: ["width", "height", "batch_size"],
|
||||
CLIPLoader: ["clip_name", "type", "device"],
|
||||
ClipLoaderGGUF: ["clip_name", "type"],
|
||||
DualClipLoaderGGUF: ["clip_name1", "clip_name2", "type"],
|
||||
FluxGuidance: ["guidance"],
|
||||
CFGNorm: ["strength"],
|
||||
// Impact Pack wildcard nodes — only map wildcard_text (index 0); remaining widgets
|
||||
// vary between versions and include frontend-only extras, so we leave them unmapped.
|
||||
ImpactWildcardProcessor: ["wildcard_text"],
|
||||
ImpactWildcardEncode: ["wildcard_text"],
|
||||
WFS_PromptText: ["positive", "negative"],
|
||||
};
|
||||
return mappings[nodeType] || null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
/**
|
||||
* Feeder Tab - ImageFeeder node control with embedded image library
|
||||
* 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
|
||||
images: [], // filenames in current folder
|
||||
selected: new Set(),// selected filenames (across all interactions)
|
||||
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 ──────────────────────────────────────────────────
|
||||
@@ -24,12 +36,40 @@ function _feederNodes() {
|
||||
.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() {
|
||||
const sel = document.getElementById("wfm-feeder-node-sel");
|
||||
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 = _feederNodes();
|
||||
const nodes = _galFeederNodes();
|
||||
if (nodes.length === 0) {
|
||||
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
|
||||
sel.innerHTML = `<option value="">-- No WFS_GalleryFeeder node --</option>`;
|
||||
return;
|
||||
}
|
||||
const prevVal = sel.value;
|
||||
@@ -37,7 +77,7 @@ export function refreshFeederNodeList() {
|
||||
`<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);
|
||||
_loadFromGalNode(sel.value || nodes[0].id);
|
||||
}
|
||||
|
||||
function _loadFromNode(nodeId) {
|
||||
@@ -54,7 +94,6 @@ function _loadFromNode(nodeId) {
|
||||
_setVal("wfm-feeder-seed", inp.seed ?? 0);
|
||||
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
|
||||
|
||||
// Restore selected_files
|
||||
_s.selected.clear();
|
||||
try {
|
||||
const arr = JSON.parse(inp.selected_files || "[]");
|
||||
@@ -72,15 +111,44 @@ function _loadFromNode(nodeId) {
|
||||
_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("No ImageFeeder node selected", "error");
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
_applyToNode(nodeId);
|
||||
showToast("Applied to workflow", "success");
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@@ -92,6 +160,13 @@ function _setRunUI(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;
|
||||
@@ -106,20 +181,28 @@ function _applyToNode(nodeId) {
|
||||
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("No workflow loaded", "error"); return; }
|
||||
if (!comfyUI.connected) { showToast("Not connected to ComfyUI", "error"); 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("No ImageFeeder node selected", "error");
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// WebSocket を先に接続してノード sync メッセージを受け取れるようにする
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) { showToast("WebSocket connection failed", "error"); return; }
|
||||
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
|
||||
|
||||
_s.running = true;
|
||||
_setRunUI(true);
|
||||
@@ -128,13 +211,16 @@ async function _startRun() {
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
|
||||
// image_loop_node_sync メッセージを捕捉するリスナー
|
||||
let _lastSync = null;
|
||||
const _syncHandler = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "image_loop_node_sync" && String(msg.data?.node_id) === String(nodeId)) {
|
||||
_lastSync = msg.data;
|
||||
if (msg.data.thumbnail_path) {
|
||||
const p = msg.data.thumbnail_path;
|
||||
_showPreview(p, p.split("/").pop());
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -152,7 +238,6 @@ async function _startRun() {
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = `#${++count} 0%`;
|
||||
|
||||
// 右ペインのseed設定を使用(KSamplerに適用される)
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
|
||||
@@ -168,7 +253,6 @@ async function _startRun() {
|
||||
}
|
||||
);
|
||||
|
||||
// 右ペインのseed表示を更新(通常の生成と同じ挙動)
|
||||
const seedEl = document.getElementById("wfm-gen-seed-value");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
@@ -182,7 +266,6 @@ async function _startRun() {
|
||||
|
||||
if (!_s.running) break;
|
||||
|
||||
// control after generate: ノードから返された next_index を反映
|
||||
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
|
||||
if (control === "fixed") {
|
||||
// index を変えない
|
||||
@@ -191,14 +274,13 @@ async function _startRun() {
|
||||
if (indexEl) indexEl.value = _lastSync.next_index;
|
||||
if (control === "increment" && !_lastSync.has_next) {
|
||||
_s.running = false;
|
||||
showToast(`Feeder complete (${count} generated)`, "success");
|
||||
showToast(t("feederComplete", count), "success");
|
||||
break;
|
||||
}
|
||||
// loop: has_next=false でも次は index=0 からなので続行
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (_s.running) showToast("Feeder run error: " + err.message, "error");
|
||||
if (_s.running) showToast(t("feederRunError", err.message), "error");
|
||||
} finally {
|
||||
comfyUI.socket?.removeEventListener("message", _syncHandler);
|
||||
_s.running = false;
|
||||
@@ -211,10 +293,107 @@ async function _stopRun() {
|
||||
_s.running = false;
|
||||
try { await comfyUI.interrupt(); } catch {}
|
||||
_setRunUI(false);
|
||||
showToast("Feeder stopped", "info");
|
||||
showToast(t("feederStopped"), "info");
|
||||
}
|
||||
|
||||
// ── Folder Tree ───────────────────────────────────────────────────
|
||||
// ── 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");
|
||||
@@ -271,7 +450,7 @@ function _highlightTreeRow(path) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image Grid ────────────────────────────────────────────────────
|
||||
// ── Image Grid (loop mode) ────────────────────────────────────────
|
||||
|
||||
async function _loadImages(dir) {
|
||||
const grid = document.getElementById("wfm-feeder-grid");
|
||||
@@ -362,8 +541,6 @@ function _deselectAll() {
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
// ── Status Bar ───────────────────────────────────────────────────
|
||||
|
||||
function _updateStatus() {
|
||||
const el = document.getElementById("wfm-feeder-status");
|
||||
if (!el) return;
|
||||
@@ -371,6 +548,104 @@ function _updateStatus() {
|
||||
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) {
|
||||
@@ -395,7 +670,20 @@ async function _showPreview(relPath, fname) {
|
||||
} catch { if (infoEl) infoEl.textContent = ""; }
|
||||
}
|
||||
|
||||
// ── Presets ──────────────────────────────────────────────────────
|
||||
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 {
|
||||
@@ -416,7 +704,7 @@ function _renderPresets() {
|
||||
|
||||
async function _savePreset() {
|
||||
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
|
||||
if (!name) { showToast("Enter a preset name", "error"); return; }
|
||||
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", {
|
||||
@@ -425,20 +713,20 @@ async function _savePreset() {
|
||||
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(`Preset saved: ${name}`, "success");
|
||||
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("Save failed", "error"); }
|
||||
} catch (err) { showToast("Save error: " + err.message, "error"); }
|
||||
} 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("Select a preset first", "error"); return; }
|
||||
if (!name || !_s.presets[name]) { showToast(t("selectPresetFirst"), "error"); return; }
|
||||
const preset = _s.presets[name];
|
||||
const dir = preset.directory || "";
|
||||
|
||||
@@ -453,7 +741,7 @@ async function _applyPreset() {
|
||||
}
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
showToast(`Preset loaded: ${name}`, "success");
|
||||
showToast(t("presetLoaded", name), "success");
|
||||
}
|
||||
|
||||
async function _deletePreset() {
|
||||
@@ -461,9 +749,37 @@ async function _deletePreset() {
|
||||
if (!name) return;
|
||||
try {
|
||||
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
showToast(`Preset deleted: ${name}`, "info");
|
||||
showToast(t("presetDeleted", name), "info");
|
||||
await _loadPresets();
|
||||
} catch (err) { showToast("Delete error", "error"); }
|
||||
} 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 ───────────────────────────────────────────────────────
|
||||
@@ -480,6 +796,11 @@ function _setCheck(id, 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);
|
||||
});
|
||||
@@ -506,5 +827,33 @@ export async function initFeederTab() {
|
||||
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
|
||||
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
|
||||
|
||||
await Promise.all([_loadTree(), _loadPresets()]);
|
||||
// ── 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; }
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -154,7 +156,9 @@ function extractLoRAs(wf) {
|
||||
function add(name, sm, sc) {
|
||||
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
results.push({ name, strength_model: typeof sm === "number" ? sm : 1.0, strength_clip: typeof sc === "number" ? sc : 1.0 });
|
||||
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)) {
|
||||
@@ -170,6 +174,11 @@ function extractLoRAs(wf) {
|
||||
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;
|
||||
@@ -236,11 +245,22 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
const isPos = name === "positive" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit") {
|
||||
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
|
||||
const text = srcNode.widgets_values?.[widgetIdx];
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(originId);
|
||||
if (!text) continue;
|
||||
const name = inp.name ?? "";
|
||||
if (name === "positive" || name.startsWith("positive")) pos.add(text);
|
||||
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler) return null;
|
||||
@@ -338,10 +358,20 @@ function extractPromptsAPI(wf) {
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const isPos = key === "positive" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
|
||||
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(String(val[0]));
|
||||
if (!text) continue;
|
||||
if (key === "positive" || key.startsWith("positive")) pos.add(text);
|
||||
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
|
||||
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 }; }
|
||||
@@ -443,10 +473,22 @@ async function extractAllMetadata(file) {
|
||||
const isJSON = file.type === "application/json" || name.endsWith(".json");
|
||||
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
|
||||
|
||||
function fromWorkflow(wf, source) {
|
||||
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 など)
|
||||
const mdm = extractMarkdownNoteModels(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;
|
||||
@@ -459,20 +501,20 @@ async function extractAllMetadata(file) {
|
||||
|
||||
if (isJSON) {
|
||||
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
|
||||
return wf ? fromWorkflow(wf, "comfyui") : 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 ? fromWorkflow(wf, "comfyui") : null;
|
||||
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 ? fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.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);
|
||||
@@ -497,7 +539,6 @@ async function extractAllMetadata(file) {
|
||||
}
|
||||
|
||||
// ── UI helpers ────────────────────────────────────────────────
|
||||
function escapeHtml(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
|
||||
function buildModelItem(label) {
|
||||
const el = document.createElement("div");
|
||||
@@ -543,6 +584,15 @@ function renderSection(sectionEl, listEl, items, buildFn) {
|
||||
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");
|
||||
@@ -736,6 +786,8 @@ export function initMetadataTab() {
|
||||
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"); });
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
|
||||
@@ -25,6 +26,8 @@ const state = {
|
||||
activeSubView: "browser",
|
||||
loaded: false,
|
||||
currentPage: 0,
|
||||
selectedNodes: new Set(),
|
||||
lastSelectionIndex: -1,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
@@ -50,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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -275,6 +275,9 @@ function createNodeCard(node) {
|
||||
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)}`;
|
||||
|
||||
@@ -296,7 +299,32 @@ function createNodeCard(node) {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
card.addEventListener("click", () => showNodeSidePanel(node));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -321,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;
|
||||
@@ -334,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);
|
||||
@@ -353,6 +408,100 @@ 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) {
|
||||
@@ -586,7 +735,7 @@ function renderSideGroups(node) {
|
||||
const name = input?.value?.trim();
|
||||
if (!name) return;
|
||||
if (state.nodeGroups[name]) {
|
||||
showToast("Group already exists", "warning");
|
||||
showToast(t("groupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[name] = [node.name];
|
||||
@@ -604,7 +753,7 @@ function renderSideGroups(node) {
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (state.nodeGroups[newName] !== undefined) {
|
||||
showToast("Group name already exists", "warning");
|
||||
showToast(t("groupNameExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[newName] = state.nodeGroups[oldName];
|
||||
@@ -613,7 +762,7 @@ function renderSideGroups(node) {
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(`Renamed to "${newName}"`, "success");
|
||||
showToast(t("renamedTo", newName), "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
@@ -627,7 +776,7 @@ function renderSideGroups(node) {
|
||||
if (state.groupFilter === name) state.groupFilter = "";
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
showToast(t("groupDeleted", name), "success");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1046,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 => {
|
||||
@@ -1111,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 {
|
||||
@@ -825,20 +908,23 @@ function wcUpdateFilePicker() {
|
||||
// ============================================
|
||||
|
||||
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) {
|
||||
@@ -858,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"; }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -977,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
|
||||
@@ -1020,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 ---
|
||||
@@ -1081,6 +1163,10 @@ export function initPromptTab() {
|
||||
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", () => {
|
||||
@@ -1122,7 +1208,7 @@ export function initPromptTab() {
|
||||
const n = prompt("Number of items to pick (n):", "2");
|
||||
if (n === null) return;
|
||||
const num = parseInt(n, 10);
|
||||
if (isNaN(num) || num < 1) { showToast("Enter a positive integer", "error"); return; }
|
||||
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
|
||||
const template = "{" + num + "$$|}";
|
||||
const start = ta.selectionStart;
|
||||
wcInsertAtCursor(ta, template);
|
||||
@@ -1160,7 +1246,7 @@ export function initPromptTab() {
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast("Sent to Positive Prompt", "success");
|
||||
showToast(t("sentToPositive"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
|
||||
@@ -1169,7 +1255,7 @@ export function initPromptTab() {
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast("Sent to Negative Prompt", "success");
|
||||
showToast(t("sentToNegative"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
|
||||
@@ -1197,22 +1283,22 @@ export function initPromptTab() {
|
||||
const ext = extSelect?.value || "txt";
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!rawName) { showToast("Enter a filename", "error"); return; }
|
||||
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("Invalid path. Use folder/name format, each part: letters, numbers, -, _, . only", "error");
|
||||
showToast(t("invalidPathFormat"), "error");
|
||||
return;
|
||||
}
|
||||
const name = rawName;
|
||||
const filename = `${name}.${ext}`;
|
||||
const saved = await wcSaveFile(filename, content);
|
||||
if (saved) {
|
||||
showToast(`Saved: ${filename}`, "success");
|
||||
showToast(t("savedAs", filename), "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
} else {
|
||||
showToast("Failed to save file", "error");
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1221,7 +1307,7 @@ export function initPromptTab() {
|
||||
if (!wcEditingFilename) return;
|
||||
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
|
||||
await wcDeleteFile(wcEditingFilename);
|
||||
showToast(`Deleted: ${wcEditingFilename}`, "success");
|
||||
showToast(t("deletedName", wcEditingFilename), "success");
|
||||
wcCloseEditor();
|
||||
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) {
|
||||
@@ -393,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 {
|
||||
@@ -634,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>
|
||||
|
||||
@@ -695,6 +765,24 @@ 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>
|
||||
@@ -925,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);
|
||||
});
|
||||
|
||||
@@ -1021,56 +1109,122 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Ollama model list loader ---
|
||||
async function loadOllamaModels() {
|
||||
const select = document.getElementById("wfm-settings-ollama-model");
|
||||
// --- 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 {
|
||||
const res = await fetch("/api/wfm/ollama/models");
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
const savedModel = serverSettings.ollama_model || "llava";
|
||||
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");
|
||||
@@ -1083,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");
|
||||
@@ -1111,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));
|
||||
@@ -1306,4 +1489,28 @@ export async function initSettingsTab() {
|
||||
// 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,66 @@
|
||||
/**
|
||||
* Shared utilities
|
||||
*/
|
||||
|
||||
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// localStorage からJSONを安全に読む(不正JSON・未設定時はfallback)
|
||||
export function readJsonStorage(key, fallback = {}) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// プラグイン共通設定 (wfm_settings)
|
||||
export function getSettings() {
|
||||
return readJsonStorage("wfm_settings");
|
||||
}
|
||||
|
||||
// AIバックエンド(Ollama/LM Studio/Lemonade)ごとのデフォルトURL
|
||||
export const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
};
|
||||
|
||||
export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索inputにオーバーレイXボタンを設定する。
|
||||
* @param {string} inputId - 検索inputのID
|
||||
* @param {string} clearBtnId - クリアボタンのID
|
||||
* @param {Function} onClear - クリア時に呼び出すコールバック(inputは既に空になった後で呼ばれる)
|
||||
*/
|
||||
export function setupSearchClearBtn(inputId, clearBtnId, onClear) {
|
||||
const input = document.getElementById(inputId);
|
||||
const btn = document.getElementById(clearBtnId);
|
||||
if (!input || !btn) return;
|
||||
|
||||
const sync = () => {
|
||||
btn.style.display = input.value ? "flex" : "none";
|
||||
};
|
||||
|
||||
input.addEventListener("input", sync);
|
||||
btn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
btn.style.display = "none";
|
||||
input.focus();
|
||||
onClear();
|
||||
});
|
||||
|
||||
// 初期値が入っている場合に備えて同期
|
||||
sync();
|
||||
}
|
||||
@@ -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,34 +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,
|
||||
};
|
||||
|
||||
|
||||
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.
|
||||
@@ -88,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() {
|
||||
@@ -117,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];
|
||||
@@ -154,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
|
||||
// ============================================
|
||||
@@ -216,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
|
||||
// ============================================
|
||||
@@ -250,6 +383,7 @@ function renderModelFilters() {
|
||||
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>
|
||||
<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;"` : "";
|
||||
@@ -259,6 +393,13 @@ function renderModelFilters() {
|
||||
|
||||
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();
|
||||
@@ -278,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] || [];
|
||||
@@ -333,30 +479,24 @@ function renderGrid() {
|
||||
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();
|
||||
@@ -390,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");
|
||||
|
||||
@@ -414,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();
|
||||
@@ -447,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,7 +770,7 @@ function renderSideGroup(wf) {
|
||||
groups.remove(filename, btn.dataset.group);
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Removed from "${btn.dataset.group}"`, "success");
|
||||
showToast(t("removedFromGroupName", btn.dataset.group), "success");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -633,7 +780,7 @@ function renderSideGroup(wf) {
|
||||
if (!g) return;
|
||||
groups.assign(filename, g);
|
||||
renderSideGroup(wf);
|
||||
showToast(`Added to "${g}"`, "success");
|
||||
showToast(t("addedToGroupName", g), "success");
|
||||
});
|
||||
|
||||
// グループ作成(作成後に現在のWFに追加)
|
||||
@@ -642,36 +789,44 @@ function renderSideGroup(wf) {
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(`"${name}" already exists`, "warning");
|
||||
showToast(t("nameAlreadyExists", name), "warning");
|
||||
return;
|
||||
}
|
||||
groups.assign(filename, name);
|
||||
input.value = "";
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" created`, "success");
|
||||
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("Rename failed", "error");
|
||||
showToast(t("renameFailed"), "error");
|
||||
return;
|
||||
}
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Renamed to "${newName}"`, "success");
|
||||
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) {
|
||||
@@ -681,7 +836,7 @@ function renderSideGroup(wf) {
|
||||
}
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
showToast(t("groupDeleted", name), "success");
|
||||
});
|
||||
|
||||
_refreshGroupFilter();
|
||||
@@ -803,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">
|
||||
@@ -841,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);
|
||||
@@ -884,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(); }
|
||||
@@ -951,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");
|
||||
}
|
||||
@@ -972,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));
|
||||
@@ -1050,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);
|
||||
@@ -1122,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 = "";
|
||||
});
|
||||
@@ -1149,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");
|
||||
@@ -1165,10 +1336,10 @@ 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1228,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");
|
||||
}
|
||||
@@ -1242,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)
|
||||
@@ -1271,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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -43,9 +43,11 @@ const state = {
|
||||
wfLoaded: false,
|
||||
// Prompts sub-tabs
|
||||
promptSubTab: "prompt-all", // "prompt-all" | "prompt-favorites" | "prompt-categories"
|
||||
promptSubTab2: null, // "prompt-groups" | null
|
||||
promptList: [], // full prompt array from API
|
||||
promptFavorites: [], // favorite === true
|
||||
promptCategories: [], // unique category strings
|
||||
promptGroups: {}, // { groupName: [promptId, ...] } from localStorage
|
||||
promptLoaded: false,
|
||||
// Models sub-tabs
|
||||
modelSubTab: "model-all", // "model-all" | "model-favorites" | "model-groups"
|
||||
@@ -59,7 +61,7 @@ const state = {
|
||||
infoSubTab: "info-model", // "info-model" | "info-lora" | "info-prompt"
|
||||
infoMeta: null, // parsed metadata from dropped file
|
||||
// AI tab
|
||||
aiSubTab: "ai-translate", // "ai-translate" | "ai-vlm" | "ai-settings"
|
||||
aiSubTab: "ai-translate", // "ai-translate" | "ai-chat" | "ai-vlm" | "ai-settings"
|
||||
};
|
||||
|
||||
// ============================================
|
||||
@@ -143,6 +145,15 @@ const loadPromptData = async () => {
|
||||
if (c) catSet.add(c);
|
||||
}
|
||||
state.promptCategories = [...catSet].sort();
|
||||
|
||||
try {
|
||||
state.promptGroups = JSON.parse(localStorage.getItem("wfm_prompt_preset_groups") || "{}");
|
||||
} catch { state.promptGroups = {}; }
|
||||
const validIds = new Set(prompts.map(p => p.id));
|
||||
for (const g of Object.keys(state.promptGroups)) {
|
||||
state.promptGroups[g] = (state.promptGroups[g] || []).filter(id => validIds.has(id));
|
||||
}
|
||||
|
||||
state.promptLoaded = true;
|
||||
};
|
||||
|
||||
@@ -230,6 +241,52 @@ const fetchWorkflowRaw = async (filename) => {
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
// Returns true if the JSON is in ComfyUI API format (dict of nodeId→{class_type, inputs})
|
||||
// rather than the UI format (has nodes[] and links[] arrays).
|
||||
const isApiWorkflowFormat = (data) => {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return false;
|
||||
if (Array.isArray(data.nodes)) return false;
|
||||
const keys = Object.keys(data);
|
||||
return keys.length > 0 && keys.every((k) => data[k]?.class_type);
|
||||
};
|
||||
|
||||
// Minimal API→UI conversion so app.loadGraphData() can handle API-format workflows.
|
||||
// Nodes are arranged in a grid; link types use "*" since object_info is not queried here.
|
||||
const convertApiToUiWorkflow = (api) => {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
let linkId = 1;
|
||||
const sortedIds = Object.keys(api).sort((a, b) => Number(a) - Number(b));
|
||||
sortedIds.forEach((id, idx) => {
|
||||
const node = api[id];
|
||||
const col = idx % 5;
|
||||
const row = Math.floor(idx / 5);
|
||||
const uiNode = {
|
||||
id: Number(id),
|
||||
type: node.class_type,
|
||||
title: node._meta?.title || node.class_type,
|
||||
pos: [col * 300 + 50, row * 250 + 50],
|
||||
size: [250, 200],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets_values: [],
|
||||
mode: 0,
|
||||
};
|
||||
for (const [key, val] of Object.entries(node.inputs || {})) {
|
||||
if (Array.isArray(val) && val.length === 2 && typeof val[0] === "string" && typeof val[1] === "number") {
|
||||
uiNode.inputs.push({ name: key, type: "*", link: linkId });
|
||||
links.push([linkId, Number(val[0]), val[1], Number(id), uiNode.inputs.length - 1, "*"]);
|
||||
linkId++;
|
||||
} else {
|
||||
uiNode.widgets_values.push(val);
|
||||
}
|
||||
}
|
||||
nodes.push(uiNode);
|
||||
});
|
||||
const maxNodeId = Math.max(0, ...sortedIds.map(Number));
|
||||
return { nodes, links, groups: [], config: {}, extra: {}, version: 0.4, last_node_id: maxNodeId, last_link_id: linkId };
|
||||
};
|
||||
|
||||
const loadWfData = async () => {
|
||||
const workflows = await fetchWorkflows();
|
||||
state.wfList = workflows.filter(w => w.filename !== ".index.json");
|
||||
@@ -351,7 +408,7 @@ const placePromptNode = (posText, negText, promptName, pos) => {
|
||||
}
|
||||
|
||||
node.pos = pos || [100, 100];
|
||||
if (promptName) node.title = promptName;
|
||||
node.title = "Wfs Prompt";
|
||||
|
||||
graph.add(node);
|
||||
|
||||
@@ -478,9 +535,21 @@ const placeLoraMgrNode = (loras, pos) => {
|
||||
node.pos = pos || centerPos;
|
||||
graph.add(node);
|
||||
if (node.widgets) {
|
||||
const loraList = loras.map(l => ({ name: l.name, strength: l.strength_model ?? 1.0, clipStrength: l.strength_clip ?? l.strength_model ?? 1.0, active: true }));
|
||||
const loraList = loras.map(l => ({ name: l.name, strength: l.strength_model ?? l.strength ?? 1.0, clipStrength: l.strength_clip ?? l.strength_model ?? l.strength ?? 1.0, active: true }));
|
||||
const w = node.widgets.find(ww => Array.isArray(ww.value) || ww.name === "loras");
|
||||
if (w) { w.value = loraList; if (w.callback) w.callback(loraList); }
|
||||
const textW = node.widgets.find(ww => ww.name === "text");
|
||||
if (textW) {
|
||||
textW.value = loraList
|
||||
.filter(l => l.active !== false)
|
||||
.map(l => {
|
||||
const s = parseFloat(l.strength) || 1.0;
|
||||
const c = parseFloat(l.clipStrength) || s;
|
||||
return s === c ? `<lora:${l.name}:${s}>` : `<lora:${l.name}:${s}:${c}>`;
|
||||
})
|
||||
.join(" ");
|
||||
if (textW.callback) textW.callback(textW.value);
|
||||
}
|
||||
}
|
||||
app.canvas.setDirty(true, true);
|
||||
showToast(`Placed: Lora Loader (${loras.length} loras)`, "success");
|
||||
@@ -490,6 +559,30 @@ const placeLoraMgrNode = (loras, pos) => {
|
||||
// Workflow canvas loading
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Load workflow JSON onto the ComfyUI canvas.
|
||||
* Delegates to app.handleFile() so ComfyUI's native path handles both UI and API
|
||||
* formats (identical to dragging a file from Explorer). Falls back to
|
||||
* convertApiToUiWorkflow() + loadGraphData() if handleFile is unavailable.
|
||||
*/
|
||||
const loadDataOnCanvas = async (data) => {
|
||||
if (typeof app?.handleFile === "function") {
|
||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||
const file = new File([blob], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
} else {
|
||||
if (isApiWorkflowFormat(data)) data = convertApiToUiWorkflow(data);
|
||||
await app.loadGraphData(data);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Cross-window receiver for SPA Send-to-Canvas
|
||||
// ============================================
|
||||
window.wfmReceiveWorkflow = async (data) => {
|
||||
await loadDataOnCanvas(data);
|
||||
};
|
||||
|
||||
const loadWorkflowOnCanvas = async (filename) => {
|
||||
const displayName = filename.replace(/\.json$/i, "");
|
||||
showToast(`Loading "${displayName}"...`, "info");
|
||||
@@ -499,7 +592,7 @@ const loadWorkflowOnCanvas = async (filename) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.loadGraphData(data);
|
||||
await loadDataOnCanvas(data);
|
||||
showToast(`Loaded: ${displayName}`, "success");
|
||||
} catch (err) {
|
||||
showToast("Failed to load: " + err.message, "error");
|
||||
@@ -525,7 +618,8 @@ const installCanvasDropHandler = () => {
|
||||
e.dataTransfer.types.includes("application/x-wfm-prompt") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-model") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-lora-multi") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-clip-text")) {
|
||||
e.dataTransfer.types.includes("application/x-wfm-clip-text") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-pending")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
@@ -572,6 +666,31 @@ const installCanvasDropHandler = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle pending workflow drop (from Gallery / Workflow tab "Send to Canvas")
|
||||
const pendingRaw = e.dataTransfer.getData("application/x-wfm-pending");
|
||||
if (pendingRaw) {
|
||||
e.preventDefault();
|
||||
(async () => {
|
||||
try {
|
||||
const wfData = JSON.parse(pendingRaw);
|
||||
await loadDataOnCanvas(wfData);
|
||||
localStorage.removeItem("wfm_pending_workflow");
|
||||
if (panelEl) {
|
||||
const titleEl = panelEl.querySelector(".wfm-nlp-title");
|
||||
if (titleEl) {
|
||||
titleEl.draggable = false;
|
||||
titleEl.classList.remove("wfm-nlp-title-pending");
|
||||
titleEl.title = "Workflow Studio Library";
|
||||
}
|
||||
}
|
||||
showToast("Workflow loaded on canvas", "success");
|
||||
} catch (err) {
|
||||
showToast("Failed to load workflow: " + err.message, "error");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle workflow drop
|
||||
const wfRaw = e.dataTransfer.getData("application/x-wfm-workflow");
|
||||
if (wfRaw) {
|
||||
@@ -740,6 +859,28 @@ const createPanel = () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Pending workflow drag-to-canvas
|
||||
const titleEl = panel.querySelector(".wfm-nlp-title");
|
||||
const updateTitlePendingState = () => {
|
||||
const hasPending = !!localStorage.getItem("wfm_pending_workflow");
|
||||
titleEl.draggable = hasPending;
|
||||
titleEl.classList.toggle("wfm-nlp-title-pending", hasPending);
|
||||
titleEl.title = hasPending
|
||||
? "Drag to canvas to load workflow"
|
||||
: "Workflow Studio Library";
|
||||
};
|
||||
updateTitlePendingState();
|
||||
panel.addEventListener("mouseenter", updateTitlePendingState);
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "wfm_pending_workflow") updateTitlePendingState();
|
||||
});
|
||||
titleEl.addEventListener("dragstart", (e) => {
|
||||
const pendingRaw = localStorage.getItem("wfm_pending_workflow");
|
||||
if (!pendingRaw) { e.preventDefault(); return; }
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-pending", pendingRaw);
|
||||
});
|
||||
|
||||
// Close
|
||||
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
|
||||
|
||||
@@ -810,6 +951,7 @@ const rebuildSubTabs = () => {
|
||||
if (state.topTab === "ai") {
|
||||
const row1Tabs = [
|
||||
{ key: "ai-translate", label: "Translation" },
|
||||
{ key: "ai-chat", label: "Chat" },
|
||||
{ key: "ai-vlm", label: "TOOLS" },
|
||||
{ key: "ai-settings", label: "Settings" },
|
||||
];
|
||||
@@ -892,8 +1034,11 @@ const rebuildSubTabs = () => {
|
||||
{ key: "prompt-favorites", label: "\u2605 Favorites" },
|
||||
{ key: "prompt-categories", label: "\ud83d\udcc1 Categories" },
|
||||
];
|
||||
const row2Tabs = [
|
||||
{ key: "prompt-groups", label: "\ud83d\udcc1 Groups" },
|
||||
];
|
||||
|
||||
const activeKey = state.promptSubTab;
|
||||
const activeKey = state.promptSubTab2 || state.promptSubTab;
|
||||
|
||||
for (const t of row1Tabs) {
|
||||
const btn = document.createElement("button");
|
||||
@@ -902,13 +1047,26 @@ const rebuildSubTabs = () => {
|
||||
btn.textContent = t.label;
|
||||
btn.addEventListener("click", () => {
|
||||
state.promptSubTab = t.key;
|
||||
state.promptSubTab2 = null;
|
||||
updateAllActive();
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
row1.appendChild(btn);
|
||||
}
|
||||
// No row2 tabs for prompts
|
||||
for (const t of row2Tabs) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (activeKey === t.key ? " active" : "");
|
||||
btn.dataset.subtab = t.key;
|
||||
btn.textContent = t.label;
|
||||
btn.addEventListener("click", () => {
|
||||
state.promptSubTab2 = t.key;
|
||||
updateAllActive();
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
row2.appendChild(btn);
|
||||
}
|
||||
} else if (state.topTab === "workflows") {
|
||||
const row1Tabs = [
|
||||
{ key: "wf-all", label: "All" },
|
||||
@@ -1040,10 +1198,12 @@ const renderContent = () => {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
switch (state.promptSubTab) {
|
||||
const promptKey = state.promptSubTab2 || state.promptSubTab;
|
||||
switch (promptKey) {
|
||||
case "prompt-all": renderPromptAll(content); break;
|
||||
case "prompt-favorites": renderPromptFavorites(content); break;
|
||||
case "prompt-categories": renderPromptCategories(content); break;
|
||||
case "prompt-groups": renderPromptGroups(content); break;
|
||||
}
|
||||
} else if (state.topTab === "workflows") {
|
||||
if (!state.wfLoaded) {
|
||||
@@ -1529,6 +1689,32 @@ const renderModelGroups = async (container) => {
|
||||
const item = createModelItem(name, type);
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
|
||||
if (modelType === "lora" && filtered.length >= 1) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "wfm-nlp-info-section-title";
|
||||
sep.style.marginTop = "6px";
|
||||
listEl.appendChild(sep);
|
||||
|
||||
const multiItem = document.createElement("div");
|
||||
multiItem.className = "wfm-nlp-item wfm-nlp-model-item";
|
||||
multiItem.draggable = true;
|
||||
multiItem.title = `Drag to canvas to place Lora Loader (LoraManager) with all ${filtered.length} loras`;
|
||||
multiItem.innerHTML = `<div class="wfm-nlp-item-row"><div class="wfm-nlp-item-body"><div class="wfm-nlp-item-label">All ${filtered.length} LoRAs</div><div class="wfm-nlp-item-sub">→ Lora Loader (LoraManager)</div></div></div>`;
|
||||
const buildLoraList = () => filtered.map(n => {
|
||||
const stem = n.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
|
||||
return { name: stem, strength: 1.0, clipStrength: 1.0, active: true };
|
||||
});
|
||||
multiItem.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-lora-multi", JSON.stringify({ loras: buildLoraList() }));
|
||||
multiItem.classList.add("dragging");
|
||||
});
|
||||
multiItem.addEventListener("dragend", () => multiItem.classList.remove("dragging"));
|
||||
multiItem.addEventListener("dblclick", (e) => { e.stopPropagation(); placeLoraMgrNode(buildLoraList()); });
|
||||
listEl.appendChild(multiItem);
|
||||
}
|
||||
|
||||
section.appendChild(listEl);
|
||||
container.appendChild(section);
|
||||
}
|
||||
@@ -1722,6 +1908,55 @@ const renderPromptCategories = (container) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderPromptGroups = (container) => {
|
||||
container.innerHTML = "";
|
||||
const groupNames = Object.keys(state.promptGroups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
No groups.<br><small>Create groups in Workflow Studio</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const promptMap = new Map(state.promptList.map(p => [p.id, p]));
|
||||
let hasAny = false;
|
||||
|
||||
for (const groupName of groupNames) {
|
||||
const ids = state.promptGroups[groupName] || [];
|
||||
const prompts = ids.map(id => promptMap.get(id)).filter(Boolean);
|
||||
const filtered = prompts.filter(matchesPromptSearch);
|
||||
if (state.searchText && filtered.length === 0) continue;
|
||||
|
||||
hasAny = true;
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header collapsed";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${filtered.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";
|
||||
list.style.display = "none";
|
||||
for (const p of filtered) {
|
||||
list.appendChild(createDraggablePromptItem(p));
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">No matches.</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render – Node sub-tabs (existing)
|
||||
// ============================================
|
||||
@@ -2217,7 +2452,9 @@ function _extractLoRAs(wf) {
|
||||
function add(name, sm, sc) {
|
||||
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
results.push({ name, strength_model: typeof sm === "number" ? sm : 1.0, strength_clip: typeof sc === "number" ? sc : 1.0 });
|
||||
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)) {
|
||||
@@ -2233,6 +2470,11 @@ function _extractLoRAs(wf) {
|
||||
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;
|
||||
@@ -2761,6 +3003,14 @@ const renderInfoTab = (container) => {
|
||||
|
||||
const AI_SETTINGS_KEY = "wfm_ai_settings";
|
||||
const AI_LANG_NAMES = { ja: "Japanese", en: "English", zh: "Chinese" };
|
||||
const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
};
|
||||
function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
function isValidAiUrl(url) {
|
||||
try {
|
||||
@@ -2836,6 +3086,24 @@ async function aiCallVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCallChat(url, backend, model, messages) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/chat`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).message?.content || "";
|
||||
} else {
|
||||
const r = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).choices?.[0]?.message?.content || "";
|
||||
}
|
||||
}
|
||||
|
||||
async function aiFetchModels(url, backend) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/tags`);
|
||||
@@ -2877,6 +3145,16 @@ const renderAiTab = (container) => {
|
||||
</div>
|
||||
<textarea id="wfm-nlp-ai-output" class="wfm-nlp-ai-textarea wfm-nlp-ai-output" placeholder="Translation result..." readonly></textarea>
|
||||
</div>
|
||||
<!-- Chat sub -->
|
||||
<div id="wfm-nlp-ai-chat" class="wfm-nlp-ai-pane" style="display:none;">
|
||||
<div class="wfm-nlp-ai-chat-msgs" id="wfm-nlp-ai-chat-msgs"></div>
|
||||
<textarea id="wfm-nlp-ai-chat-input" class="wfm-nlp-ai-textarea wfm-nlp-ai-chat-input" placeholder="Type a message..."></textarea>
|
||||
<div class="wfm-nlp-ai-actions">
|
||||
<button id="wfm-nlp-ai-chat-send" class="wfm-nlp-ai-btn wfm-nlp-ai-btn-primary">Send</button>
|
||||
<button id="wfm-nlp-ai-chat-clear" class="wfm-nlp-ai-btn">Clear</button>
|
||||
<span id="wfm-nlp-ai-chat-status" class="wfm-nlp-ai-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TOOLS sub -->
|
||||
<div id="wfm-nlp-ai-vlm" class="wfm-nlp-ai-pane" style="display:none;">
|
||||
<div class="wfm-nlp-ai-vlm-drop" id="wfm-nlp-ai-vlm-drop">
|
||||
@@ -2884,11 +3162,22 @@ const renderAiTab = (container) => {
|
||||
<span id="wfm-nlp-ai-vlm-label" class="wfm-nlp-ai-vlm-label">Drop PNG / JPG / WebP</span>
|
||||
<input type="file" id="wfm-nlp-ai-vlm-file" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
||||
</div>
|
||||
<div id="wfm-nlp-ai-wc-inputs" style="display:none;flex-direction:column;gap:5px;">
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<span class="wfm-nlp-ai-lbl">Name</span>
|
||||
<input type="text" id="wfm-nlp-ai-wc-name" class="wfm-nlp-ai-input" style="flex:1;" placeholder="e.g. sports">
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<span class="wfm-nlp-ai-lbl">Count</span>
|
||||
<input type="number" id="wfm-nlp-ai-wc-count" class="wfm-nlp-ai-input wfm-nlp-ai-wc-count" value="20" min="1" max="200">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<select id="wfm-nlp-ai-vlm-task" class="wfm-nlp-ai-sel" style="flex:1;">
|
||||
<option value="describe">Describe image</option>
|
||||
<option value="prompt">Create prompt</option>
|
||||
<option value="tags">Create tags</option>
|
||||
<option value="wildcard">Create wildcards</option>
|
||||
</select>
|
||||
<button id="wfm-nlp-ai-vlm-run" class="wfm-nlp-ai-btn wfm-nlp-ai-btn-primary">Run</button>
|
||||
</div>
|
||||
@@ -2903,6 +3192,7 @@ const renderAiTab = (container) => {
|
||||
<div class="wfm-nlp-ai-radio-row">
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="ollama"> Ollama</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lmstudio"> LM Studio</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lemonade"> Lemonade</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
@@ -2946,7 +3236,7 @@ const renderAiTab = (container) => {
|
||||
|
||||
const renderAiSubContent = () => {
|
||||
if (!panelEl) return;
|
||||
const panes = { "ai-translate": "wfm-nlp-ai-translate", "ai-vlm": "wfm-nlp-ai-vlm", "ai-settings": "wfm-nlp-ai-settings" };
|
||||
const panes = { "ai-translate": "wfm-nlp-ai-translate", "ai-chat": "wfm-nlp-ai-chat", "ai-vlm": "wfm-nlp-ai-vlm", "ai-settings": "wfm-nlp-ai-settings" };
|
||||
for (const [key, id] of Object.entries(panes)) {
|
||||
const el = panelEl.querySelector(`#${id}`);
|
||||
if (el) el.style.display = state.aiSubTab === key ? "flex" : "none";
|
||||
@@ -2963,6 +3253,13 @@ const setupAiHandlers = (container) => {
|
||||
if (urlInput && cfg.backendUrl) urlInput.value = cfg.backendUrl;
|
||||
else if (urlInput) urlInput.value = "http://localhost:11434";
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
backendRadios.forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
if (urlInput) urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
});
|
||||
});
|
||||
|
||||
const freeSrcInput = container.querySelector("#wfm-nlp-ai-free-src");
|
||||
const freeDstInput = container.querySelector("#wfm-nlp-ai-free-dst");
|
||||
if (freeSrcInput && cfg.freeSrcLang) freeSrcInput.value = cfg.freeSrcLang;
|
||||
@@ -2992,7 +3289,7 @@ const setupAiHandlers = (container) => {
|
||||
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
@@ -3101,6 +3398,67 @@ const setupAiHandlers = (container) => {
|
||||
// Auto-load models if settings exist
|
||||
if (cfg.backendUrl && cfg.backend) refreshModels().catch(() => {});
|
||||
|
||||
// ---- Chat handlers ----
|
||||
let chatHistory = [];
|
||||
const chatMsgsEl = container.querySelector("#wfm-nlp-ai-chat-msgs");
|
||||
const chatInputEl = container.querySelector("#wfm-nlp-ai-chat-input");
|
||||
const chatSendBtn = container.querySelector("#wfm-nlp-ai-chat-send");
|
||||
const chatClearBtn= container.querySelector("#wfm-nlp-ai-chat-clear");
|
||||
const chatStatusEl= container.querySelector("#wfm-nlp-ai-chat-status");
|
||||
|
||||
const appendChatBubble = (role, content) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-nlp-ai-chat-msg wfm-nlp-ai-chat-msg-${role}`;
|
||||
div.textContent = content;
|
||||
chatMsgsEl.appendChild(div);
|
||||
chatMsgsEl.scrollTop = chatMsgsEl.scrollHeight;
|
||||
};
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
const text = chatInputEl?.value?.trim();
|
||||
if (!text) return;
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
|
||||
chatInputEl.value = "";
|
||||
chatHistory.push({ role: "user", content: text });
|
||||
appendChatBubble("user", text);
|
||||
|
||||
chatSendBtn.disabled = true;
|
||||
chatStatusEl.textContent = "Thinking...";
|
||||
chatStatusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
|
||||
|
||||
try {
|
||||
const reply = await aiCallChat(url, backend, model, chatHistory);
|
||||
chatHistory.push({ role: "assistant", content: reply });
|
||||
appendChatBubble("assistant", reply);
|
||||
chatStatusEl.textContent = "";
|
||||
chatStatusEl.className = "wfm-nlp-ai-status";
|
||||
} catch (err) {
|
||||
chatHistory.pop();
|
||||
if (chatMsgsEl.lastChild) chatMsgsEl.removeChild(chatMsgsEl.lastChild);
|
||||
chatInputEl.value = text;
|
||||
chatStatusEl.textContent = `エラー: ${err.message}`;
|
||||
chatStatusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
|
||||
} finally {
|
||||
chatSendBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
chatSendBtn?.addEventListener("click", sendChatMessage);
|
||||
chatInputEl?.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
|
||||
});
|
||||
chatClearBtn?.addEventListener("click", () => {
|
||||
chatHistory = [];
|
||||
if (chatMsgsEl) chatMsgsEl.innerHTML = "";
|
||||
if (chatStatusEl) { chatStatusEl.textContent = ""; chatStatusEl.className = "wfm-nlp-ai-status"; }
|
||||
});
|
||||
|
||||
// ---- VLM handlers ----
|
||||
let vlmImage = null; // { base64, mimeType }
|
||||
|
||||
@@ -3108,10 +3466,22 @@ const setupAiHandlers = (container) => {
|
||||
const vlmPreview = container.querySelector("#wfm-nlp-ai-vlm-preview");
|
||||
const vlmLabel = container.querySelector("#wfm-nlp-ai-vlm-label");
|
||||
const vlmFile = container.querySelector("#wfm-nlp-ai-vlm-file");
|
||||
const vlmTaskSel = container.querySelector("#wfm-nlp-ai-vlm-task");
|
||||
const vlmRunBtn = container.querySelector("#wfm-nlp-ai-vlm-run");
|
||||
const vlmStatus = container.querySelector("#wfm-nlp-ai-vlm-status");
|
||||
const vlmResult = container.querySelector("#wfm-nlp-ai-vlm-result");
|
||||
const vlmCopy = container.querySelector("#wfm-nlp-ai-vlm-copy");
|
||||
const wcInputs = container.querySelector("#wfm-nlp-ai-wc-inputs");
|
||||
const wcNameEl = container.querySelector("#wfm-nlp-ai-wc-name");
|
||||
const wcCountEl = container.querySelector("#wfm-nlp-ai-wc-count");
|
||||
|
||||
const updateVlmTaskUI = () => {
|
||||
const isWc = vlmTaskSel?.value === "wildcard";
|
||||
if (vlmDrop) vlmDrop.style.display = isWc ? "none" : "";
|
||||
if (wcInputs) wcInputs.style.display = isWc ? "flex" : "none";
|
||||
};
|
||||
vlmTaskSel?.addEventListener("change", updateVlmTaskUI);
|
||||
updateVlmTaskUI();
|
||||
|
||||
const loadVlmImage = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
@@ -3140,15 +3510,45 @@ const setupAiHandlers = (container) => {
|
||||
};
|
||||
|
||||
vlmRunBtn?.addEventListener("click", async () => {
|
||||
if (!vlmImage) { showToast("Please drop an image", "error"); return; }
|
||||
const task = vlmTaskSel?.value || "describe";
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
|
||||
const task = container.querySelector("#wfm-nlp-ai-vlm-task")?.value || "describe";
|
||||
if (task === "wildcard") {
|
||||
const name = wcNameEl?.value?.trim() || "";
|
||||
const count = Math.max(1, parseInt(wcCountEl?.value) || 20);
|
||||
if (!name) { showToast("Please enter a wildcard name", "error"); return; }
|
||||
|
||||
vlmRunBtn.disabled = true;
|
||||
vlmStatus.textContent = "Running...";
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
|
||||
vlmResult.value = "";
|
||||
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
try {
|
||||
const result = await aiCallLLM(url, backend, model, prompt);
|
||||
vlmResult.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
.filter(l => l.length > 0)
|
||||
.join("\n");
|
||||
vlmStatus.textContent = "完了";
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-ok";
|
||||
} catch (err) {
|
||||
vlmStatus.textContent = `エラー: ${err.message}`;
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
|
||||
} finally {
|
||||
vlmRunBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vlmImage) { showToast("Please drop an image", "error"); return; }
|
||||
|
||||
vlmRunBtn.disabled = true;
|
||||
vlmStatus.textContent = "Running...";
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
|
||||
@@ -3304,6 +3704,16 @@ const injectStyles = () => {
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.wfm-nlp-title-pending {
|
||||
cursor: grab;
|
||||
color: #66aaff;
|
||||
}
|
||||
.wfm-nlp-title-pending::after {
|
||||
content: " ●";
|
||||
font-size: 7px;
|
||||
color: #4caf50;
|
||||
vertical-align: super;
|
||||
}
|
||||
.wfm-nlp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -4005,6 +4415,46 @@ const injectStyles = () => {
|
||||
color: var(--descrip-text, #999);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Chat */
|
||||
.wfm-nlp-ai-chat-msgs {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
.wfm-nlp-ai-chat-msg {
|
||||
max-width: 92%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.wfm-nlp-ai-chat-msg-user {
|
||||
align-self: flex-end;
|
||||
background: var(--p-button-background, #4a9eff);
|
||||
color: #fff;
|
||||
}
|
||||
.wfm-nlp-ai-chat-msg-assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #4e4e4e);
|
||||
color: var(--input-text, #ddd);
|
||||
}
|
||||
.wfm-nlp-ai-chat-input {
|
||||
min-height: 50px;
|
||||
max-height: 100px;
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Wildcard count */
|
||||
.wfm-nlp-ai-wc-count {
|
||||
width: 60px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
@@ -611,27 +611,55 @@ app.registerExtension({
|
||||
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
|
||||
];
|
||||
|
||||
const replaceButtonIcons = () => {
|
||||
const applyIconToButton = (btn, cfg) => {
|
||||
// Skip if the SVG icon is already in place
|
||||
if (btn.querySelector("svg")) return;
|
||||
btn.classList.add(cfg.className);
|
||||
btn.innerHTML = cfg.getIcon();
|
||||
btn.style.borderRadius = "4px";
|
||||
btn.style.padding = "6px";
|
||||
if (cfg.styles) Object.assign(btn.style, cfg.styles);
|
||||
const svg = btn.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
};
|
||||
|
||||
// Initial application — retry until buttons are rendered by Vue
|
||||
const pollUntilFound = () => {
|
||||
let foundAny = false;
|
||||
for (const cfg of buttonConfigs) {
|
||||
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
|
||||
foundAny = true;
|
||||
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";
|
||||
}
|
||||
applyIconToButton(btn, cfg);
|
||||
});
|
||||
}
|
||||
if (!foundAny) {
|
||||
requestAnimationFrame(replaceButtonIcons);
|
||||
requestAnimationFrame(pollUntilFound);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcons);
|
||||
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,665 @@
|
||||
{
|
||||
"id": "6d392e45-b20b-4dee-a422-0b4a18e645cd",
|
||||
"revision": 0,
|
||||
"last_node_id": 35,
|
||||
"last_link_id": 102,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1351.4570000000006,
|
||||
197.9979999999999
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 42
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 84
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
22
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
-35,
|
||||
161
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
80
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
81,
|
||||
82
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
84,
|
||||
90
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"anime\\merge\\hayochamixIDXL_v11+_blueberryPlus_v10-amanatsuIllustrious_v11_x0.1_trainDifference.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
330,
|
||||
369
|
||||
],
|
||||
"size": [
|
||||
412.2799987792969,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 82
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"w\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-20.402179424849777,
|
||||
348.01722821269084
|
||||
],
|
||||
"size": [
|
||||
282.798828125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "choose file to upload",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
101
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "MASK",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
102
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.8"
|
||||
},
|
||||
"image": "clipspace/clipspace-painted-masked-1784801792090.png [input]"
|
||||
},
|
||||
"widgets_values": [
|
||||
"clipspace/clipspace-painted-masked-1784801792090.png [input]",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
997,
|
||||
174
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 80
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 72
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "steps",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "sampler_name",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "scheduler",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "denoise",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
42
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
1008854893628621,
|
||||
"randomize",
|
||||
30,
|
||||
4,
|
||||
"ddim",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "VAEEncodeForInpaint",
|
||||
"pos": [
|
||||
759,
|
||||
169
|
||||
],
|
||||
"size": [
|
||||
226.8000030517578,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 101
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 90
|
||||
},
|
||||
{
|
||||
"localized_name": "mask",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 102
|
||||
},
|
||||
{
|
||||
"localized_name": "grow_mask_by",
|
||||
"name": "grow_mask_by",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "grow_mask_by"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
72
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncodeForInpaint",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1362.0710000000008,
|
||||
369.32099999999986
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
326.97,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
422.8500061035156,
|
||||
164.30999755859375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 81
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"smile"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
42,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
72,
|
||||
26,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
80,
|
||||
29,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
81,
|
||||
29,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
82,
|
||||
29,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
84,
|
||||
29,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
90,
|
||||
29,
|
||||
2,
|
||||
26,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
101,
|
||||
32,
|
||||
0,
|
||||
26,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
102,
|
||||
32,
|
||||
1,
|
||||
26,
|
||||
2,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9229599817706418,
|
||||
"offset": [
|
||||
365.6203139679832,
|
||||
118.10373545614928
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": []
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 250 KiB |