Compare commits
@@ -0,0 +1,60 @@
|
||||
"""PostToolUse hook: regenerate README.index.md / DEVLOG.index.md right after
|
||||
Claude Code edits README.md or DEVLOG.md, so graphify's excluded-doc index
|
||||
stand-ins (see .graphifyignore) never drift from the source files within a
|
||||
session. A git pre-commit hook covers edits made outside Claude Code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGETS = {"readme.md": "readme", "devlog.md": "devlog"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
|
||||
if payload.get("tool_name") not in ("Edit", "Write"):
|
||||
return
|
||||
|
||||
file_path = payload.get("tool_input", {}).get("file_path", "")
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
name = Path(file_path).name.lower()
|
||||
target = TARGETS.get(name)
|
||||
if target is None:
|
||||
return
|
||||
|
||||
project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or str(Path(file_path).resolve().parent)
|
||||
script = Path(project_dir) / "tools" / "generate_doc_index.py"
|
||||
if not script.exists():
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), target],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(json.dumps({"systemMessage": f"[doc-index] {result.stdout.strip()}"}))
|
||||
else:
|
||||
print(
|
||||
json.dumps(
|
||||
{"systemMessage": f"[doc-index] generate_doc_index.py {target} failed: {result.stderr.strip()}"}
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/on_doc_edit.py\"",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# graphify: keep graph.json/graphify-out/ out of Claude Code's automatic
|
||||
# context so re-running `graphify extract` doesn't churn the prompt cache
|
||||
# on every rebuild. These are still tracked by git (see .gitignore).
|
||||
graph.json
|
||||
graphify-out/
|
||||
@@ -0,0 +1,21 @@
|
||||
# ComfyUI-Workflow-Studio - Unsloth backend (AI TOOL tab / sidebar A tab)
|
||||
#
|
||||
# Unsloth Desktop requires an API key even for local access
|
||||
# (Settings -> API in the Unsloth app, shown only once when issued).
|
||||
#
|
||||
# Alternatively leave the key empty and turn on Keyless API access ->
|
||||
# "Chat and inference" in Unsloth Desktop's Settings -> API; requests are then
|
||||
# sent without a key. That setting also serves your private LAN, and keep
|
||||
# "Allow tools" OFF.
|
||||
#
|
||||
# 1. Copy this file to ".env" (same directory as this example file)
|
||||
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
|
||||
# line becomes a comment and the key will not be loaded)
|
||||
# 3. Restart ComfyUI
|
||||
|
||||
UNSLOTH_API_KEY=
|
||||
|
||||
# ComfyUI-Workflow-Studio - Gallery Semantic Search (Gemini Embedding 2)
|
||||
# Get a key at https://aistudio.google.com (free tier available).
|
||||
# Same steps as above: copy to ".env", paste the key, restart ComfyUI.
|
||||
GEMINI_API_KEY=
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Publish to Comfy Registry
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9 # 1.0.1
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -5,3 +5,10 @@ __pycache__/
|
||||
.venv
|
||||
*.egg-info/
|
||||
data/
|
||||
|
||||
# graphify: local-only knowledge graph output, not shared with other clones
|
||||
graphify-out/
|
||||
|
||||
# Working-notes plan docs — local scratch, not meant to ship with the repo
|
||||
VIDEO_EDIT_TAB_PLAN.md
|
||||
QWEN_CHAT_INTEGRATION_PLAN.md
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Private/unpublished planning notes — never committed to git, must not leak
|
||||
# into the published graphify-out/ knowledge graph either.
|
||||
QWEN_CHAT_INTEGRATION_PLAN.md
|
||||
|
||||
# Too large for reliable local-Ollama semantic extraction (large chunks make the
|
||||
# model summarize in prose instead of emitting JSON, and time out on slower
|
||||
# models/hardware). README.index.md / DEVLOG.index.md are condensed stand-ins
|
||||
# that graphify extracts instead — regenerate them after editing the originals.
|
||||
README.md
|
||||
DEVLOG.md
|
||||
@@ -185,3 +185,43 @@ 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. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
4. 上記すべてを同一コミットにまとめてプッシュ
|
||||
5. `gh release create vX.Y.Z` でリリースを作成(リリースノート本文に変更点を記載する — v0.3.90でREADME.mdのChangelogセクションは廃止したため、以後のバージョン履歴はGitHub Releaseのリリースノートのみで管理する)
|
||||
|
||||
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
|
||||
|
||||
<!-- graphify運用は一時停止中(2026-08-23〜、ユーザー指示により凍結)。再開時はこのコメントブロックを解除すること。graphify-out/ 自体は削除しておらずそのまま残っている。
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
### README.md / DEVLOG.md の索引ファイル
|
||||
|
||||
`README.md`/`DEVLOG.md`はサイズが大きすぎてローカルOllamaでの意味抽出が不安定になる(大きな塊を渡すとJSONではなく散文の要約を返す、または処理がタイムアウトする)ため、`.graphifyignore`でgraphifyの抽出対象から除外している。代わりに縮小版の`README.index.md`/`DEVLOG.index.md`を抽出対象にしている。
|
||||
|
||||
- 生成スクリプト: `python tools/generate_doc_index.py [readme|devlog|all]`
|
||||
- 自動化: `README.md`/`DEVLOG.md`を編集すると以下の2つのフックが自動でindexを再生成する
|
||||
- Claude Code PostToolUseフック(`.claude/hooks/on_doc_edit.py`)— Edit/Write直後に即時実行
|
||||
- git pre-commitフック(`.git/hooks/pre-commit`)— コミット時にstagedファイルを検知して再生成し、同じコミットに含める(他エディタでの編集にも対応)
|
||||
- 索引ファイルを手動で編集しない(次の生成で上書きされる)
|
||||
- 索引ファイル再生成後は`graphify update .`を実行してグラフに反映する
|
||||
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# DEVLOG Index — ComfyUI-Workflow-Studio
|
||||
|
||||
Condensed version-by-version index of DEVLOG.md (each line: version + one-line summary of what changed and why). Full rationale, code details, and "How to apply" lessons live in the excluded DEVLOG.md itself; this index exists so graphify can extract a queryable semantic node per release without feeding the full log to the local Ollama model. Regenerate with `python tools/generate_doc_index.py devlog` whenever DEVLOG.md changes.
|
||||
|
||||
- **v0.8.2(2026-10-12)** — Gallery: セマンティック検索(Gemini Embedding 2 / EmbeddingGemma 2)
|
||||
- **v0.8.1(2026-10-11)** — バグ修正
|
||||
- **v0.8.0(2026-10-11)** — Gallery: 音声ファイル対応(wav / mp3 / ogg / m4a / flac / opus)
|
||||
- **v0.7.14(2026-10-09)** — Comic Creator連携: nanobananaタブの生成履歴をGallery/Metadataタブに表示
|
||||
- **v0.7.13(2026-10-06)** — VRAM調整(Ollama)— Comic Creatorから移植
|
||||
- **v0.7.12(2026-10-04)** — セキュリティ: バックエンド接続先をブラウザから任意指定できた問題(SSRF)を修正
|
||||
- **v0.7.11(2026-10-02)** — セキュリティ: CivitAI情報の説明文のHTML無害化(保存型XSS対策)
|
||||
- **v0.7.10(2026-10-02)** — Windows: ComfyUIが固まる問題の原因特定と対策(Selectorイベントループ)
|
||||
- **v0.7.9(2026-10-02)** — Models: CivitAIベースモデルからの自動バッジ作成
|
||||
- **v0.7.8(2026-10-01)** — Video Edit: マルチトラック化(ビデオ/オーバーレイ/テキスト/オーディオ)
|
||||
- **v0.7.7(2026-09-30)** — 意思決定モデル: Ollama 0.35+ バックエンド追加
|
||||
- **v0.7.6(2026-09-30)** — Settings: 意思決定モデル (Laya) の専用設定
|
||||
- **v0.7.5(2026-09-30)** — Ming Image ワークフロー対応(GenerateUI / Gallery・Metadata / サイドパネルIタブ)
|
||||
- **v0.7.4(2026-09-29)** — Video Edit: BGM/音声トラック合成(Phase 4)— 複数クリップ結合時の音声消失も解消
|
||||
- **v0.7.3(2026-09-24)** — GenerateUIタブにQwen Image 2.1(TextEncodeQwenImage21)対応を追加
|
||||
- **v0.7.2(2026-09-18)** — Workflowタブにソート機能追加(Date/Name 昇順降順)
|
||||
- **v0.7.1(2026-09-17)** — セキュリティ修正: Tagger VLM の Unsloth 経路でAPIキーがSSRFにより外部送信され得る不備を修正
|
||||
- **v0.7.0(2026-09-17)** — プレビュー枠の高さ固定・AssetサブタブをPlan/Editと並ぶ中央下部へ移設・トリムパネルにClipchamp風スクラバー追加・Frame保存のVideo Assets登録とAsset→Plan画像連携
|
||||
- **v0.6.3** — Send CCボタンでMP4動画をComic Creatorの新設「動画ツール」へ送れるように対応
|
||||
- **v0.6.2** — Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
|
||||
- **v0.6.1** — Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
|
||||
- **v0.6.0** — Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
|
||||
- **v0.5.8** — Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
|
||||
- **v0.5.7** — 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
|
||||
- **v0.5.6** — バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
|
||||
- **v0.5.5** — Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
|
||||
- **v0.5.4** — 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
|
||||
- **v0.5.3** — バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
|
||||
- **v0.5.2** — 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
|
||||
- **v0.5.1** — 機能追加: mp4動画の埋め込みメタデータ対応(Gallery Prompt/JSON・Metadataサブタブ・サイドパネルIタブ)+ Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
|
||||
- **v0.5.0** — 機能追加: mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
|
||||
- **v0.4.8** — 機能追加: LabタブにPromptグループ一括適用+キーフレーム並べ替え + バグ修正: デフォルトワークフローの上書き保存が反映されない + Styleテーブルの「このファイルへ追加」対応
|
||||
- **v0.4.7** — 機能追加: Prompt Tab内部リファクタ + Gallery Promptタブ新設 + Send to Canvas改善 + Presetsソート
|
||||
- **v0.4.6** — 機能追加: MetadataタブをGalleryタブへ統合 + Promptタブに一覧編集可能なTable表示を新設
|
||||
- **v0.4.5** — 調査: トップバー保存ワークフローのRMBG不具合報告(原因なし) + バグ修正: LabタブModel列の無意味な空キーフレーム表示を解消
|
||||
- **v0.4.4** — 機能追加: Lab/GenerateUI ModelタブへLoRAバイパス機能 + AI TOOL Chatの画像生成パラメータ拡張
|
||||
- **v0.4.3** — バグ修正: 共有モーダルのお気に入り☆/リネームがLabモーダルへ残留 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
|
||||
- **v0.4.2** — 機能追加: Lab「Model」列統合(Checkpoint / LoRA Single / VAE)+ フィールド単位継承 + C/L/V表示切替
|
||||
- **v0.4.1** — UX改善: Mask Editor One連携ボタンを「選択中ノード優先」方式に再設計
|
||||
- **v0.4.0** — リファクタリング: image-edit-tab.js / models-tab.js の低凝集度モジュール分割
|
||||
- **v0.3.99** — 機能追加: AI TOOLタブ + サイドパネルAタブにUnslothバックエンド追加(APIキーは.env管理、SSRF脆弱性修正、reasoning_content対応)
|
||||
- **v0.3.98** — 機能追加・バグ修正: AI TOOL翻訳の信頼性向上 + GenerateUI Modelタブ再構成(ハイライト色・折りたたみ・並び替え)+ Thinking mode/Max tokens設定
|
||||
- **v0.3.97** — 機能追加・バグ修正: KREA-2ワークフロー対応 + Labタブ拡張(Clear/Get from Previous/Bypass/ライブ反映)+ Input Imageプレースホルダ機能
|
||||
- **v0.3.96** — バグ修正: カタログ作成バッチが特定件数で無言停止する問題の修正 + Style Catalog/Promptタブ機能拡張
|
||||
- **v0.3.95** — 機能追加: Style/Prompt→ImagePrompt改名 + Style_Catalogギャラリー新設 + GenerateUI「カタログ作成」機能
|
||||
- **v0.3.94** — 機能追加: Gallery タブのサブタブ化 + Style/Prompt ギャラリー新設(視覚的プロンプトビルダー)
|
||||
- **v0.3.93** — バグ修正・機能改善: Labタブ VAEキーフレーム不発バグの修正+Style適用UI刷新+Plan JSONスクロール同期修正+キーフレーム個別削除+Batch数超過警告+ワイルドカード対応+Save index image デフォルトON
|
||||
- **v0.3.92** — バグ修正: CLIPTextEncodeEditPlus使用ワークフローがGenerateUI経由だと直接実行と異なる画像になる問題(根本原因はconvertUiToApiのForceInput 未考慮バグ)+LoRA Stack機能の改善一式
|
||||
- **v0.3.91** — 機能拡張: Labインデックス画像にPlanメタデータをPNG埋め込み+README構成整理(Changelogセクション廃止)
|
||||
- **v0.3.90** — 機能拡張: Labタブの進化(ワークフロー呼び出し・T2I対応・Run時インデックス画像Output保存+Eagle連携・プラン接頭辞)+Galleryタブのルート表示簡略化
|
||||
- **v0.3.89** — バグ修正: v0.3.88で追加したLab機能への実動作確認フィードバック3件を反映
|
||||
- **v0.3.88** — 機能拡張: Labタブ Promptセルにスタイル適用・画像/GenerateUIからのプロンプト取得、Checkpoint/VAEフィルター、Plan JSONサブタブを追加
|
||||
- **v0.3.87** — 追加機能: GenerateUIタブに実験的な「Lab」サブタブを追加(I2Iバッチ生成・キーフレーム方式)
|
||||
- **v0.3.86** — バグ修正: 画像を一括生成(I2I)等の連続実行で "GalleryMetadataStore: save error: dictionary changed size during iteration" が発生
|
||||
- **v0.3.85** — 機能拡張: Generate UI連携ブリッジ(T2I)にデフォルトワークフロー・Negativeプロンプト対応を追加
|
||||
- **v0.3.84** — バグ修正: ernie_t2i.jsonをGenerateUIタブで実行すると "Prompt outputs failed validation" エラー
|
||||
- **v0.3.83** — 追加機能: Ernie Image(ernie-image / ernie-image-turbo)ワークフロー対応 + プロンプトがリンク経由のノードから供給されるパターンの修正
|
||||
- **v0.3.82** — バグ修正: MetadataタブとサイドパネルIタブでLongCat/Boogu/HiDream E1/FireRed等のプロンプトが表示されない不具合
|
||||
- **v0.3.81** — 追加機能: GenerateUIタブでFlux.2 Klein / LongCat / Boogu / HiDream E1 / FireRedのImage Editワークフローに対応
|
||||
- **v0.3.80** — 追加機能: Comic Creator「半自動マンガ作成」向けブリッジ(LLMプロンプト下書き・バッチ画像生成)
|
||||
- **v0.3.79** — 追加機能: サイドパネル(Workflow Studio Library)IタブのMage-Flow等サブグラフワークフロー対応
|
||||
- **v0.3.78** — 追加機能: Eagle自動保存のSVG対応
|
||||
- **v0.3.76** — 追加機能: Comic Creator向けI2I実行ブリッジ`_wfmReceiveI2IRunRequest`
|
||||
- **v0.3.77** — 追加機能: GalleryタブでSVGファイルの表示・管理に対応
|
||||
- **v0.3.75** — Lemonadeバックエンド追加(3つ目のAIバックエンド)
|
||||
- **v0.3.74** — AI TOOL Chatペインの画像添付対応 + I2I連携
|
||||
- **v0.3.73** — AI TOOL Chatペインからの対話式画像生成(Tool Calling)+ Checkpoint関連の安全策
|
||||
- **v0.3.72** — Comic Creator連携用の外部Inpaintエントリポイントを追加
|
||||
- **v0.3.71** — Inpaint機能の追加(GenerateUI Imageタブ + Image Edit「Inpaint」タブ)
|
||||
- **v0.3.70** — Workflow AI要約: Ollama 404エラー修正
|
||||
- **v0.3.69** — Gallery → ComfyUI Comic Creator 連携(Send CC ボタン)
|
||||
- **v0.3.68** — MASK EDITOR ONE セクション常時表示化
|
||||
- **v0.3.67** — Alt+Click Apply & Generate
|
||||
- **計画: Image Edit 拡張 — ABRブラシ連携 + Mask Editor One 統合** — フェーズ1: Draw ツール — ABRブラシ対応
|
||||
- **2026-06-29: v0.3.66 — Mask ツール 5サブツール追加(Color / Alpha / Text / Vector / Shape)・Support リンク追加** — 概要
|
||||
- **2026-06-29: v0.3.65 — Mask Editor One 連携修正・トップバーアイコン修正・Mask ABRブラシ対応** — 概要
|
||||
- **2026-06-28: v0.3.64 — Image Edit マスク Add/Subtract・BiRefNet BG Remove・SAM3 セグメンテーション・ブラシカーソルバグ修正** — 概要
|
||||
- **2026-06-28: v0.3.63 — ノードタブ複数選択・全タブフィルタークリア・検索Xボタン** — 概要
|
||||
- **2026-06-27: WFS_PromptText の GenerateUI 対応・ドロップ時 title 固定** — 概要
|
||||
- **2026-06-27: v0.3.62 — Send GenUI Image ボタン追加・tagger_settings.json バグ修正・README 更新** — 概要
|
||||
- **2026-06-27: データ管理バグ修正 — tagger_settings.json をエクスポート対象に追加** — 概要
|
||||
- **2026-06-27: v0.3.61 — G'MIC バグ修正・セキュリティ・Draw/Mask ブラシ操作性改善** — 概要
|
||||
- **2026-06-27: v0.3.60 — Image Edit Filter Tool (G'MIC-Qt 連携) 実装** — 概要
|
||||
- **2026-06-27: v0.3.59 — Image Edit Mask Tool 実装・Draw/Mask 描画バグ修正・クリッピング保存対応** — 概要
|
||||
- **2026-06-27: v0.3.58 — GenerateUI Model タブ GGUF対応拡張(Diffusion Model / Text Encoder)** — 概要
|
||||
- **2026-06-26: v0.3.57 — Image Edit Blur Tool / BG Remove Tool 追加** — 概要
|
||||
- **2026-06-26: v0.3.56 — Style機能追加(GenerateUI ツールバー & Batchタブ)** — 概要
|
||||
- **2026-06-25: v0.3.55 — グループ機能バグ修正(全タブ)** — 根本原因(データ消失): パス区切り文字の不一致
|
||||
- **2026-06-25: v0.3.54 — Image Edit Shape ツール追加・Models タブ overlay バグ修正** — Image Edit Tab — Shape ツール(□ / S キー)
|
||||
- **2026-06-25: v0.3.53 — Image Edit Tab 実装・レイヤーロック・ギャラリー保存** — Image Edit Tab(新規タブ)
|
||||
- **2026-06-25: GenUI設定タブ LATENT IMAGE にプリセット機能を追加** — 変更内容
|
||||
- **2026-06-24: GenUI ModelでLoRA適用後、Modelサブタブ再表示時に値がリセットされるバグ修正** — 問題
|
||||
- **2026-06-24: v0.3.51 — Embedding GenUI PP/NP対応 + GenerateUIプロンプトタブEmbeddingセレクタ追加** — 変更内容
|
||||
- **2026-06-24: v0.3.51 — GenerateUIタブ Hypernetwork対応 + ModelsタブGenUI Modelボタン追加** — 変更内容
|
||||
- **2026-06-24: v0.3.51 — Hypernetwork/Embeddingモデル未表示バグ修正** — 原因
|
||||
- **2026-06-24: v0.3.51 — ModelsタブサイドパネルにGenUI Modelボタン追加** — 変更内容
|
||||
- **2026-06-22: v0.3.50 — ギャラリータブ プロンプト検索負荷改善(バックグラウンドインデックス)** — 問題
|
||||
- **2026-06-22: v0.3.49 — 生成UIタブ A1111風ワイルドカード展開・ギャラリータブ バグ修正2件** — 生成UIタブ — A1111風SPAサイドワイルドカード展開
|
||||
- **2026-06-22: v0.3.48 — ギャラリータブ 画像ダウンロード・一括エクスポート機能追加** — 詳細パネルの画像プレビューにダウンロード機能を追加
|
||||
- **2026-06-22: v0.3.47 — モデルタブ ファイル移動後グループ状態修正** — モデル移動後のグループ状態不整合を修正
|
||||
- **2026-06-21: v0.3.46 — プロンプトタブ AI Assistant LM Studio対応・設定タブ AI Assistant設定に変更** — 設定タブ「AI Assistant設定(プロンプトタブ)」
|
||||
- **2026-06-21: v0.3.45 — グループ孤立エントリ自動クリーンアップ・Galleryタブ Shift+クリック範囲選択** — グループ孤立エントリ自動クリーンアップ
|
||||
- **2026-06-21: v0.3.44 — Galleryタブ高速化・比較モード・バルク強化・バルクバーUI整理** — フェーズ2-A: サーバーサイドサムネイル生成
|
||||
- **2026-06-21: ヘルプタブ改善 — Gallery mode 多言語対応・フォント拡大・検索機能追加** — Gallery mode の多言語対応
|
||||
- **2026-06-21: Send to Canvas — window.opener経由のキャンバス直接ロード対応**
|
||||
- **2026-06-20: API形式ワークフローのキャンバス読み込み修正・画像メタデータ埋め込み修正** — API形式ワークフローをキャンバスへドラッグすると空になる問題を修正
|
||||
- **2026-06-20: セキュリティ修正(XSS・パスバリデーション)** — 修正内容
|
||||
- **2026-06-20: WFS_GalleryFeeder — ComfyUI キャンバス上コントロール追加** — 新規ファイル(`web/comfyui/gallery_feeder_extension.js`)
|
||||
- **2026-06-20: __Feeder__ グループ保護 + サムネイル F ボタン + seed バグ修正** — `__Feeder__` グループの予約済み保護
|
||||
- **2026-06-20: Gallery Feeder 機能追加(WFS_GalleryFeeder ノード)** — 概要
|
||||
- **2026-06-19: Generate UI — ImpactWildcardEncode/Processor プロンプト検出バグ修正(v0.3.41)** — 修正内容(`static/js/comfyui-workflow.js`)
|
||||
- **2026-06-19: AI TOOL タブ Chat ペイン・ワイルドカード生成追加(v0.3.40)** — Chat ペイン(`templates/index.html`, `static/css/main.css`, `static/js/ai-tab.js`, `web/comfyui/node_sets_menu.js`)
|
||||
- **2026-06-18: Send to Canvas機能追加(ワークフロータブ・ギャラリータブ→LibraryタイトルDnD)** — ワークフロータブ — Send to Canvas(`static/js/workflow-tab.js`, `static/js/app.js`, `templates/index.html`)
|
||||
- **2026-06-15: TaggerシングルGenUI:Pボタン追加+生成UIのZITワークフロープロンプト解析修正** — TaggerシングルGenUI:Pボタン(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`)
|
||||
- **2026-06-15: Taggerバッチ.txt出力追加+Galleryタグ保存バグ修正** — Taggerバッチ.txt出力(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`, `py/routes/tagger_routes.py`, `py/services/tagger_service.py`)
|
||||
- **2026-06-15: Taggerタブ新規実装+ギャラリー詳細パネルUI改善+パストラバーサル修正** — Taggerタブ新規実装
|
||||
- **2026-06-15: 全選択ボタン追加+ギャラリーバルクバーi18n対応** — 変更内容
|
||||
- **2026-06-10: v0.3.36 — コードレビュー修正+リファクタリング+README刷新** — コードレビュー修正(v0.3.35の不具合10件)
|
||||
- **2026-06-10: v0.3.35 — テーブルソート・Load GenUI・ワークフロー保存** — 変更内容
|
||||
- **2026-06-09: v0.3.34 — Modelsタブ テーブルビュー強化・バルク操作改善** — 変更内容
|
||||
- **2026-06-09: v0.3.33 — LoRAペイン・ワークフロー解析バグ修正** — 変更内容
|
||||
- **2026-06-08: v0.3.32 — Lora Loader (LoraManager) LoRA検出対応・ライブラリ機能拡張** — 変更内容
|
||||
- **2026-06-08: v0.3.31 — LoRAペイン Single/Stack タブ分割・GenUI Model LoRA対応強化** — 変更内容
|
||||
- **2026-06-08: v0.3.30 — LoRAスタック トリガーワードバグ修正・ヘルプ更新** — 変更内容
|
||||
- **2026-06-08: v0.3.29 — LoRAセクション統合・ヘルプタブサイドバー・UI細部改善** — 変更内容
|
||||
- **2026-06-08: 生成UIタブ 複数改善 — LoRA Stackトリガーワード連携・Reset Workflowボタン・Inputタブタブ化** — 変更内容
|
||||
- **2026-06-08: Feederタブ UI改善 — (root)自動選択・PREVIEWペイン右移動・RUN中プレビュー反映** — 変更内容
|
||||
- **2026-06-07: v0.3.28 — Batchタブ Sampler/Scheduler対応 + Modelsサムネイルバグ修正** — 概要
|
||||
- **2026-06-07: v0.3.27 — model-and-prompt-from-metadata カスタムノード対応 + README修正** — 概要
|
||||
- **2026-06-07: v0.3.26 — Stack LORA SYNTAX 表示修正 + 全体強度調整 UI** — 概要
|
||||
- **2026-06-07: v0.3.25 — Lora Loader (LoraManager) 対応 + Stack 有効無効切り替え** — 概要
|
||||
- **2026-06-06: v0.3.24 — Lora Stack 登録機能 + 生成UI Model タブ 3ペイン化** — 概要
|
||||
- **2026-06-05: v0.3.23 — バグ修正2件** — バグ修正
|
||||
- **2026-06-05: v0.3.22 — カードビュー廃止 + Batch タイプ切り替え UI + 4タイプバッチ生成** — 概要
|
||||
- **2026-06-05: (未リリース) — 生成UIタブ Batch 4タイプ対応(Lora/Prompt/Workflow グループ選択 + BATCH QUEUE 4列化)** — 概要
|
||||
- **2026-06-05: (未リリース) — Prompt・Workflow Batchグループ登録UI** — 概要
|
||||
- **2026-06-04: (未リリース) — モデルBatchグループ登録UI(サムネイル/カード/テーブル・フィルター・一括解除)** — 概要
|
||||
- **2026-06-04: (未リリース) — Batch予約グループ・Presets Save後リセット・グループフィルター種別絞り込み** — 概要
|
||||
- **2026-05-31: v0.3.21 — CivitAI ホスト設定・Sample サブタブ・URL 修正・ヘルプ i18n 整備** — 概要
|
||||
- **2026-05-31: v0.3.20 — CivitAI 詳細パネル強化(Type / Hash 表示・URL 修正・画像別タブ)** — 概要
|
||||
- **2026-05-31: v0.3.19 — CivitAI 連携強化(バッチ高速化・リトライ・APIキー・フィールド拡充)** — 概要
|
||||
- **2026-05-31: v0.3.18 — Batch タブ追加・Models 複数選択強化** — 概要
|
||||
- **2026-05-31: v0.3.17 patch — SSLコンテキスト セキュリティ強化** — 概要
|
||||
- **2026-05-30: v0.3.17 — SSL証明書エラー修正・Lora Manager対応** — 概要
|
||||
- **2026-05-20: v0.3.16 — CivitAIプレビューフォールバック表示** — 概要
|
||||
- **2026-05-20: v0.3.15 — サンプルワークフロー同梱・CivitAI自動プレビュー・Create Tagsオプション** — 概要
|
||||
- **2026-05-20: AI TOOL タブ 英語化(SPA・Libraryパネル)** — 概要
|
||||
- **2026-05-19: v0.3.14 — AI TOOL タブ 3ペイン化・RAW JSON 文字色カスタマイズ** — 概要
|
||||
- **2026-05-19: v0.3.13 — AI タブ(A)追加** — 概要
|
||||
- **2026-05-18: v0.3.12 リリース — README スクリーンショット刷新 / workflow_analyzer 拡張** — 概要
|
||||
- **2026-05-17: RAW JSON パネルに検索機能を追加** — 概要
|
||||
- **2026-05-17: v0.3.11 Metadata タブ対応ノードタイプ拡張 / Settings テキストサイズ UI 追加** — 概要
|
||||
- **2026-05-17: v0.3.10 Information タブ キャンバスドラッグ対応 / ノードタイプ拡張 / バグ修正** — 概要
|
||||
- **2026-05-16: v0.3.9 サイドパネル I タブ追加 / トップバーアイコン修正** — 概要
|
||||
- **2026-05-16: v0.3.8 Metadata タブ: data9 対応 / プロンプト判別不能テキスト** — 概要
|
||||
- **2026-05-15: v0.3.7 Metadata タブ: Flux2 / Qwen / Z-Image 対応** — 概要
|
||||
- **2026-05-15: v0.3.6 Metadata タブ追加** — 概要
|
||||
- **2026-05-09: v0.3.5 GenerateUI — Feeder サブタブ・ワークフロー解析精度向上** — 概要
|
||||
- **2026-04-29: v0.3.4 GenerateUI — Checkpoint Batch 刷新・Settings 横並びレイアウト・ヘルプ i18n 修正** — 概要
|
||||
- **2026-04-28: v0.3.3 Promptタブ — ワイルドカード支援パネル・Impact Packシンボリックリンク連携・サブディレクトリ対応・GenerateUI修正** — 概要
|
||||
- **2026-04-28: v0.3.2 生成UIタブ — Checkpoint Batch・Seed UIレイアウト修正** — 概要
|
||||
- **2026-04-27: v0.3.1 ギャラリー拡張(フォルダ/ファイル操作・ワークフロー保存)** — 概要
|
||||
- **2026-04-18: v0.3.0 モデルグループ表示改善(全タイプ横断・サイドパネル修正)** — 概要
|
||||
- **2026-04-18: v0.2.9 モデル有効/無効・複数選択・一括削除** — 概要
|
||||
- **2026-04-14: v0.2.8 データ保存先変更・エクスポート/インポート機能追加** — 概要
|
||||
- **2026-04-06: v0.2.6 UI改善・バッジ保存修正・フォルダフィルター追加** — 概要
|
||||
- **2026-04-04: v0.2.5 追加修正** — 概要
|
||||
- **2026-04-03: v0.2.5 サイドパネル Category/Package サブタブ・テーマ設定・Workflow修正** — 概要
|
||||
- **2026-03-29: v0.2.4 GenerateUIタブ レイアウト再設計** — 概要
|
||||
- **2026-03-29: v0.2.3 バッジ統一・GenUI Model・サイドパネルModels拡充** — 概要
|
||||
- **2026-03-28: v0.2.2 Modelsタブ・CivitAI連携** — 概要
|
||||
- **2026-03-27: テーマカスタマイズ機能・設定タブ改善** — 概要
|
||||
- **2026-03-26: トップバーボタンアイコン修正・Appモードバッジ調査** — 概要
|
||||
- **2026-03-25: v0.2.0 プロンプトプリセット機能・Workflow Studio Library** — 概要
|
||||
- **2026-03-24: v0.1.9 サイドパネルUI改善・保存ダイアログ・バグ修正** — 概要
|
||||
- **2026-03-24: v0.1.8 WF & Node Library サイドパネル拡張** — 概要
|
||||
- **2026-03-23: v0.1.7 Nodes タブ追加** — 概要
|
||||
- **2026-03-23: v0.1.6 パストラバーサル脆弱性修正** — 概要
|
||||
- **2026-03-22: v0.1.5 テーマシステム追加** — 概要
|
||||
- **2026-03-22: v0.1.4 App形式対応・プリセットコピー・分析バグ修正** — 概要
|
||||
- **2026-03-21: v0.1.3 ヘルプ&サポートタブ追加** — 概要
|
||||
- **2026-03-19: v0.1.2 リリース** — 概要
|
||||
- **2026-03-19: v0.1.1 リリース** — 概要
|
||||
- **2026-03-18: プロジェクトリネーム & アイコン適用** — 概要
|
||||
- **2026-03-18: デフォルトビュー設定追加** — 概要
|
||||
- **2026-03-18: MarkdownNote表示専用ノード除外** — 概要
|
||||
- **2026-03-18: サブグラフワークフロー対応 & 変換精度修正** — 概要
|
||||
- **2026-03-18: Raw JSON同期修正** — 概要
|
||||
- **プロジェクト構成 (現在)**
|
||||
- **2026-03-18: JSONシンタックスハイライト** — 概要
|
||||
- **2026-03-18: プロンプトタブ 中国語↔英語翻訳追加** — 概要
|
||||
- **2026-03-18: v0.1.0 リリース** — 概要
|
||||
- **2026-04-13: Gallery タブ追加 & 全タブ Group UI 統一** — 概要
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ketle-man
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,268 @@
|
||||
# README Index — ComfyUI-Workflow-Studio
|
||||
|
||||
Condensed index of README.md: intro summary, feature names per tab (full descriptions live in the excluded README.md), and the installation/usage/requirements/project-structure sections verbatim. Exists so graphify can extract this without feeding the full README (with its screenshot table) to the local Ollama model. Regenerate with `python tools/generate_doc_index.py readme` whenever README.md changes.
|
||||
|
||||
# ComfyUI-Workflow-Studio
|
||||
|
||||
A comprehensive workflow, asset management, and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
|
||||
|
||||
**📁 Management** — organize all your assets in one studio
|
||||
|
||||
- Workflow, model, image, and prompt management
|
||||
- AI-powered prompt writing assistance, translation, and tag generation
|
||||
- File drop & Gallery-linked metadata viewing — inspect and reuse generation settings instantly
|
||||
- Semantic image search in the Gallery — find images by describing them, or list images similar to a selected one
|
||||
|
||||
**⚡ GenerateUI** — a productivity-focused generation tab connected to every other tab
|
||||
|
||||
- Batch generation across models, samplers, prompts, and workflows
|
||||
- Image Feeder for continuous folder-based generation
|
||||
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Model (Checkpoint/LoRA Single/VAE)/Prompt/KSampler
|
||||
|
||||
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
|
||||
|
||||
- Drag & drop models, nodes, prompts, and workflows straight onto the canvas
|
||||
- **Send to Canvas**: click "Send to Canvas" (Workflow tab) or "Copy & Send Canvas" (Gallery tab) to send the workflow directly to the ComfyUI canvas — UI and API formats both supported
|
||||
- View metadata from images / JSON, then drop the detected models and prompts onto the canvas
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||
|
||||
## Features (tab -> feature names)
|
||||
|
||||
### Workflow Tab
|
||||
Thumbnail / Table views, Thumbnail side panel, Badge filtering, Search, Side panel tabs, Badge management, AI summary, Import / Export, Side panel toolbar buttons, Default view setting, Search clear (✕), Clear all filters (✕ Clear), Sort, GenerateUI Check
|
||||
|
||||
### Canvas Snapshot (v0.1.2)
|
||||
One-click capture, Auto-save as thumbnail, Embedded workflow metadata, Auto-import
|
||||
|
||||
### GenerateUI Tab (v0.3.5)
|
||||
6-tab layout, Save button, Input tab, Model tab, Settings tab, Always-visible Raw JSON, Bypass/Mute node handling, One-click generation, Seed control, Style selector, Create Catalog / Catalog buttons, Batch type selector, Batch tab, UI-to-API conversion, Video workflow support, Eagle integration, Qwen Image 2.1 support, Ming Image support
|
||||
|
||||
### Feeder subtab (v0.3.5 / v0.3.42)
|
||||
ImageFeeder node control, Image library, Selection management, Presets, Continuous Run loop, Index sync, Seed, WFS_GalleryFeeder node, Node & group selector, Image grid, After Gen modes, Run / Stop controls
|
||||
|
||||
### Lab subtab (v0.3.87 – v0.5.2)
|
||||
Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column — Checkpoint / LoRA Single / VAE, Empty state when no matching node exists, Wildcards, Prompt cell extras, Keyframe #1 live-reflect, Bypass checkbox, LoRA node bypass & prompt injection, T2I workflow support, Use generated image for next, Workflow recall, Plan files, Plan Load, Plan JSON tab, Save index image to Output on Run, Index images are self-contained plan files, Eagle integration, Select vs. edit, and keyframe reordering, PG — apply a Prompt tab preset group, Video workflow support, Use generated image for next, now video-aware
|
||||
|
||||
### Prompt Tab
|
||||
3-column layout, AI chat assistant, Image attachment, Translation, Prompt presets, Preset Manager, Group management, Clipboard copy, GenUI Set, Wildcard/Style tab bar, Wildcard input toolbar, Wildcard file manager, Style manager, Form / Table toggle, Table view — Presets / Presets Group / Wildcards / Style
|
||||
|
||||
### Settings Tab
|
||||
2-column layout, Collapsible sections, Theme selection, Theme customization, Workflows directory, Gallery output directory, Eagle connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, VRAM Management (Ollama), Video Playback Volume, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
|
||||
|
||||
### Gallery Tab (v0.3.44)
|
||||
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, WebM support, Embedded metadata reading fixes, Audio support, File conversion, Move To folder filter, Semantic Search, Audio waveform colors, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Nanobanana tab, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send to LV node button, Send CC button, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, PSD file support, Performance
|
||||
|
||||
### ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)
|
||||
Visual prompt library, 3-column layout, Prompt builder (right pane), Plain-text prompt storage, ponyxlWildcardsVault format support, Seed data importer
|
||||
|
||||
### Style_Catalog Gallery subtab (v0.3.95)
|
||||
Visual Style picker, 3-column layout, Positive/Negative panel, Select as Style button, Load in GenerateUI / Open in Metadata Tab buttons, Double-click to enlarge
|
||||
|
||||
### Metadata Gallery subtab (v0.3.8, moved from a top-level tab in v0.4.6)
|
||||
Now inside Gallery, 3-column layout, File drop, MP4 embedded metadata, Settings section, Nanobanana section, Model extraction, LoRA extraction, Prompt extraction, Prompt actions, Format support, Format note
|
||||
|
||||
### Nodes Tab (v0.1.7)
|
||||
Node Browser, Search & Filter, Package badges, Node detail panel, Node Sets, Right-click context menu, Multi-select, Search clear (✕), Clear all filters (✕ Clear)
|
||||
|
||||
### Models Tab (v0.2.3)
|
||||
Model Browser, Thumbnail / Table views, Table column sort, Search & Filter, User-defined badges, Side panel tabs, CivitAI integration, CivitAI panel — Info / Sample sub-tabs, CivitAI panel states, Batch CivitAI fetch, Detail modal, GenUI Model button, Group management, Table view memo, Preview images, Enable / Disable models, Multi-select & bulk operations, Search clear (✕), Clear all filters (✕ Clear)
|
||||
|
||||
### Tagger Tab (v0.3.38)
|
||||
3 sub-tabs, Model support, Threshold sliders, VLM tagging, Single tab, Single output options, Batch tab, DB tab, Dependencies
|
||||
|
||||
### Image Edit Tab (v0.3.65)
|
||||
Layer-based image editor, Loading images, Open PSD button, Close button, New button, Tools, Layer panel, Duplicate layer (⧉), Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
|
||||
|
||||
### Video Tab (v0.5.0 – v0.7.4)
|
||||
Dedicated video generation UI, independent of GenerateUI, Plan subtab, Base settings row, Edit subtab, Edit: Multi-track, Edit: Audio / BGM, Edit: Text overlays, Edit: Crop, Edit: Undo / Redo, Edit project persistence, Asset subtab, Project subtab, Two center previews, First Frame / Last Frame are both optional, Semantic node lookup, not hardcoded IDs, Field highlight color, Load in Video, Frame tab, GIF tab, Playback volume
|
||||
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, Decision API client, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Model unload, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
|
||||
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
|
||||
Tab layout (W / N / P / M / I / A), W — Workflows tab, N — Nodes tab, M — Models tab, P — Prompts tab, I — Information tab, A — AI TOOL tab, Drag & drop workflows, Drag & drop nodes, Drag & drop prompts, Send to Canvas, Copy prompts, Double-click, Search, ⚙ Theme settings
|
||||
|
||||
### Help & Support Tab (v0.1.3)
|
||||
Sidebar navigation, Support, Feature list, Keyboard Shortcuts
|
||||
|
||||
## Installation
|
||||
|
||||
### Via ComfyUI Manager (Recommended)
|
||||
|
||||
Search for **Workflow Studio** in ComfyUI Manager and install.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/ketle-man/ComfyUI-Workflow-Studio.git
|
||||
```
|
||||
|
||||
Restart ComfyUI after installation.
|
||||
|
||||
---
|
||||
|
||||
## Sample Workflows
|
||||
|
||||
Sample workflows are included in the `workflows/` folder. You can open them directly in ComfyUI via drag & drop, or load them from the Workflow tab.
|
||||
|
||||
> **Note:** Some sample workflows require additional custom nodes.
|
||||
> If a node is shown as missing (red/unknown) after loading, install the required custom nodes via **ComfyUI Manager** or by cloning the repository from GitHub into your `ComfyUI/custom_nodes/` directory.
|
||||
>
|
||||
> ```bash
|
||||
> cd ComfyUI/custom_nodes
|
||||
> git clone <repository-url>
|
||||
> ```
|
||||
>
|
||||
> After installation, restart ComfyUI to activate the new nodes.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Launch
|
||||
|
||||
Click the **W** button in the ComfyUI top menu bar, or navigate to:
|
||||
|
||||
```
|
||||
http://127.0.0.1:8188/wfm
|
||||
```
|
||||
|
||||
> **Tip:** Shift+Click the W button to open in a new window.
|
||||
|
||||
### Canvas Snapshot
|
||||
|
||||
Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture the current workflow canvas as a thumbnail. The image is automatically saved to the workflow data folder and appears in Workflow Studio's workflow list.
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Workflow Tab** — Your workflows from `ComfyUI/user/default/workflows/` are automatically listed
|
||||
2. **Click a workflow** — View thumbnail, JSON details, and metadata in the side panel
|
||||
3. **Load in GenerateUI** — Click the button to load a workflow into the generation interface
|
||||
4. **Adjust parameters** — Modify prompts, models, seeds, and settings via the auto-generated UI
|
||||
5. **Generate** — Hit the Generate button to queue the prompt
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **ComfyUI** — any recent version (v1.33.9+ recommended for action bar integration)
|
||||
- **Python 3.10+**
|
||||
- **Jinja2** — `pip install jinja2` (usually included with ComfyUI)
|
||||
|
||||
### Optional
|
||||
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); needs an API key set via `UNSLOTH_API_KEY` in a `.env` file, or Unsloth's Keyless API access → "Chat and inference" turned on — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **Gemini API key / EmbeddingGemma 2** (v0.8.2) — only for the Gallery's Semantic Search: either set `GEMINI_API_KEY` in the plugin's `.env` (key from [Google AI Studio](https://aistudio.google.com); free tier available, paid beyond it), or use the local **EmbeddingGemma 2** (no key; `onnxruntime`, `tokenizers` and `huggingface_hub` — normally already present with ComfyUI; the model downloads on first use). `onnxruntime-gpu` makes the local model ≈45× faster on NVIDIA GPUs
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
- **[psd-tools](https://pypi.org/project/psd-tools/)** (v0.6.0) — enables all PSD (Photoshop) features: Gallery bulk "Create PSD" export, Gallery browsing/thumbnails for `.psd` files, and Image Edit's Save PSD / Open PSD buttons; `pip install -r requirements.txt` (ships prebuilt wheels, no C compiler required); without it, PSD-related actions show an error toast with the install command instead of failing silently
|
||||
|
||||
#### Backend connection security (v0.7.12)
|
||||
|
||||
Server-side proxies for Ollama, LM Studio, Lemonade, Unsloth, the Tagger VLM and Eagle only connect to **localhost / 127.0.0.1 / ::1** (any port); `http(s)` only, no credentials in the URL, and redirects are never followed. To use a backend on another machine (e.g. an Ollama server on your LAN), list it in the `WFS_ALLOWED_BACKEND_HOSTS` environment variable (comma-separated `host` or `host:port`, e.g. `192.168.1.20:11434,gpu-box`) and restart ComfyUI. If you start ComfyUI with `--listen`, anyone who can reach it can use these routes, so only do that on a trusted network.
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages
|
||||
|
||||
| Language | Status |
|
||||
| -------- | ------ |
|
||||
| English | Full |
|
||||
| Japanese | Full |
|
||||
| Chinese | Full |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ComfyUI-Workflow-Studio/
|
||||
├── __init__.py # ComfyUI entry point
|
||||
├── py/
|
||||
│ ├── wfm.py # Main class & route registration
|
||||
│ ├── config.py # Path configuration
|
||||
│ ├── nodes/
|
||||
│ │ ├── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
|
||||
│ │ └── gallery_feeder_node.py # WFS_GalleryFeeder custom node (gallery group → IMAGE)
|
||||
│ ├── routes/
|
||||
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
|
||||
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
|
||||
│ │ ├── models_routes.py # Model metadata, preview, CivitAI API
|
||||
│ │ ├── prompts_routes.py # Prompt presets CRUD API
|
||||
│ │ ├── settings_routes.py # Settings API
|
||||
│ │ ├── ollama_routes.py # Ollama proxy API
|
||||
│ │ ├── eagle_routes.py # Eagle integration API
|
||||
│ │ └── gallery_routes.py # Gallery tab API (Output + ImagePrompt + Style_Catalog sub-tabs)
|
||||
│ └── services/
|
||||
│ ├── workflow_service.py # Workflow file operations
|
||||
│ ├── nodes_service.py # Node metadata & node sets
|
||||
│ ├── models_service.py # Model metadata & preview images
|
||||
│ ├── civitai_service.py # CivitAI API integration & cache
|
||||
│ ├── prompts_service.py # Prompt presets persistence
|
||||
│ ├── workflow_analyzer.py # Model/node detection
|
||||
│ ├── settings_service.py # Settings persistence
|
||||
│ ├── png_extractor.py # PNG metadata extraction
|
||||
│ ├── gallery_service.py # Gallery image scanning, .txt sidecar & ponyxlWildcardsVault prompt resolution
|
||||
│ └── gallery_metadata.py # Gallery per-image metadata store (favorites/tags/memo/groups)
|
||||
├── templates/
|
||||
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Metadata/Gallery/Nodes/Models/Settings/Help/AI)
|
||||
├── static/
|
||||
│ ├── favicon.svg # Browser tab icon (W+S Wave)
|
||||
│ ├── css/main.css # Styles
|
||||
│ └── js/
|
||||
│ ├── app.js # App initialization & routing
|
||||
│ ├── workflow-tab.js # Workflow browser
|
||||
│ ├── generate-tab.js # Generation UI
|
||||
│ ├── feeder-tab.js # Feeder subtab (Image Loop + Gallery modes)
|
||||
│ ├── gallery-tab.js # Gallery tab: Output sub-tab (image browser, groups, metadata) + sub-tab switching
|
||||
│ ├── image-prompt-tab.js # Gallery tab: ImagePrompt sub-tab (visual prompt builder)
|
||||
│ ├── style-catalog-tab.js # Gallery tab: Style_Catalog sub-tab (visual Style picker)
|
||||
│ ├── tagger-tab.js # Tagger tab (WD Tagger, DeepDanbooru, Ollama VLM)
|
||||
│ ├── prompt-tab.js # AI assistant & presets
|
||||
│ ├── metadata-tab.js # Metadata extraction & display (PNG/WebP/JSON)
|
||||
│ ├── settings-tab.js # Settings panel
|
||||
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
|
||||
│ ├── nodes-tab.js # Node browser & node sets
|
||||
│ ├── models-tab.js # Model browser & CivitAI integration
|
||||
│ ├── ai-tab.js # AI TOOL tab (Translation | Chat | TOOLS/VLM | Settings)
|
||||
│ ├── comfyui-workflow.js # UI-to-API format conversion
|
||||
│ ├── comfyui-editor.js # Dynamic parameter editor
|
||||
│ ├── json-highlight.js # JSON syntax highlighting
|
||||
│ ├── util.js # Shared utilities (escapeHtml, readJsonStorage, getSettings)
|
||||
│ └── i18n.js # Internationalization
|
||||
├── web/comfyui/
|
||||
│ ├── top_menu_extension.js # ComfyUI menu bar integration
|
||||
│ ├── node_sets_menu.js # Workflow Studio Library side panel
|
||||
│ └── gallery_feeder_extension.js # WFS_GalleryFeeder canvas widgets (After Gen / Run / Stop)
|
||||
├── tools/
|
||||
│ └── import_style_prompt_seed.py # Dev tool: pre-process a wildcards-vault source into ws_image_prompt (not run by the plugin itself)
|
||||
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) by comfyanonymous
|
||||
- [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss — Canvas snapshot and PNG workflow embedding implementation reference
|
||||
- [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) — Plugin architecture and UI pattern reference
|
||||
- [Ollama](https://ollama.com/) for local LLM inference
|
||||
- [Eagle](https://eagle.cool/) for image management
|
||||
- [Pillow (PIL Fork)](https://python-pillow.org/) — server-side thumbnail generation (`data/thumb_cache/`)
|
||||
- [ComfyUI-Gallery](https://github.com/PanicTitan/ComfyUI-Gallery) by PanicTitan — thumbnail grid UX reference
|
||||
- [infinite-image-browsing](https://github.com/zanllp/sd-webui-infinite-image-browsing) by zanllp — thumbnail caching and index strategy reference
|
||||
- [Fooocus](https://github.com/lllyasviel/Fooocus) by lllyasviel — style preset JSON format reference
|
||||
|
||||
@@ -1,70 +1,198 @@
|
||||
# ComfyUI-Workflow-Studio
|
||||
|
||||
A comprehensive workflow management and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
|
||||
A comprehensive workflow, asset management, and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
|
||||
|
||||
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
|
||||
**📁 Management** — organize all your assets in one studio
|
||||
|
||||
- Workflow, model, image, and prompt management
|
||||
- AI-powered prompt writing assistance, translation, and tag generation
|
||||
- File drop & Gallery-linked metadata viewing — inspect and reuse generation settings instantly
|
||||
- Semantic image search in the Gallery — find images by describing them, or list images similar to a selected one
|
||||
|
||||
**⚡ GenerateUI** — a productivity-focused generation tab connected to every other tab
|
||||
|
||||
- Batch generation across models, samplers, prompts, and workflows
|
||||
- Image Feeder for continuous folder-based generation
|
||||
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Model (Checkpoint/LoRA Single/VAE)/Prompt/KSampler
|
||||
|
||||
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
|
||||
|
||||
- Drag & drop models, nodes, prompts, and workflows straight onto the canvas
|
||||
- **Send to Canvas**: click "Send to Canvas" (Workflow tab) or "Copy & Send Canvas" (Gallery tab) to send the workflow directly to the ComfyUI canvas — UI and API formats both supported
|
||||
- View metadata from images / JSON, then drop the detected models and prompts onto the canvas
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
| Workflow Tab | GenerateUI Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
| Workflow Tab | Models Tab |
|
||||
| :-------------------------------------: | :---------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Prompt Tab | Settings Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
| Prompt Input Assistance | GenUI Batch |
|
||||
| :----------------------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Nodes Tab | Models Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
| Top Bar | Image Edit Tab |
|
||||
| :----------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Gallery Tab | Help & Support Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
| WS Library | Library Information |
|
||||
| :----------------------------------: | :---------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
| Video Tab | AI TOOL Tab |
|
||||
| :----------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Video Tab - Edit | Video Tab - Asset |
|
||||
| :----------------------------------: | :-----------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Lab Tab | Lab Index Image |
|
||||
| :--------------------------------: | :------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Workflow Tab
|
||||
- **Thumbnail / Card / Table views** — switch between view modes to browse your workflow library
|
||||
<details>
|
||||
<summary><h3>Workflow Tab</h3></summary>
|
||||
|
||||
- **Thumbnail / Table views** — switch between view modes to browse your workflow library
|
||||
- **Thumbnail side panel** — preview workflow canvas snapshots in the side panel
|
||||
- **Badge filtering** — filter by user-defined badges (free labels you assign to each workflow)
|
||||
- **Search** — full-text search across workflow names and metadata
|
||||
- **Side panel tabs** — Thumbnail preview, JSON viewer with syntax highlighting, and Group management
|
||||
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
|
||||
- **AI summary** — generate workflow descriptions using Ollama
|
||||
- **Import / Export** — import workflows from files or clipboard, open in ComfyUI directly
|
||||
- **Default view setting** — persist your preferred view mode (Thumbnail / Card / Table)
|
||||
- **Import / Export** — import workflows from files or clipboard; **Send to Canvas** button sends the selected workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **Side panel toolbar buttons** (v0.5.0) — selecting a card shows three buttons in the top toolbar: **Load in GenerateUI**, **Load in Video** (loads a MiniMax H3 Image-to-Video, LTX-2.5 (Text/Image/First&Last-Frame to Video), or Wan2.2 (Text to Video) workflow into the Video tab (LTX-2.5/Wan2.2 support: v0.5.4); shows an error toast for workflows that don't contain a supported node), and **Send to Canvas**; the detail modal's duplicate copies of these three buttons were removed in favor of this single toolbar location
|
||||
- **Default view setting** — persist your preferred view mode (Thumbnail / Table)
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, group filter, and badge filter (ALL) in one click
|
||||
- **Sort** (v0.7.2) — Date (Newest/Oldest) / Name (A-Z/Z-A) dropdown next to the group filter, persisted via localStorage — same pattern as the Gallery tab's sort control
|
||||
- **GenerateUI Check** (v0.7.13) — tells whether a workflow will run in GenerateUI before you load it. The side panel shows the format badge (UI / API / App) and two ✅ / ⚠ / ❌ result badges below the workflow name, checked automatically on selection; the detail modal shows the format badge, a **GenerateUI Check** button and the same badges below the title. Hover a badge for the reasons. **Conversion** runs the same UI→API conversion as GenerateUI: ❌ for unsupported formats, conversion errors or node types not installed in this ComfyUI (the prompt would be rejected), ⚠ for values not available here (models, LoRAs, images…) — UI-format workflows get them silently replaced with the first choice, API-format workflows are sent as-is and rejected by ComfyUI's validation. **Operability** is the share of nodes with settings that GenerateUI's fields expose (✅ 80%+ / ⚠ 50%+ / ❌ below), listing the nodes you'd still have to change in the node graph. Results aren't saved; a check takes about 20 ms once ComfyUI's node list (`/object_info`) is cached (the first one after opening the page waits about 10 s for it; the detail modal prefetches it). The Decision Model was evaluated for the operability rating but couldn't tell good and bad workflows apart, so it is rule-based
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Canvas Snapshot (v0.1.2)</h3></summary>
|
||||
|
||||
### Canvas Snapshot (v0.1.2)
|
||||
- **One-click capture** — click the camera button in ComfyUI's top bar to snapshot the current workflow canvas
|
||||
- **Auto-save as thumbnail** — the snapshot is saved directly to the workflow data folder as a PNG thumbnail
|
||||
- **Embedded workflow metadata** — workflow JSON is embedded in the PNG (tEXt chunk), compatible with ComfyUI's drag-and-drop import
|
||||
- **Auto-import** — the captured workflow is automatically imported and appears in the Workflow tab
|
||||
|
||||
### GenerateUI Tab (v0.3.4)
|
||||
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
|
||||
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>GenerateUI Tab (v0.3.5)</h3></summary>
|
||||
|
||||
- **6-tab layout** — Input / Model / Settings / Feeder / Batch / **Lab** tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
|
||||
- **Save button** — located at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API; hidden while the Batch or Lab subtab is active (v0.4.6), since those work on workflow copies / their own Plan files rather than the currently loaded workflow
|
||||
- **Default-workflow snapshot fix** (v0.4.8) — overwriting the loaded workflow under a filename that's currently set as the auto-load **Default workflow** (Settings tab, see below) now also refreshes that cached snapshot; previously only the file on disk was updated, so the old (pre-edit) version — including any checkpoint no longer installed — kept reappearing after every page reload until the default was re-set by hand
|
||||
- **Input tab** — Prompt and Image inner tabs (drag-and-drop upload); Prompt tab shows Positive Prompt and Negative Prompt textareas, plus an **Embeddings selector** at the bottom (Filter + Select + Weight input + Paste button); Paste inserts `(embedding:Name:weight)` at the cursor position of the last focused textarea (defaults to Positive when neither is focused); Raw JSON (540px) in the right column
|
||||
- **Revert button** (v0.3.70) — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value (the reverse of Apply)
|
||||
- **Emphasis weight editing** (v0.3.70) — select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05, A1111/native-ComfyUI style; parentheses are removed automatically when the weight returns to 1.0
|
||||
- **Mask slot for inpainting** (v0.3.71) — LoadImage slots whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) show an additional Mask drop zone; when a mask is set, image + mask are composited into a single RGBA upload (mask baked into the alpha channel) matching ComfyUI's native alpha→MASK extraction — no extra nodes required
|
||||
- **Placeholder Image + Clear** (v0.3.97) — each LoadImage card gets a **Clear** button (resets that card's pending selection/preview back to empty; doesn't touch the workflow node itself) and a **Placeholder** button; a collapsible **Placeholder Image** section (collapsed by default) at the top of the Image sub-panel configures one shared default — **Color** mode (set Width/Height/Color, generates a solid-color PNG on the fly) or **Image** mode (drop one default image, reused as-is) — remembered across sessions; clicking a card's own Placeholder button immediately replaces just that card's image with the configured default, so multiple LoadImage cards can each be replaced individually with one click
|
||||
- **Model tab** — Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), and Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device) are always shown; **ControlNet** and **Hypernetwork** (with Strength field) are collapsed by default (v0.3.98) since they're used less often — click their header to expand; **LoRA** (Single/Stack tabs) has its own column; all selectors have a filter; Raw JSON on the right
|
||||
- **Highlight color** (v0.3.98) — the label of each model type above, plus the LoRA column header, turns a distinct color (default bright green, customizable in the Settings tab) when the loaded workflow actually contains that type of node; stays visible even while ControlNet/Hypernetwork are collapsed, so relevance is visible without expanding
|
||||
- **LoRA Stack Group toggle** (v0.3.92) — for `Lora Loader (LoraManager)` nodes, a **Use Stack Group** checkbox next to the Single/Stack tabs switches what the Stack tab shows and edits: OFF (the default whenever a workflow is loaded) displays and edits the LoRAs actually configured in the workflow's own Lora Manager node — correctly showing every LoRA it holds, not just the first one; ON switches to the LoRA group registered in the Models tab instead; toggling either direction clears the node's LoRA list first, then loads it from the newly selected source, and switching back to OFF restores exactly the list that was present when the workflow was loaded
|
||||
- **LoRA Bypass button** (v0.4.4) — a ⛔ toggle next to the Refresh Stack group button in the LoRA column header; forces the target LoRA node's strength to 0 (`strength_model`/`strength_clip` for a standard LoraLoader, or every entry's `strength`/`clipStrength` for a Lora Manager node — its LoRA list stays intact, not replaced) without touching the actual ComfyUI node's mode; click again to restore the exact values that were there before toggling; targets whichever node Single/Stack Apply default to (the workflow's Lora Manager node if it has one, else the first LoRA node); clicking a Single or Stack Apply button clears the bypass state so it can't silently reassert itself later
|
||||
- **Settings tab** — KSampler and Latent Image side by side at 50% width each; Raw JSON on the right
|
||||
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow
|
||||
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow; built-in **search bar** (always shown) finds all matches as you type with count display (`3/12`); navigate with ↑/↓ buttons or Enter / Shift+Enter; Escape or ✕ clears; current match highlighted in orange, other matches in yellow
|
||||
- **Bypass/Mute node handling** (v0.3.75) — loading a UI-format workflow with Bypass-mode nodes now reroutes their wires to the upstream source (matching same-type input/output slots, recursively through chained bypasses) instead of leaving a dangling reference that failed validation; Mute-mode nodes are excluded without rerouting, same as ComfyUI itself; when either is present, a note listing the affected nodes appears above the Raw JSON search bar in the Input/Model/Settings tabs
|
||||
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
|
||||
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
|
||||
- **Checkpoint Batch** — enable via checkbox in the right panel to sequentially generate with every checkpoint model; select checkpoints by folder from the dropdown (check a folder to select all its files, expand with ▶ for individual file selection, supports any subfolder depth); Filter input to search; All / None buttons for quick selection; **Pause/Resume** suspends processing between models; Stop aborts after the current generation; amber progress bar tracks per-model progress
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
|
||||
- **Style selector** — checkbox and dropdown next to the Reset Workflow button; enable to apply a Fooocus-style JSON to positive and negative prompts at generation time; style files (`*.json`) are loaded from `user/default/Workflow-Studio/style/`; the style's `{prompt}` placeholder is replaced with the original prompt text, or the style is appended if no placeholder exists; `negative_prompt` is appended to the existing negative prompt; styles are applied to a per-generation copy and do not modify the loaded workflow
|
||||
- **Wildcards inside a Style now expand** (v0.4.7) — an A1111-style `__wildcard__` reference written into a Style's Positive/Negative prompt is expanded at generation time just like one typed directly into GenerateUI; the Style text is merged into the prompt *before* wildcard expansion runs (previously the order was reversed, so a Style's own wildcard references were sent to ComfyUI unexpanded)
|
||||
- **Create Catalog / Catalog buttons** (v0.3.95) — next to the Style dropdown. **Create Catalog** generates one preview image per Style checked in the Batch tab's Style sub-tab (using the currently loaded workflow, typically T2I), then saves each result into a chosen `ws_style_catalog` subfolder named after the Style (existing files with the same name are overwritten); a modal lets you create a new destination folder or pick an existing one — separate folders are useful for grouping catalogs by checkpoint or by purpose. **Catalog** jumps straight to the Gallery tab's Style_Catalog sub-tab to browse the results (see below)
|
||||
- **Don't keep a copy in the Output folder** (v0.3.96) — checkbox in the Create Catalog modal; each generation still writes a normal file to the Output folder first (the workflow's own Save Image node always does that), but once its copy has been saved into the catalog folder, checking this deletes that Output-folder original right away — keeps a large catalog run (e.g. every Style at once) from flooding the Output folder with near-duplicate files; if a style's catalog copy fails to save, its Output file is left alone rather than deleted
|
||||
- **Batch type selector** — check one of the column header checkboxes in the Batch Queue pane (Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / Style) to activate that batch type; only one type can be active at a time; the Batch panel below Generate shows the active type, progress, and Pause/Resume/Stop controls
|
||||
- **Batch tab** (v0.3.18) — dedicated 3-pane layout for assembling the batch queue:
|
||||
- **Left pane** — 4 tabs: **Checkpoints** (file-tree; Filter / All / None), **Sampler** (KSampler sampler list), **Scheduler** (KSampler scheduler list), **Style** (flat list of all styles from `Workflow-Studio/style/`; All / None buttons)
|
||||
- **Center pane** — group-based selection with inner tabs (Checkpoint | Lora | Prompt | Workflow); Checkpoint/Lora groups come from the Models tab, Prompt groups from the Prompt tab, Workflow groups from the Workflow tab — check a group to add all its members, expand ▶ to select individually
|
||||
- **Right pane (Batch Queue)** — 7 columns: Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / **Style**; each column header has an enable checkbox (radio behavior: only one at a time); count shown per column; Style batch applies each selected style sequentially to a workflow copy
|
||||
- **Lora batch prompt sync** (v0.3.69) — for each selected LoRA, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt (rebuilt from the original prompt each time, not accumulated), then restored to the pre-batch state when the batch completes or is stopped; if a selected LoRA is not yet recognized by ComfyUI's model list, a warning toast suggests a Refresh or restart
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and Guider-based "Advanced Sampling" workflows (`SamplerCustomAdvanced`/`SamplerCustom` + `CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `KSamplerSelect` + `*Scheduler` — Flux.2 Klein, LongCat, Boogu, HiDream E1, etc.)
|
||||
- **MultiType widget-value bug fix** (v0.5.4) — a node input declaring a comma-joined MultiType (e.g. LTX-2.5's `LTXVEmptyLatentAudio.frame_rate: "FLOAT,INT"`) was silently dropped from the widget list instead of being recognized as a scalar widget, which shifted every subsequent widget's value onto the wrong input (surfaced as `LTXVEmptyLatentAudio.batch_size` receiving `frame_rate`'s value and video generation failing with "Expected size 1 but got size 25")
|
||||
- **Video workflow support** (v0.5.2) — loading a MiniMax H3 (or other all-in-one video-generation) workflow now works from GenerateUI too, not just the dedicated Video tab: the Input tab's Prompt field picks up its `prompt` widget, the Image tab picks up its LoadImage-based `first_frame` the same way any other I2I workflow does, and generation runs with a longer timeout since video generation takes much longer than a still image; the result preview shows the generated mp4 inline (`<video>`) instead of trying to render it as an image
|
||||
- **LTX-2.5 / Wan2.2 support** (v0.5.4) — these follow ComfyUI's regular node-graph pattern rather than an all-in-one node, so they needed their own detection: Positive/Negative Prompt now traces through LTX-2.5's `LTXVDualCFGGuider`/`LTXVConditioning` nodes to the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's `EmptyHunyuanLatentVideo` and LTX-2.5's `EmptyLTXVLatentVideo` (width/height/batch size, plus an added **Length (frames)** field) instead of showing "No EmptyLatentImage node found"; the extended generation timeout now also triggers off a `SaveVideo`/`CreateVideo` node anywhere in the graph, not just an all-in-one node
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
- **Qwen Image 2.1 support** (v0.7.3) — `TextEncodeQwenImage21` (the unified T2I/Edit encoder used by Comfy-Org's Qwen-Image-2.1 workflow templates) is now recognized: its `prompt`/`negative_prompt` widgets populate the Input tab's Positive/Negative Prompt textareas and are editable like any other prompt node; its `resolution` (a pixel-budget int, not width/height) is exposed for Raw JSON editing. No extra work was needed for the model's up to 10 reference images (`image_1`…`image_10`, used for image editing) — each one arrives as an ordinary LoadImage node, so it already gets its own card in the Input tab's Image sub-panel via the existing generic LoadImage detection
|
||||
- **Ming Image support** (v0.7.5) — `TextEncodeMingImageEdit` (single `prompt` widget, up to 8 optional `images.image_N` reference inputs; role taken from the sampler/guider wiring, so the empty-prompt negative instance in layer-decomposition workflows shows as Negative) and `EmptyQwenImageLayeredLatentImage` (Latent panel; `layers` kept as-is) are recognized in GenerateUI, and Gallery/Metadata/sidepanel I tab now show their prompts as Positive. The Sampler field is disabled (N/A) when the SAMPLER input comes from a node without `sampler_name` (e.g. `SamplerLCM`). **Fix:** top-level nodes whose widgets are converted to wired inputs (e.g. `EmptyLatentImage` width/height fed by `GetImageSize`) keep those values in `widgets_values`; they were not stripped outside subgraphs, shifting later values (`batch_size` became `1024`, KSampler `steps`/`cfg` misread). Also: `BasicGuider`'s `conditioning` is treated as positive, and a linked text input wins over the stale widget text in UI-format metadata
|
||||
|
||||
### Prompt Tab
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
|
||||
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Feeder subtab (v0.3.5 / v0.3.42)</h3></summary>
|
||||
|
||||
Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons at the top of the left pane (persisted in `localStorage`).
|
||||
|
||||
**Image Loop mode** — requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder)** custom node.
|
||||
|
||||
- **ImageFeeder node control** — select the target node from a dropdown auto-populated from the loaded workflow; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection) and Apply to the workflow
|
||||
- **Image library** — 3-pane layout: folder tree (left) browsing `user/default/image-loop-data/`, image grid with checkbox selection (center), preview panel with resolution and file size (right)
|
||||
- **Selection management** — check individual images; All / None buttons for the current folder; selected files are reflected in `selected_files` on Apply
|
||||
- **Presets** — save the current directory + selection as a named preset; load or delete presets (server-side persistence via `image-feeder-presets.json`)
|
||||
- **Continuous Run loop** — Run / Stop buttons below the presets; **After gen** mode controls index behavior after each generation:
|
||||
- **Loop** — advance index and wrap back to 0 when all images are exhausted (runs indefinitely)
|
||||
- **Increment** — advance index and auto-stop when all images are consumed
|
||||
- **Fixed** — always use the same index
|
||||
- **Index sync** — after each generation the node returns `next_index` via WebSocket (`image_loop_node_sync`); the Index field updates automatically
|
||||
- **Seed** — Run loop uses the right-pane seed setting (Random / Fixed / Increment / Decrement); the node's own Seed field only affects random sort order
|
||||
|
||||
**Gallery mode** (v0.3.42) — no external plugin required; uses the built-in **WFS_GalleryFeeder** custom node.
|
||||
|
||||
- **WFS_GalleryFeeder node** — reads images directly from a Gallery group and outputs them one at a time; inputs: `group_name`, `index`, `sort_mode` (filename_asc / filename_desc / random), `seed`
|
||||
- **Node & group selector** — pick the WFS_GalleryFeeder node from the loaded workflow and choose which Gallery group to feed images from; `__Feeder__` group is auto-created on first open
|
||||
- **Image grid** — center pane shows all images in the selected group; click any image to update the Index to that position
|
||||
- **After Gen modes** — Loop (wrap at end and continue indefinitely), Increment (stop automatically when last image is reached), Fixed (always use the same index)
|
||||
- **Run / Stop controls** — left pane Run / Stop buttons manage the generation loop; After Gen combo (loop / increment / fixed), ▶ Run, and ■ Stop widgets are also available directly on the WFS_GalleryFeeder node in the ComfyUI canvas (`gallery_feeder_extension.js`)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Lab subtab (v0.3.87 – v0.5.2)</h3></summary>
|
||||
|
||||
An experimental batch generator: runs the workflow currently loaded in GenerateUI N times, letting Model (Checkpoint/LoRA Single/VAE) / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone. Works with both I2I and T2I workflows (v0.3.90).
|
||||
|
||||
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 3 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
|
||||
- **Per-column keyframes** — Checkpoint, LoRA, VAE, Prompt, and KSampler each hold their own fully independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it; lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
|
||||
- **Model column — Checkpoint / LoRA Single / VAE** (v0.4.2, redesigned v0.4.3) — Checkpoint and VAE (and now LoRA) share one visual "Model" column; a `[C][L][V]` toggle in its header picks which of the three independent keyframe lists the shared cell list, its own +/-, and its edit modal currently operate on — each behaves exactly like its own column (same as Prompt/KSampler), just sharing the Model column's screen space, so adding, removing, editing, reverting, or bypassing a keyframe while viewing one never touches the other two. Each toggle button lights up the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. A field left blank in a later keyframe's edit modal shows "(inherits: ...)" instead of "(workflow default)" when an earlier keyframe in that same list already set something. LoRA support is Single only (no Stack, matching the experimental scope of this tab); if a LoRA is set but the loaded workflow has no LoRA node at all, Run warns once and skips it rather than auto-inserting one — unlike VAE (which does auto-inject a VAELoader when missing), wiring in a LoraLoader would require rewiring the Checkpoint's model/clip outputs through it
|
||||
- **Empty state when no matching node exists** (v0.4.5) — Checkpoint/LoRA/VAE each show "No matching node in this workflow" instead of a keyframe list when the loaded workflow has no node that column could actually override, and the column's +/− is disabled too, so no dead "(workflow default)" keyframe row can be created in the first place; VAE still counts as usable whenever a Checkpoint node exists, since its baked-in VAE can still be overridden via auto-injected VAELoader as above
|
||||
- **Wildcards** (v0.3.93) — Prompt keyframes' Positive/Negative text supports the same A1111-style `__name__` syntax as the main GenerateUI prompt fields; each keyframe is expanded fresh right before that iteration runs, so reusing the same keyframe across iterations can still draw a different random line each time
|
||||
- **Prompt cell extras** (v0.3.88, refined in v0.3.89, v0.3.93, and v0.3.97) — **Get from GenerateUI** fills Positive/Negative from the workflow currently loaded in GenerateUI; **Get from Image** extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); **Get from Previous** (v0.3.97) copies both Positive and Negative wholesale from the keyframe immediately before this one in the same column (e.g. editing #3 copies #2's prompt; not shown on keyframe #1, which has no earlier row); **Clear** (v0.3.97) empties both fields; **Style** row — an **Apply** button merges whichever style is currently selected in GenerateUI's own top-bar Style dropdown into the Positive/Negative text below, comma-separated after any existing text (never clears it) and works regardless of that top-bar's own Style enable checkbox; a checkbox next to it tracks on/off and shows the applied style's name beside itself (not on the button) — unchecking only clears that name label, the already-merged text stays and is yours to edit/delete by hand
|
||||
- **Keyframe #1 live-reflect** (v0.3.97) — in every column, keyframe #1 shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only, LoRA is never live-reflected since there's no reliable way to infer "the" active LoRA back out of a workflow node — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it — even unchanged — locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI
|
||||
- **Bypass checkbox** (v0.3.97) — available on every keyframe including #1; skips that row entirely when picking which setting applies at a given iteration, as if it didn't exist, so whatever was active before it keeps running instead; Lab-internal only, never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (mutually exclusive with Revert); the cell shows an amber "⏭ Bypassed" label, taking priority over both the Revert and live-reflect display
|
||||
- **LoRA node bypass & prompt injection** (v0.4.4) — inside the LoRA keyframe's own edit modal, two checkboxes independent of the row-level Bypass above: **Bypass LoRA node** forces that keyframe's `strength_model`/`strength_clip` to 0 at apply-time — the behavioral equivalent of ComfyUI's node Bypass (mode:4), since the workflow used here is already API-format with no `mode` concept; **Apply LoRA syntax + trigger words to Positive prompt** appends `<lora:name:strengthModel:strengthClip>` plus that LoRA's cached CivitAI trigger words to the Positive prompt, comma-separated, at apply-time — the same format GenerateUI's own Model tab Apply button writes. Switching keyframes (a different LoRA, this checkbox turned off, or the row Bypassed) always strips exactly what the previous iteration injected before adding anything new, so a batch never accumulates every LoRA it has used; the prompt checkbox is disabled automatically while Bypass LoRA node is checked
|
||||
- **T2I workflow support** (v0.3.90) — a **T2I workflow (no source image)** checkbox in the Image pane skips the source-image requirement and never touches a LoadImage node, so workflows with no image input (e.g. plain `EmptyLatentImage` txt2img) can also use the Model/Prompt/KSampler keyframes; the Image drop zone and "Use generated image for next" are greyed out (not cleared) while it's on; saved with the plan as `t2i_mode`
|
||||
- **Use generated image for next** — optional checkbox that chains iterations: from iteration 2 onward the previous iteration's first output image is fed back in as the I2I source instead of the original dropped image, using ComfyUI's `"name [type]"` annotated-filename reference (no re-upload needed); disabled together with the Image drop zone in T2I mode
|
||||
- **Workflow recall** (v0.3.90) — every saved plan records the filename of the workflow that was loaded in GenerateUI at save time (`workflow_filename` in the Plan JSON); after loading a plan, an **Open Workflow** button appears next to it — click to load that exact workflow file back into GenerateUI (asks for confirmation first, since it replaces whatever is currently loaded)
|
||||
- **Plan files** — Plan Save (silently overwrites the loaded plan) / Save As (always asks for a new name) / Plan Clear; plans are stored as `ws_labplan_<name>.json` in `user/default/Workflow-Studio/lab_plan/` (the `ws_labplan_` prefix is added automatically, v0.3.90 — type just the plain name when prompted), alongside an auto-generated `ws_labplan_<name>.png` index image (a 3-per-row contact sheet of up to 9 result thumbnails)
|
||||
- **Plan Load** — same drag-and-drop / click-to-browse drop zone as the Image slot; drop a plan's `.json` (read directly in the browser) or any of its index-image `.png`s — since v0.3.91 every index image carries the full plan embedded in it directly (see below), so it's read straight from the file with no server round-trip; older index images without that embedding fall back to fetching a same-named `.json` from the server
|
||||
- **Plan JSON tab** (v0.3.88) — view/edit the current plan as raw JSON (same shape saved to disk); Refresh re-reads it from the Setting tab, Apply to Setting parses your edits and writes them back into the Setting tab's columns/note/batch/chain-image/source-image/results — the same effect as loading that JSON as a plan file, without saving it first
|
||||
- **Save index image to Output on Run** (v0.3.90, on by default since v0.3.93) — checkbox in the run controls; when checked, every completed Run also builds the same contact-sheet index image used for Plan files and saves it directly into ComfyUI's own Output folder as `Lab_index_<counter>_.png` (auto-numbered like a normal generated image, right alongside the images that Run just generated); independent of Plan Save, which always writes its own copy next to the plan file regardless of this checkbox; saved with the plan as `save_index_on_run`
|
||||
- **Index images are self-contained plan files** (v0.3.91) — every index-image PNG, whether from Plan Save/Save As or from Save index image to Output on Run, carries the entire plan embedded directly in the file as a PNG `wfm_lab_plan` chunk; this is what lets the Output-folder copy above work as a Plan Load source even though it has no matching `.json` anywhere
|
||||
- **Eagle integration** — every image Lab generates is auto-saved to Eagle the same way as GenerateUI's own Generate button, if enabled in the Settings tab; the Output-folder index image above is included too (v0.3.90) once saved
|
||||
- **Select vs. edit, and keyframe reordering** (v0.4.8) — a single click on a keyframe cell now only selects it (blue outline); double-click to open its edit modal as before. Each column's header (Model / Prompt / KSampler) also gets **▲ / ▼** buttons that swap the selected keyframe with its immediate neighbor, reordering keyframes without retyping them; keyframe #1 (the live-reflecting baseline) can never move, and nothing can be swapped into its slot
|
||||
- **PG — apply a Prompt tab preset group** (v0.4.8) — a **PG** button on the Prompt column header opens a picker for the Prompt tab's Table-view preset groups (the same groups the Batch tab's checklist uses); choosing one replaces the entire Prompt column with one keyframe per preset in that group's order, starting at #1 (#1 is overwritten, not appended after). If the group has more presets than the current Batch count, Batch count is raised automatically to fit (capped at 50, the Batch input's own limit); anything still beyond that shows the existing "keyframes beyond Batch count" warning instead of being dropped
|
||||
- **Video workflow support** (v0.5.2) — loading a MiniMax H3 (or other all-in-one video-generation) workflow into GenerateUI and running Lab now works end to end: the Prompt column writes to its `prompt` widget, the KSampler column auto-hides (same "no matching node" treatment as Model sub-columns) since the sampler wired internally is a fixed part of the model's own preset rather than something meant to be tuned per keyframe, the Results grid plays mp4 results inline, and the contact-sheet index image draws each video result's last frame instead of failing silently onto a black square
|
||||
- **Use generated image for next, now video-aware** (v0.5.2) — when a Run's output is a video, the "Use generated image for next" chain extracts its last frame client-side (canvas) and uploads it as the next iteration's source image, instead of trying to feed the raw mp4 into a LoadImage node
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Prompt Tab</h3></summary>
|
||||
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard/Style support (right)
|
||||
- **AI chat assistant** — generate and refine prompts interactively using Ollama, LM Studio, Lemonade, or Unsloth (multi-backend: v0.5.4)
|
||||
- **⚙ Settings modal** (v0.5.4) — replaces the old central Settings-tab panel; opens a dedicated modal (Backend / Connection / Model / Generation / Unload / Save) scoped to this tab only, saved under its own `localStorage` key so it stays independent of the Tagger tab's VLM settings; **Thinking mode** and **Max tokens** apply to every chat request, and an **Unload** button frees the selected model from Ollama/LM Studio/Lemonade (Unsloth has no such API and is shown as unsupported)
|
||||
- **Image attachment** — attach reference images for vision-capable models
|
||||
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
|
||||
- **Prompt presets** — save/load reusable prompt templates (positive & negative) with category support
|
||||
@@ -72,10 +200,32 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
|
||||
- **Clipboard copy** — copy positive/negative prompts individually (PP Copy / NP Copy)
|
||||
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
|
||||
- **Wildcard/Style tab bar** (v0.3.96) — the right pane switches between the wildcard tools below and a Style manager (same pane, two modes)
|
||||
- **Wildcard input toolbar** — one-click buttons to insert `{|}`, `{n$|}`, `__|__`, `<lora::1:LBW=;>` and other wildcard syntax; wraps selected text when applicable
|
||||
- **Wildcard file manager** — create, view, and edit `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; click a filename in the file picker to insert `__filename__` at cursor
|
||||
- **Style manager** (v0.3.96) — create, edit, and delete registered Styles (the same ones used by GenerateUI's Style dropdown, the Batch tab, and the Style Catalog), using the same list→editor CRUD pattern as the Wildcard file manager above; each entry has a Name, a Positive prompt (supports the `{prompt}` placeholder, or appends if omitted), and an optional Negative prompt
|
||||
- **Source-file badge & in-place editing** — each list entry shows its defining file as a small badge next to its name (e.g. `sdxl_styles_diva.json`); renaming a style keeps its position in that same file rather than moving it; a name must be unique across all styles
|
||||
- **New styles go to a dedicated file** — created via **+ New**, a style is appended to its own `custom.json` inside the style folder, so the bundled style packs are never modified directly
|
||||
- **+ Add to this file** — while editing an existing style, this button clears the form for a brand-new entry that gets appended to that *same* file instead of the default `custom.json` — the way to add a style into one of the bundled packs
|
||||
- Saving, renaming, or deleting a style immediately refreshes GenerateUI's Style dropdown, the Batch tab's checklist, and the Style Catalog's name list — no reload needed
|
||||
- **Form / Table toggle** (v0.4.6) — two top-level tabs at the top of Prompt Tab: **Form** is the one-item-at-a-time editor described above; **Table** shows the same Presets / Wildcards / Style data all at once as editable spreadsheets, useful once there are more than a handful of items to manage
|
||||
- **Table view — Presets / Presets Group / Wildcards / Style** (v0.4.6) — four sub-tabs, each an editable table built from the exact same data as the Form tab above (changes on either side stay in sync)
|
||||
- **Row selection & bulk delete** — every table shares a left-most auto-numbered **#** column; click a number to select that row (multiple at once), then use the toolbar's **Delete** button to remove every selected row in one confirm, or **Deselect** to clear the selection — replacing per-row delete buttons, which were too easy to misclick
|
||||
- **Double-click to edit in a modal** — double-clicking a row's number opens a larger prompt-editing modal instead of the small in-table textarea; Presets/Style show Positive/Negative toggle buttons above one shared textarea (switching preserves unsaved text on the other side), Wildcards shows just the file content; Save writes back and closes the modal, Close discards changes
|
||||
- **Wildcard syntax toolbar in the Presets modal** (v0.4.7) — the same `{|}` / `__|__` / `<lora>` / file-picker toolbar as the Form tab's Wildcard composer appears above the Presets modal's textarea, since that's where a wildcard reference typically ends up; not shown in the Wildcards or Style modals (the Wildcard modal edits the raw file content itself, and Style prompts have no such wildcard-composer connection)
|
||||
- **+ New row** — the toolbar's **+ New ...** button adds a blank editable row with small ✓ (Add) / ✕ (Cancel) icons in place of its number
|
||||
- **Presets table** — **★** (favorite) and **B** (Batch group) columns work as toggle switches, same as the Form tab's Preset Manager; the toolbar's **BC** button clears the whole Batch group at once; **PP Copy** / **NP Copy** copy the single selected row's positive/negative prompt to the clipboard; a **group select + Add to Group** control bulk-adds every selected preset to a chosen group in one click — searching/selecting from the table itself (rather than a long per-group preset picker) keeps group assignment usable once there are many presets
|
||||
- **★ / B sort toggles** (v0.4.7) — toolbar buttons next to the search box bring favorited and/or batch-selected presets to the top of the list; both can be enabled together (★ takes priority, B breaks ties), and everything else keeps its original relative order; toggling a row's own ★/B while a sort is active re-sorts immediately
|
||||
- **Presets Group table** — create, rename, and delete groups, with each group's members shown as removable badges; the reserved **Batch** group is not listed here — it's managed entirely through the Presets table's B column and BC button instead
|
||||
- **Wildcards table** — same files as the Form tab's Wildcard file manager above; editing the File path or the Ext dropdown renames the file (saves the content under the new name, then removes the old file)
|
||||
- **Style table** — same styles as the Form tab's Style manager above, editable inline the same way as Presets
|
||||
- **+ Add to this file** (v0.4.8) — select exactly one row whose File badge isn't `custom.json` to enable this toolbar button, bringing the Form tab's same-file-add capability (above) to the Table view too; it adds a blank row targeting that same file (shown as a badge in the File column even before saving); custom.json entries and multi-row selections leave it disabled — use the regular + New Style button for those instead
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Settings Tab</h3></summary>
|
||||
|
||||
### Settings Tab
|
||||
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
|
||||
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
|
||||
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
|
||||
@@ -83,70 +233,299 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Workflows directory** — configure which folder to scan for workflows
|
||||
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
|
||||
- **Eagle connection** — set Eagle API endpoint for auto-save
|
||||
- **Ollama connection** — configure Ollama server URL
|
||||
- **CivitAI Host** — choose which site opens when clicking a model link: `civitai.com` (SFW only) or `civitai.red` (unrestricted); saved to `settings.json` and synced to `localStorage` for the Models tab to use without extra fetches
|
||||
- **CivitAI API Key** — optional Bearer token for authenticated CivitAI access; stored in `settings.json` (excluded from data exports); environment variable `CIVITAI_API_KEY` takes priority if set
|
||||
- **Default workflow** — set a workflow to auto-load on startup
|
||||
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling)
|
||||
- **Default Checkpoint** (v0.3.73) — optional checkbox to force every checkpoint loader node's model to a chosen default whenever a workflow is loaded into GenerateUI; a safeguard against generation errors or garbled images from a stale/mismatched checkpoint saved in the workflow file
|
||||
- ~~AI Assistant Settings~~ (removed in v0.5.4) — the central Ollama backend/URL/model panel that used to live here was replaced by a ⚙ Settings button + modal directly in the Prompt tab and the Tagger tab, each with its own independent configuration (backend, URL, model, Thinking mode, Max tokens, and a model Unload button) — see those tabs below; the AI TOOL tab's own Settings pane is unaffected
|
||||
- **VRAM Management (Ollama)** (v0.7.13) — right before every image generation from Workflow Studio (GenerateUI, Lab, batches, Video, AI TOOL chat generation — anything that goes through its queue), unload the models Ollama has loaded so ComfyUI has free VRAM. **Before generating**: Do nothing (default) / Unload only as much as needed when free VRAM is below a target (biggest model first, one at a time, stopping once the target is reached) / Unload everything every time; **Target free VRAM (GB)** for the second mode; **Unload all now** button. Free VRAM is measured with `nvidia-smi` (plus ComfyUI's own unused torch cache), because ComfyUI's free-VRAM figure may not change when Ollama loads a model; non-NVIDIA GPUs fall back to ComfyUI's value. Targets Ollama on localhost — the default `127.0.0.1:11434` plus the AI TOOL / Prompt / Tagger AI settings and the Decision Model when they use Ollama (other hosts only via `WFS_ALLOWED_BACKEND_HOSTS`); LM Studio etc. and ComfyUI's own models are not touched. A toast reports what was unloaded, an unreachable target or a failure; generation continues either way. Running a workflow from the ComfyUI canvas itself is not covered. Server route: `POST /api/wfm/vram/prepare` (JSON only). Ported from ComfyUI Comic Creator
|
||||
- **Video Playback Volume** (v0.5.4) — the video preview's volume is remembered here and shared across every video player in the plugin (Video tab, Gallery tab), instead of resetting to maximum whenever a different video is opened
|
||||
- **Data Management** — export core settings/metadata/prompts/CivitAI cache to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
|
||||
- **Full Backup (ZIP)** (v0.5.2) — additionally covers what the JSON bundle can't represent: folder-based data (AI Skills, Lab Plans, Video Plans, Video Edit projects, Style presets) and the Tagger SQLite database; two opt-in checkboxes can also include ComfyUI's default workflows folder (`user/default/workflows`) and the Impact Pack's wildcard folder in the export — both of those are export-only and stored under an `_external/` prefix in the ZIP that Restore always skips, since auto-overwriting files a different plugin/ComfyUI core manages would be unsafe; copy them back by hand if needed. A third opt-in checkbox (v0.7.6) bundles the clips/BGM/sounds/overlay clips that saved Video Edit projects reference in ComfyUI's input folder (the project JSON only stores references) under `_video_edit_media/`; unlike `_external/`, these are restored into the input folder, never overwriting a file that already exists there. Restoring overwrites existing files; restart ComfyUI afterward for either restore path
|
||||
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
|
||||
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`; the same color and mechanism also drives the Video tab's field highlighting (v0.5.4, see above)
|
||||
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
|
||||
- **G'MIC-Qt Integration** — set the path to `gmic_qt.exe` for the Image Edit Filter tool; download G'MIC-Qt Standalone from `gmic.eu/download.html` (Windows: `gmic_qt-win64.zip`), extract anywhere, then enter the full path to `gmic_qt.exe` and click Save
|
||||
- **Language** — English / Japanese / Chinese
|
||||
|
||||
### Gallery Tab (v0.3.1)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Gallery Tab (v0.3.44)</h3></summary>
|
||||
|
||||
- **Output / ImagePrompt / Style_Catalog / Metadata sub-tabs** (v0.3.94, v0.3.95, v0.4.6) — four sub-tabs at the top of the Gallery tab: **Output** (the image browser described below), **ImagePrompt** and **Style_Catalog** (separate visual libraries — see their own sections below), and **Metadata** (embeds the full Metadata tab — see its own section below)
|
||||
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
|
||||
- **Folder tree root label** (v0.3.90) — the root entry in the folder tree is labeled simply **[root]**, no longer suffixed with the scanned folder's own directory name (which could be a misleading, environment-specific name — e.g. StabilityMatrix installs where the output folder is a symlink named `Text2Img` — even though the folder holds every kind of generated output, not just text-to-image results)
|
||||
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
|
||||
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
|
||||
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To... and Delete from the multi-select bar
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations (group, favorite, move, delete)
|
||||
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To..., Export, and Delete File from the multi-select bar
|
||||
- **Download** (v0.5.0) — a dedicated orange **Download** button in the detail panel's action-button row (next to Tagger); replaces the old preview-image hover overlay, which blocked video playback controls
|
||||
- **MP4 video support** (v0.5.0) — `.mp4` files are browsable, previewable (detail panel with native `<video controls>`, lightbox, and Compare), and included in favorites/tags/groups/move/delete/export alongside images; server-side thumbnails are generated from the first video frame via PyAV (falls back to a placeholder if PyAV/ffmpeg decoding isn't available); a small ▶ badge overlays video thumbnails in the grid
|
||||
- **MP4 embedded metadata** (v0.5.1) — the Prompt and JSON tabs now also work for `.mp4` files: reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4 (via PyAV server-side), including all-in-one video-generation nodes that carry their prompt directly on a `prompt` input rather than a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
|
||||
- **WebM support** (v0.7.14) — `.webm` files get the same treatment as `.mp4` everywhere above (browsing, thumbnails, dimensions/duration, embedded workflow/prompt metadata, favorites/tags/groups/move/delete/export, Compare, Comic Creator video send): all the video-specific code paths go through PyAV, which handles both containers identically, so no format-specific logic was needed beyond recognizing the extension
|
||||
|
||||
- **Embedded metadata reading fixes** (v0.8.0) — PNG chunks appended after `IEND` (ComfyUI-Custom-Scripts writes `workflow` there) are now read; `.webp` files expose their EXIF `workflow`/`prompt` to the Prompt and JSON tabs; images without a ComfyUI workflow (A1111 / Forge / Fooocus) show their `parameters` prompts in the Prompt tab; API-format `ImageMetadataLoRALoader` LoRAs are listed in the Metadata tab and the side-panel I tab
|
||||
- **Audio support** (v0.8.0) — `.wav` / `.mp3` / `.ogg` / `.m4a` / `.flac` / `.opus` files are browsable alongside images and videos (for ComfyUI music generation output): thumbnails are waveform images generated server-side (PyAV, cached), the detail panel / lightbox / Compare show the waveform with a built-in audio player (shared volume setting with videos), the Info tab shows the duration, and the Prompt / JSON tabs read the `prompt` / `workflow` tags that SaveAudio-type nodes embed (FLAC / MP3 / Opus); favorites, tags, groups, move, delete, and export work as for other files, while image-only actions (PSD export, Edit Layers) skip audio; in the Video tab's Asset sub-tab, audio files added to the Video Assets group appear with an "Audio Only" filter, an inline player, and **Set as BGM** / **Add as sound** buttons that feed the Edit sub-tab
|
||||
- **File conversion** (v0.8.0) — a **Convert...** button in the Gallery bulk bar (shown when the selection contains audio or video) converts audio files to FLAC / MP3 / Opus / M4A / WAV, or extracts the audio track of videos, using PyAV (no system ffmpeg needed); bitrate, sample rate (44.1 / 48 kHz) and channels (mono / stereo) are selectable, output goes next to the source as a new file (`name.ext`, or `name_converted.ext` if taken). ComfyUI `prompt` / `workflow` tags (and title / artist / album tags) are carried over to formats that can hold them — WAV cannot, so they are skipped there; favorite / tags / memo / groups can be carried over, and the original can optionally be deleted after a successful conversion (with a confirmation). `.ogg` is readable but not offered as an output (the bundled PyAV has no Vorbis encoder)
|
||||
- **Move To folder filter** (v0.8.0) — the Move To dialog (detail panel and bulk bar) has a filter box that narrows the folder list by name or path (space = AND), Enter moves to the first match; the move toast now reads "Moved N file(s)" since audio/video files are moved too
|
||||
- **Semantic Search** (v0.8.2) — find images by meaning instead of keywords. The **S** button next to the search box turns it on: the search box then takes a free-text description (e.g. "sunset city street"), Enter searches, and results are ordered by similarity with a score badge on each thumbnail. Turning **S** on also shows a second toolbar row (left-aligned): **Similar** (lists images similar to the selected one; selecting another image keeps the list, **↻** re-runs it for the newly selected image, pressing Similar again returns to the normal list), the **⚙** settings button, and the result count. Searches cover the selected folder and its subfolders; sort and tag/group filters do not apply to semantic results
|
||||
- **Index window (⚙)** — three tabs. **Index**: target folder (optionally with subfolders), "new/changed only" or "rebuild all", progress bar, stop, delete all. **Model**: choose with radio buttons between **Gemini Embedding 2** (API; set `GEMINI_API_KEY` in the plugin's `.env` and restart ComfyUI — the free tier exists but content sent on it may be used by Google to improve its products, beyond it the API is paid) and **EmbeddingGemma 2** (runs locally, free, no key; the ~300 MB q4 ONNX model is downloaded on first use), plus vector size (Gemini 768 / 1536 / 3072, EmbeddingGemma 2 128 / 256 / 512 / 768), and for the local model the device (auto / CPU / GPU CUDA), CPU thread count (default: half the cores) and image precision (high / standard ≈2.5× faster / light ≈5× faster). **Search**: minimum score for similar-image results (e.g. `0.6` shows only images scoring 0.6 or higher), minimum score for text search, and maximum results — stored per model because score levels differ between models
|
||||
- **Index state in the folder tree** — a green dot next to a folder's image count means every image directly in that folder is indexed; the index is one shared store, so folders can be indexed one at a time. Images, plus videos / PSD via their thumbnails (audio is skipped), are embedded; changing the model, vector size or image precision requires "Rebuild all"
|
||||
- **Local GPU** — EmbeddingGemma 2 is a port of Transformers.js' pipeline onto `onnxruntime` (no `transformers` support needed). On an RTX 4070 it indexes ≈0.13 s per image with CUDA versus ≈6 s on the CPU (≈45× faster, identical vectors); to use the GPU, install `onnxruntime-gpu` in place of `onnxruntime` (`pip uninstall onnxruntime onnxruntime-gpu` then `pip install onnxruntime-gpu`). DirectML is not supported (its vision model fails). The model is released from memory when indexing finishes
|
||||
- **Audio waveform colors** (v0.8.0) — waveform thumbnails are colored per format (MP3 blue, WAV green, FLAC amber, OGG/Opus purple, M4A rose)
|
||||
- **Info tab: dimensions & duration** (v0.5.1) — the detail panel's Info tab now also shows image dimensions (width×height) or video duration, in addition to filename, size, date, tags, and memo
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations: select group → "Add to Group" / "Remove from Group"; Favorite All / Unfavorite All; Compare (2–4 images); Move To...; **Export** (downloads all selected images as a ZIP file); **Create PSD** (v0.6.0, combines the selected images into one layered PSD file, positioned top-left on a canvas sized to the largest image); **Edit as Layers** (v0.6.0, opens all selected images in the Image Edit tab at once, each as its own layer); Delete File
|
||||
- **Image Compare** — select 2–4 images with Ctrl+click, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; grid adapts to the number of selected images
|
||||
- **Prompt search** — text search covers filename, tags, memo, and prompt text (A1111 `parameters` field cached on first detail panel open)
|
||||
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
|
||||
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
|
||||
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab; **`__Feeder__`** is a reserved group (🔒 prefix) that cannot be renamed or deleted; used by the Feeder Gallery mode to define the generation queue; **FC** button in the toolbar clears all members without deleting the group
|
||||
- **Thumbnail F button** — cyan overlay button (top-left of each image card) toggles the image's membership in the `__Feeder__` group; cyan and always-visible when the image is in the group; visible on hover only when inactive
|
||||
- **Favorites** — star images inline without reopening the detail panel
|
||||
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
|
||||
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab
|
||||
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel; four or five tabs — **Info** / **JSON** / **Groups** / **Prompt** / **Nanobanana** (only for `cc_nanobanana`-folder images, see below)
|
||||
- **Prompt tab** (v0.4.7) — selected by default (the most frequently checked piece of info shouldn't need an extra click); lists every positive/negative prompt candidate found in the embedded workflow with POS/NEG badges, reusing the exact same list→click→full-text UI as the Metadata tab; extracts from the API-format embedding when available rather than the UI-format one, since a workflow with independent top-level and subgraph sampling chains (e.g. Krea-2) can otherwise have some of its prompts missed; hidden entirely for images in the `cc_nanobanana` folder (see Nanobanana tab below), since they carry no embedded ComfyUI workflow to extract a prompt from
|
||||
- **Nanobanana tab** (v0.7.14) — shown only for images in a `cc_nanobanana` folder (written by ComfyUI Comic Creator's Nanobanana tab) that have a matching entry in that folder's `history.jsonl` generation log; shows engine, model, image size, seed, timestamp, and the prompt/negative prompt actually used — the same info is also surfaced in the Metadata tab (see below) when opened from here via the **Metadata** button
|
||||
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab; **Copy & Send Canvas** button copies the JSON to the clipboard and sends the workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **GenUI button** (renamed from "Load GenUI" in v0.5.0 for readability) — loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; shows a warning toast if no workflow is embedded or the format is unsupported; Metadata button is styled green, GenUI button uses the primary accent color
|
||||
- **Image Edit button** — toolbar button; sends the selected image directly to the Image Edit tab as the base layer; PSD files (v0.6.0) are fully decomposed into their original layers instead of being flattened into one image
|
||||
- **Send GenUI Image button** — toolbar button (next to Image Edit); uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded
|
||||
- **Send to LI node button** (v0.5.2) — toolbar button (next to Send GenUI Image); uploads the selected image and writes its filename into the `image` widget of the node currently selected on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination and cross-window bridge as the Image Edit tab's **Send to LI node** button (renamed from "Send to Workflow" in this same release), so both entry points now share one consistent name; requires Workflow Studio to have been opened from ComfyUI's top menu button
|
||||
- **Send to LV node button** (v0.7.14) — toolbar button (next to Send to LI node), the video equivalent: uploads the selected video and writes its filename into the `file` widget of the node currently selected on the ComfyUI canvas, restricted to ComfyUI's native **Load Video** node (`LoadVideo`) since `file` as a widget name isn't unique enough to search for generically the way `image` is; only enabled when the selected item is a video (mp4/webm); same cross-window bridge and launch requirement as Send to LI node
|
||||
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay; when the selected file is an MP4 video (v0.6.3), it is routed to Comic Creator's Layout-tab Video tool instead — inserted as a new video object in the selected panel (playable, capturable to a still frame) rather than as a flattened image; sending a video while the Image tab is the intended destination is not yet supported and shows an info toast instead
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — toolbar button next to the view-mode (Thumbnail/Table) switch (v0.5.2, moved from the toolbar's rightmost position so it sits with the other display-affecting controls); resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
|
||||
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
|
||||
- **Output folder configurable** — set the scanned output folder from Settings tab
|
||||
- **Performance** — folder-level mtime cache (30s TTL) for fast incremental refresh; tree expansion state preserved across folder operations
|
||||
- **SVG file support** (v0.3.77) — `.svg` files are browsable, previewable (detail panel and lightbox), and included in favorites/tags/groups/move/delete/export alongside raster formats; served as-is with no rasterized thumbnail, since `<img>` scales vector graphics natively at any size
|
||||
- **PSD file support** (v0.6.0) — `.psd` files are browsable alongside other formats; thumbnails and full-size previews show every layer merged together (hidden layers excluded), rendered server-side via `psd-tools` and disk-cached; the underlying PSD file itself is never modified — only the Gallery preview is a flattened rendering
|
||||
- **Performance** — server-side 256px JPEG thumbnail generation with disk cache (`data/thumb_cache/`); infinite-scroll paging (50 images per page, IntersectionObserver); folder-level mtime cache (60s TTL); bulk operations use single-request API endpoints
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)</h3></summary>
|
||||
|
||||
- **Visual prompt library** — a Gallery sub-tab for browsing reference images and building prompts from them visually, separate from the Output browser; manages a dedicated `ws_image_prompt` folder, created automatically inside the ComfyUI output directory
|
||||
- **3-column layout** — folder tree (left) | thumbnail grid (center) | prompt builder (right); selecting any folder (including a parent category folder) recursively shows every image in its subfolders too, both in the grid and in the tree's count badges
|
||||
- **Prompt builder (right pane)** — clicking a thumbnail previews it and its prompt text without adding it yet; **+ Add** appends it as a removable chip under "Selected Prompts" (**Clear All** to reset); **Save** edits and persists the prompt text for that image; **Final Prompt** combines all chips into one comma-separated, freely-editable text; **Copy** sends it to the clipboard
|
||||
- **Plain-text prompt storage** — each image's prompt lives in a `.txt` sidecar file next to it (same base name, no database) — anyone can add their own entries by dropping an image + matching `.txt` file into a subfolder; the Output Gallery's own detail panel reads and displays the same file when browsing into `ws_image_prompt`
|
||||
- **ponyxlWildcardsVault format support** — if no `.txt` sidecar exists, prompts are resolved on the fly from a `*.yaml` + `thumbnails/` (also `thumbnails_option2/`) pack placed directly under the folder, using the same key/tag structure as Navimixu's [PonyXL Wildcards Vault](https://civitai.com/models/615967/ponyxl-wildcards-vault) packs — just drop the downloaded pack folder in, no import step required
|
||||
- **Seed data importer** — `tools/import_style_prompt_seed.py` (dev tool, ships no image data) pre-processes a wildcards-vault-style source folder into portable image + `.txt` pairs under `ws_image_prompt`, for archiving a stable, portable copy instead of relying on the on-the-fly YAML fallback
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Style_Catalog Gallery subtab (v0.3.95)</h3></summary>
|
||||
|
||||
- **Visual Style picker** — a third Gallery sub-tab for picking a registered GenerateUI Style (`user/default/Workflow-Studio/style/*.json`) by its preview image instead of its name in a dropdown; manages a dedicated `ws_style_catalog` folder, created automatically inside the ComfyUI output directory; populated by GenerateUI's **Create Catalog** button (see GenerateUI Tab above)
|
||||
- **3-column layout** — folder tree (left) | thumbnail grid (center) | Positive/Negative prompt panel (right); like ImagePrompt, selecting a parent folder recursively shows every image in its subfolders
|
||||
- **Positive/Negative panel** — clicking a thumbnail reads the Positive/Negative prompt actually embedded in that generated image (reusing the Metadata tab's extraction logic — no separate metadata store), each with its own **Copy** button
|
||||
- **Select as Style button** — matches the selected image's filename (without extension) against a registered Style name and switches the GenerateUI tab's Style dropdown to it; a visual shortcut into the existing named-Style system, not a one-off apply of the embedded prompt — if the Style was renamed or deleted since the catalog image was created, a "no matching style" toast is shown instead
|
||||
- **Load in GenerateUI / Open in Metadata Tab buttons** (v0.3.96) — read the workflow embedded in the catalog image itself (a byte-identical copy of the original output image, so it carries the same PNG metadata) and either load it straight into the GenerateUI editor or open the image in the Metadata tab — no need to hunt down the original file in the Output folder, and works even on catalog images created before this feature existed
|
||||
- **Double-click to enlarge** (v0.3.96) — double-click a thumbnail in the center grid, or the selected-image preview in the right panel, for the same full-size lightbox view as the Output Gallery
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Metadata Gallery subtab (v0.3.8, moved from a top-level tab in v0.4.6)</h3></summary>
|
||||
|
||||
- **Now inside Gallery** (v0.4.6) — this used to be its own top-level tab; it's now the fourth Gallery sub-tab, to the right of Style_Catalog — everything below works exactly the same as before
|
||||
- **3-column layout** — Drop zone (left) | Model info + LoRA + Prompt (center) | Settings + Nanobanana (right, v0.7.14 — LoRA and Prompt moved here from the right column, swapping places with these two new sections)
|
||||
- **File drop** — drop a ComfyUI-generated PNG / WebP / MP4 or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
|
||||
- **MP4 embedded metadata** (v0.5.1) — reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4's `moov/udta/meta` box (parsed client-side, no server round-trip); also recognizes all-in-one video-generation nodes that carry their prompt directly on a `prompt` input instead of a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`); WebM isn't supported here (this parser only understands MP4's ISO-BMFF container) — the Gallery tab's own video metadata reading (PyAV-based, see Gallery Tab above) covers WebM instead
|
||||
- **Settings section** (v0.7.14) — image size (width×height) and, when the embedded workflow has a KSampler-type node, its Seed / Steps / CFG / Sampler / Scheduler / Denoise values, read from the API-format embedding's named inputs (UI-format workflows are converted through the same UI→API path GenerateUI uses first)
|
||||
- **Nanobanana section** (v0.7.14) — shown only for images from ComfyUI Comic Creator's Nanobanana tab (a `cc_nanobanana`-folder image opened via the Gallery tab's **Metadata** button, with a matching entry in that folder's `history.jsonl` log); shows engine, model, image size, seed, timestamp, and the prompt/negative prompt actually used — since these images carry no embedded ComfyUI workflow, this section's data replaces what would otherwise be a "no metadata found" error, with the rest of the layout (Checkpoint/LoRA/Prompt/Settings) simply staying empty
|
||||
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2, Mage-Flow, LongCat, Boogu, HiDream E1, FireRed, Krea-2 Turbo); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
|
||||
- **LoRA extraction** — lists all LoRA models with `strength_model / strength_clip` values
|
||||
- **Prompt extraction** — lists prompts with POS / NEG badges when positive/negative can be determined; when distinction is not possible (e.g. `SamplerCustomAdvanced`, intermediate nodes, cross-level connections), prompts are shown without a badge as plain **Text**; click any entry to view the full text below
|
||||
- **CLIP Text Encode edit+ support** (v0.3.92) — `CLIPTextEncodeEditPlus` nodes are resolved using the same RAW / EDIT / front / back combination rule as the node itself, correctly merging the linked `text1` (e.g. a Lora Manager's trigger words) with the editable `text_edit` field instead of showing only one side
|
||||
- **Custom multi-output text node fix** (v0.7.14) — nodes whose text output comes from a differently-named widget (e.g. ComfyUI-LiveChatStream's `response`/`positive`/`negative` outputs, backed by `response_text`/`prompt_text`/`negative_text` widgets) previously resolved to nothing when the API-format embedding was used, since the generic key lookup didn't recognize those widget names — LiveChatStream is now resolved by name explicitly; confirmed working with response text up to 40,000 characters
|
||||
- **Prompt actions** — Copy to clipboard, **GenUI:P/N** (set GenerateUI positive/negative prompt), **Prompt:P/N** (set Prompt tab preset positive/negative)
|
||||
- **Format support** — ComfyUI PNG/WebP/MP4/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Krea-2 Turbo subgraph workflows), SD WebUI, SD Forge, Fooocus
|
||||
- **Format note** — supported formats and covered model types are always shown in the left column
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Nodes Tab (v0.1.7)</h3></summary>
|
||||
|
||||
### Nodes Tab (v0.1.7)
|
||||
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
|
||||
- **Search & Filter** — full-text search, filter by category, package, tags, groups, and favorites
|
||||
- **Package badges** — color-coded badges generated from package names
|
||||
- **Node detail panel** — view I/O specifications, edit tags, manage groups
|
||||
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
|
||||
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
|
||||
- **Multi-select** — Ctrl+click to toggle individual nodes, Shift+click for range selection; bulk action bar appears with: add to group / remove from group / create & add group / bulk favorite / unfavorite
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, category, package, tag, group, and favorites filter in one click
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Models Tab (v0.2.3)</h3></summary>
|
||||
|
||||
### Models Tab (v0.2.3)
|
||||
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding) with sub-tab switching
|
||||
- **Thumbnail / Card / Table views** — switch between view modes with pagination (24 items per page)
|
||||
- **Thumbnail / Table views** — switch between view modes with pagination (24 items per page); Table view includes **Type** and **Base Model** columns sourced from cached CivitAI data, displayed between the Subdirectory and Extension columns; Enable/Disable column labeled **E/D**
|
||||
- **Table column sort** — click any column header to sort ascending (▲), click again for descending (▼), click a third time to clear; active column highlighted in accent color
|
||||
- **Search & Filter** — full-text search, filter by tags, groups, and favorites
|
||||
- **User-defined badges** — assign free-label badges to models; badge colors shared with the Workflow tab palette
|
||||
- **Side panel tabs** — Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
|
||||
- **CivitAI integration** — fetch model metadata by SHA256 hash, view base model, trained words, tags, and model page link
|
||||
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
|
||||
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
|
||||
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
|
||||
- **Group management** — create, rename, delete groups and assign/remove models; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab)
|
||||
- **Side panel tabs** — opens to CivitAI tab by default when a model is selected; Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
|
||||
- **CivitAI integration** — fetch model metadata by SHA256 hash; side panel shows **Type** (badge), **Base Model**, **Hash** (BLAKE3/SHA256, click to copy), trained words, tags, and model page link; model link opens in the site selected in Settings (civitai.com or civitai.red)
|
||||
- **CivitAI panel — Info / Sample sub-tabs** — Info tab shows model details (name, type, base model, hash, trigger words, tags, description); Sample tab shows all sample images (count shown in tab label); clicking any image opens the full-size version in a new tab
|
||||
- **CivitAI panel states** — three distinct states: not yet checked (fetch button), checked but not found on CivitAI (re-check button with notice), and found (full info display); clicking the CivitAI tab always refreshes to the latest state
|
||||
- **Batch CivitAI fetch** — one-click batch fetch using `POST /model-versions/by-hash` (up to 100 models per request) with SSE progress streaming; previews are auto-saved for models without one
|
||||
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload; **Delete** button permanently removes the model file and all associated sidecar files (preview images, `.json`, `.civitai.info`, `.metadata.json`, `.cm-info.json`)
|
||||
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork); **Embedding** type shows **GenUI PP** / **GenUI NP** buttons instead — appends `(embedding:Name:1.0)` to the Positive or Negative prompt of the loaded workflow; also accessible from the side panel nav bar and the detail modal
|
||||
- **Group management** — create, rename, delete groups and assign/remove models; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab); Checkpoint and LoRA tabs include **B (Batch)** and **S (Stack)** quick-assign buttons per card/row for one-click group membership toggle without grid re-render
|
||||
- **Table view memo** — memo column displayed in table view for quick reference
|
||||
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
|
||||
- **Enable / Disable models** — hide models from ComfyUI by renaming the file extension (`.disabled` suffix); toggle per card (⏸ button), per group (Enable All / Disable All), or filter by status (All / Enabled / Disabled)
|
||||
- **Multi-select & bulk operations** — enter selection mode to check multiple models; bulk action bar supports add/remove from groups and permanent file deletion (model file + preview images + sidecar files such as `.json` / `.info`)
|
||||
- **Multi-select & bulk operations** — click the green **Select** button to enter selection mode and check multiple models; the bulk action bar is organized in three rows:
|
||||
- **Group row** — select an existing group and **Add** / **Remove**, or type a new name and **Create & Add**
|
||||
- **Badge row** — select a badge and **+Badge** / **−Badge** to apply or remove from all selected
|
||||
- **File row** — select a destination subfolder (or type a new folder name and create it) then **Move** to relocate the model file with all sidecar files; **Delete Files** (right end) permanently removes selected models and all associated files
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, tag filter, folder filter, group filter, favorites filter, and status filter in one click
|
||||
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.2.5)
|
||||
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
|
||||
- **Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Tagger Tab (v0.3.38)</h3></summary>
|
||||
|
||||
- **3 sub-tabs** — Single / Batch / DB for single-image tagging, folder batch processing, and tag database management
|
||||
- **Model support** — WD Tagger (ONNX, NCHW/NHWC auto-detect), SwinV2 (ONNX), DeepDanbooru (.h5, requires TensorFlow); place each model in its own subfolder under `ComfyUI/models/tagger/<model-name>/` containing the `.onnx` + `selected_tags.csv` (or `.h5` + label file)
|
||||
- **Threshold sliders** — General threshold (default 0.35) and Character threshold for `character:` prefix tags (default 0.85); sliders update the display value in real time
|
||||
- **VLM tagging** (v0.3.38, multi-backend: v0.5.4) — optional vision model alongside WD Tagger, running on Ollama, LM Studio, Lemonade, or Unsloth through the same unified backend used by the Prompt tab; results from both models are merged into one comma-separated tag string
|
||||
- **⚙ Settings modal** (v0.5.4) — a Settings button (Single and Batch tabs) opens the same modal component as the Prompt tab's AI Assistant (Backend / Connection / Model / Generation / Unload), but saved under its own `localStorage` key so Tagger's VLM backend/model choice is independent of the Prompt tab's; **Thinking mode** and **Max tokens** apply to VLM requests too, and the **Unload** button frees the selected model (Ollama/LM Studio/Lemonade; Unsloth unsupported) — replaces the old Tagger-only direct Ollama URL/model fields
|
||||
- **Single tab** — drag & drop an image onto the preview area or click Upload; Gallery detail panel **Tagger** button opens the selected image directly here
|
||||
- **Single output options** — (1) **GenUI:P** — appends tags to the GenerateUI tab's positive prompt and immediately applies to the loaded workflow; (2) **Send to Prompt** — appends tags to the Prompt tab's positive textarea; (3) **Save to Gallery** — saves tags to Gallery image metadata (requires image opened from Gallery); (4) **Write to File** — embeds tags into JPEG EXIF (`ImageDescription`) or PNG `tEXt` chunk (`Tags` key), other formats get a `.tags.json` sidecar; (5) **Save to DB** — stores in internal SQLite database
|
||||
- **Batch tab** — enter a folder path, configure WD Tagger thresholds and (via the same ⚙ Settings modal as Single) the VLM backend; output options: **Save to DB** (default on), **Write to File** (EXIF/PNG metadata), **Write .txt** (creates `<filename>.txt` alongside each image with all tags); real-time progress bar and log; Stop cancels after the current image
|
||||
- **DB tab** — searchable SQLite database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records; **bulk delete** (v0.5.8) — per-row checkboxes plus a header checkbox to select/deselect all, a confirmation dialog before deleting the selected records
|
||||
- **Dependencies** — install into ComfyUI's embedded Python: `python_embedded\python.exe -m pip install -r requirements.txt`; for GPU inference use `onnxruntime-gpu`; TensorFlow (DeepDanbooru) is optional and commented out in `requirements.txt`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Image Edit Tab (v0.3.65)</h3></summary>
|
||||
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG or a fully layered PSD
|
||||
- **Loading images** — drag & drop onto the canvas, **File Open** button (renamed from Upload in v0.6.0), or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
- **Open PSD button** (v0.6.0, next to File Open) — loads a layered PSD file from disk and decomposes it into individual layers, restoring each layer's position, size, visibility, opacity, and blend mode; groups and non-raster layers (adjustment layers, etc.) are flattened into their rendered appearance
|
||||
- **Close button** (v0.6.0, next to Open PSD) — closes the current document after a confirmation prompt, discarding all layers and unsaved edits and returning to the empty placeholder view
|
||||
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers and automatically adds an empty Layer 1 (v0.3.70)
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Fill (G), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Select** — click to select; drag to move; drag corner handles to resize; drag circle handle to rotate; Flip H / Flip V / Rotate angle in the options bar; double-click a text layer to re-edit its content
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge; **pen tablet support** (v0.6.2) — brush size and opacity respond to pen pressure (mouse behaves exactly as before); the canvas listens for Pointer Events so pen, touch, and mouse all work through the same code path
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Fill** (v0.6.2) — bucket fill; click a contiguous same-color region on the active layer to flood-fill it (scanline-based, ported from the Comic Creator plugin's Image tab); **Properties pane**: Mode (Solid / Gradient) — Solid: Color picker; Gradient: Shape (Linear / Radial), a draggable color-stop Ramp (click a stop to select it, drag to reposition, +/- to add/remove stops, color swatch to edit the selected stop's color), and a Direction dial (drag to set angle/radius); both Tolerance (0–255, color-match threshold) and Opacity apply to either mode; Ramp and Direction dial are pointer-event based with pointer capture, so pen tablets work the same as mouse
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **Mask** on the second row of the Layers header (renamed from "M" and moved to its own row in v0.6.0) to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); brush size responds to pen pressure on a tablet (v0.6.2, same as the Draw tool); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends the current canvas composite (plus the active mask layer's content, if one is selected) to a MaskEditorOne node on the ComfyUI canvas — the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph (same selection logic as **Send to LI node**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
|
||||
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
|
||||
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
|
||||
- **Inpaint** (v0.3.71) — composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like `VAEEncodeForInpaint`); also supports workflows built around the **Mask Editor One** custom node (`comfyui-mask-editor-one`) instead of LoadImage — image/mask are pushed via its node_id-keyed server cache and `layer_data` widget; **Properties pane**: Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), Denoise (applied to KSampler), Run button; **Use dedicated workflow** checkbox — when off, generates against the workflow currently loaded in GenerateUI; when on, pick a saved workflow from the dropdown to load, edit, and generate entirely in the background without disturbing what's shown in GenerateUI; the result image is shown back in the Properties pane when generation completes; requires a Mask layer painted first (Mask tool)
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load; header is arranged in two rows (v0.6.0) — +, **⧉ Duplicate**, −, ↑, ↓ on top, **Mask** alone below
|
||||
- **Duplicate layer (⧉)** (v0.6.0) — copies the active layer, including its pixels, position, size, opacity, blend mode, and visibility, and inserts the copy directly above the original as the new active layer; works on any layer type (image, draw, text, mask)
|
||||
- **Layer lock** — locked layers show an orange bounding box and 🔒 icon on the canvas; move/resize/rotate are disabled while locked; click the 🔓 button in the layer row to unlock
|
||||
- **Text quality** — text layers are rendered at their measured bounding-box size; resizing with the Select tool regenerates the canvas at the new display resolution so text stays sharp
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Save PSD** (v0.6.0, downloads a layered PSD file with every layer preserved separately — position, rotation, flip, and scale are baked into each layer's pixels since PSD layers cannot store a rotation angle, while opacity, blend mode, and visibility are kept as real, editable PSD layer attributes; clipping masks are merged into their target layer since PSD's native layer-mask mechanism isn't used); **Send to LI node** (renamed from "Send to Workflow" in v0.5.2) — uploads to ComfyUI's input folder via `/upload/image`, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable; the Gallery tab's toolbar has the same button (v0.5.2, see Gallery Tab below) so a selected Gallery image can be sent the same way without going through Image Edit first
|
||||
- **Canvas navigation** — scroll-wheel zoom, Space + drag to pan; zoom indicator in the bottom bar
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), G (Fill), Delete (remove selected layer when 2+ exist)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Video Tab (v0.5.0 – v0.7.4)</h3></summary>
|
||||
|
||||
- **Dedicated video generation UI, independent of GenerateUI** — a purpose-built tab for MiniMax H3 (Image to Video), LTX-2.5 (Text / Image / First & Last Frame to Video), and Wan2.2 (Text to Video) workflows (LTX-2.5/Wan2.2: v0.5.4); does not share state with the GenerateUI tab, and model selection (UNet/CLIP/VAE/Audio VAE) is fixed to the loaded workflow's own defaults rather than editable here
|
||||
- **Plan subtab** (center panel, batch redesign in v0.5.5) — a **plan** is a sequence of timeline blocks, each a fully self-contained generation job with its own Prompt, Duration, and First/Last Image; split the timeline with **+ Split** / **+ Block** / **Delete**, then **Run** to generate every block in order (Pause/Cancel work mid-run) — a plan with a single block behaves exactly like the old one-shot form. First Image can be None, an explicit image, or "use previous block's last frame" (chaining — an explicit Last Image on the previous block always wins over extracting one from its generated output). Save / Save As / Clear a plan (`ws_videoplan_*.json`, with a PNG contact-sheet thumbnail that embeds the full plan data so dropping the image back on works too)
|
||||
- **Base settings row** (topbar, v0.5.7) — Aspect Ratio / Megapixels / Multiple / Duration (seconds) / Noise Seed on one row — Duration always reflects the currently selected block (each block keeps its own value), the rest are plan-wide — plus a second row of model-specific fields: Width / Height (become directly editable for workflows with no Resolution Selector node, e.g. Wan2.2 and LTX-2.5's First & Last Frame to Video), Turbo Mode / Strength / Steps (MiniMax H3's turbo LoRA — Turbo Mode doubles as Wan2.2's own turbo toggle, sharing one field), and Prompt Enhance (LTX-2.5 only). Every field in both rows is enabled only when the loaded workflow actually has it — see Field highlight color below (one exception: Wan2.2's Text-to-Video variant doesn't expose Noise Seed at all, so it alone leaves that field disabled)
|
||||
- **Edit subtab** (v0.7.0) — a lightweight clip timeline for trimming and concatenating video/image clips outside of generation: add clips via the Video Source panel's **Add to Edit** button or the Asset subtab's **Send to Edit** button, drag blocks to reorder (or use the ◀/▶ toolbar buttons), Duplicate/Delete the selected clip, Clear to empty the timeline. The trim panel for a selected video clip shows a Clipchamp-style scrubber — a time ruler, a draggable playhead synced to the Source preview, and draggable in/out handles with time badges (image clips get a simple hold-duration field instead); the playhead's own time badge sits a row above the in/out handle badges (v0.7.2) so it stays legible even when the playhead lands right on a trim point, and the scrubber has extra headroom/height and spacing from the clip name above it (v0.7.2). **▶ Preview** plays every ready clip back-to-back (trimmed to its in/out points) in the result preview, and **Export** builds and runs the actual ComfyUI graph (trim → crop → concatenate if more than one clip → save), tagging the result into Video Temp automatically. Multi-clip exports keep each clip's own audio (v0.7.4 — previously they were always silent). **Clear** empties the clips and also resets the Audio section to its defaults (v0.7.4)
|
||||
- **Edit: Multi-track** (v0.7.8) — the buttons above the timeline switch the track view: **Video** (blue), **Overlay** (blue), **Text** (green) and **Audio** (red), and the tool menu below follows it (Video: trim + crop / Overlay: overlay clips / Text: text overlays / Audio: audio settings); Save / Load / Export stay fixed on the right. **Text** now belongs to the timeline (absolute seconds, no longer to a clip): drag a band to move it — even across clips — or drag its edges to retime it; projects saved with clip-relative text are converted on load. **Audio** adds any number of **+ Sound...** clips (sound effects, voice) that you place by dragging their bands, trim by dragging an edge, with their own start / length / volume, on top of the full-length BGM. **Overlay** adds picture-in-picture clips (video or image) via **+ Overlay clip...** or the Asset subtab's **Add as overlay (PinP)** button, each with its own time, position (9 presets or X/Y), size and opacity, shown as dashed boxes in the previews and composited over the main video in the same pass that burns in text on Export (an overlay's own audio is not mixed). Undo/Redo and project Save/Load cover all of it, and Full Backup's "Include media used by Video Edit projects" now also bundles the sounds' and overlays' media.
|
||||
- **Edit: Audio / BGM** (v0.7.4) — the export panel's **Audio** section sets the soundtrack: **Keep original audio** (with its own volume in dB) keeps every clip's audio aligned to its segment even across multiple clips (image clips contribute silence), and one **BGM** file is laid over the whole video with its own volume and start offset (a longer BGM is cut to the video length). Pick the BGM with **BGM...**, by dropping an audio/video file onto the Audio section, or with **Set as BGM** on a video in the Asset subtab (that video's audio track is used; videos without audio are rejected). Built entirely inside the export graph with ComfyUI core audio nodes (EmptyAudio / AudioMerge / AudioConcat / LoadAudio / TrimAudioDuration / AudioAdjustVolume) passed to ConcatenateVideo's `complete_audio` — no extra dependencies. ▶ Preview plays the BGM alongside the clips
|
||||
- **Edit: Text overlays** (v0.7.4) — each clip can carry any number of text overlays (**+ Add text** under the trim panel): text, start/end seconds within the clip, size as % of frame height, color, 9-position anchor, outline, and an optional background box. They follow the clip when it's reordered, are previewed live over the Source/Result panes, and are burned in on Export as a final pass (PyAV decode → Pillow → H.264 encode, frame by frame so memory stays flat; the soundtrack is remuxed untouched). Japanese/CJK text renders with a system CJK font (Meiryo / Yu Gothic / Noto Sans JP on Windows)
|
||||
- **Edit: Crop** (v0.7.4) — **Edit crop** puts a draggable/corner-resizable rectangle over the Source preview (optionally locked to 16:9 / 9:16 / 1:1 / 4:3 / 3:4); **Done** keeps it, **Cancel** restores the crop from before editing, and switching to another subtab/tab ends editing like Done. Export applies it with ComfyUI core `VideoCrop`; the output size is the first video clip's cropped size, and other clips of a different size are center-fit to it. Text overlay previews are drawn relative to the cropped frame
|
||||
- **Edit: Undo / Redo** (v0.7.4) — **↶ / ↷** in the timeline toolbar (or Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z outside text fields) step through edits to clips, trims, crops, text and audio settings; a deleted clip comes back without re-uploading. History resets when a project is loaded
|
||||
- **Edit project persistence** (v0.7.2) — **Save** / **Save As** / **Load** buttons in the Edit subtab's export panel persist the timeline itself (clip order, trim points, and which uploaded server-side file each clip refers to — plus crops, text overlays and audio/BGM settings since v0.7.4) as a `ws_videoeditproj_*.json` project file; Load re-fetches and re-probes each clip from its saved server reference, so the original files need to still exist
|
||||
- **Asset subtab** (center panel, next to Plan/Edit — moved here from the sidebar in v0.7.0) — browses generated videos/images through three groups: **Video Assets** (a manually-curated keeper group you organize yourself, on your own schedule), **Video Temp** (every plan run's output lands here automatically regardless of whether the plan itself was saved — a **Clear Video Temp** button in the Gallery tab's detail panel empties it in one click), and **All Video Assets** (the union of both); a filename search box, a video/image type filter dropdown (All Types / Video Only / Image Only, v0.7.2), and a thumbnail-grid/table (▦/☰) view toggle sit above the list (v0.7.0). Selecting an item shows it in the center preview and lets you tag/memo it, **Send to Edit** (queues it as a clip in the Edit subtab), **Set as BGM** for videos (v0.7.4 — uses its audio track as the Edit BGM), jump straight to the full Gallery tab, or — for images only — **Set as First Image** / **Set as Last Image** (v0.7.0, uploads it straight into the currently-selected Plan block and switches to the Plan subtab)
|
||||
- **Project subtab** (sidebar) — lists saved plan files while the center Plan subtab is active, or saved Edit projects while the center Edit subtab is active (v0.7.2 — thumbnail/block-or-clip-count/last-updated) — click one to load it into the matching editor, or delete it with the × button; the panel collapses back down when its own tab is clicked again, and switching Plan↔Edit while it's open refreshes the list in place
|
||||
- **Two center previews** (v0.5.5) — the left preview shows either the video currently selected in the Asset subtab or whatever's dropped/picked in the **Video Source** panel to its left (**Clear** resets it to empty); the right preview always shows the most recently generated clip; both preview frames are a fixed height (v0.7.0) so switching sources never shifts the Plan/Edit/Asset panel below them. The Frame/GIF tools act on whichever preview was touched most recently
|
||||
- **First Frame / Last Frame are both optional** — leaving First Image empty runs the workflow text-only (the node is simply left unconnected — a "bypass"); workflows that don't even include a LoadImage node for First Frame are supported out of the box (Wan2.2's Text-to-Video graph has neither slot at all), and a LoadImage node is only injected into the graph on the fly if an image is actually supplied
|
||||
- **Semantic node lookup, not hardcoded IDs** (generalized in v0.5.4) — detects which of the three families is loaded from its subgraph's internal node types (or, for Wan2.2, the subgraph's own name — it has no distinctive internal node type), then locates its Resolution Selector and LoadImage node(s) by the subgraph's *public input label* (`prompt`, `duration`, `width`, `first_frame`, `last_frame`) rather than a fixed slot index — so it keeps working even though the three families expose those inputs under different internal names; a workflow no longer needs to match any bundled reference file's exact node IDs, and no fallback test workflow is auto-loaded on tab open
|
||||
- **Field highlight color** — Prompt / Duration / First Image / Last Image / Aspect Ratio / Megapixels / Multiple / Noise Seed / Width / Height / Turbo Mode / Strength / Steps / Prompt Enhance labels turn the same highlight color as the GenerateUI Model tab (shared setting, customizable in the Settings tab — see below) when the loaded workflow actually exposes that field; e.g. a Wan2.2 workflow (no Resolution Selector node) leaves Aspect Ratio / Megapixels / Multiple unhighlighted while Width / Height light up instead
|
||||
- **Load in Video** (Workflow tab) — load any workflow containing a MiniMax H3 Image-to-Video node, an LTX-2.5 Text/Image/First&Last-Frame-to-Video node, or a Wan2.2 Text-to-Video node straight into this tab from the Workflow tab's side-panel toolbar (next to Load in GenerateUI) — starts a fresh single-block plan seeded from the workflow's own current values; unsupported workflows show an error toast instead of loading
|
||||
- **Frame tab** (right panel) — captures the active preview's current playback position as a still frame: play/seek to the position you want, click **Capture Frame** (client-side canvas, no server round-trip), then **Save to Output** to write it as a PNG into ComfyUI's own Output folder (auto-numbered, same pattern as other generated images); an optional **Add to Video Assets group** checkbox (v0.7.0) also tags the saved frame into Gallery's Video Assets group
|
||||
- **GIF tab** (right panel) — converts the active preview to an animated GIF; set Start/End (seconds), FPS, and an optional Max Width, then **Convert to GIF** — the server decodes the video (PyAV) and encodes an animated GIF (Pillow), both dependencies ComfyUI itself already requires, so no ffmpeg binary or extra install is needed; the result is saved into ComfyUI's Output folder and previewed inline
|
||||
- **Playback volume** — the preview player's volume is saved to the Settings tab and shared across every video player in the plugin (Video tab, Gallery tab), so it no longer resets to maximum each time a different video is opened
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>AI TOOL Tab (v0.3.14)</h3></summary>
|
||||
|
||||
- **4-pane layout** — Translation | Chat | TOOLS | Settings; all panes always visible simultaneously; no sub-tab switching required
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, Lemonade, or Unsloth; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
- **Reliability fix** (v0.3.98) — switched from a raw single-string prompt to a system-role chat request (Ollama `/api/chat`, LM Studio/Lemonade `/v1/chat/completions` with a `system` message) so small local models follow the "translation only" instruction more reliably; output is cleaned of common LLM preambles/quotes, and a warning toast appears if the model appears to have echoed the source text unchanged instead of translating it
|
||||
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio, Lemonade, and Unsloth use `/v1/chat/completions`
|
||||
- **Chat pane — image generation via Tool Calling** (v0.3.73) — if the selected model supports tool calling and the user asks for an image, a `generate_image` tool is invoked and the request runs through the same generation pipeline as the GenerateUI Generate button, with the result shown inline in the chat; an "Allow image generation" checkbox in the input row toggles this on/off (default on); the workflow used is the one currently loaded in GenerateUI, or a dedicated saved workflow configured in the Settings pane; models verified to work well for tool calling and vision: **Gemma 4:e4b**, **Qwen 3.5:9b**, **Ministral 3:3b** (Ollama)
|
||||
- **Chat pane — generate_image extra parameters** (v0.4.4) — besides `prompt`/`negative_prompt`, the tool call also accepts optional `steps`, `cfg`, `sampler_name`, `scheduler`, `denoise`, `seed`, `width`, `height`, `batch_size` (number of images produced in that one call), and `aspect_ratio` (a ratio preset like `3:2`, resolved against the node's actual COMBO options via `/object_info` and applied only if the workflow has a **Resolution Selector** node feeding width/height — ignored otherwise); anything the model doesn't supply keeps the loaded workflow's own current setting; `batch_size` > 1 shows every generated image inline in the same chat bubble
|
||||
- **TOOLS pane (VLM)** — drop an image into the 110px drop zone, select a task (Describe Image / Create Prompt / Create Tags), and click Run to analyze with a vision model; result shown in the output area with a Copy button
|
||||
- **TOOLS pane — shared Chat attachment** (v0.3.74) — an image dropped in the TOOLS drop zone is also usable as the Chat pane's attachment: it's sent to the LLM as vision input, and used as the base image when `generate_image` runs image-to-image; a ✕ button clears it (from either the TOOLS preview or the Chat attachment indicator); it stays attached across turns until explicitly cleared
|
||||
- **TOOLS pane (Wildcards)** (v0.3.40) — select "Create wildcards" from the task dropdown; enter a category name and count; click Run to generate plain-text wildcard entries one per line (no markdown, no numbering); result can be copied directly into wildcard `.txt` files
|
||||
- **Chat pane — image-to-image (I2I)** (v0.3.74) — when an image is attached and `generate_image` is invoked, the attachment is uploaded and swapped into the target workflow's LoadImage node instead of running text-to-image; the target workflow is the one currently loaded in GenerateUI, or a dedicated I2I workflow configured in the Settings pane
|
||||
- **Chat pane — SVG generation, no ComfyUI workflow involved** (v0.3.77) — the LLM can also produce SVG graphics directly as text; if a reply contains SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`), it's rendered inline as a preview and a "Save to Gallery" button writes it as a `.svg` file to the Gallery tab's output folder; unlike the Tool Calling image generation flow above, this is plain text pattern matching, so it works with any model regardless of tool-calling support
|
||||
- **Chat pane — Skills** (v0.3.79) — select a `.md` skill file from a dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); a pencil button opens an in-pane manager (list + inline editor, same pattern as the Prompt tab's wildcard file manager) to create, edit, or delete skill files stored in `user/default/Workflow-Studio/ai_skills/`; a file's `---`-delimited frontmatter (`name`/`description`) is shown in the dropdown; two starter skills are bundled — **SVG Icon Generator** (explicitly decomposes the subject into parts before drawing, which markedly improves recognizability from small local models) and **Skill Creator** (interviews you conversationally and drafts a new skill as a ```skill code block; a "Save as new skill" button appears under such replies and opens the manager pre-filled with the draft)
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade / Unsloth), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Unsloth backend** (v0.3.99) — Unsloth Desktop's OpenAI-compatible API requires an API key even for local access unless its **Keyless API access → "Chat and inference"** setting is on; unlike the other backends it's proxied server-side (`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`) so the key never reaches the browser: set `UNSLOTH_API_KEY` in a `.env` file in the plugin folder (copy `.env.example`, loaded via `python-dotenv` in `prestartup_script.py`) and restart ComfyUI — or, since v0.7.7, leave `UNSLOTH_API_KEY` unset and turn on Keyless API access → "Chat and inference" in Unsloth Desktop (requests are then relayed without an `Authorization` header, still only to localhost; that Unsloth setting also opens the API to your private LAN, and "Allow tools" should stay off). A 401 with no key set returns a message explaining both options; also requires **"Auto model switching (OpenAI API)"** to be turned on in the Unsloth Desktop app's own Settings screen — with it off, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI
|
||||
- **Decision API client** (v0.7.5, foundation) — `static/js/decision-client.js` calls Unsloth Desktop's Decision API (`/v1/systemone`, TypeSafe-compatible Laya decision models) through the same key-attaching server proxy: `decide(state, questions)` / `decideMany`, question builders `noul`/`choice`/`score`, and `isYes`/`pickChoice`/`pickScore` helpers that threshold on `probabilities` (as Unsloth recommends) and flag low-probability answers as not confident. Requires **Decision API → Serve requests** enabled in Unsloth Desktop. **Ollama 0.35+ backend** (v0.7.7) — Ollama's own `/v1/systemone` (decision models `tev1` 4B / `tev1:0.8b` 812 MB / `nimble` 9B / `clef` 27B vision-capable, pulled with `ollama pull`; no key, called directly from the browser); the model list shows only installed models whose `capabilities` include `decision`, with a ↻ refresh button and a pull hint when none are installed. **Image input** (v0.7.14, ported from ComfyUI Comic Creator) — most decision models are text-only, but Ollama's `clef` accepts images directly: `decide()` takes an optional 4th `images` argument (base64, no `data:` prefix), `imageToBase64()` converts a Blob/data URL/image URL, and `supportsDecisionVision()` checks whether the currently-configured model accepts them (via `/api/show`'s `capabilities`/`projector_info`, since not every Ollama build reports `vision` for decision models) — call it before passing images, since a non-vision model errors out on them; confirmed working end to end against `clef`. Connection settings live in the Settings tab's own **Decision Model** section (v0.7.6): backend (Unsloth / Ollama), URL, model and default probability threshold, stored under `wfm_decision_settings` independently of the AI TOOL / Tagger backends, with a Test button (one yes/no question). No feature uses it yet
|
||||
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
|
||||
- **Settings pane — Generation** (v0.3.98) — **Thinking mode** toggle (off by default; sends Ollama's `think` flag, and regardless of backend, any `<think>...</think>` block is stripped from the output when off so reasoning models don't clutter Translation/Chat/TOOLS results) and **Max tokens** (Ollama: `options.num_predict`; LM Studio/Lemonade/Unsloth: `max_tokens`; `0` = unlimited)
|
||||
- **Model unload** (v0.5.4) — an Unload button next to the model selector frees VRAM/memory by unloading the currently selected model from Ollama (`keep_alive:0` via `/api/generate`, the officially documented method — Ollama has no dedicated unload endpoint), LM Studio (`/api/v1/models/unload`), or Lemonade (`/api/v1/unload`); Unsloth exposes no equivalent API and the button is disabled for that backend. **Since v0.7.13**, with the Ollama backend the Unload button here and in the Prompt / Tagger ⚙ Settings modals unloads **every** model loaded in Ollama on localhost, decision models included — the same server step as Settings → VRAM Management's **Unload all now** (a toast shows the models unloaded and the free VRAM before → after)
|
||||
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API directly from the browser (`/v1/chat/completions`, `/v1/models`); Unsloth via the same OpenAI-compatible API but relayed through a server-side proxy that attaches the `Authorization: Bearer` header; VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade/Unsloth)
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
|
||||
- **Lemonade's other endpoints (not integrated)** — Lemonade Server also exposes OpenAI-compatible image generation (`/v1/images/generations`), text-to-speech (`/v1/audio/speech`), and transcription (`/v1/audio/transcriptions`) endpoints; these are not wired up — image generation would duplicate the existing ComfyUI-workflow-based `generate_image` tool call, and audio/TTS has no corresponding UI yet, so both are future considerations rather than a natural extension of the current backend switch
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)</h3></summary>
|
||||
|
||||
- **Tab layout (W / N / P / M / I / A)** — compact single-letter tabs with full name shown on hover
|
||||
- **W — Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
|
||||
- **N — Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view; **Alt+click** a node to add it to (or remove it from) Favorites; the tab now opens without waiting for `/object_info` (v0.8.0)
|
||||
- **Category sub-tab** — dropdown to filter nodes by top-level category
|
||||
- **Package sub-tab** — dropdown to filter nodes by custom node package name
|
||||
- **Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs)
|
||||
- **Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
|
||||
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs); LoRA groups show an **All N LoRAs** item — drag to canvas to place a `Lora Loader (LoraManager)` node with all LoRAs pre-loaded
|
||||
- **P — Prompts tab** — browse prompt presets with All / ★ Favorites / Categories sub-tabs; **Groups sub-tab** (row 2) — view presets by group (shared with the Batch tab's `wfm_prompt_preset_groups`)
|
||||
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP/MP4 or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, `Lora Loader (LoraManager)`, and `ImageMetadataLoRALoader` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
|
||||
- **MP4 embedded metadata** (v0.5.1) — reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4's `moov/udta/meta` box, parsed entirely client-side (same box-walking logic as the Gallery tab's Metadata sub-tab); also recognizes all-in-one video-generation nodes that carry their prompt directly on a `prompt` input rather than a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
|
||||
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **VRAM (Ollama) → Unload all now** (v0.7.13) — in the A tab's Settings sub-tab; unloads every model loaded in Ollama on localhost, decision models included (same as Workflow Studio's Settings → VRAM Management)
|
||||
- **model sub-tab** — Checkpoint, VAE, Diffusion Model, and Text Encoder; drag items to canvas to place the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`); double-click also places at canvas center
|
||||
- **lora sub-tab** — detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format `inputs.loras.__value__` supported); shows `strength_model / strength_clip` values; drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (appears for 1+ LoRAs) drags all LoRAs into a single `Lora Loader (LoraManager)` node with LoRA syntax pre-filled
|
||||
- **Prompts sub-tab** — POS / NEG badge list; drag a prompt to place `CLIPTextEncode` with text pre-filled; click any entry to view full text + Copy button
|
||||
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
|
||||
- **Drag & drop nodes** — drag nodes/node sets onto the canvas to place them
|
||||
- **Drag & drop prompts** — drag a preset onto the canvas to create a WFS_PromptText node with positive/negative prompts
|
||||
- **Send to Canvas** — clicking "Send to Canvas" (Workflow tab toolbar / detail modal) sends the workflow directly to the ComfyUI canvas via `window.opener` when available (UI and API formats both supported); if Workflow Studio was opened from a bookmark instead of the ComfyUI toolbar (or in another tab), it filters the Library's **W** tab down to that workflow's card instead (v0.4.7) — drag the card onto the canvas yourself; this replaced the old title-drag fallback, which had no thumbnail and didn't work for API-format workflows at all. "Copy & Send Canvas" (Gallery tab JSON panel) still uses only `window.opener` + title-drag for UI-format workflows, since the embedded workflow it sends isn't a saved Library file to filter down to
|
||||
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
|
||||
- **Double-click** — load workflows or place nodes without dragging
|
||||
- **Search** — search within each sub-tab to quickly find items
|
||||
- **⚙ Theme settings** — customize panel background, sub-header background, text, border, and secondary text colors; saved to localStorage and applied on every open
|
||||
|
||||
### Help & Support Tab (v0.1.3)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Help & Support Tab (v0.1.3)</h3></summary>
|
||||
|
||||
- **Sidebar navigation** — 2-column layout: left sidebar (15 topics) + right content pane; click any topic to switch the displayed content
|
||||
- **Support** — fixed at the bottom of the sidebar; shows GitHub, Ko-fi, **ComfyUI Image Feeder**, and **Mask Editor One** repository links in the right pane
|
||||
- **Feature list** — overview of all features organized by tab
|
||||
- **Tips** — quick tips for drag & drop import, favorites, and default workflow
|
||||
- **Support links** — GitHub repository and Ko-fi donation page
|
||||
- **Keyboard Shortcuts** and **Troubleshooting** sections included
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -167,6 +546,22 @@ Restart ComfyUI after installation.
|
||||
|
||||
---
|
||||
|
||||
## Sample Workflows
|
||||
|
||||
Sample workflows are included in the `workflows/` folder. You can open them directly in ComfyUI via drag & drop, or load them from the Workflow tab.
|
||||
|
||||
> **Note:** Some sample workflows require additional custom nodes.
|
||||
> If a node is shown as missing (red/unknown) after loading, install the required custom nodes via **ComfyUI Manager** or by cloning the repository from GitHub into your `ComfyUI/custom_nodes/` directory.
|
||||
>
|
||||
> ```bash
|
||||
> cd ComfyUI/custom_nodes
|
||||
> git clone <repository-url>
|
||||
> ```
|
||||
>
|
||||
> After installation, restart ComfyUI to activate the new nodes.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Launch
|
||||
@@ -201,152 +596,31 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
### Optional
|
||||
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant and translation features
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); needs an API key set via `UNSLOTH_API_KEY` in a `.env` file, or Unsloth's Keyless API access → "Chat and inference" turned on — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **Gemini API key / EmbeddingGemma 2** (v0.8.2) — only for the Gallery's Semantic Search: either set `GEMINI_API_KEY` in the plugin's `.env` (key from [Google AI Studio](https://aistudio.google.com); free tier available, paid beyond it), or use the local **EmbeddingGemma 2** (no key; `onnxruntime`, `tokenizers` and `huggingface_hub` — normally already present with ComfyUI; the model downloads on first use). `onnxruntime-gpu` makes the local model ≈45× faster on NVIDIA GPUs
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
- **[psd-tools](https://pypi.org/project/psd-tools/)** (v0.6.0) — enables all PSD (Photoshop) features: Gallery bulk "Create PSD" export, Gallery browsing/thumbnails for `.psd` files, and Image Edit's Save PSD / Open PSD buttons; `pip install -r requirements.txt` (ships prebuilt wheels, no C compiler required); without it, PSD-related actions show an error toast with the install command instead of failing silently
|
||||
|
||||
#### Backend connection security (v0.7.12)
|
||||
|
||||
Server-side proxies for Ollama, LM Studio, Lemonade, Unsloth, the Tagger VLM and Eagle only connect to **localhost / 127.0.0.1 / ::1** (any port); `http(s)` only, no credentials in the URL, and redirects are never followed. To use a backend on another machine (e.g. an Ollama server on your LAN), list it in the `WFS_ALLOWED_BACKEND_HOSTS` environment variable (comma-separated `host` or `host:port`, e.g. `192.168.1.20:11434,gpu-box`) and restart ComfyUI. If you start ComfyUI with `--listen`, anyone who can reach it can use these routes, so only do that on a trusted network.
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages
|
||||
|
||||
| Language | Status |
|
||||
|----------|--------|
|
||||
| -------- | ------ |
|
||||
| English | Full |
|
||||
| Japanese | Full |
|
||||
| Chinese | Full |
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.3
|
||||
- **Wildcard support panel** — new right column in the Prompt tab with a one-click toolbar for inserting wildcard syntax (`{|}`, `{n$|}`, `__|__`, `:`, `;`, `$`, `<lora::1:LBW=;>`, `[]`, single/multi pick) and wrapping selected text; dedicated prompt textarea and wildcard file picker (click to insert `__filename__`)
|
||||
- **Wildcard file manager** — create, view, edit, and delete `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; editor opens inline with filename input and save/cancel controls
|
||||
- **Preset/Preset Manager tab switch** — the center pane of the Prompt tab is now a two-tab panel (Presets / Preset Manager) to reclaim horizontal space for the new wildcard panel
|
||||
- **Wildcard Integration setting** — new accordion section in Settings tab; auto-detects ComfyUI-Impact-Pack; when installed, provides a button to create a directory junction (Windows) or symlink (other OS) from the WFS wildcard directory to Impact Pack's `wildcards/` directory; existing WFS files are migrated automatically; a Remove Link button reverses the operation
|
||||
|
||||
### v0.3.2
|
||||
- **Checkpoint Batch** — new panel in the GenerateUI right column (below Seed, above results); enable via checkbox to auto-generate once per checkpoint model; filter included/excluded subfolders by comma-separated names (empty = all checkpoints); real-time preview of matched model count; amber progress bar shows current model name and index; Stop button aborts the loop after the current generation completes; failed models are counted and reported in the summary toast
|
||||
- **Seed layout fix** — seed value input and mode selector (Random / Fixed / Increment / Decrement) now stacked vertically so neither is clipped in narrow panels
|
||||
|
||||
### v0.3.1
|
||||
- **Folder operations** — create subfolders and delete folders (with all contents) directly from the folder tree header buttons
|
||||
- **File operations** — move or delete individual images from the detail panel; bulk move and bulk delete from the multi-select bar
|
||||
- **Tree state preserved** — folder expansion state is maintained after create/delete/move/refresh operations
|
||||
- **Workflow viewer improved** — Metadata tab now reads both `prompt` (API format, the default for most ComfyUI-generated images) and `workflow` (UI format) keys from PNG metadata, covering virtually all ComfyUI output
|
||||
- **Workflow auto-save** — after each generation in the Generate UI tab, the current workflow is automatically saved to gallery metadata for the generated images; shown in the Metadata tab even if PNG embedding is absent
|
||||
|
||||
### v0.3.0
|
||||
- **Cross-type group filter** — the group filter dropdown in the Models tab now shows groups from all model types simultaneously, each prefixed with a type label (`[Checkpoint]`, `[LoRA]`, etc.); selecting a group automatically switches to the corresponding model type
|
||||
- **Sidebar group display fixed** — the ComfyUI sidebar "Model Groups" view now correctly renders per-type groups (introduced in v0.2.9) with a type label on each group header; fixes a crash where `.filter is not a function` was thrown on the new nested group structure
|
||||
|
||||
### v0.2.9
|
||||
- **Model Enable/Disable** — hide models from ComfyUI by renaming the file extension to `.disabled`; toggle per model (⏸ button on each card/row), per group (Enable All / Disable All buttons in the Group panel), or filter the grid by status (All / Enabled only / Disabled only). Changes take effect after ComfyUI refresh.
|
||||
- **Per-type model groups** — groups are now scoped to each model type; checkpoint groups only appear in the Checkpoint tab, LoRA groups only in the LoRA tab, etc. Existing flat groups are automatically migrated.
|
||||
- **Multi-select mode** — click the Select button in the toolbar to enter selection mode; check individual models across Thumbnail / Card / Table views
|
||||
- **Bulk group operations** — with models selected, add or remove them from any group in one click, or create a new group and add them simultaneously
|
||||
- **Bulk file delete** — permanently delete selected model files along with all associated preview images and sidecar files (`.json`, `.civitai.info`, `.info`); requires confirmation before deleting
|
||||
- **Bug fix** — models in subdirectories could not be disabled due to a path-doubling error; fixed by computing the rename target from `path.name` instead of `model_name`
|
||||
- **Bug fix** — Hypernetwork tab showed `C`, `O`, `M`, `B` as fake model items; caused by the new ComfyUI API format returning `"COMBO"` as the first element; fixed in `comfyui-client.js`
|
||||
|
||||
### v0.2.8
|
||||
- **Data storage moved** — plugin data (settings, metadata, prompts, etc.) is now stored in `ComfyUI/user/default/Workflow-Studio/` instead of `custom_nodes/ComfyUI-Workflow-Studio/data/`; falls back to the old `data/` directory if `user/default/` does not exist
|
||||
- **Data Management section** — new Settings tab section to export all plugin data to a single JSON file and import it back; useful for migration, backup, and reinstallation
|
||||
|
||||
### v0.2.7
|
||||
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
|
||||
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
|
||||
- **Settings 2-column layout** — Theme panel fixed on the right (50% width, sticky); all other settings flow continuously on the left with no visual gap
|
||||
- **Gallery output directory setting** — configure the output folder scanned by Gallery tab from Settings
|
||||
- **Help tab** — Gallery tab description card added
|
||||
|
||||
### v0.2.5
|
||||
- **Sidebar Category/Package sub-tabs** — added 📂 Category and 🧩 Package sub-tabs to the Nodes tab in Workflow Studio Library; each shows a dropdown to filter nodes by category or custom node package (reads `/object_info` for accurate package detection)
|
||||
- **Sidebar theme settings** — added ⚙ button in the panel header to customize background, sub-header background, text, border, and secondary text colors with live preview; settings saved to localStorage
|
||||
- **Sidebar TextEncoder (By Type)** — fixed TextEncoder not appearing in Models → By Type; now uses `DualCLIPLoader` → `CLIPLoader` fallback to retrieve `clip_name1` list
|
||||
- **Hide `.index.json`** — excluded `.index.json` from both the Workflow tab and the Workflow Studio Library side panel
|
||||
|
||||
### v0.2.4
|
||||
- **GenerateUI tab redesign** — reorganized from 5 tabs (Prompt/Image/Model/Settings/Raw JSON) to 3 tabs (Input/Model/Settings); each tab shows a Raw JSON column (540px) for instant JSON preview and direct editing from any tab
|
||||
- **Input tab** — Prompt and Image stacked vertically in the left column
|
||||
- **Settings tab** — KSampler and LatentImage stacked vertically in the left column
|
||||
- **Raw JSON tab removed** — Raw JSON is now always visible as a right-column panel within each tab
|
||||
|
||||
### v0.2.3
|
||||
- **Badge system unified** — Workflow and Models tabs now share a single badge palette (`⚙ Badge` button); workflow auto-analysis model type system removed in favor of user-defined free-label badges
|
||||
- **GenUI Model button** — apply any model from the Models tab directly to the corresponding node in GenerateUI's current workflow (available in detail modal and side panel; Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)
|
||||
- **Sidebar Models tab** — added All / ★ Favorites / Groups / By Type sub-tabs to the Models section of Workflow Studio Library
|
||||
- **Sidebar All views** — ★ star displayed for favorite items in Workflows and Nodes All sub-tabs
|
||||
- **Sidebar panel width** — widened from 280px to 310px
|
||||
|
||||
### v0.2.2
|
||||
- **Models tab** — browse, search, and manage all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with Thumbnail/Card/Table views
|
||||
- **CivitAI integration** — fetch model metadata by SHA256 hash (individual or batch per model type) with SSE progress streaming
|
||||
- **Model groups** — create, rename, delete groups and assign/remove models for organization
|
||||
- **Model metadata** — persistent favorites, tags, memo, and SHA256 hash per model
|
||||
- **Preview images** — auto-detect and display preview images, change via file upload in detail modal
|
||||
- **Table view memo column** — memo displayed in table view with compact subdir/extension columns
|
||||
- **Side panel file path** — display full file path with click-to-copy in model info panel
|
||||
- **Node card redesign** — removed package badge, added left border color-coding by package
|
||||
- **Toolbar pagination** — pagination controls moved to toolbar for all tabs (Models, Workflows, Nodes)
|
||||
- **Help tab updated** — Models Tab section added to feature list
|
||||
|
||||
### v0.2.0
|
||||
- **Prompt presets in sidebar** — added Prompts tab to Workflow Studio Library (ComfyUI side panel) with All / Favorites / Categories sub-tabs
|
||||
- **WFS_PromptText custom node** — drag prompt presets onto the canvas to create nodes with positive/negative prompt outputs
|
||||
- **Preset Manager** — 3-column layout in Prompt tab: AI Assistant | Presets editor | Preset Manager (All / Favorites / Groups)
|
||||
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
|
||||
- **GenUI Set** — renamed "Apply" button to clarify its purpose (applies presets to GenerateUI)
|
||||
- **Sidebar P/N copy buttons** — copy positive or negative prompts individually from sidebar items
|
||||
- **Panel renamed** — "WF & Node Library" renamed to "Workflow Studio Library"
|
||||
- **Backend API for presets** — presets migrated from localStorage to server-side API with one-time migration
|
||||
- **Help tab updated** — Prompt Tab and Workflow Studio Library sections reflect new features
|
||||
|
||||
### v0.1.9
|
||||
- **Side panel 2-row sub-tabs** — Workflows: row 1 (Workflows / Favorites / Groups), row 2 (Model Type); Nodes: row 1 (Nodes / Favorites / Groups), row 2 (Sets)
|
||||
- **Save dialog** — canvas snapshot now shows a filename edit dialog instead of auto-naming
|
||||
- **API/App format badge** — workflow items in side panel show red (API) or orange (APP) badge
|
||||
- **Group auto-cleanup** — deleted workflows are automatically removed from groups on refresh
|
||||
|
||||
### v0.1.8
|
||||
- **WF & Node Library side panel** — renamed from "Node Library", added two top-level tabs (Workflows / Nodes) accessible from ComfyUI top bar
|
||||
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group with collapsible sections
|
||||
- **Workflow drag & drop** — drag workflows from the side panel onto the canvas to load them instantly
|
||||
- **Help tab update** — added Nodes Tab and WF & Node Library sections to the feature list
|
||||
|
||||
### v0.1.7
|
||||
- **Nodes tab** — browse, search, and filter all installed ComfyUI nodes with Card/Table views, pagination (50 nodes/page), package color badges, and node detail side panel with I/O specs
|
||||
- **Node Sets** — save selected nodes + connections from ComfyUI canvas as reusable sets via right-click context menu
|
||||
- **Node Library side panel** — accessible from ComfyUI top bar with Favorites, Sets, and Groups tabs, drag & drop placement
|
||||
- **3 top bar buttons** — Workflow Studio, Canvas Snapshot, and Node Library buttons in ComfyUI's action bar
|
||||
|
||||
### v0.1.6
|
||||
- **Security fix** — path traversal vulnerability in `workflow_service.py` (reported via ComfyUI-Manager PR review)
|
||||
|
||||
### v0.1.5
|
||||
- **Theme system** — 13 built-in themes selectable from Settings tab with instant preview (Deep Ocean Dark, Pop & Vibrant, Light Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro 8-bit, Pastel Dream, Brutalism, Earthy, Material UI, Monotone + Accent, Corporate Trust)
|
||||
- Theme preference persisted in localStorage and restored on page load (no flash)
|
||||
- Special CSS effects per theme: neon glow (Cyberpunk), backdrop blur (Glassmorphism), dual shadow (Neumorphism), pixel borders (Retro/Brutalism)
|
||||
|
||||
### v0.1.4
|
||||
- **App format support** — detect `.app.json` workflows (ComfyUI App mode), show "App Format" badge, block loading in GenerateUI with guidance message
|
||||
- **Preset clipboard copy** — added PP Copy / NP Copy buttons to copy positive/negative prompts to clipboard
|
||||
- **Analysis bugfix** — fixed workflow analysis crash when `widgets_values` contains non-string values (e.g. integers)
|
||||
|
||||
### v0.1.3
|
||||
- Added **Help & Support tab** — feature list, tips, and support links (GitHub, Ko-fi)
|
||||
- Multi-language support (EN/JA/ZH) for all help content
|
||||
|
||||
### v0.1.2
|
||||
- Added **Canvas Snapshot** button to ComfyUI top bar — capture workflow canvas as PNG thumbnail with embedded workflow metadata
|
||||
- Added **Thumbnail tab** to the workflow side panel for quick visual preview
|
||||
- Added **Thumbnail section** to the workflow detail modal
|
||||
- Snapshot images are auto-imported as workflows with thumbnails
|
||||
|
||||
### v0.1.1
|
||||
- Initial feature set: Workflow management, GenerateUI, Prompt assistant, Settings
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -356,7 +630,8 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── wfm.py # Main class & route registration
|
||||
│ ├── config.py # Path configuration
|
||||
│ ├── nodes/
|
||||
│ │ └── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
|
||||
│ │ ├── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
|
||||
│ │ └── gallery_feeder_node.py # WFS_GalleryFeeder custom node (gallery group → IMAGE)
|
||||
│ ├── routes/
|
||||
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
|
||||
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
|
||||
@@ -364,7 +639,8 @@ ComfyUI-Workflow-Studio/
|
||||
│ │ ├── prompts_routes.py # Prompt presets CRUD API
|
||||
│ │ ├── settings_routes.py # Settings API
|
||||
│ │ ├── ollama_routes.py # Ollama proxy API
|
||||
│ │ └── eagle_routes.py # Eagle integration API
|
||||
│ │ ├── eagle_routes.py # Eagle integration API
|
||||
│ │ └── gallery_routes.py # Gallery tab API (Output + ImagePrompt + Style_Catalog sub-tabs)
|
||||
│ └── services/
|
||||
│ ├── workflow_service.py # Workflow file operations
|
||||
│ ├── nodes_service.py # Node metadata & node sets
|
||||
@@ -373,9 +649,11 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── prompts_service.py # Prompt presets persistence
|
||||
│ ├── workflow_analyzer.py # Model/node detection
|
||||
│ ├── settings_service.py # Settings persistence
|
||||
│ └── png_extractor.py # PNG metadata extraction
|
||||
│ ├── png_extractor.py # PNG metadata extraction
|
||||
│ ├── gallery_service.py # Gallery image scanning, .txt sidecar & ponyxlWildcardsVault prompt resolution
|
||||
│ └── gallery_metadata.py # Gallery per-image metadata store (favorites/tags/memo/groups)
|
||||
├── templates/
|
||||
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
|
||||
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Metadata/Gallery/Nodes/Models/Settings/Help/AI)
|
||||
├── static/
|
||||
│ ├── favicon.svg # Browser tab icon (W+S Wave)
|
||||
│ ├── css/main.css # Styles
|
||||
@@ -383,18 +661,29 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── app.js # App initialization & routing
|
||||
│ ├── workflow-tab.js # Workflow browser
|
||||
│ ├── generate-tab.js # Generation UI
|
||||
│ ├── feeder-tab.js # Feeder subtab (Image Loop + Gallery modes)
|
||||
│ ├── gallery-tab.js # Gallery tab: Output sub-tab (image browser, groups, metadata) + sub-tab switching
|
||||
│ ├── image-prompt-tab.js # Gallery tab: ImagePrompt sub-tab (visual prompt builder)
|
||||
│ ├── style-catalog-tab.js # Gallery tab: Style_Catalog sub-tab (visual Style picker)
|
||||
│ ├── tagger-tab.js # Tagger tab (WD Tagger, DeepDanbooru, Ollama VLM)
|
||||
│ ├── prompt-tab.js # AI assistant & presets
|
||||
│ ├── metadata-tab.js # Metadata extraction & display (PNG/WebP/JSON)
|
||||
│ ├── settings-tab.js # Settings panel
|
||||
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
|
||||
│ ├── nodes-tab.js # Node browser & node sets
|
||||
│ ├── models-tab.js # Model browser & CivitAI integration
|
||||
│ ├── ai-tab.js # AI TOOL tab (Translation | Chat | TOOLS/VLM | Settings)
|
||||
│ ├── comfyui-workflow.js # UI-to-API format conversion
|
||||
│ ├── comfyui-editor.js # Dynamic parameter editor
|
||||
│ ├── json-highlight.js # JSON syntax highlighting
|
||||
│ ├── util.js # Shared utilities (escapeHtml, readJsonStorage, getSettings)
|
||||
│ └── i18n.js # Internationalization
|
||||
├── web/comfyui/
|
||||
│ ├── top_menu_extension.js # ComfyUI menu bar integration
|
||||
│ └── node_sets_menu.js # Workflow Studio Library side panel
|
||||
│ ├── node_sets_menu.js # Workflow Studio Library side panel
|
||||
│ └── gallery_feeder_extension.js # WFS_GalleryFeeder canvas widgets (After Gen / Run / Stop)
|
||||
├── tools/
|
||||
│ └── import_style_prompt_seed.py # Dev tool: pre-process a wildcards-vault source into ws_image_prompt (not run by the plugin itself)
|
||||
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
|
||||
```
|
||||
|
||||
@@ -410,6 +699,11 @@ MIT License
|
||||
|
||||
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) by comfyanonymous
|
||||
- [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss — Canvas snapshot and PNG workflow embedding implementation reference
|
||||
- [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager) — Plugin architecture and UI pattern reference
|
||||
- [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) — Plugin architecture and UI pattern reference
|
||||
- [Ollama](https://ollama.com/) for local LLM inference
|
||||
- [Eagle](https://eagle.cool/) for image management
|
||||
- [Pillow (PIL Fork)](https://python-pillow.org/) — server-side thumbnail generation (`data/thumb_cache/`)
|
||||
- [ComfyUI-Gallery](https://github.com/PanicTitan/ComfyUI-Gallery) by PanicTitan — thumbnail grid UX reference
|
||||
- [infinite-image-browsing](https://github.com/zanllp/sd-webui-infinite-image-browsing) by zanllp — thumbnail caching and index strategy reference
|
||||
- [Fooocus](https://github.com/lllyasviel/Fooocus) by lllyasviel — style preset JSON format reference
|
||||
|
||||
|
||||
@@ -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: 273 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 975 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,69 @@
|
||||
"""Runs before ComfyUI imports any custom node — the only place early enough
|
||||
to populate os.environ for code that reads env vars at import/request time.
|
||||
|
||||
Loads a .env file (if present) from this plugin's root directory so the
|
||||
Unsloth backend's API key (UNSLOTH_API_KEY) can be set without editing
|
||||
settings.json or exposing it to the frontend.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = PLUGIN_DIR / ".env"
|
||||
|
||||
|
||||
def _maybe_use_selector_event_loop():
|
||||
"""Windows: ProactorEventLoop(IOCP) を SelectorEventLoop に切り替える(既定はオフ)。
|
||||
|
||||
環境によっては、ソケットclose(closesocket/NtClose)でイベントループがOSカーネル内で停止し、
|
||||
ComfyUI全体が無応答になる(再起動するまでポートも解放されない)。Selector方式はIOCPを使わず、
|
||||
この停止を避けられる可能性がある。副作用: asyncioのサブプロセス機能が使えない、
|
||||
同時接続数の上限(約512)がある。
|
||||
|
||||
有効化: 環境変数 WFS_SELECTOR_LOOP=1、または
|
||||
user/default/Workflow-Studio/settings.json に "windows_selector_event_loop": true
|
||||
ComfyUIがイベントループを作成する前に呼ぶ必要があるため、ここ(prestartup)で設定する。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
enabled = os.environ.get("WFS_SELECTOR_LOOP", "").strip().lower() in ("1", "true", "yes")
|
||||
if not enabled:
|
||||
try:
|
||||
import json
|
||||
candidates = [
|
||||
PLUGIN_DIR.parent.parent / "user" / "default" / "Workflow-Studio" / "settings.json",
|
||||
PLUGIN_DIR / "data" / "settings.json", # config.py fallback when user/default is absent
|
||||
]
|
||||
for settings in candidates:
|
||||
if settings.is_file():
|
||||
with open(settings, "r", encoding="utf-8") as f:
|
||||
enabled = bool(json.load(f).get("windows_selector_event_loop", False))
|
||||
break
|
||||
except Exception:
|
||||
enabled = False
|
||||
if not enabled:
|
||||
return
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
print("[Workflow Studio] Using WindowsSelectorEventLoopPolicy (windows_selector_event_loop enabled)")
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to switch to selector event loop: {e}")
|
||||
|
||||
|
||||
_maybe_use_selector_event_loop()
|
||||
|
||||
if ENV_FILE.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(ENV_FILE)
|
||||
except ImportError:
|
||||
print(
|
||||
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY / GEMINI_API_KEY will not be loaded from .env."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
|
||||
@@ -69,3 +69,12 @@ NODE_SETS_FILE = DATA_DIR / "node_sets.json"
|
||||
PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
|
||||
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
|
||||
VIDEO_PLAN_DIR = DATA_DIR / "video_plan"
|
||||
VIDEO_EDIT_PROJECT_DIR = DATA_DIR / "video_edit_project"
|
||||
|
||||
# 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,11 +3,14 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.url_guard import require_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -17,6 +20,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 _guarded_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("/")
|
||||
@@ -48,7 +129,7 @@ def _eagle_add(eagle_url, image_url, name, tags):
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
with _guarded_urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
@@ -59,7 +140,7 @@ def _eagle_test(eagle_url):
|
||||
f"{eagle_url}/api/application/info",
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with _guarded_urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
@@ -67,15 +148,37 @@ async def handle_add(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/eagle/add - Add image to Eagle."""
|
||||
try:
|
||||
body = await request.json()
|
||||
eagle_url = body.get("eagleUrl", "http://localhost:41595")
|
||||
eagle_url = require_backend_url(body.get("eagleUrl", "http://localhost:41595"))
|
||||
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)
|
||||
@@ -92,7 +195,7 @@ async def handle_test(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/eagle/test - Test Eagle connection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
eagle_url = body.get("eagleUrl", "http://localhost:41595")
|
||||
eagle_url = require_backend_url(body.get("eagleUrl", "http://localhost:41595"))
|
||||
result = await asyncio.to_thread(_eagle_test, eagle_url)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
@@ -9,32 +10,243 @@ from aiohttp import web
|
||||
|
||||
from ..config import DATA_DIR
|
||||
from ..services.gallery_service import GalleryService
|
||||
from ..services.semantic_service import SemanticService, SemanticError, DEFAULT_DIM, MODEL as SEMANTIC_DEFAULT_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = GalleryService(DATA_DIR)
|
||||
_semantic = SemanticService(DATA_DIR, _service)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# ImagePrompt/Style Catalogギャラリー用に実outputフォルダを常に別途保持する
|
||||
# (Settingsで_allowed_rootが別フォルダに変更されていても影響しないようにするため)
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir and Path(output_dir).is_dir():
|
||||
_service.set_comfy_output_root(output_dir)
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir for image-prompt root: %s", e)
|
||||
|
||||
|
||||
_init_allowed_root()
|
||||
|
||||
|
||||
# Windowsのレジストリ依存で音声のMIMEが返らない/不統一なため明示登録する(<audio>の再生に必要)
|
||||
for _ext, _mime in ((".wav", "audio/wav"), (".mp3", "audio/mpeg"), (".ogg", "audio/ogg"),
|
||||
(".opus", "audio/ogg"), (".m4a", "audio/mp4"), (".flac", "audio/flac")):
|
||||
mimetypes.add_type(_mime, _ext)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/folders", list_folders)
|
||||
app.router.add_get("/wfm/gallery/semantic/status", semantic_status)
|
||||
app.router.add_get("/wfm/gallery/semantic/folders", semantic_folders)
|
||||
app.router.add_post("/wfm/gallery/semantic/index", semantic_index)
|
||||
app.router.add_post("/wfm/gallery/semantic/cancel", semantic_cancel)
|
||||
app.router.add_post("/wfm/gallery/semantic/search", semantic_search)
|
||||
app.router.add_post("/wfm/gallery/semantic/similar", semantic_similar)
|
||||
app.router.add_post("/wfm/gallery/semantic/clear", semantic_clear)
|
||||
app.router.add_post("/wfm/gallery/semantic/test", semantic_test)
|
||||
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_get("/wfm/gallery/image-prompt/root", get_image_prompt_root)
|
||||
app.router.add_post("/wfm/gallery/image/image-prompt", save_image_prompt)
|
||||
app.router.add_get("/wfm/gallery/style-catalog/root", get_style_catalog_root)
|
||||
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
|
||||
app.router.add_post("/wfm/gallery/image/save-to-folder", save_image_to_folder_route)
|
||||
app.router.add_post("/wfm/gallery/output-image/delete", delete_output_image)
|
||||
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/convert", convert_file_route)
|
||||
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)
|
||||
app.router.add_post("/wfm/gallery/images/export-psd", export_images_psd)
|
||||
app.router.add_post("/wfm/gallery/image/export-psd-layers", export_psd_from_layers)
|
||||
app.router.add_post("/wfm/gallery/image/import-psd-layers", import_psd_layers)
|
||||
app.router.add_get("/wfm/gallery/image/psd-layers", get_psd_layers)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# セマンティック検索 (Gemini Embedding 2)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _semantic_error(e: SemanticError) -> web.Response:
|
||||
# code はフロントのi18n用。message は詳細(APIキー等の秘密は含まない)
|
||||
return web.json_response({"error": str(e), "code": e.code}, status=400)
|
||||
|
||||
|
||||
def _num(body: dict, key: str, default, lo, hi, cast=int):
|
||||
"""リクエストの数値パラメータを検証して範囲内に収める。数値でなければ400相当のSemanticErrorにする"""
|
||||
raw = body.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
raw = default
|
||||
try:
|
||||
v = cast(raw)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
raise SemanticError("bad_param", key)
|
||||
if v != v: # NaN
|
||||
raise SemanticError("bad_param", key)
|
||||
return max(lo, min(v, hi))
|
||||
|
||||
|
||||
async def _json_body(request: web.Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
return body if isinstance(body, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def semantic_status(request: web.Request) -> web.Response:
|
||||
folder = request.rel_url.query.get("folder", "")
|
||||
recursive = request.rel_url.query.get("recursive", "true") == "true"
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(_semantic.status, folder, recursive))
|
||||
except Exception as e:
|
||||
logger.error("semantic_status error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_folders(request: web.Request) -> web.Response:
|
||||
root = request.rel_url.query.get("root", "")
|
||||
try:
|
||||
return web.json_response({"folders": await asyncio.to_thread(_semantic.folder_status, root)})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_folders error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _apply_local_options(body: dict) -> None:
|
||||
"""リクエストに実行デバイス/スレッド数が含まれていれば反映・保存する"""
|
||||
if "device" in body:
|
||||
_semantic.set_local_options(str(body.get("device", "auto")), _num(body, "threads", 0, 0, 256))
|
||||
|
||||
|
||||
async def semantic_index(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
_apply_local_options(body)
|
||||
await asyncio.to_thread(
|
||||
_semantic.start_index,
|
||||
str(body.get("folder", "")),
|
||||
bool(body.get("recursive", True)),
|
||||
"all" if body.get("mode") == "all" else "missing",
|
||||
str(body.get("model", SEMANTIC_DEFAULT_MODEL)),
|
||||
_num(body, "dim", DEFAULT_DIM, 0, 100000),
|
||||
_num(body, "soft", 280, 0, 100000),
|
||||
)
|
||||
return web.json_response({"ok": True})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_index error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_cancel(request: web.Request) -> web.Response:
|
||||
_semantic.cancel()
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def semantic_search(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
limit = _num(body, "limit", 200, 1, 1000)
|
||||
min_score = _num(body, "min_score", 0.0, -1.0, 1.0, float)
|
||||
images = await asyncio.to_thread(
|
||||
_semantic.search_text, str(body.get("query", "")), str(body.get("folder", "")),
|
||||
bool(body.get("recursive", True)), limit, min_score,
|
||||
)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_search error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_similar(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
limit = _num(body, "limit", 200, 1, 1000)
|
||||
min_score = _num(body, "min_score", 0.0, -1.0, 1.0, float)
|
||||
images = await asyncio.to_thread(
|
||||
_semantic.search_similar, str(body.get("path", "")), str(body.get("folder", "")),
|
||||
bool(body.get("recursive", True)), limit, min_score,
|
||||
)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_similar error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_clear(request: web.Request) -> web.Response:
|
||||
try:
|
||||
await asyncio.to_thread(_semantic.clear)
|
||||
return web.json_response({"ok": True})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
|
||||
|
||||
async def semantic_test(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
_apply_local_options(body)
|
||||
res = await asyncio.to_thread(_semantic.test_connection, str(body.get("model", SEMANTIC_DEFAULT_MODEL)), _num(body, "dim", DEFAULT_DIM, 0, 100000))
|
||||
return web.json_response({"ok": True, **res})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -64,19 +276,25 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
|
||||
tag_filter = request.rel_url.query.get("tag", "")
|
||||
group_filter = request.rel_url.query.get("group", "")
|
||||
recursive = request.rel_url.query.get("recursive", "false") == "true"
|
||||
|
||||
if not folder:
|
||||
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,
|
||||
favorite_only=favorite_only,
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
recursive=recursive,
|
||||
)
|
||||
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
|
||||
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)
|
||||
@@ -104,9 +322,15 @@ async def get_image_workflow(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
wf = _service.extract_workflow_from_metadata(path)
|
||||
if wf is None:
|
||||
return web.json_response({"workflow": None, "has_workflow": False})
|
||||
return web.json_response({"workflow": wf, "has_workflow": True})
|
||||
# prompt_workflow: プロンプト抽出専用(PNG[prompt]優先)。トップレベルとサブグラフに
|
||||
# 独立した複数系統を持つワークフローではUI形式(workflow)からの抽出がトップレベル
|
||||
# 系統しか拾えないことがあるため、Promptタブはこちらを使う(Metadataタブと揃える)。
|
||||
prompt_wf = _service.extract_prompt_workflow_from_metadata(path)
|
||||
return web.json_response({
|
||||
"workflow": wf,
|
||||
"has_workflow": wf is not None,
|
||||
"prompt_workflow": prompt_wf,
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -116,7 +340,10 @@ async def serve_image(request: web.Request) -> web.Response:
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
|
||||
img_path = _service.serve_image(path)
|
||||
# PSDの合成処理はCPU負荷が高く同期実行するとイベントループ全体をブロックする
|
||||
# (他の並行リクエスト、例えば別画像のサムネイル配信も止まってしまう)ためexecutorで実行する
|
||||
loop = asyncio.get_event_loop()
|
||||
img_path = await loop.run_in_executor(None, _service.serve_image, path)
|
||||
if img_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
@@ -127,6 +354,88 @@ 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
|
||||
|
||||
# PSD/MP4のサムネイル生成はCPU負荷が高く同期実行するとイベントループ全体をブロックするため
|
||||
# executorで実行する(他画像のサムネイル配信が巻き添えで遅延・停止するのを防ぐ)
|
||||
loop = asyncio.get_event_loop()
|
||||
thumb_path = await loop.run_in_executor(None, _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 convert_file_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/convert — 音声/動画の音声を別形式へ変換(1ファイルずつ呼ぶ)"""
|
||||
try:
|
||||
body = await request.json()
|
||||
path = body.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"ok": False, "error": "path required"}, status=400)
|
||||
# 変換はCPU負荷が高く同期実行するとイベントループ全体が止まるためexecutorで実行する
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.convert_file, path, body)
|
||||
return web.json_response(result, status=200 if result.get("ok") else 400)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
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()
|
||||
@@ -140,6 +449,36 @@ async def save_image_meta(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_image_prompt_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/image-prompt/root — ImagePromptギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_image_prompt_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def save_image_prompt(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/image-prompt — 画像の.txtサイドカーにプロンプトを保存"""
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
prompt = body.get("prompt", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
ok = _service.save_image_prompt(img_path, prompt)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_style_catalog_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/style-catalog/root — Style Catalogギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_style_catalog_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -152,6 +491,78 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
result = _service.save_image_to_folder(str(_service._allowed_root), filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
|
||||
logger.info("save_image_to_gallery: saved %s", result["path"])
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_gallery error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def save_image_to_folder_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/save-to-folder — 任意フォルダへ画像を保存(同名なら上書き)。
|
||||
Style Catalogのカタログ作成で、スタイル名をファイル名として使う際に利用する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
folder = body.get("folder", "").strip()
|
||||
filename = body.get("filename", "").strip()
|
||||
image_data = body.get("imageData", "")
|
||||
|
||||
if not folder or not filename or not image_data:
|
||||
return web.json_response({"error": "folder, filename and imageData required"}, status=400)
|
||||
|
||||
result = _service.save_image_to_folder(folder, filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_folder_route error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_output_image(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/output-image/delete — ComfyUI標準の画像参照(filename/subfolder/type)から
|
||||
実ファイルを削除する。Style Catalogのカタログ作成で「Outputフォルダに画像を残さない」
|
||||
オプションが有効な時、カタログへコピー保存した直後の元画像を消すために使う。
|
||||
type="output" 以外は拒否する(temp/inputの誤削除を防止)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = (body.get("filename") or "").strip()
|
||||
subfolder = body.get("subfolder") or ""
|
||||
img_type = body.get("type") or ""
|
||||
if not filename or img_type != "output":
|
||||
return web.json_response({"error": "filename required and type must be 'output'"}, status=400)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
target = output_dir / subfolder / filename
|
||||
|
||||
result = await asyncio.to_thread(_service.delete_images, [str(target)])
|
||||
if result["deleted"]:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"error": "; ".join(result["errors"]) or "File not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("delete_output_image error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -174,10 +585,15 @@ async def create_group(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_RESERVED_GROUPS = {"__Feeder__", "__Video Assets__", "__Video Temp__"}
|
||||
|
||||
|
||||
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 +608,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 +642,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 +720,131 @@ 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)
|
||||
|
||||
|
||||
async def export_images_psd(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/images/export-psd - 複数画像を1つのPSD(レイヤー分割)にしてダウンロード"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
return web.json_response({"error": "paths required"}, status=400)
|
||||
|
||||
psd_bytes = _service.build_psd_from_images(paths)
|
||||
|
||||
return web.Response(
|
||||
body=psd_bytes,
|
||||
content_type="image/vnd.adobe.photoshop",
|
||||
headers={"Content-Disposition": "attachment; filename=gallery_export.psd"}
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD export dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting images to PSD: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def export_psd_from_layers(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/export-psd-layers - Image Editタブのレイヤー配列を1つのPSDにしてダウンロード"""
|
||||
try:
|
||||
body = await request.json()
|
||||
width = int(body.get("width", 0))
|
||||
height = int(body.get("height", 0))
|
||||
layers = body.get("layers", [])
|
||||
if not layers:
|
||||
return web.json_response({"error": "layers required"}, status=400)
|
||||
|
||||
psd_bytes = _service.build_psd_from_layers(width, height, layers)
|
||||
|
||||
return web.Response(
|
||||
body=psd_bytes,
|
||||
content_type="image/vnd.adobe.photoshop",
|
||||
headers={"Content-Disposition": "attachment; filename=image_edit_export.psd"}
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD export dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting Image Edit layers to PSD: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def import_psd_layers(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/import-psd-layers - アップロードされたPSDをレイヤー配列(JSON)に分解して返す"""
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None:
|
||||
return web.json_response({"error": "file required"}, status=400)
|
||||
psd_bytes = await field.read(decode=False)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.import_psd_layers, psd_bytes)
|
||||
return web.json_response(result)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD import dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error importing PSD layers: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_psd_layers(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/image/psd-layers - サーバー上のPSDファイルをレイヤー配列(JSON)に分解して返す
|
||||
(Galleryから直接Image Editへ送る際、アップロードの往復を挟まずに済ませるため)"""
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.get_psd_layers_from_path, path)
|
||||
return web.json_response(result)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD import dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error reading PSD layers: %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.isabs(gmic_exe) or os.path.splitext(gmic_exe)[1].lower() != ".exe" or not os.path.isfile(gmic_exe):
|
||||
return web.json_response({
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure an absolute .exe path in settings."
|
||||
}, status=400)
|
||||
|
||||
ext = ".png"
|
||||
if image_b64.startswith("data:"):
|
||||
header, image_b64 = image_b64.split(",", 1)
|
||||
mime = header.split(";")[0].split(":")[1]
|
||||
guessed = mimetypes.guess_extension(mime)
|
||||
if guessed:
|
||||
ext = ".jpg" if guessed == ".jpe" else guessed
|
||||
|
||||
img_bytes = base64.b64decode(image_b64)
|
||||
|
||||
temp_dir = Path(DATA_DIR) / "gmic_temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
input_path = str(temp_dir / f"gmic_input_{now_str}{ext}")
|
||||
output_path = str(temp_dir / f"gmic_output_{now_str}_{str(uuid.uuid4())[:8]}{ext}")
|
||||
|
||||
with open(input_path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id] = {
|
||||
"status": "pending",
|
||||
"message": "G'MIC GUIを起動中...",
|
||||
"result_path": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Start G'MIC thread
|
||||
t = threading.Thread(
|
||||
target=_gmic_run_gui,
|
||||
args=(job_id, input_path, output_path, gmic_exe),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
return web.json_response({"job_id": job_id, "status": "pending", "message": "起動中"})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] open error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_status(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/gmic/status/{job_id} - Check job status."""
|
||||
try:
|
||||
job_id = request.match_info.get("job_id", "")
|
||||
with _gmic_jobs_lock:
|
||||
job = _gmic_jobs.get(job_id)
|
||||
if job is None:
|
||||
return web.json_response({"error": "Job not found"}, status=404)
|
||||
return web.json_response({
|
||||
"job_id": job_id,
|
||||
"status": job["status"],
|
||||
"message": job.get("message", ""),
|
||||
"result_path": job.get("result_path"),
|
||||
"error": job.get("error"),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] status error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_result(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/gmic/result - Get result image base64."""
|
||||
try:
|
||||
body = await request.json()
|
||||
result_path = body.get("result_path", "")
|
||||
if not result_path or not os.path.exists(result_path):
|
||||
return web.json_response({"error": f"Result file not found: {result_path}"}, status=404)
|
||||
|
||||
# Path traversal guard: only allow files inside gmic_temp
|
||||
allowed_dir = (Path(DATA_DIR) / "gmic_temp").resolve()
|
||||
try:
|
||||
Path(result_path).resolve().relative_to(allowed_dir)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Access denied: path outside gmic_temp"}, status=403)
|
||||
|
||||
with open(result_path, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
|
||||
ext = os.path.splitext(result_path)[1].lower()
|
||||
mime_map = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff"
|
||||
}
|
||||
mime = mime_map.get(ext, "image/png")
|
||||
b64 = base64.b64encode(img_bytes).decode("ascii")
|
||||
return web.json_response({"image_b64": f"data:{mime};base64,{b64}"})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] result error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Lab tab plan file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import LAB_PLAN_DIR
|
||||
from ..services.lab_plan_service import LabPlanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = LabPlanService(LAB_PLAN_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/lab/plans", handle_list)
|
||||
app.router.add_get("/api/wfm/lab/plans/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/lab/plans/save", handle_save)
|
||||
app.router.add_post("/api/wfm/lab/plans/delete", handle_delete)
|
||||
app.router.add_post("/api/wfm/lab/index-image/save-to-output", handle_save_index_to_output)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing lab plans: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting lab plan content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_service.delete_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_index_to_output(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.save_index_image_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab index image to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -20,6 +20,7 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/models/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/models/metadata", handle_save_metadata)
|
||||
app.router.add_get("/api/wfm/models/preview", handle_get_preview)
|
||||
app.router.add_get("/api/wfm/models/preview-keys", handle_get_preview_keys)
|
||||
app.router.add_get("/api/wfm/models/groups", handle_get_groups)
|
||||
app.router.add_post("/api/wfm/models/groups", handle_save_groups)
|
||||
app.router.add_post("/api/wfm/models/civitai/fetch", handle_civitai_fetch)
|
||||
@@ -31,6 +32,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 ─────────────────────────────────────────
|
||||
@@ -136,24 +140,48 @@ async def handle_civitai_fetch(request: web.Request) -> web.Response:
|
||||
)
|
||||
if not sha256:
|
||||
return web.json_response({"error": "Failed to calculate hash"}, status=500)
|
||||
# Cache the hash in model metadata
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"sha256": sha256}
|
||||
)
|
||||
# Cache the hash (+ size/mtime so a replaced file invalidates it) in model metadata
|
||||
try:
|
||||
st = file_path.stat()
|
||||
hash_meta = {"sha256": sha256, "sha256Size": st.st_size, "sha256Mtime": st.st_mtime}
|
||||
except OSError:
|
||||
hash_meta = {"sha256": sha256}
|
||||
await asyncio.to_thread(_service.update_metadata, model_name, hash_meta)
|
||||
|
||||
# Fetch from CivitAI
|
||||
info = await asyncio.to_thread(_civitai.fetch_by_hash, sha256)
|
||||
if not info:
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"civitaiNotFound": True}
|
||||
)
|
||||
return web.json_response({
|
||||
"status": "not_found",
|
||||
"sha256": sha256,
|
||||
"message": "Model not found on CivitAI"
|
||||
})
|
||||
|
||||
# Auto-set preview if none exists and CivitAI has images
|
||||
preview_saved = False
|
||||
if info.get("images"):
|
||||
has_preview = await asyncio.to_thread(_service.find_preview_image, model_type, model_name)
|
||||
if not has_preview:
|
||||
image_url = info["images"][0]
|
||||
preview_path = file_path.parent / (file_path.stem + ".preview.png")
|
||||
preview_saved = await asyncio.to_thread(
|
||||
CivitaiService.download_image, image_url, preview_path
|
||||
)
|
||||
if preview_saved:
|
||||
logger.info("Auto-saved preview for %s from CivitAI", model_name)
|
||||
|
||||
if model_meta.get("civitaiNotFound"):
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"civitaiNotFound": False}
|
||||
)
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"sha256": sha256,
|
||||
"civitai": info,
|
||||
"preview_saved": preview_saved,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("CivitAI fetch error: %s", e)
|
||||
@@ -227,55 +255,169 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
def on_progress(current, total, model_name, status):
|
||||
progress_q.put({"current": current, "total": total, "model": model_name, "status": status})
|
||||
|
||||
# 保存済みsha256(size/mtime付き)を渡して再ハッシュを避ける
|
||||
import time as _time
|
||||
all_meta0 = _service.get_all_metadata()
|
||||
known = {}
|
||||
for n, _p in model_files:
|
||||
m = all_meta0.get(n) or {}
|
||||
if m.get("sha256"):
|
||||
known[n] = {"sha256": m["sha256"], "size": m.get("sha256Size"), "mtime": m.get("sha256Mtime")}
|
||||
|
||||
# 未計算のものは Lora Manager が保持済みのsha256を借りる(サイズ一致時のみ。ハッシュ計算を省く)
|
||||
if len(known) < len(model_files):
|
||||
from ..services.lora_manager_bridge import fetch_lm_hashes, _norm
|
||||
# Hostヘッダは信用せず、自分自身のループバック+実際のポートへ接続する(Host偽装/DNSリバインディング対策)
|
||||
sockname = request.transport.get_extra_info("sockname") if request.transport else None
|
||||
port = sockname[1] if sockname else (request.url.port or 8188)
|
||||
origin = f"http://127.0.0.1:{port}"
|
||||
lm = await asyncio.to_thread(fetch_lm_hashes, origin, model_type)
|
||||
for n, p in model_files:
|
||||
if n in known:
|
||||
continue
|
||||
e = lm.get(_norm(p))
|
||||
try:
|
||||
if e and e.get("size") is not None and int(e["size"]) == p.stat().st_size:
|
||||
known[n] = {"sha256": e["sha256"], "size": int(e["size"]), "mtime": None}
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ハッシュ確定ごとに溜め、5秒おきにメタデータへ書き出す(途中で止めても計算済み分が残る)
|
||||
pending_hashes = {}
|
||||
last_flush = [_time.monotonic()]
|
||||
|
||||
def flush_hashes():
|
||||
if not pending_hashes:
|
||||
return
|
||||
try:
|
||||
all_meta = _service.get_all_metadata()
|
||||
for mname, (sha, size, mtime) in list(pending_hashes.items()):
|
||||
entry = all_meta.setdefault(mname, {"tags": [], "favorite": False, "memo": ""})
|
||||
entry["sha256"] = sha
|
||||
entry["sha256Size"] = size
|
||||
entry["sha256Mtime"] = mtime
|
||||
_service._save_metadata(all_meta)
|
||||
pending_hashes.clear()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to flush hashes: %s", e)
|
||||
last_flush[0] = _time.monotonic()
|
||||
|
||||
def on_hash(name, sha, size, mtime):
|
||||
pending_hashes[name] = (sha, size, mtime)
|
||||
if _time.monotonic() - last_flush[0] > 5:
|
||||
flush_hashes()
|
||||
|
||||
# Start batch in background thread
|
||||
import threading
|
||||
cancel = threading.Event()
|
||||
loop = asyncio.get_event_loop()
|
||||
fetch_task = loop.run_in_executor(None, _civitai.batch_fetch, model_files, on_progress)
|
||||
fetch_task = loop.run_in_executor(
|
||||
None, lambda: _civitai.batch_fetch(model_files, on_progress, known, on_hash, cancel=cancel)
|
||||
)
|
||||
|
||||
# Stream progress events
|
||||
import time as _time
|
||||
try:
|
||||
while not fetch_task.done():
|
||||
# Drain queue
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
while not fetch_task.done():
|
||||
# Drain queue
|
||||
# Drain remaining events
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Drain remaining events
|
||||
while not progress_q.empty():
|
||||
except (ConnectionResetError, asyncio.CancelledError) as e:
|
||||
# クライアント切断(再読み込み・タブ遷移): 重いハッシュ計算を打ち切り、計算済み分だけ保存して終了
|
||||
logger.info("civitai batch: client disconnected, cancelling (%s)", type(e).__name__)
|
||||
cancel.set()
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.shield(fetch_task)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.to_thread(flush_hashes)
|
||||
if isinstance(e, asyncio.CancelledError):
|
||||
raise
|
||||
return response
|
||||
|
||||
# Get results and update metadata
|
||||
results = fetch_task.result()
|
||||
try:
|
||||
results = fetch_task.result()
|
||||
finally:
|
||||
await asyncio.to_thread(flush_hashes)
|
||||
meta_updates = {}
|
||||
for model_name, result in results.items():
|
||||
sha256 = result.get("sha256")
|
||||
if sha256:
|
||||
meta_updates[model_name] = sha256
|
||||
|
||||
# Bulk update sha256 in metadata
|
||||
# Bulk update sha256 (+ CivitAI not-found marker) in metadata
|
||||
if meta_updates:
|
||||
all_meta = _service.get_all_metadata()
|
||||
for mname, sha in meta_updates.items():
|
||||
if mname not in all_meta:
|
||||
all_meta[mname] = {"tags": [], "favorite": False, "memo": ""}
|
||||
all_meta[mname]["sha256"] = sha
|
||||
r = results[mname]
|
||||
if r.get("civitai"):
|
||||
all_meta[mname].pop("civitaiNotFound", None)
|
||||
elif not r.get("fetch_failed") and not r.get("error"):
|
||||
all_meta[mname]["civitaiNotFound"] = _service._now_iso()
|
||||
_service._save_metadata(all_meta)
|
||||
|
||||
# Auto-set previews for models without preview image
|
||||
# (ネットワークI/Oなのでイベントループを塞がないようスレッドで並列実行)
|
||||
await response.write(send_sse("progress", {
|
||||
"current": len(model_files), "total": len(model_files), "model": "", "status": "previews",
|
||||
}))
|
||||
dirs = _get_model_dirs(model_type)
|
||||
|
||||
def _save_previews():
|
||||
jobs = []
|
||||
for model_name, result in results.items():
|
||||
civitai = result.get("civitai")
|
||||
if not civitai or not civitai.get("images"):
|
||||
continue
|
||||
if _service.find_preview_image(model_type, model_name):
|
||||
continue
|
||||
model_file = None
|
||||
for d in dirs:
|
||||
p = d / model_name
|
||||
if p.is_file():
|
||||
model_file = p
|
||||
break
|
||||
if not model_file:
|
||||
continue
|
||||
jobs.append((model_name, civitai["images"][0],
|
||||
model_file.parent / (model_file.stem + ".preview.png")))
|
||||
if not jobs:
|
||||
return 0
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
oks = list(pool.map(lambda j: CivitaiService.download_image(j[1], j[2]), jobs))
|
||||
for (mname, _u, _p), ok in zip(jobs, oks):
|
||||
if ok:
|
||||
logger.info("Auto-saved preview for %s from CivitAI", mname)
|
||||
return sum(1 for ok in oks if ok)
|
||||
|
||||
preview_saved_count = await asyncio.to_thread(_save_previews)
|
||||
|
||||
# Send final result
|
||||
summary = {
|
||||
"total": len(model_files),
|
||||
"found": sum(1 for r in results.values() if r.get("civitai")),
|
||||
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
|
||||
"errors": sum(1 for r in results.values() if r.get("error")),
|
||||
"needs_hash": sum(1 for r in results.values() if r.get("needs_hash")),
|
||||
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
|
||||
"preview_saved": preview_saved_count,
|
||||
}
|
||||
await response.write(send_sse("done", summary))
|
||||
await response.write_eof()
|
||||
@@ -285,6 +427,22 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
# ── Model Preview Image ───────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_preview_keys(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/preview-keys?type=checkpoint
|
||||
|
||||
プレビュー画像を持つモデルのキー一覧を返す(無いモデルへの画像リクエスト=404を避けるため)。
|
||||
"""
|
||||
model_type = request.query.get("type", "")
|
||||
if not model_type:
|
||||
return web.json_response({"error": "type required"}, status=400)
|
||||
try:
|
||||
keys = await asyncio.to_thread(_service.list_preview_keys, model_type)
|
||||
return web.json_response({"keys": keys})
|
||||
except Exception as e:
|
||||
logger.error("Error listing preview keys: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_preview(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/preview?type=checkpoint&name=model.safetensors"""
|
||||
model_type = request.query.get("type", "")
|
||||
@@ -307,7 +465,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)
|
||||
@@ -346,7 +504,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
|
||||
@@ -355,6 +513,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)
|
||||
@@ -529,3 +692,67 @@ 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:
|
||||
extra_only = request.query.get("extra_only") == "1"
|
||||
result = await asyncio.to_thread(_service.list_model_files, model_type, extra_only)
|
||||
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)
|
||||
|
||||
@@ -15,6 +15,7 @@ _service = NodesService()
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all node management API routes."""
|
||||
# Node metadata
|
||||
app.router.add_get("/api/wfm/nodes/light", handle_get_light_nodes)
|
||||
app.router.add_get("/api/wfm/nodes/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/nodes/metadata", handle_save_metadata)
|
||||
# Node groups
|
||||
@@ -28,6 +29,50 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/node-sets/export", handle_export_set)
|
||||
|
||||
|
||||
# ── Light node list ────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_light_node_list() -> list[dict]:
|
||||
"""全ノードの軽量情報(INPUT_TYPES を呼ばない)。
|
||||
|
||||
/object_info は各ノードの INPUT_TYPES() を呼ぶため、モデル一覧を走査するノードが
|
||||
多い環境では十数秒かかる。Nodesタブの一覧表示に必要なのはクラス属性だけなので、
|
||||
それらのみを読んで高速に返す(入力定義は呼び出し側が /object_info で後から補う)。
|
||||
"""
|
||||
import nodes as comfy_nodes
|
||||
|
||||
result = []
|
||||
for name, cls in list(comfy_nodes.NODE_CLASS_MAPPINGS.items()):
|
||||
try:
|
||||
return_types = getattr(cls, "RETURN_TYPES", ()) or ()
|
||||
result.append({
|
||||
"name": name,
|
||||
"display_name": comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS.get(name, name),
|
||||
"description": getattr(cls, "DESCRIPTION", "") or "",
|
||||
"category": getattr(cls, "CATEGORY", "sd") or "sd",
|
||||
"python_module": getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes",
|
||||
"output": list(return_types),
|
||||
"output_name": list(getattr(cls, "RETURN_NAMES", None) or return_types),
|
||||
"output_node": getattr(cls, "OUTPUT_NODE", False) is True,
|
||||
"search_aliases": list(getattr(cls, "SEARCH_ALIASES", []) or []),
|
||||
"deprecated": bool(getattr(cls, "DEPRECATED", False)),
|
||||
"experimental": bool(getattr(cls, "EXPERIMENTAL", False)),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("light node info failed for %s: %s", name, e)
|
||||
return result
|
||||
|
||||
|
||||
async def handle_get_light_nodes(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/nodes/light"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_build_light_node_list)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error building light node list: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Node Metadata ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import urllib.request
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..services.url_guard import require_backend_url, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,11 +40,12 @@ async def handle_models(request: web.Request) -> web.Response:
|
||||
import asyncio
|
||||
try:
|
||||
cfg = _get_ollama_config()
|
||||
base_url = require_backend_url(cfg["url"])
|
||||
|
||||
def _fetch():
|
||||
url = f"{cfg['url']}/api/tags"
|
||||
url = f"{base_url}/api/tags"
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
@@ -61,11 +63,12 @@ async def handle_test(request: web.Request) -> web.Response:
|
||||
import asyncio
|
||||
try:
|
||||
cfg = _get_ollama_config()
|
||||
base_url = require_backend_url(cfg["url"])
|
||||
|
||||
def _test():
|
||||
url = f"{cfg['url']}/api/tags"
|
||||
url = f"{base_url}/api/tags"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
return resp.status == 200
|
||||
|
||||
ok = await asyncio.to_thread(_test)
|
||||
@@ -82,7 +85,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 = require_backend_url(body.get("url") or cfg["url"])
|
||||
messages = body.get("messages", [])
|
||||
|
||||
def _chat():
|
||||
@@ -92,14 +96,14 @@ 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,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
resp_data = await asyncio.to_thread(_chat)
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Settings API routes."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR, VIDEO_EDIT_PROJECT_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,6 +27,8 @@ _DATA_FILES = [
|
||||
"prompts.json",
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
"tagger_settings.json",
|
||||
"civitai_cache.json",
|
||||
]
|
||||
|
||||
|
||||
@@ -32,10 +38,18 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/settings", handle_post)
|
||||
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
|
||||
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
|
||||
app.router.add_get("/api/wfm/settings/models-dir", handle_get_models_dir)
|
||||
app.router.add_post("/api/wfm/settings/models-dir", handle_set_models_dir)
|
||||
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
|
||||
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
app.router.add_post("/api/wfm/settings/import", handle_import)
|
||||
app.router.add_get("/api/wfm/settings/export-full", handle_export_full)
|
||||
app.router.add_post("/api/wfm/settings/import-full", handle_import_full)
|
||||
app.router.add_get("/api/wfm/styles", handle_get_styles)
|
||||
app.router.add_post("/api/wfm/styles", handle_create_style)
|
||||
app.router.add_put("/api/wfm/styles/{name}", handle_update_style)
|
||||
app.router.add_delete("/api/wfm/styles/{name}", handle_delete_style)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -110,6 +124,47 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_models_dir(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/models-dir - 追加モデルフォルダ設定と現在有効な探索先を返す。"""
|
||||
try:
|
||||
from ..services.models_service import (
|
||||
MODEL_TYPE_FOLDER_KEYS, _get_model_dirs, parse_extra_model_roots,
|
||||
)
|
||||
saved = (await asyncio.to_thread(_service.load)).get("models_dir", "")
|
||||
effective = await asyncio.to_thread(
|
||||
lambda: {t: [str(d) for d in _get_model_dirs(t)] for t in MODEL_TYPE_FOLDER_KEYS}
|
||||
)
|
||||
return web.json_response({
|
||||
"saved": saved,
|
||||
"roots": parse_extra_model_roots(saved),
|
||||
"effective": effective,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting models dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_set_models_dir(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/models-dir - 追加モデルフォルダを保存(`;`/改行区切りで複数可、空でリセット)。"""
|
||||
try:
|
||||
from ..services.models_service import parse_extra_model_roots
|
||||
body = await request.json()
|
||||
roots = parse_extra_model_roots(body.get("models_dir", ""))
|
||||
missing = [r for r in roots if not Path(r).is_dir()]
|
||||
if missing:
|
||||
return web.json_response(
|
||||
{"error": f"Folder not found: {', '.join(missing)}"}, status=400)
|
||||
saved = ";".join(roots)
|
||||
await asyncio.to_thread(_service.update, {"models_dir": saved})
|
||||
|
||||
from ..routes.models_routes import _service as models_service
|
||||
models_service._scan_cache.clear()
|
||||
return await handle_get_models_dir(request)
|
||||
except Exception as e:
|
||||
logger.error("Error setting models dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _get_comfyui_output_dir() -> str:
|
||||
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
|
||||
try:
|
||||
@@ -179,13 +234,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 +296,424 @@ 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)
|
||||
|
||||
|
||||
# Full-backup (ZIP) export/import: unlike the flat _DATA_FILES JSON bundle above, this
|
||||
# covers the whole DATA_DIR — directory-shaped data (ai_skills/, lab_plan/, video_plan/,
|
||||
# style/) and the SQLite tagger.db that the JSON bundle can't represent. Excludes transient/cache
|
||||
# dirs, and wildcard/ specifically because it's typically a symlink into a *different*
|
||||
# custom_node's folder (comfyui-impact-pack) rather than this plugin's own data.
|
||||
_FULL_BACKUP_EXCLUDE_NAMES = {"gmic_temp", "thumb_cache", "wildcard"}
|
||||
|
||||
|
||||
def _add_file_to_zip(zf: zipfile.ZipFile, path: Path, base: Path):
|
||||
rel = str(path.relative_to(base)).replace("\\", "/")
|
||||
if path.name == "settings.json":
|
||||
# Same API-key scrubbing as the JSON-bundle export — fall through to a raw
|
||||
# copy if the file turns out to be unreadable/corrupt rather than skipping it.
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data = {k: v for k, v in data.items() if k not in _SETTINGS_EXPORT_EXCLUDE}
|
||||
zf.writestr(rel, json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
zf.write(path, rel)
|
||||
|
||||
|
||||
# Two folders this plugin doesn't own but users often want backed up alongside their
|
||||
# own data: ComfyUI's own default workflows dir, and the Impact Pack's wildcard
|
||||
# library (a separate custom_node — the plugin's own wildcard/ is usually just a
|
||||
# symlink into this same folder, see _FULL_BACKUP_EXCLUDE_NAMES above). Opt-in only
|
||||
# (see handle_export_full's include_workflows/include_wildcard query params), and
|
||||
# stored under an "_external/" prefix so _apply_full_backup_zip can recognize and
|
||||
# skip them on restore — see that function's docstring for why.
|
||||
def _default_workflows_dir() -> Path:
|
||||
from ..config import COMFYUI_ROOT
|
||||
return COMFYUI_ROOT / "user" / "default" / "workflows"
|
||||
|
||||
|
||||
def _impact_pack_wildcard_dir() -> Path:
|
||||
from ..config import COMFYUI_ROOT
|
||||
return COMFYUI_ROOT / "custom_nodes" / "comfyui-impact-pack" / "wildcards"
|
||||
|
||||
|
||||
def _add_external_dir_to_zip(zf: zipfile.ZipFile, src_dir: Path, zip_prefix: str):
|
||||
if not src_dir.is_dir() or src_dir.is_symlink():
|
||||
return
|
||||
for path in src_dir.rglob("*"):
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
rel = f"{zip_prefix}/{path.relative_to(src_dir)}".replace("\\", "/")
|
||||
zf.write(path, rel)
|
||||
|
||||
|
||||
# Video Edit projects (video_edit_project/*.json, inside DATA_DIR so always backed up) only
|
||||
# store *references* — serverRef {filename, subfolder, type:"input"} — to clips, the BGM,
|
||||
# timeline sounds and overlay (PinP) clips uploaded into ComfyUI's input dir, not the media itself. Backing up just the JSON means a
|
||||
# restored project on another machine (or after input/ is cleaned up) opens with its clips
|
||||
# missing. Opt-in (include_video_media) because the media can be large; stored under its own
|
||||
# prefix so _apply_full_backup_zip can route it back into the input dir, not DATA_DIR.
|
||||
_VIDEO_MEDIA_PREFIX = "_video_edit_media/"
|
||||
|
||||
|
||||
def _comfyui_input_dir() -> Path:
|
||||
import folder_paths
|
||||
return Path(folder_paths.get_input_directory())
|
||||
|
||||
|
||||
def _iter_video_edit_media_refs():
|
||||
"""Yields (subfolder, filename) for every input-dir file referenced by a saved Video
|
||||
Edit project (clips + BGM + timeline sounds + overlay clips). Unreadable project files
|
||||
are skipped."""
|
||||
if not VIDEO_EDIT_PROJECT_DIR.is_dir():
|
||||
return
|
||||
for path in sorted(VIDEO_EDIT_PROJECT_DIR.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
# "sounds" / "pips" are top-level lists of {name, serverRef, ...} (absent in older projects).
|
||||
refs = [
|
||||
c.get("serverRef")
|
||||
for key in ("clips", "sounds", "pips")
|
||||
for c in data.get(key) or []
|
||||
if isinstance(c, dict)
|
||||
]
|
||||
bgm = (data.get("audio") or {}).get("bgm")
|
||||
if isinstance(bgm, dict):
|
||||
refs.append(bgm.get("serverRef"))
|
||||
for ref in refs:
|
||||
if not isinstance(ref, dict) or not ref.get("filename"):
|
||||
continue
|
||||
if (ref.get("type") or "input") != "input":
|
||||
continue
|
||||
yield (ref.get("subfolder") or "").strip("/\\"), ref["filename"]
|
||||
|
||||
|
||||
def _add_video_edit_media_to_zip(zf: zipfile.ZipFile) -> None:
|
||||
input_dir = _comfyui_input_dir().resolve()
|
||||
seen = set()
|
||||
for subfolder, filename in _iter_video_edit_media_refs():
|
||||
src = (input_dir / subfolder / filename).resolve()
|
||||
try:
|
||||
rel = src.relative_to(input_dir) # a crafted project file must not reach outside input/
|
||||
except ValueError:
|
||||
continue
|
||||
if rel in seen or not src.is_file():
|
||||
continue
|
||||
seen.add(rel)
|
||||
zf.write(src, _VIDEO_MEDIA_PREFIX + str(rel).replace("\\", "/"))
|
||||
|
||||
|
||||
def _build_full_backup_zip(include_workflows: bool = False, include_wildcard: bool = False,
|
||||
include_video_media: bool = False) -> bytes:
|
||||
"""Zips everything under DATA_DIR except the excluded names above. Walks
|
||||
top-level entries first so an excluded symlinked directory (wildcard/) is never
|
||||
even opened, rather than filtering after a recursive walk has already followed it.
|
||||
Optionally also includes the two external folders above under "_external/"."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for top in sorted(DATA_DIR.iterdir()):
|
||||
if top.name in _FULL_BACKUP_EXCLUDE_NAMES or top.is_symlink():
|
||||
continue
|
||||
if top.is_file():
|
||||
_add_file_to_zip(zf, top, DATA_DIR)
|
||||
elif top.is_dir():
|
||||
for path in top.rglob("*"):
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
_add_file_to_zip(zf, path, DATA_DIR)
|
||||
|
||||
if include_workflows:
|
||||
_add_external_dir_to_zip(zf, _default_workflows_dir(), "_external/default_workflows")
|
||||
if include_wildcard:
|
||||
_add_external_dir_to_zip(zf, _impact_pack_wildcard_dir(), "_external/wildcard")
|
||||
if include_video_media:
|
||||
_add_video_edit_media_to_zip(zf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _apply_full_backup_zip(zip_source) -> dict:
|
||||
"""Extracts a full-backup ZIP into DATA_DIR, overwriting existing files. Guards
|
||||
against Zip Slip (entries whose path resolves outside DATA_DIR via '..' or an
|
||||
absolute path) by checking each entry before writing it.
|
||||
|
||||
Entries under "_external/" (default workflows dir, Impact Pack wildcards — see
|
||||
_build_full_backup_zip) are always skipped here rather than restored: they live
|
||||
outside DATA_DIR and are managed by ComfyUI core / a different custom_node, so
|
||||
auto-restoring them could silently overwrite files this plugin doesn't own. They
|
||||
stay in the ZIP for the user to copy back manually if they want them.
|
||||
|
||||
Entries under "_video_edit_media/" (media referenced by Video Edit projects) are restored
|
||||
into ComfyUI's input dir instead, but never overwrite an existing file there — a file
|
||||
with the same name is assumed to be the same upload (ComfyUI dedupes uploads by renaming),
|
||||
and input/ is shared with every other workflow."""
|
||||
summary = {"extracted": [], "skipped": [], "media_restored": [], "media_existing": []}
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
base = DATA_DIR.resolve()
|
||||
input_base = None
|
||||
# zip_source: raw bytes, or a seekable file object (the upload handler spools to a temp file).
|
||||
if isinstance(zip_source, (bytes, bytearray)):
|
||||
zip_source = io.BytesIO(zip_source)
|
||||
with zipfile.ZipFile(zip_source) as zf:
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
if info.filename.startswith("_external/"):
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
if info.filename.startswith(_VIDEO_MEDIA_PREFIX):
|
||||
if input_base is None:
|
||||
input_base = _comfyui_input_dir().resolve()
|
||||
target = (input_base / info.filename[len(_VIDEO_MEDIA_PREFIX):]).resolve()
|
||||
try:
|
||||
target.relative_to(input_base)
|
||||
except ValueError:
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
if target.exists():
|
||||
summary["media_existing"].append(info.filename)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info) as src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
summary["media_restored"].append(info.filename)
|
||||
continue
|
||||
target = (base / info.filename).resolve()
|
||||
try:
|
||||
target.relative_to(base)
|
||||
except ValueError:
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info) as src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
summary["extracted"].append(info.filename)
|
||||
return summary
|
||||
|
||||
|
||||
async def handle_export_full(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/export-full - Download a ZIP of the entire data
|
||||
directory, including data the flat JSON bundle (handle_export) can't represent.
|
||||
Optional query params include_workflows=1 / include_wildcard=1 additionally bundle
|
||||
ComfyUI's default workflows dir / the Impact Pack's wildcard dir (see
|
||||
_build_full_backup_zip) — both restore-excluded, export-only, manual-copy-back.
|
||||
include_video_media=1 adds the input-dir media referenced by Video Edit projects,
|
||||
which *is* restored (into input/, without overwriting)."""
|
||||
try:
|
||||
include_workflows = request.rel_url.query.get("include_workflows") == "1"
|
||||
include_wildcard = request.rel_url.query.get("include_wildcard") == "1"
|
||||
include_video_media = request.rel_url.query.get("include_video_media") == "1"
|
||||
zip_bytes = await asyncio.to_thread(
|
||||
_build_full_backup_zip, include_workflows, include_wildcard, include_video_media)
|
||||
return web.Response(
|
||||
body=zip_bytes,
|
||||
content_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="wfm-full-backup.zip"'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error building full backup: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_import_full(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/import-full - Upload and restore a full-backup ZIP."""
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None or field.name != "file":
|
||||
return web.json_response({"error": "file field required"}, status=400)
|
||||
# Stream to a temp file with read_chunk() instead of field.read(): aiohttp (3.14+)
|
||||
# enforces ComfyUI's --max-upload-size (default 100 MB) inside BodyPartReader.read(),
|
||||
# and a backup with the opt-in workflows/wildcard/Video Edit media easily exceeds it
|
||||
# ("Request Entity Too Large"). Also avoids holding the whole ZIP in memory.
|
||||
with tempfile.TemporaryFile() as tmp:
|
||||
while True:
|
||||
chunk = await field.read_chunk(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
await asyncio.to_thread(tmp.write, chunk)
|
||||
tmp.seek(0)
|
||||
summary = await asyncio.to_thread(_apply_full_backup_zip, tmp)
|
||||
return web.json_response({"status": "ok", **summary})
|
||||
except zipfile.BadZipFile:
|
||||
return web.json_response({"error": "Not a valid ZIP file"}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error importing full backup: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _style_files() -> list:
|
||||
style_dir = DATA_DIR / "style"
|
||||
if not style_dir.is_dir():
|
||||
return []
|
||||
return sorted(style_dir.glob("*.json"))
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory.
|
||||
各エントリに定義元ファイル名を "file" として付与する(Promptタブの一覧表示・
|
||||
「このファイルへ追加」機能で使用)。"""
|
||||
styles = []
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
for entry in data:
|
||||
if isinstance(entry, dict):
|
||||
entry = dict(entry)
|
||||
entry["file"] = json_file.name
|
||||
styles.append(entry)
|
||||
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)
|
||||
|
||||
|
||||
# Promptタブ「Style」サブタブでの新規作成分の保存先。既存スタイルの編集は
|
||||
# 元々そのスタイルが記載されているファイルをそのまま書き換える(他ファイルへは移動しない)。
|
||||
_CUSTOM_STYLE_FILENAME = "custom.json"
|
||||
|
||||
|
||||
def _find_style(name: str):
|
||||
"""名前が一致する最初のスタイルを探し、(ファイルパス, そのファイルの全データ, インデックス) を返す。
|
||||
見つからなければ None。"""
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
continue
|
||||
if not isinstance(data, list):
|
||||
continue
|
||||
for i, entry in enumerate(data):
|
||||
if isinstance(entry, dict) and entry.get("name") == name:
|
||||
return json_file, data, i
|
||||
return None
|
||||
|
||||
|
||||
def _write_style_file(path: Path, data: list) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
async def handle_create_style(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/styles - 新規スタイルを作成する。
|
||||
body.file を指定すると既存のそのファイルへ追記する(Promptタブの「このファイルへ追加」用、
|
||||
ファイル名はstyleディレクトリに実在するもののみ許可しパストラバーサルを防ぐ)。
|
||||
未指定ならデフォルトのcustom.jsonへ追記する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
target_file = (body.get("file") or "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _create():
|
||||
if _find_style(name) is not None:
|
||||
return "duplicate"
|
||||
style_dir = DATA_DIR / "style"
|
||||
style_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target_file:
|
||||
path = next((f for f in _style_files() if f.name == target_file), None)
|
||||
if path is None:
|
||||
return "file_not_found"
|
||||
else:
|
||||
path = style_dir / _CUSTOM_STYLE_FILENAME
|
||||
|
||||
data = []
|
||||
if path.is_file():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, list):
|
||||
data = loaded
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load %s, recreating: %s", path.name, e)
|
||||
data.append({"name": name, "prompt": prompt, "negative_prompt": negative_prompt})
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_create)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{name}" already exists'}, status=400)
|
||||
if result == "file_not_found":
|
||||
return web.json_response({"error": f'File "{target_file}" not found'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error creating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_update_style(request: web.Request) -> web.Response:
|
||||
"""PUT /api/wfm/styles/{name} - 既存スタイルを更新する(記載元のファイルをそのまま書き換え、リネームも可)。"""
|
||||
try:
|
||||
original_name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
new_name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
if not new_name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _update():
|
||||
found = _find_style(original_name)
|
||||
if found is None:
|
||||
return "not_found"
|
||||
path, data, idx = found
|
||||
if new_name != original_name and _find_style(new_name) is not None:
|
||||
return "duplicate"
|
||||
data[idx] = {"name": new_name, "prompt": prompt, "negative_prompt": negative_prompt}
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_update)
|
||||
if result == "not_found":
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{new_name}" already exists'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error updating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_style(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/wfm/styles/{name} - スタイルを削除する(記載元のファイルから該当エントリのみ除去)。"""
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
|
||||
def _delete():
|
||||
found = _find_style(name)
|
||||
if found is None:
|
||||
return False
|
||||
path, data, idx = found
|
||||
del data[idx]
|
||||
_write_style_file(path, data)
|
||||
return True
|
||||
|
||||
ok = await asyncio.to_thread(_delete)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""AI skill (.md system prompt) file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import AI_SKILLS_DIR
|
||||
from ..services.skill_service import SkillService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = SkillService(AI_SKILLS_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/skills", handle_list)
|
||||
app.router.add_get("/api/wfm/skills/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/skills/save", handle_save)
|
||||
app.router.add_post("/api/wfm/skills/delete", handle_delete)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_skills)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing skills: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
content = await asyncio.to_thread(_service.get_content, filename)
|
||||
return web.json_response({"content": content})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("Error getting skill content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
content = body.get("content", "")
|
||||
result = await asyncio.to_thread(_service.save_file, filename, content)
|
||||
return web.json_response({"status": "ok", "file": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_service.delete_file, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""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/vlm/models", handle_vlm_models)
|
||||
app.router.add_post("/wfm/tagger/vlm/predict", handle_vlm_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_delete("/wfm/tagger/db", handle_db_delete_bulk)
|
||||
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_vlm_models(request: web.Request) -> web.Response:
|
||||
backend = request.rel_url.query.get("backend", "ollama")
|
||||
api_url = request.rel_url.query.get("api_url", "http://127.0.0.1:11434")
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.vlm_models, backend, api_url)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_vlm_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.vlm_predict,
|
||||
body.get("image_b64", ""),
|
||||
body.get("backend", "ollama"),
|
||||
body.get("api_url", "http://127.0.0.1:11434"),
|
||||
body.get("model", ""),
|
||||
body.get("prompt", ""),
|
||||
int(body.get("max_tags", 40)),
|
||||
bool(body.get("thinking_mode", False)),
|
||||
int(body.get("max_tokens", 0)),
|
||||
)
|
||||
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("vlm_backend", "ollama"),
|
||||
body.get("vlm_api_url", "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,
|
||||
bool(body.get("thinking_mode", False)),
|
||||
int(body.get("max_tokens", 0)),
|
||||
)
|
||||
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_delete_bulk(request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
ids = data.get("ids", [])
|
||||
|
||||
if not ids:
|
||||
return web.json_response(
|
||||
{"error": "No IDs specified"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
ids = [int(row_id) for row_id in ids]
|
||||
|
||||
deleted_count = await asyncio.to_thread(_db.delete_bulk, ids)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"count": deleted_count,
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_db_export(request: web.Request) -> web.Response:
|
||||
try:
|
||||
csv_data = await asyncio.to_thread(_db.export_csv)
|
||||
return web.Response(
|
||||
body=csv_data.encode("utf-8"),
|
||||
content_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=tagger_tags.csv"},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
row_id = await asyncio.to_thread(
|
||||
_db.save,
|
||||
body.get("filename", ""),
|
||||
body.get("interrogator_tags", ""),
|
||||
body.get("vlm_tags", ""),
|
||||
)
|
||||
return web.json_response({"ok": True, "id": row_id})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_write_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.write_meta_to_file,
|
||||
body.get("path", ""),
|
||||
body.get("tags", ""),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_settings_get(request: web.Request) -> web.Response:
|
||||
return web.json_response(_svc.load_settings())
|
||||
|
||||
|
||||
async def handle_settings_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
await asyncio.to_thread(_svc.save_settings, body)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Unsloth API proxy routes.
|
||||
|
||||
Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop requires an API key even
|
||||
for local access (Authorization: Bearer sk-unsloth-...). The key lives in
|
||||
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
|
||||
never sent to the frontend — the frontend calls this proxy with the target
|
||||
path/method/payload, and the server attaches the Authorization header before
|
||||
relaying to Unsloth's OpenAI-compatible API — and, via the same relay, to its
|
||||
Decision API (/v1/systemone, TypeSafe-compatible Laya decision models; see
|
||||
static/js/decision-client.js).
|
||||
|
||||
The key is optional: when UNSLOTH_API_KEY is unset the request is relayed
|
||||
without an Authorization header, which works when Unsloth Desktop has
|
||||
Keyless API access → "Chat and inference" turned on (localhost / private LAN).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.url_guard import HINT as _URL_HINT, is_allowed_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
|
||||
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions", "/v1/systemone"}
|
||||
# baseUrl is client-supplied (so a custom Unsloth port works), but the
|
||||
# Authorization header carries a real secret — restrict the host (loopback, or
|
||||
# WFS_ALLOWED_BACKEND_HOSTS set by the operator) so this proxy can't be used to
|
||||
# exfiltrate UNSLOTH_API_KEY to an arbitrary server (SSRF). Redirects are not followed.
|
||||
|
||||
|
||||
def _get_api_key():
|
||||
"""Return the Unsloth API key from the environment (.env), or None."""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
# Shown when a keyless request is rejected — also used by the Tagger's Unsloth VLM path.
|
||||
KEYLESS_REJECTED_MESSAGE = (
|
||||
"Unsloth rejected the request without an API key (HTTP 401). Either set UNSLOTH_API_KEY "
|
||||
"in the plugin's .env (copy .env.example) and restart ComfyUI, or turn on Keyless API "
|
||||
"access -> \"Chat and inference\" in Unsloth Desktop's Settings -> API."
|
||||
)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register Unsloth API routes."""
|
||||
app.router.add_post("/api/wfm/unsloth/proxy", handle_proxy)
|
||||
|
||||
|
||||
async def handle_proxy(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible / Decision API.
|
||||
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions" | "/v1/systemone",
|
||||
method: "GET" | "POST", payload }
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
body = await request.json()
|
||||
base_url = (body.get("baseUrl") or UNSLOTH_DEFAULT_URL).rstrip("/")
|
||||
path = body.get("path") or "/v1/models"
|
||||
method = (body.get("method") or "GET").upper()
|
||||
payload = body.get("payload")
|
||||
|
||||
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
|
||||
return web.json_response({"message": "Unsupported proxy target"}, status=400)
|
||||
|
||||
if not is_allowed_backend_url(base_url):
|
||||
return web.json_response({"message": _URL_HINT}, status=400)
|
||||
|
||||
# No key → relay without Authorization (Unsloth's Keyless API access); if Unsloth
|
||||
# still demands one, the 401 below explains both ways to fix it.
|
||||
api_key = _get_api_key()
|
||||
|
||||
def _fetch():
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
|
||||
with _guarded_urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
return web.json_response(data)
|
||||
|
||||
except HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
|
||||
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
|
||||
if e.code == 401 and not _get_api_key():
|
||||
return web.json_response({"message": KEYLESS_REJECTED_MESSAGE}, status=401)
|
||||
# Include a short excerpt of Unsloth's own error body — Decision API schema errors
|
||||
# (e.g. a malformed question) are otherwise impossible to diagnose from the frontend.
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code} {detail[:300]}".strip()}, status=e.code)
|
||||
except URLError as e:
|
||||
logger.warning("Unsloth proxy connection error: %s", e)
|
||||
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
|
||||
except Exception as e:
|
||||
logger.error("Unsloth proxy error: %s", e)
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Video tab API routes: last-frame capture, animated GIF conversion, Edit
|
||||
tab probe/text-overlay burn-in, and Plan/Edit project file management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import VIDEO_PLAN_DIR, VIDEO_EDIT_PROJECT_DIR
|
||||
from ..services.video_service import VideoService
|
||||
from ..services.video_plan_service import VideoPlanService
|
||||
from ..services.video_edit_project_service import VideoEditProjectService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = VideoService()
|
||||
_plan_service = VideoPlanService(VIDEO_PLAN_DIR)
|
||||
_edit_project_service = VideoEditProjectService(VIDEO_EDIT_PROJECT_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/video/frame/save-to-output", handle_save_frame)
|
||||
app.router.add_post("/api/wfm/video/to-gif", handle_to_gif)
|
||||
app.router.add_get("/api/wfm/video/edit/probe", handle_probe_video)
|
||||
app.router.add_post("/api/wfm/video/edit/overlay-text", handle_overlay_text)
|
||||
|
||||
app.router.add_get("/api/wfm/video/plans", handle_list_plans)
|
||||
app.router.add_get("/api/wfm/video/plans/content", handle_get_plan_content)
|
||||
app.router.add_post("/api/wfm/video/plans/save", handle_save_plan)
|
||||
app.router.add_post("/api/wfm/video/plans/delete", handle_delete_plan)
|
||||
app.router.add_post("/api/wfm/video/index-image/save-to-output", handle_save_index_to_output)
|
||||
|
||||
app.router.add_get("/api/wfm/video/edit/projects", handle_list_edit_projects)
|
||||
app.router.add_get("/api/wfm/video/edit/projects/content", handle_get_edit_project_content)
|
||||
app.router.add_post("/api/wfm/video/edit/projects/save", handle_save_edit_project)
|
||||
app.router.add_post("/api/wfm/video/edit/projects/delete", handle_delete_edit_project)
|
||||
|
||||
|
||||
async def handle_save_frame(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.save_frame_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving video frame to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_to_gif(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
subfolder = body.get("subfolder", "")
|
||||
type_ = body.get("type", "output")
|
||||
start_time = float(body.get("start_time") or 0)
|
||||
end_time_raw = body.get("end_time")
|
||||
end_time = float(end_time_raw) if end_time_raw not in (None, "") else None
|
||||
fps = float(body.get("fps") or 10)
|
||||
max_width_raw = body.get("max_width")
|
||||
max_width = int(max_width_raw) if max_width_raw not in (None, "") else None
|
||||
|
||||
if not filename:
|
||||
return web.json_response({"error": "filename required"}, status=400)
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
_service.convert_to_gif, filename, subfolder, type_, start_time, end_time, fps, max_width
|
||||
)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error converting video to GIF: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_probe_video(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
subfolder = request.rel_url.query.get("subfolder", "")
|
||||
type_ = request.rel_url.query.get("type", "input")
|
||||
if not filename:
|
||||
return web.json_response({"error": "filename required"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.probe_video, filename, subfolder, type_)
|
||||
return web.json_response(result)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error probing video: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_overlay_text(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
subfolder = body.get("subfolder", "")
|
||||
type_ = body.get("type", "output")
|
||||
overlays = body.get("overlays") or []
|
||||
delete_source = bool(body.get("delete_source"))
|
||||
pips = body.get("pips") or []
|
||||
if not filename:
|
||||
return web.json_response({"error": "filename required"}, status=400)
|
||||
if not isinstance(overlays, list) or not isinstance(pips, list):
|
||||
return web.json_response({"error": "overlays and pips must be lists"}, status=400)
|
||||
|
||||
# Decode+re-encode of every frame — run off the event loop so other
|
||||
# requests aren't stalled for the duration of the burn-in.
|
||||
result = await asyncio.to_thread(
|
||||
_service.overlay_text_on_video, filename, subfolder, type_, overlays, "video/wfm_edit", delete_source, pips
|
||||
)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error burning text overlay into video: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_list_plans(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_plan_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing video plans: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_plan_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_plan_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting video plan content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_plan(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_plan_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving video plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_plan(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_plan_service.delete_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting video plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_list_edit_projects(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_edit_project_service.list_projects)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing video edit projects: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_edit_project_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_edit_project_service.get_project, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting video edit project content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_edit_project(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
result = await asyncio.to_thread(_edit_project_service.save_project, filename, data)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving video edit project: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_edit_project(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_edit_project_service.delete_project, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting video edit project: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_index_to_output(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_plan_service.save_index_image_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving video index image to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""VRAM management: unload Ollama models before image generation to free VRAM.
|
||||
|
||||
Ported from ComfyUI Comic Creator (/api/ccc/vram/prepare), which follows ComfyUI-LiveChatStream's
|
||||
vram_prepare. ComfyUI's own free-VRAM figure (get_free_memory / system_stats vram_free) may not react
|
||||
to Ollama loading/unloading models (measured on ComfyUI_5: 8462MB before and after loading a 9B model
|
||||
while nvidia-smi went 6530 -> 469MB), so free VRAM is measured with nvidia-smi plus ComfyUI's own
|
||||
unused torch cache, falling back to get_free_memory when nvidia-smi is unavailable.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.url_guard import is_allowed_backend_url, LOOPBACK_HOSTS, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MB = 1024 * 1024
|
||||
_OLLAMA_DEFAULT_URL = "http://127.0.0.1:11434"
|
||||
_MAX_URLS = 6 # incl. the default; each unreachable URL costs a connect timeout
|
||||
_lock = asyncio.Lock() # never run concurrently (unloading and measuring interfere)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/vram/prepare", handle_prepare)
|
||||
|
||||
|
||||
def _smi_free_mb(ident):
|
||||
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
out = subprocess.run(
|
||||
["nvidia-smi", "-i", str(ident), "--query-gpu=memory.free", "--format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=5, creationflags=flags,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
raise RuntimeError(out.stderr.strip() or "nvidia-smi failed")
|
||||
return float(out.stdout.strip().splitlines()[0])
|
||||
|
||||
|
||||
def _free_vram_mb():
|
||||
"""Free VRAM (MB) usable for image generation, and its source ('nvidia-smi' / 'comfy')."""
|
||||
import torch
|
||||
import comfy.model_management as mm
|
||||
dev = mm.get_torch_device()
|
||||
if getattr(dev, "type", "cpu") == "cuda":
|
||||
try:
|
||||
st = torch.cuda.memory_stats(dev)
|
||||
cached = max(0, st["reserved_bytes.all.current"] - st["active_bytes.all.current"])
|
||||
try:
|
||||
free = _smi_free_mb(f"GPU-{torch.cuda.get_device_properties(dev).uuid}")
|
||||
except Exception:
|
||||
free = _smi_free_mb(dev.index or 0)
|
||||
return free + cached / _MB, "nvidia-smi"
|
||||
except Exception as e:
|
||||
logger.warning("[WFS] nvidia-smi free VRAM unavailable, falling back to ComfyUI: %s", e)
|
||||
return mm.get_free_memory(dev) / _MB, "comfy"
|
||||
|
||||
|
||||
def _wait_settled(prev_mb, timeout_s=6.0):
|
||||
"""After an unload, wait until free VRAM grows and levels off (Ollama runner exit); return it (MB)."""
|
||||
now = prev_mb
|
||||
last = None
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.4)
|
||||
now, _ = _free_vram_mb()
|
||||
if last is not None and abs(now - last) < 32 and now > prev_mb + 32:
|
||||
break
|
||||
last = now
|
||||
return now
|
||||
|
||||
|
||||
def _ollama_json(base, path, payload=None, timeout=60):
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(base + path, data=data, headers={"Content-Type": "application/json"},
|
||||
method="POST" if data is not None else "GET")
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
|
||||
|
||||
def _ollama_loaded_names(base):
|
||||
_, raw = _ollama_json(base, "/api/ps", timeout=5)
|
||||
return {m.get("name") for m in json.loads(raw.decode("utf-8")).get("models", [])}
|
||||
|
||||
|
||||
def _ollama_unload(base, name):
|
||||
"""Unload with keep_alive=0. Models that reject /api/generate (e.g. decision models) are retried
|
||||
with /api/chat. Ollama sometimes unloads a little later even after an error response, so finally
|
||||
check /api/ps to see whether it is still loaded."""
|
||||
errors = []
|
||||
for path, payload in (("/api/generate", {"model": name, "keep_alive": 0}),
|
||||
("/api/chat", {"model": name, "messages": [], "keep_alive": 0})):
|
||||
try:
|
||||
status, _ = _ollama_json(base, path, payload)
|
||||
if status == 200:
|
||||
return True
|
||||
errors.append(f"{path}: HTTP {status}")
|
||||
except Exception as e:
|
||||
errors.append(f"{path}: {e}")
|
||||
for _ in range(5):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
if name not in _ollama_loaded_names(base):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("[WFS] vram: unload failed for %s: %s", name, "; ".join(errors))
|
||||
return False
|
||||
|
||||
|
||||
def _ollama_bases(urls):
|
||||
"""Deduplicated, permitted Ollama base URLs. localhost / 127.0.0.1 / ::1 count as one server.
|
||||
URLs the backend guard rejects are skipped (logged) rather than failing the whole request."""
|
||||
if urls is None:
|
||||
urls = []
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("urls must be an array")
|
||||
bases, seen = [], set()
|
||||
for raw in [_OLLAMA_DEFAULT_URL, *urls]:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
base = raw.strip().rstrip("/")
|
||||
if not is_allowed_backend_url(base):
|
||||
logger.warning("[WFS] vram: skipped backend URL not permitted by the guard: %s", base)
|
||||
continue
|
||||
p = urlparse(base)
|
||||
host = (p.hostname or "").lower()
|
||||
key = ("loopback" if host in LOOPBACK_HOSTS else host, p.scheme, p.port or (443 if p.scheme == "https" else 80))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
bases.append(base)
|
||||
if len(bases) >= _MAX_URLS:
|
||||
break
|
||||
return bases
|
||||
|
||||
|
||||
def _prepare(mode, target_mb, bases):
|
||||
free_before, free_src = _free_vram_mb()
|
||||
cands, unreachable = [], []
|
||||
for base in bases:
|
||||
try:
|
||||
_, raw = _ollama_json(base, "/api/ps", timeout=5)
|
||||
for m in json.loads(raw.decode("utf-8")).get("models", []):
|
||||
if m.get("name"):
|
||||
cands.append({"base": base, "name": m["name"], "vram_mb": int(m.get("size_vram", 0) / _MB)})
|
||||
except Exception as e:
|
||||
# Ollama not running etc. Normal for the default URL, so not a failure; keep the reason in the
|
||||
# log only (the response doesn't reveal the state of caller-chosen local ports)
|
||||
logger.info("[WFS] vram: %s/api/ps failed: %r", base, e)
|
||||
unreachable.append({"url": base})
|
||||
unloaded, failed = [], []
|
||||
free_after = free_before
|
||||
if mode == "auto":
|
||||
# Ollama's size_vram excludes the KV cache etc. and is smaller than the VRAM actually freed.
|
||||
# Choosing by estimate could free too much, so unload the biggest first, one at a time, and stop
|
||||
# as soon as the measured free VRAM reaches the target
|
||||
cands.sort(key=lambda c: c["vram_mb"], reverse=True)
|
||||
for c in cands:
|
||||
if free_after >= target_mb:
|
||||
break
|
||||
if _ollama_unload(c["base"], c["name"]):
|
||||
unloaded.append(c)
|
||||
free_after = _wait_settled(free_after)
|
||||
else:
|
||||
failed.append(c)
|
||||
else:
|
||||
for c in cands:
|
||||
(unloaded if _ollama_unload(c["base"], c["name"]) else failed).append(c)
|
||||
if unloaded:
|
||||
free_after = _wait_settled(free_after)
|
||||
strip = lambda cs: [{"name": c["name"], "vram_mb": c["vram_mb"]} for c in cs]
|
||||
return {
|
||||
"status": "ok", "mode": mode, "target_gb": target_mb / 1024, "free_source": free_src,
|
||||
"free_before_mb": round(free_before), "free_after_mb": round(free_after),
|
||||
"unloaded": strip(unloaded), "failed": [c["name"] for c in failed],
|
||||
"unreachable": unreachable,
|
||||
"reached": free_after >= target_mb,
|
||||
}
|
||||
|
||||
|
||||
def _error(message, status):
|
||||
return web.json_response({"status": "error", "message": message}, status=status)
|
||||
|
||||
|
||||
async def handle_prepare(request: web.Request) -> web.Response:
|
||||
"""POST {mode:'auto'|'all', target_gb, urls:[Ollama URLs]}.
|
||||
auto: only when free VRAM is below target_gb, unload the models using the most VRAM until it is reached.
|
||||
all: unload every loaded model.
|
||||
The default 127.0.0.1:11434 is always included; other URLs must pass the backend URL guard."""
|
||||
# JSON only, so another site can't unload models with a simple (preflight-free) text/plain request
|
||||
if request.content_type != "application/json":
|
||||
return _error("Content-Type must be application/json", 415)
|
||||
try:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("body must be a JSON object")
|
||||
mode = data.get("mode")
|
||||
if mode not in ("auto", "all"):
|
||||
raise ValueError("mode must be auto or all")
|
||||
target_gb = float(data.get("target_gb", 8) or 0)
|
||||
if not math.isfinite(target_gb):
|
||||
raise ValueError("target_gb must be a finite number")
|
||||
target_mb = min(max(target_gb, 0.0), 64.0) * 1024
|
||||
bases = _ollama_bases(data.get("urls"))
|
||||
except Exception as e:
|
||||
return _error(str(e), 400)
|
||||
if _lock.locked():
|
||||
return _error("VRAM management is already running", 409)
|
||||
try:
|
||||
async with _lock:
|
||||
result = await asyncio.get_running_loop().run_in_executor(None, _prepare, mode, target_mb, bases)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("[WFS] vram prepare error: %s", e)
|
||||
return _error(str(e), 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,80 @@ 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
|
||||
|
||||
|
||||
# サイドカーJSON(.metadata.json / .cm-info.json)の最大サイズ。巨大ファイルでメモリを使い切らないための上限
|
||||
_SIDECAR_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _as_int(v):
|
||||
"""数値IDだけを受け付ける(文字列に細工されたIDをURL等に埋め込まないため)。不正ならNone。"""
|
||||
try:
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
n = int(v)
|
||||
return n if n > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# プレビュー画像のダウンロード許可ホスト(サイドカーJSON由来のURLでSSRF/ローカルファイル読み取りをさせない)
|
||||
_IMAGE_HOST_SUFFIXES = (".civitai.com", ".civitai.red", ".civitai.green")
|
||||
_IMAGE_HOST_EXACT = ("civitai.com", "civitai.red", "civitai.green")
|
||||
_IMAGE_MAX_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
def _is_allowed_image_url(url):
|
||||
"""https かつ CivitAI 系ホストのURLだけ許可する(file:// や内部アドレス、他ホストは拒否)。"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
u = urlparse(str(url).strip())
|
||||
host = (u.hostname or "").lower()
|
||||
return u.scheme == "https" and (host in _IMAGE_HOST_EXACT or host.endswith(_IMAGE_HOST_SUFFIXES))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class CivitaiService:
|
||||
"""Fetch and cache CivitAI model metadata."""
|
||||
@@ -21,6 +94,8 @@ class CivitaiService:
|
||||
def __init__(self):
|
||||
self._cache = None
|
||||
|
||||
# ── キャッシュ管理 ────────────────────────────────────────
|
||||
|
||||
def _load_cache(self):
|
||||
if self._cache is not None:
|
||||
return self._cache
|
||||
@@ -42,62 +117,184 @@ 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 calculate_sha256(file_path, chunk_size=65536):
|
||||
"""Calculate SHA256 hash of a file."""
|
||||
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=512 * 1024, cancel=None):
|
||||
"""Calculate SHA256 hash of a file. cancel(threading.Event)がセットされたらNoneを返して中断。"""
|
||||
h = hashlib.sha256()
|
||||
path = Path(file_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
if cancel is not None and cancel.is_set():
|
||||
return None
|
||||
chunk = f.read(chunk_size)
|
||||
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]
|
||||
cached = cache.get(sha256_lower)
|
||||
# Stability Matrix(.cm-info.json)由来の簡易情報は画像一覧を持たないため、
|
||||
# 明示的な取得/更新ではAPIから完全な情報で上書きする
|
||||
if cached is not None and cached.get("source") != "cm-info":
|
||||
return cached
|
||||
|
||||
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()
|
||||
failed = set()
|
||||
|
||||
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)
|
||||
failed.update(chunk_lower)
|
||||
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)
|
||||
failed.update(chunk_lower)
|
||||
break
|
||||
|
||||
# 通信失敗したチャンクは「CivitAIに存在しない」と区別するため記録する
|
||||
self.last_failed_hashes = failed
|
||||
self._save_cache()
|
||||
return results
|
||||
|
||||
# ── 情報抽出 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_info(data):
|
||||
@@ -106,101 +303,416 @@ 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):
|
||||
# ── バッチフェッチ ────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _known_hash(known, st):
|
||||
"""メタデータに保存済みのsha256が、現在のファイルに対して有効ならそれを返す。
|
||||
|
||||
size/mtime が記録されていれば一致を確認する(ファイル差し替え検知)。
|
||||
記録の無い旧データは信頼する。
|
||||
"""
|
||||
sha = (known or {}).get("sha256")
|
||||
if not sha:
|
||||
return None
|
||||
try:
|
||||
ks, km = known.get("size"), known.get("mtime")
|
||||
if ks is not None and int(ks) != st.st_size:
|
||||
return None
|
||||
if km is not None and abs(float(km) - st.st_mtime) > 2:
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return str(sha).lower()
|
||||
|
||||
def _read_sidecar(self, file_path, size):
|
||||
"""サイドカーからsha256とCivitAI情報を読む(ハッシュ計算・通信不要)。
|
||||
|
||||
Lora Manager の `<stem>.metadata.json` → Stability Matrix の `<stem>.cm-info.json` の順に試す。
|
||||
Returns: (sha256_lower or None, extracted_info or None)
|
||||
"""
|
||||
sha, info = self._read_lm_sidecar(file_path, size)
|
||||
if sha:
|
||||
return sha, info
|
||||
return self._read_cm_info(file_path)
|
||||
|
||||
@staticmethod
|
||||
def _parse_iso_ts(value):
|
||||
"""ISO8601文字列→epoch秒。.NETの7桁小数秒にも対応。失敗時はNone。"""
|
||||
try:
|
||||
import re
|
||||
from datetime import datetime
|
||||
s = str(value).replace("Z", "+00:00")
|
||||
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
|
||||
return datetime.fromisoformat(s).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _read_cm_info(self, file_path):
|
||||
"""Stability Matrix の `<stem>.cm-info.json` から sha256 とCivitAI簡易情報を読む。
|
||||
|
||||
ファイルサイズ情報が無いため、ImportedAt より後にモデルファイルが更新されていたら不採用。
|
||||
画像一覧は持たないので info には "source": "cm-info" を付け、個別取得で完全情報に置き換えられる。
|
||||
"""
|
||||
sc = file_path.with_name(file_path.stem + ".cm-info.json")
|
||||
try:
|
||||
if sc.stat().st_size > _SIDECAR_MAX_BYTES: # 巨大ファイルによるメモリ消費を避ける
|
||||
return None, None
|
||||
with open(sc, "r", encoding="utf-8-sig") as f:
|
||||
d = json.load(f)
|
||||
if not isinstance(d, dict):
|
||||
return None, None
|
||||
sha = str((d.get("Hashes") or {}).get("SHA256", "")).lower()
|
||||
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
|
||||
return None, None
|
||||
imported = self._parse_iso_ts(d.get("ImportedAt"))
|
||||
if imported is None or file_path.stat().st_mtime > imported + 2:
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
info = None
|
||||
try:
|
||||
if d.get("Source", 0) == 0 and _as_int(d.get("VersionId")): # 0 = Civitai
|
||||
info = self._info_from_cm_info(d)
|
||||
except Exception:
|
||||
info = None
|
||||
return sha, info
|
||||
|
||||
@staticmethod
|
||||
def _info_from_cm_info(d):
|
||||
"""cm-info.json を _extract_info と同じ形式のdictに変換する。"""
|
||||
model_id, version_id = _as_int(d.get("ModelId")), _as_int(d.get("VersionId"))
|
||||
stats = d.get("Stats") or {}
|
||||
return {
|
||||
"versionId": version_id,
|
||||
"modelId": model_id,
|
||||
"modelName": d.get("ModelName", ""),
|
||||
"versionName": d.get("VersionName", ""),
|
||||
"type": d.get("ModelType", ""),
|
||||
"description": d.get("VersionDescription") or d.get("ModelDescription", ""),
|
||||
"tags": [str(x) for x in (d.get("Tags") or []) if isinstance(x, (str, int, float))][:100],
|
||||
"nsfw": bool(d.get("Nsfw", False)),
|
||||
"nsfwLevel": 0,
|
||||
"air": "",
|
||||
"creator": d.get("AuthorUsername") or "",
|
||||
"images": [],
|
||||
"imageDetails": [],
|
||||
"trainedWords": [str(x) for x in (d.get("TrainedWords") or []) if isinstance(x, (str, int, float))][:200],
|
||||
"baseModel": d.get("BaseModel") or "",
|
||||
"fileSize": 0,
|
||||
"fileMeta": d.get("FileMetadata") or {},
|
||||
"fileHashes": d.get("Hashes") or {},
|
||||
"downloadUrl": "",
|
||||
"modelUrl": (
|
||||
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
|
||||
if model_id else f"https://civitai.com/model-versions/{version_id}"
|
||||
),
|
||||
"stats": {
|
||||
"downloadCount": stats.get("downloadCount", 0),
|
||||
"thumbsUpCount": stats.get("thumbsUpCount", 0),
|
||||
"thumbsDownCount": stats.get("thumbsDownCount", 0),
|
||||
},
|
||||
"updatedAt": "",
|
||||
"publishedAt": "",
|
||||
"source": "cm-info",
|
||||
}
|
||||
|
||||
def _read_lm_sidecar(self, file_path, size):
|
||||
"""`<stem>.metadata.json` (Lora Manager) サイドカーからsha256とCivitAI情報を読む。
|
||||
|
||||
サイドカーのsizeがファイルサイズと一致する場合のみ採用する。
|
||||
Returns: (sha256_lower or None, extracted_info or None)
|
||||
"""
|
||||
sc = file_path.with_name(file_path.stem + ".metadata.json")
|
||||
try:
|
||||
if sc.stat().st_size > _SIDECAR_MAX_BYTES:
|
||||
return None, None
|
||||
with open(sc, "r", encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
if not isinstance(d, dict):
|
||||
return None, None
|
||||
sha = str(d.get("sha256", "")).lower()
|
||||
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
|
||||
return None, None
|
||||
if d.get("size") is None or int(float(d["size"])) != size:
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
info = None
|
||||
civ = d.get("civitai")
|
||||
if isinstance(civ, dict) and civ.get("id"):
|
||||
# 別ファイルの情報を取り込まないよう、files[].hashes.SHA256 に一致するものだけ採用
|
||||
matched = any(
|
||||
str(f.get("hashes", {}).get("SHA256", "")).lower() == sha
|
||||
for f in civ.get("files", []) if isinstance(f, dict)
|
||||
)
|
||||
if matched:
|
||||
try:
|
||||
info = self._extract_info(civ)
|
||||
except Exception:
|
||||
info = None
|
||||
return sha, info
|
||||
|
||||
def batch_fetch(self, model_files, progress_callback=None, known=None, on_hash=None, workers=2, cancel=None, hash_missing=False):
|
||||
"""Batch fetch CivitAI info for multiple model files.
|
||||
|
||||
Phase 0: 保存済みsha256 / サイドカーから解決(ハッシュ計算・通信なし)
|
||||
Phase 1: 残りのSHA256を並列計算("hashing")— hash_missing=True のときのみ。
|
||||
既定では計算せず needs_hash を返す
|
||||
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
|
||||
known: { model_name: {"sha256", "size", "mtime"} } メタデータ保存済みハッシュ
|
||||
on_hash: fn(model_name, sha256, size, mtime) ハッシュが新たに確定するたびに呼ばれる
|
||||
workers: ハッシュ計算の並列数
|
||||
cancel: threading.Event。セットされると未処理分を打ち切って戻る(クライアント切断時)
|
||||
|
||||
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
|
||||
"""
|
||||
import time
|
||||
|
||||
results = {}
|
||||
total = len(model_files)
|
||||
cache = self._load_cache()
|
||||
known = known or {}
|
||||
finished = 0
|
||||
cache_dirty = False
|
||||
|
||||
for i, (model_name, file_path) in enumerate(model_files):
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "hashing")
|
||||
hashes_needed = [] # [(model_name, sha256_lower)]
|
||||
to_hash = [] # [(model_name, file_path, stat)]
|
||||
|
||||
# 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]
|
||||
def _resolved(name, sha, st, new_hash):
|
||||
"""sha256確定後の共通処理。キャッシュにあれば完了、無ければ取得待ちへ。"""
|
||||
nonlocal finished
|
||||
results[name] = {"sha256": sha, "civitai": None}
|
||||
if new_hash and on_hash:
|
||||
on_hash(name, sha, st.st_size, st.st_mtime)
|
||||
if sha in cache:
|
||||
results[name]["civitai"] = cache[sha]
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "cached")
|
||||
progress_callback(finished, total, name, "cached")
|
||||
else:
|
||||
hashes_needed.append((name, sha))
|
||||
|
||||
# Phase 0: ハッシュ計算なしで解決できるものを先に処理
|
||||
for model_name, file_path in model_files:
|
||||
try:
|
||||
st = Path(file_path).stat()
|
||||
except OSError:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(finished, total, model_name, "not_found")
|
||||
continue
|
||||
|
||||
# Fetch from API
|
||||
sha = self._known_hash(known.get(model_name), st)
|
||||
if sha:
|
||||
_resolved(model_name, sha, st, new_hash=False)
|
||||
continue
|
||||
|
||||
sha, info = self._read_sidecar(Path(file_path), st.st_size)
|
||||
if sha:
|
||||
if info and sha not in cache:
|
||||
cache[sha] = info
|
||||
cache_dirty = True
|
||||
_resolved(model_name, sha, st, new_hash=True)
|
||||
continue
|
||||
|
||||
to_hash.append((model_name, file_path, st))
|
||||
|
||||
if cache_dirty:
|
||||
self._save_cache()
|
||||
|
||||
logger.info("CivitAI batch: total=%d resolved_without_hash=%d need_hash=%d hash_missing=%s",
|
||||
total, len(results), len(to_hash), hash_missing)
|
||||
# ハッシュ未確定のものは、hash_missing=False(既定)なら計算せず "needs_hash" として返す
|
||||
# (個別取得でハッシュ計算する運用。全件ハッシュ計算は負荷が高いため行わない)
|
||||
if to_hash and not hash_missing:
|
||||
for model_name, _fp, _st in to_hash:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "needs_hash": True}
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(finished, total, model_name, "needs_hash")
|
||||
to_hash = []
|
||||
|
||||
# Phase 1: 残りを並列でハッシュ計算(hashlibは大きなチャンクでGILを解放する)
|
||||
if to_hash:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _hash(item):
|
||||
return self.calculate_sha256(item[1], cancel=cancel)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
||||
futures = {pool.submit(_hash, item): item for item in to_hash}
|
||||
for fut in as_completed(futures):
|
||||
if cancel is not None and cancel.is_set():
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
break
|
||||
model_name, _fp, st = futures[fut]
|
||||
try:
|
||||
sha = fut.result()
|
||||
except Exception:
|
||||
sha = None
|
||||
if not sha:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(finished, total, model_name, "not_found")
|
||||
continue
|
||||
if progress_callback:
|
||||
progress_callback(finished, total, model_name, "hashing")
|
||||
_resolved(model_name, sha.lower(), st, new_hash=True)
|
||||
|
||||
if cancel is not None and cancel.is_set():
|
||||
return results
|
||||
|
||||
# Phase 2: POST で一括取得(未キャッシュ分)
|
||||
if hashes_needed:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "fetching")
|
||||
progress_callback(finished, total, "", "fetching")
|
||||
|
||||
info = self.fetch_by_hash(sha256)
|
||||
if info:
|
||||
results[model_name]["civitai"] = info
|
||||
# 同一ハッシュが複数モデルに対応する場合を考慮
|
||||
sha256_to_names: dict[str, list] = {}
|
||||
for name, sha256_lower in hashes_needed:
|
||||
sha256_to_names.setdefault(sha256_lower, []).append(name)
|
||||
|
||||
if progress_callback:
|
||||
status = "found" if info else "not_found"
|
||||
progress_callback(i, total, model_name, status)
|
||||
logger.info("CivitAI batch: POST %d hashes", len(sha256_to_names))
|
||||
batch_results = self._batch_fetch_post(list(sha256_to_names.keys()))
|
||||
logger.info("CivitAI batch: POST done")
|
||||
failed = getattr(self, "last_failed_hashes", set())
|
||||
|
||||
# Rate limit: small delay between API calls
|
||||
time.sleep(0.5)
|
||||
for sha256_lower, info in batch_results.items():
|
||||
for name in sha256_to_names.get(sha256_lower, []):
|
||||
results[name]["civitai"] = info
|
||||
if not info and sha256_lower in failed:
|
||||
results[name]["fetch_failed"] = True
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
status = "found" if info else "not_found"
|
||||
progress_callback(finished, 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."""
|
||||
if not _is_allowed_image_url(url):
|
||||
logger.warning("Refusing to download preview from non-CivitAI URL: %s", str(url)[:120])
|
||||
return False
|
||||
try:
|
||||
from urllib.request import HTTPRedirectHandler, HTTPSHandler, build_opener
|
||||
|
||||
class _SafeRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if not _is_allowed_image_url(newurl): # リダイレクト先も検証
|
||||
raise ValueError("redirect to disallowed host")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
opener = build_opener(_SafeRedirect, HTTPSHandler(context=_get_ssl_context()))
|
||||
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
data = resp.read(_IMAGE_MAX_BYTES + 1)
|
||||
if len(data) > _IMAGE_MAX_BYTES:
|
||||
logger.warning("Preview too large, skipped: %s", str(url)[:120])
|
||||
return False
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download preview from %s: %s", url, e)
|
||||
return False
|
||||
|
||||
# ── キャッシュ操作 ────────────────────────────────────────
|
||||
|
||||
def clear_cache(self, sha256_hash=None):
|
||||
"""Clear cache for a specific hash or all."""
|
||||
cache = self._load_cache()
|
||||
if sha256_hash:
|
||||
cache.pop(sha256_hash, None)
|
||||
cache.pop(sha256_hash.lower(), None)
|
||||
else:
|
||||
cache.clear()
|
||||
self._save_cache()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Audio conversion (and audio extraction from video) using PyAV.
|
||||
|
||||
Gallery の「Convert」機能のコア。PyAV(av)だけで完結し、システムの ffmpeg には依存しない。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 出力フォーマット -> (拡張子, エンコーダ, 標準サンプル形式, ビットレート指定可否, タグ保持可否)
|
||||
# .ogg は PyAV 同梱ビルドに Vorbis エンコーダが無いため出力対象外(入力は読める)。
|
||||
# wav は任意キー(prompt/workflow)のタグを持てないため、メタデータ引き継ぎの対象外。
|
||||
OUTPUT_FORMATS = {
|
||||
"flac": {"ext": ".flac", "codec": "flac", "sample_fmt": "s16", "bitrate": False, "tags": True},
|
||||
"mp3": {"ext": ".mp3", "codec": "libmp3lame", "sample_fmt": "fltp", "bitrate": True, "tags": True},
|
||||
"opus": {"ext": ".opus", "codec": "libopus", "sample_fmt": "flt", "bitrate": True, "tags": True},
|
||||
"m4a": {"ext": ".m4a", "codec": "aac", "sample_fmt": "fltp", "bitrate": True, "tags": True},
|
||||
"wav": {"ext": ".wav", "codec": "pcm_s16le", "sample_fmt": "s16", "bitrate": False, "tags": False},
|
||||
}
|
||||
|
||||
# 引き継ぐタグ(小文字で比較)。ComfyUIの prompt/workflow と一般的な曲情報のみ。
|
||||
_CARRY_TAGS = {"prompt", "workflow", "title", "artist", "album", "date", "genre", "comment"}
|
||||
|
||||
_OPUS_RATES = (8000, 12000, 16000, 24000, 48000)
|
||||
|
||||
|
||||
def supports_tags(fmt: str) -> bool:
|
||||
return bool(OUTPUT_FORMATS.get(fmt, {}).get("tags"))
|
||||
|
||||
|
||||
def _channel_layout(opt: str, src_channels: int) -> str:
|
||||
if opt == "mono":
|
||||
return "mono"
|
||||
if opt == "stereo":
|
||||
return "stereo"
|
||||
return "mono" if src_channels == 1 else "stereo"
|
||||
|
||||
|
||||
def convert_audio(src: Path, dst: Path, fmt: str, *, bitrate_kbps: int = 0,
|
||||
sample_rate: int = 0, channels: str = "keep",
|
||||
inherit_metadata: bool = True) -> dict:
|
||||
"""src(音声または音声トラックを持つ動画)から dst へ変換する。
|
||||
|
||||
sample_rate=0 / channels="keep" は元の設定を維持。opusは48kHz固定扱い。
|
||||
戻り値: {"tags_written": bool}(タグを持てない形式や元にタグが無い場合は False)
|
||||
"""
|
||||
import av
|
||||
|
||||
spec = OUTPUT_FORMATS[fmt]
|
||||
tags_written = False
|
||||
with av.open(str(src)) as in_c:
|
||||
if not in_c.streams.audio:
|
||||
raise ValueError("No audio stream in the source file")
|
||||
in_stream = in_c.streams.audio[0]
|
||||
|
||||
rate = sample_rate or in_stream.rate or 44100
|
||||
if fmt == "opus":
|
||||
rate = min(_OPUS_RATES, key=lambda r: abs(r - rate)) if sample_rate else 48000
|
||||
elif fmt == "mp3" and rate not in (8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000):
|
||||
rate = 44100
|
||||
layout = _channel_layout(channels, in_stream.channels)
|
||||
|
||||
options = {"movflags": "use_metadata_tags"} if fmt == "m4a" else {}
|
||||
with av.open(str(dst), "w", options=options) as out_c:
|
||||
# タグ: 引き継ぎ対象かつ形式がタグを持てる場合のみ
|
||||
if inherit_metadata and spec["tags"]:
|
||||
src_tags = dict(in_c.metadata or {})
|
||||
for k, v in (in_stream.metadata or {}).items(): # Opus等はストリーム側にタグを持つ
|
||||
src_tags.setdefault(k, v)
|
||||
for k, v in src_tags.items():
|
||||
if k.lower() in _CARRY_TAGS and isinstance(v, str) and v:
|
||||
out_c.metadata[k.lower()] = v
|
||||
tags_written = True
|
||||
|
||||
out_stream = out_c.add_stream(spec["codec"], rate=rate)
|
||||
out_stream.layout = layout
|
||||
if spec["bitrate"] and bitrate_kbps:
|
||||
out_stream.bit_rate = int(bitrate_kbps) * 1000
|
||||
|
||||
resampler = av.AudioResampler(format=out_stream.format.name, layout=layout, rate=rate)
|
||||
|
||||
def _encode(frames):
|
||||
for f in frames:
|
||||
for pkt in out_stream.encode(f):
|
||||
out_c.mux(pkt)
|
||||
|
||||
for frame in in_c.decode(in_stream):
|
||||
_encode(resampler.resample(frame))
|
||||
_encode(resampler.resample(None))
|
||||
for pkt in out_stream.encode(None):
|
||||
out_c.mux(pkt)
|
||||
return {"tags_written": tags_written}
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,14 +26,27 @@ class GalleryMetadataStore:
|
||||
{"name": str}
|
||||
]
|
||||
}
|
||||
|
||||
self._data(特に "images" 辞書)は、バックグラウンドのフォルダインデックススレッド
|
||||
(gallery_service.pyの_bg_index_folder)と、リクエストハンドラを処理するメインスレッドの
|
||||
双方から並行に読み書きされる。ロックなしで辞書を反復(.items()/.values()/for key in ...や
|
||||
json.dumpによるシリアライズ)している最中に別スレッドがキーを追加/削除すると
|
||||
"dictionary changed size during iteration" が発生するため、_data へアクセスする
|
||||
全メソッドを self._lock で保護する(re-entrant: ensure_group()がcreate_group()を
|
||||
内部呼び出しするため、通常のLockではなくRLockを使う)。
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._lock = threading.RLock()
|
||||
# ディスクへの実書き込み(JSONシリアライズ+ファイルI/O)を直列化するための
|
||||
# 別ロック。_save_to_disk() を参照。
|
||||
self._write_lock = threading.Lock()
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
# コンストラクタからのみ呼ばれ、この時点ではまだ他スレッドと共有されていないためロック不要
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
@@ -42,101 +57,233 @@ class GalleryMetadataStore:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
# 破損ファイルをリネームしてバックアップしておく。ここでリネームせずに
|
||||
# 空の self._data のまま次の save() が走ると、破損直前までの全メタデータ
|
||||
# (タグ/メモ/グループ登録)が跡形もなく上書きされ、二度と復元できなくなる
|
||||
# (実際に発生した不具合: _save_to_disk が非アトミック書き込みだったため、
|
||||
# 大量画像フォルダのBGインデックス処理中の頻繁な保存とComfyUIの終了タイミング
|
||||
# が重なるとファイルが不完全な状態のまま残ることがあった — _save_to_disk側は
|
||||
# os.replace()によるアトミック書き込みに修正済みだが、過去に壊れたファイルや
|
||||
# 他要因による破損に備えてここでも保護しておく)。
|
||||
try:
|
||||
backup_path = self.file_path.with_name(self.file_path.name + ".corrupt")
|
||||
self.file_path.replace(backup_path)
|
||||
logger.warning("GalleryMetadataStore: corrupt file backed up to %s", backup_path)
|
||||
except Exception as backup_err:
|
||||
logger.warning("GalleryMetadataStore: failed to back up corrupt file: %s", backup_err)
|
||||
|
||||
def _save_to_disk(self):
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド。
|
||||
# ここではself._lock配下でスナップショット(deepcopy)を取るだけに留め、実際のJSON
|
||||
# シリアライズ+ファイルI/Oはロックの外、別途self._write_lockで直列化して行う。
|
||||
# 理由: gallery_metadata.jsonは画像数が多い環境では1MBを超えることがあり、
|
||||
# self._lockを保持したままjson.dump+ファイル書き込みまで行うと、その間
|
||||
# 他スレッドのget()(検索リクエストなど)が丸ごとブロックされてしまう
|
||||
# (実際に発生した不具合: バックグラウンドインデックス処理が10枚ごとに保存する際、
|
||||
# 検索リクエストのget()呼び出しがロック待ちになり、50枚程度のフォルダに対する
|
||||
# 検索でも数秒〜十数秒かかっていた)。deepcopy自体はメモリ内操作でファイルI/Oより
|
||||
# 大幅に軽いため、ロック内に残しても実用上のブロック時間は無視できるレベルになる。
|
||||
snapshot = copy.deepcopy(self._data)
|
||||
return self._write_snapshot(snapshot)
|
||||
|
||||
def _write_snapshot(self, snapshot: dict) -> bool:
|
||||
"""self._lockの外から呼ばれる。一時ファイルに書き出してから置き換える
|
||||
(os.replace相当のPath.replace)ことで書き込みをアトミックにする。素朴な
|
||||
open(path, "w") での直接上書きだと、書き込み完了前に他プロセスが読むと
|
||||
不完全なJSONを掴む(torn read)うえ、書き込み中にComfyUIプロセスが終了すると
|
||||
ファイルが壊れたまま残る — 大量画像フォルダのBGインデックス処理で書き込み
|
||||
頻度が高い環境ほど発生しやすく、次回起動時のロード失敗→空データでの全上書き
|
||||
という形でユーザーの登録済みグループ/タグ/メモが消失する原因になっていた。
|
||||
self._write_lockで直列化し、複数スレッドの書き込みが競合して古いスナップショット
|
||||
が新しいものを上書きしないようにする。"""
|
||||
with self._write_lock:
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.file_path.with_name(self.file_path.name + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(snapshot, f, ensure_ascii=False, indent=2)
|
||||
tmp_path.replace(self.file_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
|
||||
def _normalize_path(self, image_path: str) -> str:
|
||||
return str(Path(image_path)).replace("\\", "/")
|
||||
|
||||
def get(self, image_path: str) -> dict:
|
||||
key = self._normalize_path(image_path)
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
with self._lock:
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
with self._lock:
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
def save_batch(self, entries: dict[str, dict]) -> bool:
|
||||
"""複数画像のメタデータを1回のディスク書き込みでまとめて保存する。
|
||||
バックグラウンドインデックス処理のように多数の画像を連続して保存する場合、
|
||||
1件ずつsave()を呼ぶと画像の数だけJSONシリアライズ+ファイルI/Oが発生してしまう
|
||||
(実際に発生した不具合: gallery_metadata.jsonが1MBを超える環境で10枚ごとに
|
||||
個別save()していたため、検索リクエストが完了するまで数秒〜十数秒かかっていた)。"""
|
||||
if not entries:
|
||||
return True
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
for image_path, data in entries.items():
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return list(self._data.get("groups", []))
|
||||
with self._lock:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
with self._lock:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
|
||||
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
|
||||
with self._lock:
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
"""グループ内の全画像を除外する(グループ自体は残す)"""
|
||||
with self._lock:
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
"""グループが存在しない場合のみ作成する"""
|
||||
with self._lock:
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
|
||||
def delete(self, image_path: str) -> bool:
|
||||
"""画像のメタデータエントリを削除する"""
|
||||
key = self._normalize_path(image_path)
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def rename_path(self, old_path: str, new_path: str) -> bool:
|
||||
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
|
||||
old_key = self._normalize_path(old_path)
|
||||
new_key = self._normalize_path(new_path)
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def cleanup_stale_images(self, folder_path: str, existing_paths: set, recursive: bool = True) -> int:
|
||||
"""folder_path 配下の存在しないファイルのメタデータを削除する。
|
||||
existing_paths: そのフォルダの現在のファイルパス集合(_normalize_path 済み)
|
||||
recursive: False の場合、folder_path 直下のファイルのみを対象にする。
|
||||
呼び出し元(list_images)が非recursiveスキャン(フォルダ直下のみ列挙)の
|
||||
結果を existing_paths として渡してくる場合、サブフォルダ内のファイルは
|
||||
existing_paths に含まれない。それにもかかわらず対象範囲を「folder_path配下
|
||||
全階層」のままにすると、サブフォルダ内画像のメタデータ(tags/memo等)を
|
||||
"存在しない"と誤判定して全削除してしまう(実際に発生した不具合: サブフォルダ
|
||||
に動画を保存する運用で、ルートフォルダを非recursiveで開くたびに
|
||||
サブフォルダ内の全メタデータが失われていた)。
|
||||
"""
|
||||
folder_key = self._normalize_path(folder_path)
|
||||
if not folder_key.endswith("/"):
|
||||
folder_key += "/"
|
||||
|
||||
def _in_scope(key: str) -> bool:
|
||||
if not key.startswith(folder_key):
|
||||
return False
|
||||
if recursive:
|
||||
return True
|
||||
return "/" not in key[len(folder_key):]
|
||||
|
||||
with self._lock:
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if _in_scope(key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Lab tab plan file management service (JSON plan + optional PNG index thumbnail)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable lab plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/lab_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"batch_count": data.get("batch_count", 1),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Lab_index") -> dict:
|
||||
"""Saves the contact-sheet index image into ComfyUI's own output folder,
|
||||
alongside the images generated during the run (unlike save_index_image,
|
||||
which saves it next to the plan file in plan_dir)."""
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def delete_plan(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""ComfyUI-Lora-Manager 連携: LMが保持済みのsha256を再利用してハッシュ計算を省く。
|
||||
|
||||
LMの `/api/lm/{prefix}/list` は sha256 / file_path / file_size を返す(CivitAI詳細は薄いので
|
||||
情報そのものは使わず、重いハッシュ計算の省略にのみ利用する)。LM未導入・失敗時は空を返す。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.request import urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 当アプリのモデルタイプ → LMのルート接頭辞
|
||||
_LM_PREFIX = {"checkpoint": "checkpoints", "lora": "loras", "embedding": "embeddings"}
|
||||
|
||||
_PAGE_SIZE = 100 # LM側の上限
|
||||
_MAX_PAGES = 200
|
||||
_TIMEOUT = 5
|
||||
|
||||
|
||||
def _norm(path):
|
||||
return os.path.normcase(os.path.abspath(path))
|
||||
|
||||
|
||||
def fetch_lm_hashes(origin, model_type):
|
||||
"""Returns { normalized_file_path: {"sha256", "size"} }。LMが使えなければ {}。"""
|
||||
prefix = _LM_PREFIX.get(model_type)
|
||||
if not prefix:
|
||||
return {}
|
||||
out = {}
|
||||
try:
|
||||
page = 1
|
||||
while page <= _MAX_PAGES:
|
||||
url = f"{origin}/api/lm/{prefix}/list?page={page}&page_size={_PAGE_SIZE}"
|
||||
with urlopen(url, timeout=_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
for it in data.get("items", []):
|
||||
sha, fp = it.get("sha256"), it.get("file_path")
|
||||
if sha and fp and len(str(sha)) == 64:
|
||||
out[_norm(fp)] = {"sha256": str(sha).lower(), "size": it.get("file_size")}
|
||||
if page >= int(data.get("total_pages", 1) or 1):
|
||||
break
|
||||
page += 1
|
||||
except Exception as e:
|
||||
logger.info("Lora Manager hashes unavailable (%s): %s", prefix, e)
|
||||
return {}
|
||||
return out
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -9,6 +12,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 +22,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",
|
||||
@@ -35,32 +42,126 @@ MODEL_TYPE_FOLDER_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _iter_files(root):
|
||||
"""root 配下の全ファイルを返す。フォルダのシンボリックリンク/ジャンクションも辿る。
|
||||
|
||||
リンクが祖先フォルダを指していて循環する場合のみ、その枝には入らない
|
||||
(rglob だとパス長上限まで再帰し続ける恐れがある)。
|
||||
"""
|
||||
def walk(d, ancestors):
|
||||
try:
|
||||
entries = list(os.scandir(d))
|
||||
except OSError:
|
||||
return
|
||||
for e in entries:
|
||||
try:
|
||||
if e.is_dir(): # リンクは辿る
|
||||
real = os.path.realpath(e.path)
|
||||
if real in ancestors:
|
||||
logger.warning("Skipping looping folder link: %s -> %s", e.path, real)
|
||||
continue
|
||||
yield from walk(e.path, ancestors | {real})
|
||||
elif e.is_file():
|
||||
yield Path(e.path)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
root = Path(root)
|
||||
yield from walk(root, frozenset([os.path.realpath(root)]))
|
||||
|
||||
|
||||
def _is_within(path, root):
|
||||
"""path が root 配下か。`..` は正規化するが、リンクは解決しない(リンク先フォルダへの移動を許可)。"""
|
||||
try:
|
||||
a, r = os.path.abspath(path), os.path.abspath(root)
|
||||
return os.path.commonpath([os.path.normcase(a), os.path.normcase(r)]) == os.path.normcase(r)
|
||||
except ValueError: # 別ドライブなど
|
||||
return False
|
||||
|
||||
|
||||
# 追加モデルルート配下で探すサブフォルダ名(大小無視)。ComfyUI標準名 + Stability Matrix名
|
||||
_EXTRA_ROOT_SUBDIRS = {
|
||||
"checkpoint": ["checkpoints", "StableDiffusion"],
|
||||
"lora": ["loras", "Lora"],
|
||||
"vae": ["vae", "VAE"],
|
||||
"controlnet": ["controlnet", "ControlNet"],
|
||||
"unet": ["diffusion_models", "DiffusionModels", "unet"],
|
||||
"textencoder": ["text_encoders", "TextEncoders", "clip"],
|
||||
"hypernetwork": ["hypernetworks", "HyperNetworks"],
|
||||
"embedding": ["embeddings", "Embeddings"],
|
||||
}
|
||||
|
||||
|
||||
def parse_extra_model_roots(value):
|
||||
"""設定値(`;` または改行区切りの文字列)を重複なしのパス文字列リストにする。"""
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = ";".join(str(v) for v in value)
|
||||
parts = str(value or "").replace("\n", ";").split(";")
|
||||
seen, out = set(), []
|
||||
for p in parts:
|
||||
p = p.strip().strip('"')
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _get_extra_model_dirs(model_type):
|
||||
"""設定 `models_dir` で追加指定されたルート配下の、該当タイプのフォルダを返す。"""
|
||||
names = _EXTRA_ROOT_SUBDIRS.get(model_type)
|
||||
if not names:
|
||||
return []
|
||||
try:
|
||||
from .settings_service import SettingsService
|
||||
roots = parse_extra_model_roots(SettingsService().load().get("models_dir", ""))
|
||||
except Exception:
|
||||
return []
|
||||
wanted = {n.lower() for n in names}
|
||||
found = []
|
||||
for root in roots:
|
||||
try:
|
||||
for e in os.scandir(root):
|
||||
if e.is_dir() and e.name.lower() in wanted:
|
||||
found.append(Path(e.path))
|
||||
except OSError:
|
||||
logger.warning("Configured models_dir not accessible: %s", root)
|
||||
return found
|
||||
|
||||
|
||||
def _get_model_dirs(model_type):
|
||||
"""Get all model directories for a type using ComfyUI's folder_paths.
|
||||
|
||||
Returns a list of Path objects for all configured model directories
|
||||
(includes extra_model_paths.yaml settings).
|
||||
(includes extra_model_paths.yaml settings and the `models_dir` setting).
|
||||
Falls back to plugin-relative path if folder_paths is unavailable.
|
||||
"""
|
||||
folder_key = MODEL_TYPE_FOLDER_KEYS.get(model_type)
|
||||
if not folder_key:
|
||||
return []
|
||||
|
||||
result = []
|
||||
try:
|
||||
import folder_paths # type: ignore # ComfyUI module
|
||||
paths = folder_paths.get_folder_paths(folder_key)
|
||||
result = [Path(p) for p in paths if Path(p).is_dir()]
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug("folder_paths unavailable (%s), using fallback", e)
|
||||
|
||||
# Fallback: custom_nodes/../../models/{folder_key}
|
||||
plugin_dir = Path(__file__).resolve().parent.parent.parent
|
||||
models_dir = plugin_dir.parent.parent / "models" / folder_key
|
||||
if models_dir.is_dir():
|
||||
return [models_dir]
|
||||
return []
|
||||
if not result:
|
||||
# Fallback: custom_nodes/../../models/{folder_key}
|
||||
plugin_dir = Path(__file__).resolve().parent.parent.parent
|
||||
models_dir = plugin_dir.parent.parent / "models" / folder_key
|
||||
if models_dir.is_dir():
|
||||
result = [models_dir]
|
||||
|
||||
# 設定で追加されたルート(既存と同一実体は除外)
|
||||
known = {os.path.normcase(os.path.realpath(p)) for p in result}
|
||||
for d in _get_extra_model_dirs(model_type):
|
||||
key = os.path.normcase(os.path.realpath(d))
|
||||
if key not in known:
|
||||
known.add(key)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
class ModelsService:
|
||||
@@ -68,6 +169,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")
|
||||
@@ -104,11 +206,68 @@ class ModelsService:
|
||||
entry["sha256"] = updates["sha256"]
|
||||
if "badges" in updates:
|
||||
entry["badges"] = updates["badges"]
|
||||
if "civitaiNotFound" in updates:
|
||||
# CivitAIに存在しなかった記録(一括取得のスキップ用)。None/False で解除
|
||||
if updates["civitaiNotFound"]:
|
||||
entry["civitaiNotFound"] = self._now_iso()
|
||||
else:
|
||||
entry.pop("civitaiNotFound", None)
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
data[model_name] = entry
|
||||
self._save_metadata(data)
|
||||
return entry
|
||||
|
||||
def _scan_model_names(self, model_type: str) -> set:
|
||||
"""モデルタイプの全ファイル名をスキャンして返す(相対パス、/区切り、.disabled除く)。
|
||||
結果は30秒キャッシュされ、起動時の多重スキャンによる高負荷を防ぐ。"""
|
||||
now = time.monotonic()
|
||||
cached = self._scan_cache.get(model_type)
|
||||
if cached is not None:
|
||||
ts, names = cached
|
||||
if now - ts < _SCAN_CACHE_TTL:
|
||||
return names
|
||||
|
||||
dirs = _get_model_dirs(model_type)
|
||||
names = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
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, extra_only: bool = False) -> list:
|
||||
"""モデルタイプのモデルファイル名をソートして返す(拡張子付き、/区切り)。
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。
|
||||
extra_only=True なら設定 models_dir で追加されたフォルダのみ対象。"""
|
||||
dirs = _get_extra_model_dirs(model_type) if extra_only else _get_model_dirs(model_type)
|
||||
seen = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
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 +281,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.
|
||||
@@ -180,8 +378,8 @@ class ModelsService:
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
|
||||
for f in _iter_files(d):
|
||||
if f.name.endswith(_DISABLED_SUFFIX):
|
||||
try:
|
||||
rel = str(f.relative_to(d))
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
@@ -191,6 +389,32 @@ class ModelsService:
|
||||
pass
|
||||
return disabled
|
||||
|
||||
def list_preview_keys(self, model_type):
|
||||
"""プレビュー画像を持つモデルのキー(`サブフォルダ/ファイル名(拡張子なし)`、小文字・/区切り)を返す。
|
||||
|
||||
一覧表示で「プレビューが無いモデルには画像リクエストを出さない」ために、フォルダを1回走査して求める
|
||||
(画像ごとの404リクエストと接続の開閉を避ける)。判定は find_preview_image と同じ拡張子・サイズ条件。
|
||||
"""
|
||||
keys = set()
|
||||
for d in _get_model_dirs(model_type):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
lname = f.name.lower()
|
||||
exts = [e for e in _PREVIEW_EXTENSIONS if lname.endswith(e)]
|
||||
if not exts:
|
||||
continue
|
||||
try:
|
||||
if f.stat().st_size < 100:
|
||||
continue
|
||||
parent = str(f.parent.relative_to(d)).replace("\\", "/")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
prefix = "" if parent == "." else parent.lower() + "/"
|
||||
for e in exts:
|
||||
keys.add(prefix + lname[: -len(e)])
|
||||
return sorted(keys)
|
||||
|
||||
def find_preview_image(self, model_type, model_name):
|
||||
"""Find preview image for a model file.
|
||||
|
||||
@@ -211,7 +435,8 @@ class ModelsService:
|
||||
for type_dir in dirs:
|
||||
# model_name can include subdirectory (e.g., "subdir/model.safetensors")
|
||||
model_path = type_dir / model_name
|
||||
if not model_path.is_file():
|
||||
# 無効化されたモデル(<name>.disabled)でも、隣のプレビュー画像は探す
|
||||
if not model_path.is_file() and not (type_dir / (model_name + _DISABLED_SUFFIX)).is_file():
|
||||
continue
|
||||
|
||||
stem = model_path.stem
|
||||
@@ -219,7 +444,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 +499,143 @@ 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 (`..`/絶対パスは拒否。
|
||||
# フォルダのシンボリックリンク/ジャンクション配下への移動は許可する)
|
||||
dest_dir = root_dir / dest_subdir if dest_subdir else root_dir
|
||||
if not _is_within(dest_dir, root_dir):
|
||||
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}
|
||||
|
||||
@@ -44,8 +44,7 @@ def extract_png_workflow(buf):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# IENDで止めない: IENDの後ろに追記されたworkflowチャンクも読む
|
||||
pos += length + 12
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
EmbeddingGemma 2 ローカル実行 (onnx-community/embeddinggemma-2-ONNX)
|
||||
|
||||
onnxruntime + tokenizers + huggingface_hub だけで動かす(transformers の対応を待たない)。
|
||||
前処理は Transformers.js の Gemma4ImageProcessor / EmbeddingGemma2Processor の移植:
|
||||
画像 -> アスペクト比維持でリサイズ(48px倍数, 最大2520パッチ) -> 16pxパッチ化 -> vision_encoder
|
||||
-> image_features を text model の <|image|> トークン位置に差し込み -> sentence_embedding(768)
|
||||
モデルは初回のみ固定リポジトリからダウンロードする(約300MB, q4)。
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPO_ID = "onnx-community/embeddinggemma-2-ONNX"
|
||||
LOCAL_MODEL = "embeddinggemma-2"
|
||||
LOCAL_DIMS = (128, 256, 512, 768) # MRL対応次元
|
||||
DEFAULT_LOCAL_DIM = 768
|
||||
# 量子化精度 -> ファイル名サフィックス
|
||||
DTYPES = {"q4": "_q4", "q8": "_quantized", "fp16": "_fp16", "fp32": ""}
|
||||
DEFAULT_DTYPE = "q4"
|
||||
|
||||
_PATCH = 16
|
||||
_POOL = 3
|
||||
SOFT_TOKENS = (70, 140, 280) # 画像1枚あたりのソフトトークン数(多いほど高精度・低速)
|
||||
DEFAULT_SOFT = 280
|
||||
_MAX_TOKENS = 1024 # テキスト入力のトークン数上限(画像用プロンプトは約290トークン)
|
||||
_MAX_IMAGE_SIDE = 2048 # 画像の長辺上限(超える場合は縮小してから処理)
|
||||
_IMAGE_TOKEN = "<|image|>"
|
||||
_BOI = "<|image>"
|
||||
_EOI = "<image|>"
|
||||
|
||||
DEVICES = ("auto", "cpu", "cuda", "dml")
|
||||
_PROVIDER_BY_DEVICE = {"cuda": "CUDAExecutionProvider", "dml": "DmlExecutionProvider"}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_runtime = None # ((dtype, device, threads), _Runtime)
|
||||
|
||||
|
||||
_opts = {"device": "auto", "threads": 0} # 実行デバイス/CPUスレッド数(configureで更新)
|
||||
|
||||
|
||||
def configure(device: str = "auto", threads: int = 0) -> None:
|
||||
"""ローカル推論の実行設定。次回のensure_runtimeから反映(device/threads変更時はセッション再作成)。"""
|
||||
_opts["device"] = device if device in DEVICES else "auto"
|
||||
_opts["threads"] = max(0, min(int(threads), (os.cpu_count() or 1) * 2))
|
||||
|
||||
|
||||
def current_config() -> dict:
|
||||
return dict(_opts)
|
||||
|
||||
|
||||
def release() -> None:
|
||||
"""セッションを破棄してVRAM/RAMを解放する(インデックス作成後など)。次回利用時に再ロードされる。"""
|
||||
global _runtime
|
||||
with _lock:
|
||||
_runtime = None
|
||||
|
||||
|
||||
def active_provider() -> str:
|
||||
"""直近にロードしたセッションの実行プロバイダ(未ロードなら空)"""
|
||||
rt = _runtime[1] if _runtime else None
|
||||
return rt.providers[0].replace("ExecutionProvider", "") if rt else ""
|
||||
|
||||
|
||||
def available_devices() -> dict:
|
||||
"""onnxruntime が使える実行デバイス。GPU用ビルド(onnxruntime-gpu / -directml)が無ければcpuのみ。"""
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
prov = ort.get_available_providers()
|
||||
version = ort.__version__
|
||||
except Exception:
|
||||
return {"cpu": True, "cuda": False, "dml": False, "ort_version": ""}
|
||||
return {"cpu": True, "cuda": "CUDAExecutionProvider" in prov, "dml": "DmlExecutionProvider" in prov,
|
||||
"ort_version": version, "cpu_count": os.cpu_count() or 1}
|
||||
|
||||
|
||||
def default_threads() -> int:
|
||||
"""既定のCPUスレッド数: 画像生成と同居しても邪魔になりにくいよう、コアの半分(最低1)"""
|
||||
return max(1, (os.cpu_count() or 2) // 2)
|
||||
|
||||
|
||||
def _target_size(h: int, w: int, max_patches: int) -> tuple[int, int]:
|
||||
"""Gemma4ImageProcessor の get_aspect_ratio_preserving_size"""
|
||||
factor = math.sqrt(max_patches * _PATCH ** 2 / (h * w))
|
||||
side = _POOL * _PATCH
|
||||
th = int(math.floor(factor * h / side)) * side
|
||||
tw = int(math.floor(factor * w / side)) * side
|
||||
max_side = (max_patches // _POOL ** 2) * side
|
||||
if th == 0 and tw == 0:
|
||||
raise ValueError("image too small")
|
||||
if th == 0:
|
||||
th, tw = side, min((w // h) * side, max_side)
|
||||
elif tw == 0:
|
||||
tw, th = side, min((h // w) * side, max_side)
|
||||
return th, tw
|
||||
|
||||
|
||||
class _Runtime:
|
||||
def __init__(self, model_dir: Path, dtype: str, device: str = "auto", threads: int = 0):
|
||||
import onnxruntime as ort
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
sfx = DTYPES[dtype]
|
||||
so = ort.SessionOptions()
|
||||
so.log_severity_level = 3 # CUDA使用時のMemcpy警告などを抑止
|
||||
if threads > 0:
|
||||
so.intra_op_num_threads = threads
|
||||
so.inter_op_num_threads = 1
|
||||
avail = ort.get_available_providers()
|
||||
if device in _PROVIDER_BY_DEVICE:
|
||||
providers = [_PROVIDER_BY_DEVICE[device]] if _PROVIDER_BY_DEVICE[device] in avail else []
|
||||
elif device == "cpu":
|
||||
providers = []
|
||||
else: # auto: 使えるGPUを優先
|
||||
providers = [p for p in ("CUDAExecutionProvider", "DmlExecutionProvider") if p in avail]
|
||||
if "DmlExecutionProvider" in providers:
|
||||
# DirectMLは並列実行/メモリパターン最適化に非対応
|
||||
so.enable_mem_pattern = False
|
||||
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
||||
providers.append("CPUExecutionProvider")
|
||||
self.providers = providers
|
||||
|
||||
def make(name):
|
||||
path = str(model_dir / "onnx" / f"{name}{sfx}.onnx")
|
||||
try:
|
||||
return ort.InferenceSession(path, so, providers=providers)
|
||||
except Exception as e:
|
||||
if providers == ["CPUExecutionProvider"]:
|
||||
raise
|
||||
logger.warning("EmbeddingGemma2: %s failed on %s (%s); falling back to CPU", name, providers[0], e)
|
||||
self.providers = ["CPUExecutionProvider"]
|
||||
return ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
|
||||
|
||||
self.text = make("model")
|
||||
self.vision = make("vision_encoder")
|
||||
self.tok = Tokenizer.from_file(str(model_dir / "tokenizer.json"))
|
||||
self.tok.no_truncation()
|
||||
self.tok.no_padding()
|
||||
|
||||
def _run_text(self, text: str, image_features: np.ndarray | None) -> np.ndarray:
|
||||
ids = np.asarray([self.tok.encode(text).ids[:_MAX_TOKENS]], dtype=np.int64)
|
||||
feeds = {
|
||||
"input_ids": ids,
|
||||
"attention_mask": np.ones_like(ids),
|
||||
"image_features": image_features if image_features is not None else np.zeros((0, 512), np.float32),
|
||||
"video_features": np.zeros((0, 512), np.float32),
|
||||
"audio_features": np.zeros((0, 512), np.float32),
|
||||
}
|
||||
return self.text.run(["sentence_embedding"], feeds)[0][0]
|
||||
|
||||
def embed_text(self, text: str, prefix: str) -> np.ndarray:
|
||||
return self._run_text(f"{prefix}{text.strip()}", None)
|
||||
|
||||
def embed_image(self, image, soft_tokens: int = DEFAULT_SOFT) -> np.ndarray:
|
||||
from PIL import Image
|
||||
im = image.convert("RGB")
|
||||
if im.width < 1 or im.height < 1:
|
||||
raise ValueError("empty image")
|
||||
if max(im.size) > _MAX_IMAGE_SIDE: # 巨大画像でメモリを使い切らないよう先に縮小
|
||||
im.thumbnail((_MAX_IMAGE_SIDE, _MAX_IMAGE_SIDE), Image.LANCZOS)
|
||||
max_patches = soft_tokens * _POOL ** 2
|
||||
th, tw = _target_size(im.height, im.width, max_patches)
|
||||
if (th, tw) != (im.height, im.width):
|
||||
im = im.resize((tw, th), Image.BICUBIC)
|
||||
arr = np.asarray(im, dtype=np.float32) * (1.0 / 255.0) # H,W,3
|
||||
ph, pw = th // _PATCH, tw // _PATCH
|
||||
n = ph * pw
|
||||
# (pH, dy, pW, dx, C) -> (pH, pW, dy, dx, C) -> (n, 768)
|
||||
patches = arr.reshape(ph, _PATCH, pw, _PATCH, 3).transpose(0, 2, 1, 3, 4).reshape(n, _PATCH * _PATCH * 3)
|
||||
# パディング無し(n個ちょうど)で渡す: 計算量が減り、パディングは出力に影響しない
|
||||
pixel = patches[None].astype(np.float32)
|
||||
pos = np.zeros((1, n, 2), dtype=np.int64)
|
||||
rows, cols = np.divmod(np.arange(n), pw)
|
||||
pos[0, :, 0], pos[0, :, 1] = cols, rows
|
||||
feats = self.vision.run(["image_features"], {"pixel_values": pixel, "pixel_position_ids": pos})[0]
|
||||
text = f"{_BOI}{_IMAGE_TOKEN * (n // _POOL ** 2)}{_EOI}"
|
||||
return self._run_text(text, feats.astype(np.float32))
|
||||
|
||||
|
||||
def model_dir(data_dir: Path) -> Path:
|
||||
return data_dir / "models" / LOCAL_MODEL
|
||||
|
||||
|
||||
def is_downloaded(data_dir: Path, dtype: str = DEFAULT_DTYPE) -> bool:
|
||||
d, sfx = model_dir(data_dir), DTYPES.get(dtype, "_q4")
|
||||
need = [d / "tokenizer.json"] + [d / "onnx" / f"{n}{sfx}.onnx{e}" for n in ("model", "vision_encoder") for e in ("", "_data")]
|
||||
return all(p.exists() for p in need)
|
||||
|
||||
|
||||
def ensure_runtime(data_dir: Path, dtype: str = DEFAULT_DTYPE, progress=None,
|
||||
device: str | None = None, threads: int | None = None) -> _Runtime:
|
||||
"""モデルをダウンロード(未取得時)してセッションを返す。dtype/device/threads変更時は作り直す。"""
|
||||
global _runtime
|
||||
if dtype not in DTYPES:
|
||||
dtype = DEFAULT_DTYPE
|
||||
device = _opts["device"] if device is None else device
|
||||
threads = _opts["threads"] if threads is None else threads
|
||||
if device not in DEVICES:
|
||||
device = "auto"
|
||||
key = (dtype, device, int(threads))
|
||||
with _lock:
|
||||
if _runtime and _runtime[0] == key:
|
||||
return _runtime[1]
|
||||
d, sfx = model_dir(data_dir), DTYPES[dtype]
|
||||
if not is_downloaded(data_dir, dtype):
|
||||
if progress:
|
||||
progress("downloading")
|
||||
from huggingface_hub import hf_hub_download
|
||||
files = ["tokenizer.json"] + [f"onnx/{n}{sfx}.onnx{e}" for n in ("model", "vision_encoder") for e in ("", "_data")]
|
||||
for f in files:
|
||||
hf_hub_download(REPO_ID, f, local_dir=str(d))
|
||||
if progress:
|
||||
progress("loading")
|
||||
_runtime = (key, _Runtime(d, dtype, device, int(threads)))
|
||||
return _runtime[1]
|
||||
|
||||
|
||||
def _finish(vec: np.ndarray, dim: int) -> np.ndarray:
|
||||
v = np.asarray(vec, dtype=np.float32)
|
||||
if dim < v.shape[0]:
|
||||
v = v[:dim]
|
||||
n = float(np.linalg.norm(v))
|
||||
return v / n if n > 0 else v
|
||||
|
||||
|
||||
def embed_text(data_dir: Path, text: str, dim: int, dtype: str = DEFAULT_DTYPE) -> np.ndarray:
|
||||
rt = ensure_runtime(data_dir, dtype)
|
||||
return _finish(rt.embed_text(text[:8000], "task: search result | query: "), dim)
|
||||
|
||||
|
||||
def embed_image(data_dir: Path, image, dim: int, soft: int = DEFAULT_SOFT, dtype: str = DEFAULT_DTYPE) -> np.ndarray:
|
||||
rt = ensure_runtime(data_dir, dtype)
|
||||
return _finish(rt.embed_image(image, soft if soft in SOFT_TOKENS else DEFAULT_SOFT), dim)
|
||||
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
Semantic Service - Gallery用セマンティック検索(Gemini Embedding 2)
|
||||
|
||||
画像(動画・PSDはサムネイル)をGemini Embedding APIでベクトル化してローカルに保存し、
|
||||
テキストクエリ/類似画像でコサイン類似度検索する。インデックス構築後の検索は
|
||||
(テキストクエリの埋め込み1回を除き)ローカル処理でAPI費用はかからない。
|
||||
|
||||
APIキーは環境変数 GEMINI_API_KEY (または GOOGLE_API_KEY) から読む(.env経由)。
|
||||
settings.json やフロントエンドには出さない。接続先は固定ホストのみ(SSRF対策)。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import semantic_local as local
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
||||
MODEL = "gemini-embedding-2" # Gemini API (クラウド)
|
||||
LOCAL_MODEL = local.LOCAL_MODEL # EmbeddingGemma 2 (ローカルONNX)
|
||||
DEFAULT_DIM = 1536
|
||||
VALID_DIMS = (768, 1536, 3072)
|
||||
# バックエンド(モデル名) -> 選択可能な出力次元
|
||||
DIMS_BY_MODEL = {MODEL: VALID_DIMS, LOCAL_MODEL: local.LOCAL_DIMS}
|
||||
DEFAULT_DIM_BY_MODEL = {MODEL: DEFAULT_DIM, LOCAL_MODEL: local.DEFAULT_LOCAL_DIM}
|
||||
IMAGE_MAX_SIZE = 768 # 送信前の長辺リサイズ(px)
|
||||
_MAX_QUERY_CHARS = 2000 # 検索文の最大長
|
||||
_WORKERS = 4 # 並列API呼び出し数
|
||||
_CHECKPOINT_EVERY = 100 # この件数ごとにディスクへ保存
|
||||
_RETRIES = 4
|
||||
|
||||
# PILで直接開ける(静止画/GIFは先頭フレーム)
|
||||
_PIL_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
|
||||
# GalleryService.serve_thumbnail 経由でJPEG化する(動画先頭フレーム/PSD合成)
|
||||
_THUMB_EXTS = {".mp4", ".webm", ".psd"}
|
||||
INDEXABLE_EXTS = _PIL_EXTS | _THUMB_EXTS
|
||||
|
||||
|
||||
class SemanticError(Exception):
|
||||
"""ユーザーに見せられるエラー(code はフロントのi18n用)"""
|
||||
|
||||
def __init__(self, code: str, message: str = ""):
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
return (os.environ.get("GEMINI_API_KEY", "") or os.environ.get("GOOGLE_API_KEY", "")).strip()
|
||||
|
||||
|
||||
def _normalize(v: np.ndarray) -> np.ndarray:
|
||||
n = float(np.linalg.norm(v))
|
||||
return v / n if n > 0 else v
|
||||
|
||||
|
||||
def _call_embed(content: dict, task_type: str, dim: int) -> np.ndarray:
|
||||
key = get_api_key()
|
||||
if not key:
|
||||
raise SemanticError("no_api_key", "GEMINI_API_KEY is not set")
|
||||
body = {"model": f"models/{MODEL}", "content": content, "taskType": task_type}
|
||||
if dim != 768:
|
||||
body["outputDimensionality"] = dim
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
url = f"{API_BASE}/models/{MODEL}:embedContent"
|
||||
last_err = ""
|
||||
for attempt in range(_RETRIES + 1):
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json", "x-goog-api-key": key},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as res:
|
||||
payload = json.loads(res.read().decode("utf-8"))
|
||||
values = (payload.get("embedding") or {}).get("values")
|
||||
if not values:
|
||||
raise SemanticError("bad_response", "no embedding.values in response")
|
||||
return _normalize(np.asarray(values, dtype=np.float32))
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
msg = json.loads(e.read().decode("utf-8")).get("error", {}).get("message", "")
|
||||
except Exception:
|
||||
msg = ""
|
||||
last_err = f"HTTP {e.code}: {msg or e.reason}"
|
||||
if e.code in (400, 403) and ("API key" in msg or "API_KEY" in msg or e.code == 403):
|
||||
raise SemanticError("api_key_invalid", last_err)
|
||||
if e.code in (429, 500, 502, 503, 504) and attempt < _RETRIES:
|
||||
time.sleep(1.5 * (2 ** attempt))
|
||||
continue
|
||||
raise SemanticError("api_error", last_err)
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
||||
last_err = str(getattr(e, "reason", e))
|
||||
if attempt < _RETRIES:
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
raise SemanticError("network_error", last_err)
|
||||
raise SemanticError("api_error", last_err)
|
||||
|
||||
|
||||
def embed_text(text: str, dim: int) -> np.ndarray:
|
||||
return _call_embed({"parts": [{"text": text[:8000]}]}, "RETRIEVAL_QUERY", dim)
|
||||
|
||||
|
||||
def embed_image_bytes(data: bytes, mime: str, dim: int) -> np.ndarray:
|
||||
import base64
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return _call_embed({"parts": [{"inlineData": {"mimeType": mime, "data": b64}}]}, "RETRIEVAL_DOCUMENT", dim)
|
||||
|
||||
|
||||
class SemanticService:
|
||||
def __init__(self, data_dir: Path, gallery_service):
|
||||
self.data_dir = data_dir
|
||||
self.gallery = gallery_service
|
||||
self._file = data_dir / "semantic_index.npz"
|
||||
self._lock = threading.RLock()
|
||||
self._vecs: dict[str, np.ndarray] = {} # path -> 正規化済みベクトル
|
||||
self._stamp: dict[str, tuple[int, float]] = {} # path -> (size, mtime)
|
||||
self._dim = 0 # インデックスの次元(0=空)
|
||||
self._model = MODEL # インデックスを作ったモデル
|
||||
self._soft = local.DEFAULT_SOFT # ローカルモデルの画像ソフトトークン数(インデックスと一致させる)
|
||||
self._settings_file = data_dir / "semantic_settings.json"
|
||||
self._load_settings()
|
||||
self._mat_cache = None # (paths, matrix)
|
||||
self._loaded = False
|
||||
self._dirty = 0
|
||||
# ジョブ
|
||||
self._job = self._idle_job()
|
||||
self._cancel = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
# ── ローカル推論の実行設定(デバイス/スレッド数) ─────────────
|
||||
|
||||
def _load_settings(self) -> None:
|
||||
try:
|
||||
if self._settings_file.exists():
|
||||
d = json.loads(self._settings_file.read_text(encoding="utf-8"))
|
||||
local.configure(str(d.get("device", "auto")), int(d.get("threads", 0)))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: failed to load settings: %s", e)
|
||||
local.configure("auto", local.default_threads())
|
||||
|
||||
def set_local_options(self, device: str, threads: int) -> None:
|
||||
"""実行デバイス/CPUスレッド数を反映して保存する(検索時のクエリ埋め込みにも使われる)"""
|
||||
local.configure(device, threads)
|
||||
try:
|
||||
self._settings_file.write_text(json.dumps(local.current_config()), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: failed to save settings: %s", e)
|
||||
|
||||
# ── 永続化 ─────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _idle_job() -> dict:
|
||||
return {"running": False, "total": 0, "done": 0, "failed": 0, "current": "",
|
||||
"error": "", "error_code": "", "cancelled": False, "finished": False}
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
with self._lock:
|
||||
if self._loaded:
|
||||
return
|
||||
self._loaded = True
|
||||
if not self._file.exists():
|
||||
return
|
||||
try:
|
||||
with np.load(self._file, allow_pickle=False) as z:
|
||||
model = str(z["model"])
|
||||
if model not in DIMS_BY_MODEL:
|
||||
return
|
||||
self._model = model
|
||||
self._soft = int(z["soft"]) if "soft" in z.files else local.DEFAULT_SOFT
|
||||
paths = [str(p) for p in z["paths"]]
|
||||
sizes, mtimes, vecs = z["sizes"], z["mtimes"], z["vecs"]
|
||||
self._dim = int(z["dim"])
|
||||
for i, p in enumerate(paths):
|
||||
self._vecs[p] = vecs[i]
|
||||
self._stamp[p] = (int(sizes[i]), float(mtimes[i]))
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: failed to load index: %s", e)
|
||||
self._vecs.clear()
|
||||
self._stamp.clear()
|
||||
self._dim = 0
|
||||
|
||||
def _save(self) -> None:
|
||||
with self._lock:
|
||||
if not self._vecs:
|
||||
if self._file.exists():
|
||||
self._file.unlink()
|
||||
self._dirty = 0
|
||||
return
|
||||
paths = list(self._vecs.keys())
|
||||
tmp = self._file.with_suffix(".tmp.npz")
|
||||
np.savez(
|
||||
tmp,
|
||||
model=np.array(self._model), dim=np.array(self._dim), soft=np.array(self._soft),
|
||||
paths=np.array(paths),
|
||||
sizes=np.array([self._stamp[p][0] for p in paths], dtype=np.int64),
|
||||
mtimes=np.array([self._stamp[p][1] for p in paths], dtype=np.float64),
|
||||
vecs=np.stack([self._vecs[p] for p in paths]).astype(np.float32),
|
||||
)
|
||||
os.replace(tmp, self._file)
|
||||
self._dirty = 0
|
||||
|
||||
def _put(self, path: str, vec: np.ndarray, size: int, mtime: float) -> None:
|
||||
with self._lock:
|
||||
self._vecs[path] = vec
|
||||
self._stamp[path] = (size, mtime)
|
||||
self._mat_cache = None
|
||||
self._dirty += 1
|
||||
|
||||
# ── 状態 ───────────────────────────────────────────────
|
||||
|
||||
def _scope_entries(self, folder: str, recursive: bool) -> list[tuple[str, str, int, float]]:
|
||||
p = Path(folder).resolve()
|
||||
if not p.is_dir() or not self.gallery._check_path_allowed(p):
|
||||
raise SemanticError("folder_not_allowed", str(folder))
|
||||
raw = self.gallery._scan_folder_recursive(p) if recursive else self.gallery._scan_folder(p)
|
||||
return [e for e in raw if Path(e[0]).suffix.lower() in INDEXABLE_EXTS]
|
||||
|
||||
def status(self, folder: str = "", recursive: bool = True) -> dict:
|
||||
self._ensure_loaded()
|
||||
out = {
|
||||
"has_key": bool(get_api_key()),
|
||||
"model": MODEL,
|
||||
"backends": {
|
||||
MODEL: {"dims": list(VALID_DIMS), "default_dim": DEFAULT_DIM, "ready": bool(get_api_key())},
|
||||
LOCAL_MODEL: {"dims": list(local.LOCAL_DIMS), "default_dim": local.DEFAULT_LOCAL_DIM,
|
||||
"ready": True, "downloaded": local.is_downloaded(self.data_dir)},
|
||||
},
|
||||
"index_model": self._model,
|
||||
"index_dim": self._dim,
|
||||
"index_soft": self._soft,
|
||||
"local": {
|
||||
"devices": local.available_devices(),
|
||||
"options": local.current_config(),
|
||||
"default_threads": local.default_threads(),
|
||||
"soft_tokens": list(local.SOFT_TOKENS),
|
||||
"default_soft": local.DEFAULT_SOFT,
|
||||
},
|
||||
"indexed_total": len(self._vecs),
|
||||
"job": dict(self._job),
|
||||
}
|
||||
if folder:
|
||||
try:
|
||||
entries = self._scope_entries(folder, recursive)
|
||||
with self._lock:
|
||||
done = sum(1 for _, ap, sz, mt in entries if self._is_fresh(ap, sz, mt))
|
||||
out["scope_total"] = len(entries)
|
||||
out["scope_indexed"] = done
|
||||
except SemanticError:
|
||||
pass
|
||||
return out
|
||||
|
||||
def folder_status(self, root: str) -> dict:
|
||||
"""フォルダごとの索引状況 {絶対パス: {"indexable": n, "indexed": m}} (直下の画像のみ。Galleryツリーの枚数表示と同じ単位)"""
|
||||
self._ensure_loaded()
|
||||
entries = self._scope_entries(root, True)
|
||||
out: dict[str, dict] = {}
|
||||
with self._lock:
|
||||
for _, ap, sz, mt in entries:
|
||||
d = out.setdefault(ap.rsplit("/", 1)[0], {"indexable": 0, "indexed": 0})
|
||||
d["indexable"] += 1
|
||||
if self._is_fresh(ap, sz, mt):
|
||||
d["indexed"] += 1
|
||||
return out
|
||||
|
||||
def _is_fresh(self, path: str, size: int, mtime: float) -> bool:
|
||||
st = self._stamp.get(path)
|
||||
return st is not None and st[0] == size and abs(st[1] - mtime) < 1e-3
|
||||
|
||||
# ── インデックス作成ジョブ ────────────────────────────
|
||||
|
||||
def start_index(self, folder: str, recursive: bool, mode: str, model: str, dim: int,
|
||||
soft: int = local.DEFAULT_SOFT) -> None:
|
||||
self._ensure_loaded()
|
||||
if model not in DIMS_BY_MODEL:
|
||||
raise SemanticError("bad_model", model)
|
||||
if model == MODEL and not get_api_key():
|
||||
raise SemanticError("no_api_key")
|
||||
if dim not in DIMS_BY_MODEL[model]:
|
||||
raise SemanticError("bad_dim", str(dim))
|
||||
if model != LOCAL_MODEL or soft not in local.SOFT_TOKENS:
|
||||
soft = local.DEFAULT_SOFT # ソフトトークン数はローカルモデルのみの設定
|
||||
with self._lock:
|
||||
if self._job["running"]:
|
||||
raise SemanticError("already_running")
|
||||
entries = self._scope_entries(folder, recursive)
|
||||
mismatch = bool(self._vecs) and (self._dim != dim or self._model != model or self._soft != soft)
|
||||
if mismatch and mode != "all":
|
||||
raise SemanticError("dim_mismatch", f"{self._model}/{self._dim}/{self._soft}")
|
||||
if mode == "all":
|
||||
# 範囲内の既存ベクトルを破棄して作り直す(モデル/次元違いなら全消去)
|
||||
if mismatch:
|
||||
self._vecs.clear()
|
||||
self._stamp.clear()
|
||||
else:
|
||||
for _, ap, _, _ in entries:
|
||||
self._vecs.pop(ap, None)
|
||||
self._stamp.pop(ap, None)
|
||||
self._mat_cache = None
|
||||
self._dim = dim
|
||||
self._model = model
|
||||
self._soft = soft
|
||||
todo = [e for e in entries if not self._is_fresh(e[1], e[2], e[3])]
|
||||
# スコープ内で消えたファイルの残骸を掃除(スコープ配下のキーのみ)
|
||||
self._prune_missing(folder, recursive, {e[1] for e in entries})
|
||||
self._cancel.clear()
|
||||
self._job = self._idle_job()
|
||||
self._job.update(running=True, total=len(todo))
|
||||
self._thread = threading.Thread(target=self._run_index, args=(todo, model, dim, soft), daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _prune_missing(self, folder: str, recursive: bool, existing: set[str]) -> None:
|
||||
base = Path(folder).resolve().as_posix().rstrip("/") + "/"
|
||||
for p in list(self._vecs.keys()):
|
||||
if not p.startswith(base):
|
||||
continue
|
||||
if not recursive and "/" in p[len(base):]:
|
||||
continue
|
||||
if p not in existing:
|
||||
self._vecs.pop(p, None)
|
||||
self._stamp.pop(p, None)
|
||||
self._mat_cache = None
|
||||
self._dirty += 1
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancel.set()
|
||||
|
||||
def _run_index(self, todo: list, model: str, dim: int, soft: int = local.DEFAULT_SOFT) -> None:
|
||||
def work(entry):
|
||||
if self._cancel.is_set():
|
||||
return
|
||||
name, ap, size, mtime = entry
|
||||
self._job["current"] = name
|
||||
try:
|
||||
vec = self._embed_image(model, dim, Path(ap), soft)
|
||||
self._put(ap, vec, size, mtime)
|
||||
self._job["done"] += 1
|
||||
except SemanticError as e:
|
||||
if e.code in ("no_api_key", "api_key_invalid"):
|
||||
self._job["error_code"], self._job["error"] = e.code, str(e)
|
||||
self._cancel.set()
|
||||
else:
|
||||
self._job["failed"] += 1
|
||||
if not self._job["error"]:
|
||||
self._job["error_code"], self._job["error"] = e.code, str(e)
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: embed failed for %s: %s", ap, e)
|
||||
self._job["failed"] += 1
|
||||
if self._dirty >= _CHECKPOINT_EVERY:
|
||||
try:
|
||||
self._save()
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: checkpoint failed: %s", e)
|
||||
|
||||
try:
|
||||
if model == LOCAL_MODEL:
|
||||
# 初回はモデルのダウンロード/ロードを先に行う(失敗したらジョブ全体を中止)
|
||||
try:
|
||||
local.ensure_runtime(self.data_dir, progress=lambda ph: self._job.update(current=f"({ph} model...)"))
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: local model load failed: %s", e)
|
||||
self._job["error_code"], self._job["error"] = "local_error", str(e)
|
||||
self._cancel.set()
|
||||
workers = 1 if model == LOCAL_MODEL else _WORKERS # ローカルはORT側がマルチスレッド
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
list(ex.map(work, todo))
|
||||
finally:
|
||||
try:
|
||||
self._save()
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: save failed: %s", e)
|
||||
if model == LOCAL_MODEL:
|
||||
local.release() # 画像生成とVRAM/RAMを奪い合わないよう、作成後はモデルを解放(検索時に再ロード)
|
||||
self._job["running"] = False
|
||||
self._job["finished"] = True
|
||||
self._job["cancelled"] = self._cancel.is_set() and not self._job["error_code"]
|
||||
self._job["current"] = ""
|
||||
|
||||
def _load_pil(self, path: Path):
|
||||
"""埋め込み用に縮小したPIL画像を返す(動画/PSDはサムネイルから)"""
|
||||
from PIL import Image
|
||||
if path.suffix.lower() in _THUMB_EXTS:
|
||||
thumb = self.gallery.serve_thumbnail(str(path), IMAGE_MAX_SIZE)
|
||||
if not thumb:
|
||||
raise SemanticError("thumb_failed", str(path))
|
||||
with Image.open(thumb) as im:
|
||||
return im.convert("RGB")
|
||||
with Image.open(path) as im:
|
||||
im.seek(0)
|
||||
im = im.convert("RGBA") if im.mode in ("RGBA", "LA", "P") else im.convert("RGB")
|
||||
im.thumbnail((IMAGE_MAX_SIZE, IMAGE_MAX_SIZE), Image.LANCZOS)
|
||||
return im
|
||||
|
||||
def _embed_image(self, model: str, dim: int, path: Path, soft: int | None = None) -> np.ndarray:
|
||||
from PIL import Image
|
||||
im = self._load_pil(path)
|
||||
if model == LOCAL_MODEL:
|
||||
if im.mode == "RGBA": # 透過部は白背景に合成
|
||||
bg = Image.new("RGB", im.size, (255, 255, 255))
|
||||
bg.paste(im, mask=im.getchannel("A"))
|
||||
im = bg
|
||||
try:
|
||||
return local.embed_image(self.data_dir, im, dim, self._soft if soft is None else soft)
|
||||
except Exception as e:
|
||||
raise SemanticError("local_error", str(e))
|
||||
buf = io.BytesIO()
|
||||
if im.mode == "RGBA":
|
||||
im.save(buf, "PNG")
|
||||
return embed_image_bytes(buf.getvalue(), "image/png", dim)
|
||||
im.save(buf, "JPEG", quality=88)
|
||||
return embed_image_bytes(buf.getvalue(), "image/jpeg", dim)
|
||||
|
||||
def _embed_text(self, model: str, dim: int, text: str) -> np.ndarray:
|
||||
if model == LOCAL_MODEL:
|
||||
try:
|
||||
return local.embed_text(self.data_dir, text, dim)
|
||||
except Exception as e:
|
||||
raise SemanticError("local_error", str(e))
|
||||
return embed_text(text, dim)
|
||||
|
||||
# ── 検索 ───────────────────────────────────────────────
|
||||
|
||||
def _matrix(self):
|
||||
with self._lock:
|
||||
if self._mat_cache is None:
|
||||
paths = list(self._vecs.keys())
|
||||
mat = np.stack([self._vecs[p] for p in paths]) if paths else np.zeros((0, max(self._dim, 1)), np.float32)
|
||||
self._mat_cache = (paths, mat)
|
||||
return self._mat_cache
|
||||
|
||||
def _rank(self, qvec: np.ndarray, folder: str, recursive: bool, limit: int, exclude: str = "",
|
||||
min_score: float = 0.0) -> list[dict]:
|
||||
entries = self._scope_entries(folder, recursive)
|
||||
info = {ap: (name, sz, mt) for name, ap, sz, mt in entries}
|
||||
paths, mat = self._matrix()
|
||||
if not paths:
|
||||
raise SemanticError("index_empty")
|
||||
if mat.shape[1] != qvec.shape[0]:
|
||||
raise SemanticError("dim_mismatch", str(self._dim))
|
||||
scores = mat @ qvec
|
||||
order = np.argsort(-scores)
|
||||
out = []
|
||||
for i in order:
|
||||
if float(scores[i]) < min_score:
|
||||
break # スコア降順なので、以降はすべてしきい値未満
|
||||
p = paths[i]
|
||||
if p == exclude or p not in info:
|
||||
continue
|
||||
name, sz, mt = info[p]
|
||||
item = self.gallery.make_list_item(name, p, sz, mt)
|
||||
item["score"] = round(float(scores[i]), 4)
|
||||
out.append(item)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
def search_text(self, query: str, folder: str, recursive: bool, limit: int, min_score: float = 0.0) -> list[dict]:
|
||||
self._ensure_loaded()
|
||||
query = (query or "").strip()[:_MAX_QUERY_CHARS]
|
||||
if not query:
|
||||
return []
|
||||
if not self._vecs:
|
||||
raise SemanticError("index_empty")
|
||||
qvec = self._embed_text(self._model, self._dim, query)
|
||||
return self._rank(qvec, folder, recursive, limit, min_score=min_score)
|
||||
|
||||
def search_similar(self, path: str, folder: str, recursive: bool, limit: int, min_score: float = 0.0) -> list[dict]:
|
||||
self._ensure_loaded()
|
||||
p = Path(path).resolve()
|
||||
if not p.is_file() or not self.gallery._check_path_allowed(p):
|
||||
raise SemanticError("folder_not_allowed", path)
|
||||
ap = p.as_posix()
|
||||
vec = self._vecs.get(ap)
|
||||
if vec is None or not self._is_fresh(ap, p.stat().st_size, p.stat().st_mtime):
|
||||
# 未インデックスの基準画像は、その場で1枚だけ埋め込む
|
||||
if not self._vecs:
|
||||
raise SemanticError("index_empty")
|
||||
if p.suffix.lower() not in INDEXABLE_EXTS:
|
||||
raise SemanticError("unsupported_type", p.suffix)
|
||||
vec = self._embed_image(self._model, self._dim, p)
|
||||
st = p.stat()
|
||||
self._put(ap, vec, st.st_size, st.st_mtime)
|
||||
self._save()
|
||||
return self._rank(vec, folder, recursive, limit, exclude=ap, min_score=min_score)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
if self._job["running"]:
|
||||
raise SemanticError("already_running")
|
||||
self._vecs.clear()
|
||||
self._stamp.clear()
|
||||
self._dim = 0
|
||||
self._mat_cache = None
|
||||
self._save()
|
||||
|
||||
def test_connection(self, model: str, dim: int) -> dict:
|
||||
if model not in DIMS_BY_MODEL:
|
||||
raise SemanticError("bad_model", model)
|
||||
if dim not in DIMS_BY_MODEL[model]:
|
||||
dim = DEFAULT_DIM_BY_MODEL[model]
|
||||
n = int(self._embed_text(model, dim, "test").shape[0])
|
||||
return {"dimensions": n, "provider": local.active_provider() if model == LOCAL_MODEL else "API"}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""AI skill (.md system prompt) file management service."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_EXTS = {"md"}
|
||||
|
||||
DEFAULT_SKILL_FILENAME = "svg-icon.md"
|
||||
|
||||
DEFAULT_SKILL_CONTENT = """---
|
||||
name: SVG Icon Generator
|
||||
description: Produces clean, minimal, valid SVG icons and simple illustrations.
|
||||
---
|
||||
|
||||
You are an expert SVG icon and illustration designer. When the user asks for an icon, logo, pictogram, diagram, character, animal, or any vector graphic, respond with valid, clean SVG markup.
|
||||
|
||||
## Composition process (do this before writing markup - skipping this step is the main cause of unrecognizable output)
|
||||
- Break the subject down into 3-8 distinct parts that make it recognizable (e.g. a bird: body, head, beak, eye, wing, tail; a house: wall, roof, door, window). Drop parts that are not essential.
|
||||
- Decide each part's approximate position, size, and draw order within the viewBox (elements listed later in the markup are drawn on top).
|
||||
- Pick the simplest shape that captures each part's silhouette: `<circle>`/`<ellipse>` for round parts, `<rect>` for rectangular parts, `<polygon>` for angular parts (beaks, ears, leaves, roofs). For a rounded organic outline that no primitive can approximate well (a body silhouette, a wing, a leaf edge), use a short `<path>` with 2-4 smooth curve commands (`Q` or `C`) instead of a plain circle - a few deliberate curve points beat a distorted primitive.
|
||||
- Never fall back to drawing an unrelated blob plus a few disconnected decorative lines when a subject is hard to render - always decompose it into the named parts above instead, even if each part is crude.
|
||||
|
||||
## Output rules
|
||||
- Always wrap the SVG markup in a fenced code block labeled `svg`, e.g. ```svg ... ```.
|
||||
- Keep any explanation brief and place it OUTSIDE the code block, never mixed inside the SVG.
|
||||
- Output exactly one `<svg>...</svg>` element per code block.
|
||||
|
||||
## Structure rules
|
||||
- Always include a `viewBox` attribute (e.g. `viewBox="0 0 24 24"`). Do not hardcode large pixel `width`/`height`; if you set them, make them match the viewBox aspect ratio.
|
||||
- Use a small, round coordinate system (0-24 or 0-100).
|
||||
- Round coordinate values to at most 2 decimal places.
|
||||
- Keep every `<path>` short (at most about 6 commands) - long, intricate paths are where quality breaks down most often.
|
||||
- Group related elements with `<g>` when it clarifies structure, but do not over-nest.
|
||||
|
||||
## Color rules
|
||||
- For single-color icons, use `fill="currentColor"` (or `stroke="currentColor"` for line icons) so the icon inherits the surrounding text color.
|
||||
- For multi-color illustrations, use explicit hex colors (e.g. `#4A90D9`) - never named CSS colors like `"blue"`.
|
||||
- Avoid gradients, filters, and blend modes unless the user explicitly asks for a glossy/gradient style - they rarely render correctly from generated coordinates.
|
||||
|
||||
## Accessibility
|
||||
- Include a `<title>` element as the first child of `<svg>` describing what the icon depicts.
|
||||
|
||||
## Security (mandatory - do not violate)
|
||||
- Never include `<script>`, `<foreignObject>`, event handler attributes (`onload`, `onclick`, etc.), or `<a>`/`href` links to external URLs.
|
||||
- Never reference external resources (no `xlink:href` to remote URLs, no `<image>` with external `src`).
|
||||
|
||||
## Example 1 - simple geometric icon (single color, primitives only)
|
||||
|
||||
Request: "Sun icon"
|
||||
|
||||
Parts: disc (circle), 8 rays (lines).
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Sun</title>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="12" y1="1" x2="12" y2="4"/>
|
||||
<line x1="12" y1="20" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="4" y2="12"/>
|
||||
<line x1="20" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
|
||||
</g>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Example 2 - organic subject (multi-color, decomposed into parts)
|
||||
|
||||
Request: "Bird icon"
|
||||
|
||||
Parts: body (rounded outline via a short curved path), head (circle), beak (small triangle), eye (dot), wing (small curved path) - each drawn as its own element, layered head/beak/eye on top of the body.
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Bird</title>
|
||||
<path d="M4 16 Q4 10 11 9 Q18 8 20 13 Q20 17 14 18 Q7 19 4 16 Z" fill="#4A90D9"/>
|
||||
<path d="M8 14 Q5 12 3 14 Q6 16 9 16 Z" fill="#3576B8"/>
|
||||
<circle cx="17" cy="10" r="3.2" fill="#4A90D9"/>
|
||||
<polygon points="20,10 23,9.3 20,11.4" fill="#F2B807"/>
|
||||
<circle cx="18" cy="9" r="0.6" fill="#1A1A1A"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Follow this pattern for every request: list the parts first, represent each with the simplest adequate shape, then assemble - never skip straight to unstructured shapes.
|
||||
"""
|
||||
|
||||
SKILL_CREATOR_FILENAME = "skill-creator.md"
|
||||
|
||||
SKILL_CREATOR_CONTENT = """---
|
||||
name: Skill Creator
|
||||
description: Interactively interviews you and drafts a new WFS chat skill (.md system prompt) ready to save.
|
||||
---
|
||||
|
||||
You are a skill-design assistant embedded in ComfyUI Workflow Studio's AI TOOL Chat pane. Your job is to interactively help the user design a NEW "skill" - a markdown file with frontmatter (`name`, `description`) followed by system-prompt instructions. Once saved, a skill can be selected from a dropdown and is silently prepended to future chat messages as a system prompt.
|
||||
|
||||
## How to conduct the interview
|
||||
- Ask ONE focused question at a time, never a bulleted list of questions. Wait for the user's answer before asking the next one.
|
||||
- Cover, in this order, only what is not already clear from context:
|
||||
1. What task or domain should the skill specialize in (what should the model do differently once this skill is active)?
|
||||
2. What output format is expected - plain prose, a specific fenced code block language, a fixed structure, a length limit?
|
||||
3. Any hard constraints or things to avoid (tone, forbidden content, security concerns, things past attempts got wrong)?
|
||||
4. Would a short worked example help smaller/local models follow the pattern more reliably? If yes, ask what a good example input/output looks like, or offer to draft one.
|
||||
- Keep it short - 3 to 5 questions is usually enough. If the user says "just generate it", "that's enough", or similar at any point, stop asking and produce the best skill you can from what you already have.
|
||||
|
||||
## Output format for the finished skill
|
||||
- Once you have enough information, output the complete skill file in a single fenced code block labeled `skill` (```skill ... ```). Never mix prose inside that block - explanation goes outside it.
|
||||
- The block must start with frontmatter exactly in this shape:
|
||||
---
|
||||
name: <short display name, shown in the skill dropdown>
|
||||
description: <one sentence, shown as a tooltip in the dropdown>
|
||||
---
|
||||
- After the frontmatter, write clear, imperative system-prompt instructions for the target skill: what role the model should take, concrete output-format rules, hard constraints, and - if useful - one short worked example following a "request -> ideal output" shape.
|
||||
- After the code block, tell the user in one line that they can click the "Save as new skill" button below the message to add it, or copy the block manually into a new skill.
|
||||
"""
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
"""Extract a shallow `key: value` frontmatter block, if present."""
|
||||
m = _FRONTMATTER_RE.match(content)
|
||||
if not m:
|
||||
return {}
|
||||
fields: dict = {}
|
||||
for line in m.group(1).splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key:
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
class SkillService:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills_dir = skills_dir
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_default_skill()
|
||||
|
||||
def _seed_default_skill(self) -> None:
|
||||
"""Seed a starter skill file the first time the directory is empty."""
|
||||
try:
|
||||
has_any = any(
|
||||
p.is_file() and p.suffix.lstrip(".").lower() in ALLOWED_EXTS
|
||||
for p in self.skills_dir.iterdir()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
has_any = False
|
||||
if not has_any:
|
||||
(self.skills_dir / DEFAULT_SKILL_FILENAME).write_text(
|
||||
DEFAULT_SKILL_CONTENT, encoding="utf-8"
|
||||
)
|
||||
(self.skills_dir / SKILL_CREATOR_FILENAME).write_text(
|
||||
SKILL_CREATOR_CONTENT, encoding="utf-8"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(self) -> list[dict]:
|
||||
"""Return sorted list of skill files with parsed frontmatter metadata."""
|
||||
if not self.skills_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for path in sorted(self.skills_dir.glob("*")):
|
||||
if not (path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS):
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
content = ""
|
||||
meta = _parse_frontmatter(content)
|
||||
files.append({
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
})
|
||||
return files
|
||||
|
||||
def get_content(self, filename: str) -> str:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def save_file(self, filename: str, content: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
meta = _parse_frontmatter(content)
|
||||
return {
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
|
||||
def delete_file(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
|
||||
def _safe_path(self, filename: str) -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
# Skills are a flat directory - reject any path separator (no subfolders).
|
||||
name = filename.strip()
|
||||
if "/" in name or "\\" in name or name in (".", ".."):
|
||||
return None
|
||||
ext = Path(name).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.skills_dir / name
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tagger タグ DB サービス(SQLite)。"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
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 delete_bulk(self, row_ids: List[int]) -> int:
|
||||
if not row_ids:
|
||||
return 0
|
||||
|
||||
placeholders = ",".join("?" for _ in row_ids)
|
||||
|
||||
with self._conn() as conn:
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM images WHERE id IN ({placeholders})",
|
||||
row_ids,
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
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,510 @@
|
||||
"""WD Tagger / DeepDanbooru / Ollama VLM 推論サービス。"""
|
||||
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .url_guard import HINT as _URL_HINT, is_allowed_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, PngImagePlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_model_lock = threading.Lock()
|
||||
|
||||
# api_url is client-supplied per request, so every backend (Ollama / OpenAI-compatible / Unsloth)
|
||||
# goes through url_guard: loopback or WFS_ALLOWED_BACKEND_HOSTS only, no redirects. This also
|
||||
# stops the Unsloth API key (Authorization header) from being sent to an arbitrary server.
|
||||
|
||||
|
||||
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
|
||||
if len(shape) == 4:
|
||||
if shape[1] == 3 and isinstance(shape[2], int) and shape[2] > 0:
|
||||
fmt = "NCHW"
|
||||
size = int(shape[2])
|
||||
elif shape[3] == 3 and isinstance(shape[1], int) and shape[1] > 0:
|
||||
fmt = "NHWC"
|
||||
size = int(shape[1])
|
||||
|
||||
img = ImageOps.exif_transpose(img)
|
||||
# WD Tagger系標準の前処理: 透過は白背景で合成 → アスペクト比を維持したまま
|
||||
# 白背景で正方形にパディング → リサイズ → RGB→BGR → 0-255のfloat32のまま(NHWC)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
canvas = Image.new("RGBA", img.size, (255, 255, 255))
|
||||
canvas.paste(img, mask=img)
|
||||
img = canvas.convert("RGB")
|
||||
|
||||
w, h = img.size
|
||||
max_dim = max(w, h)
|
||||
padded = Image.new("RGB", (max_dim, max_dim), (255, 255, 255))
|
||||
padded.paste(img, ((max_dim - w) // 2, (max_dim - h) // 2))
|
||||
if max_dim != size:
|
||||
padded = padded.resize((size, size), Image.Resampling.BICUBIC)
|
||||
|
||||
arr = np.array(padded, dtype=np.float32)
|
||||
arr = np.ascontiguousarray(arr[:, :, ::-1]) # RGB -> BGR
|
||||
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
|
||||
|
||||
# ── VLM Tagging (Ollama / LM Studio / Lemonade / Unsloth) ──
|
||||
|
||||
# キー未設定でUnslothに拒否された場合の案内(py/routes/unsloth_routes.pyと同文)
|
||||
_UNSLOTH_KEYLESS_REJECTED = (
|
||||
"Unsloth rejected the request without an API key (HTTP 401). Either set UNSLOTH_API_KEY "
|
||||
"in the plugin's .env (copy .env.example) and restart ComfyUI, or turn on Keyless API "
|
||||
"access -> \"Chat and inference\" in Unsloth Desktop's Settings -> API."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _unsloth_api_key() -> Optional[str]:
|
||||
"""UNSLOTH_API_KEY を環境変数(.env)から取得。未設定ならNone — その場合はAuthorization無しで
|
||||
送る(Unsloth DesktopのKeyless API access → "Chat and inference" がオンなら通る)。"""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
@staticmethod
|
||||
def _strip_thinking_tags(text: str) -> str:
|
||||
text = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<thinking>[\s\S]*?</thinking>", "", text, flags=re.IGNORECASE)
|
||||
return text.strip()
|
||||
|
||||
def vlm_models(self, backend: str, api_url: str) -> list:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
if not is_allowed_backend_url(api_url):
|
||||
logger.error("vlm_models: %s", _URL_HINT)
|
||||
return []
|
||||
if backend == "ollama":
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with _guarded_urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
|
||||
headers = {}
|
||||
if backend == "unsloth":
|
||||
key = self._unsloth_api_key()
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/v1/models", headers=headers)
|
||||
with _guarded_urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["id"] for m in data.get("data", []))
|
||||
except Exception as e:
|
||||
if (backend == "unsloth" and isinstance(e, urllib.error.HTTPError) and e.code == 401
|
||||
and not self._unsloth_api_key()):
|
||||
logger.error("vlm_models: %s", self._UNSLOTH_KEYLESS_REJECTED)
|
||||
return []
|
||||
logger.error("vlm_models: %s", e)
|
||||
return []
|
||||
|
||||
def vlm_predict(self, image_b64: str, backend: str, api_url: str, model: str, prompt: str, max_tags: int = 40,
|
||||
thinking_mode: bool = False, max_tokens: int = 0) -> dict:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
if not is_allowed_backend_url(api_url):
|
||||
return {"error": _URL_HINT}
|
||||
if backend == "ollama":
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
|
||||
"stream": False,
|
||||
"think": bool(thinking_mode),
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096},
|
||||
}
|
||||
if max_tokens > 0:
|
||||
body["options"]["num_predict"] = max_tokens
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with _guarded_urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
content = data.get("message", {}).get("content", "")
|
||||
else:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if backend == "unsloth":
|
||||
key = self._unsloth_api_key()
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}},
|
||||
]}],
|
||||
"stream": False,
|
||||
}
|
||||
if max_tokens > 0:
|
||||
body["max_tokens"] = max_tokens
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/v1/chat/completions",
|
||||
data=payload,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with _guarded_urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
content = message.get("content", "")
|
||||
# Unslothはreasoningを別フィールドで返すため<think>タグに畳んで統一的に扱う
|
||||
reasoning = message.get("reasoning_content")
|
||||
if reasoning:
|
||||
content = f"<think>{reasoning}</think>{content}"
|
||||
|
||||
if not thinking_mode:
|
||||
content = self._strip_thinking_tags(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("vlm_predict: %s", e)
|
||||
if (backend == "unsloth" and isinstance(e, urllib.error.HTTPError) and e.code == 401
|
||||
and not self._unsloth_api_key()):
|
||||
return {"error": self._UNSLOTH_KEYLESS_REJECTED}
|
||||
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, vlm_backend: str, vlm_api_url: str, ollama_model: str, ollama_prompt: str,
|
||||
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service,
|
||||
thinking_mode: bool = False, max_tokens: int = 0) -> 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.vlm_predict(b64, vlm_backend, vlm_api_url, ollama_model, ollama_prompt, ollama_max_tags,
|
||||
thinking_mode, max_tokens)
|
||||
vlm_tags = res2.get("tags", "")
|
||||
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
|
||||
if save_db:
|
||||
db_service.save(str(fpath), interrogator_tags, vlm_tags)
|
||||
if write_file and all_tags:
|
||||
self.write_meta_to_file(str(fpath), all_tags)
|
||||
if write_txt and all_tags:
|
||||
txt_path = fpath.with_suffix(".txt")
|
||||
with open(txt_path, "w", encoding="utf-8") as f:
|
||||
f.write(all_tags)
|
||||
|
||||
n = len([t for t in all_tags.split(",") if t.strip()])
|
||||
self._batch_state["log"].append(f"✓ {fpath.name}: {n} tags")
|
||||
except Exception as e:
|
||||
self._batch_state["log"].append(f"✗ {fpath.name}: {e}")
|
||||
self._batch_state["done"] += 1
|
||||
|
||||
self._batch_state["running"] = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"ok": True, "total": len(files)}
|
||||
|
||||
# ── ユーティリティ ────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _decode_b64(b64: str) -> Image.Image:
|
||||
return Image.open(io.BytesIO(base64.b64decode(b64)))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Outbound URL guard for backend proxies (SSRF mitigation).
|
||||
|
||||
Several routes (Ollama chat, Tagger VLM, Eagle, Unsloth relay) connect to a backend whose
|
||||
address is supplied by the browser. Without a check, any caller that can reach ComfyUI could
|
||||
make the server connect to an arbitrary host (cloud metadata, LAN services, file://).
|
||||
|
||||
Allowed targets:
|
||||
- loopback hosts (localhost / 127.0.0.1 / ::1), any port
|
||||
- hosts listed by the operator in the WFS_ALLOWED_BACKEND_HOSTS environment variable
|
||||
(comma-separated "host" or "host:port"; e.g. "192.168.1.20:11434,gpu-box")
|
||||
|
||||
Only http(s) URLs without credentials are accepted, and redirects are never followed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
ALLOWED_HOSTS_ENV = "WFS_ALLOWED_BACKEND_HOSTS"
|
||||
|
||||
HINT = (
|
||||
"Backend URL must point to localhost/127.0.0.1/::1. To use another host, list it in the "
|
||||
f"{ALLOWED_HOSTS_ENV} environment variable (e.g. 192.168.1.20:11434) and restart ComfyUI."
|
||||
)
|
||||
|
||||
|
||||
def _operator_allowlist():
|
||||
raw = os.environ.get(ALLOWED_HOSTS_ENV, "")
|
||||
return {item.strip().lower() for item in raw.split(",") if item.strip()}
|
||||
|
||||
|
||||
def is_allowed_backend_url(url):
|
||||
"""True if url is an http(s) URL without credentials whose host[:port] is permitted."""
|
||||
try:
|
||||
parsed = urlparse((url or "").strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
if parsed.username or parsed.password:
|
||||
return False
|
||||
host = parsed.hostname.lower()
|
||||
if host in LOOPBACK_HOSTS:
|
||||
return True
|
||||
allow = _operator_allowlist()
|
||||
return host in allow or (parsed.port is not None and f"{host}:{parsed.port}" in allow)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def require_backend_url(url):
|
||||
"""Return url stripped of trailing slashes, or raise ValueError(HINT)."""
|
||||
url = (url or "").strip()
|
||||
if not is_allowed_backend_url(url):
|
||||
raise ValueError(HINT)
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
_opener = urllib.request.build_opener(_NoRedirect)
|
||||
|
||||
|
||||
def urlopen(req, timeout):
|
||||
"""urllib urlopen that never follows redirects (a 3xx surfaces as HTTPError)."""
|
||||
return _opener.open(req, timeout=timeout)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Video tab Edit subtab project file management (JSON timeline persistence).
|
||||
|
||||
Same CRUD shape as VideoPlanService (list/get/save/delete with a safe-path
|
||||
helper), kept as its own class rather than sharing a base because the two
|
||||
JSON schemas are unrelated (batch keyframe blocks vs. an ordered clip
|
||||
timeline) — see VideoPlanService's own docstring for the same reasoning.
|
||||
No index-image thumbnail support (unlike VideoPlanService) since the Edit
|
||||
project list is plain text/JSON only for now.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoEditProjectService:
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self.project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.project_dir / name).resolve()
|
||||
project_dir_resolved = self.project_dir.resolve()
|
||||
if resolved != project_dir_resolved and not str(resolved).startswith(str(project_dir_resolved) + "\\") and not str(resolved).startswith(str(project_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(self) -> list[dict]:
|
||||
if not self.project_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.project_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable video edit project %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", path.stem),
|
||||
"clip_count": len(data.get("clips", [])),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_project(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Project not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_project(self, filename: str, data: dict) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def delete_project(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Video tab plan file management service (JSON plan + optional PNG index thumbnail).
|
||||
|
||||
Duplicated from LabPlanService rather than shared — the two plan formats are
|
||||
unrelated (batch keyframe columns vs. sequential timeline blocks) and would
|
||||
gain nothing from a forced common base beyond coincidental line-count savings.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable video plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/video_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"block_count": len(data.get("blocks", [])),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Video_index") -> dict:
|
||||
"""Saves the contact-sheet index image into ComfyUI's own output folder,
|
||||
alongside the videos generated during the run (unlike save_index_image,
|
||||
which saves it next to the plan file in plan_dir)."""
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def delete_plan(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Video tab utilities: last-frame capture and animated GIF conversion.
|
||||
|
||||
Runs entirely on dependencies ComfyUI itself already requires — PyAV for
|
||||
decoding (ComfyUI core already needs av>=17.0.0) and Pillow for GIF encoding
|
||||
— so no ffmpeg binary and no new pip installs, unlike a typical ffmpeg-based
|
||||
implementation (e.g. Eagle's video2gif/video-to-frame plugins).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
|
||||
except Exception: # pragma: no cover - Pillow is a hard ComfyUI dependency
|
||||
_RESAMPLE = None
|
||||
|
||||
# Text overlay anchors: 9-grid "<vertical>-<horizontal>" (e.g. "bottom-center").
|
||||
_ANCHOR_V = ("top", "middle", "bottom")
|
||||
_ANCHOR_H = ("left", "center", "right")
|
||||
|
||||
# Fonts tried in order for text overlays. Pillow's built-in default font has no
|
||||
# CJK glyphs (Japanese/Chinese text would render as boxes), so a system font
|
||||
# with CJK coverage is preferred; load_default() is only the last resort.
|
||||
_FONT_CANDIDATES = {
|
||||
"win32": ["meiryo.ttc", "YuGothM.ttc", "NotoSansJP-Regular.ttf", "msgothic.ttc", "arial.ttf"],
|
||||
"darwin": [
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
],
|
||||
"linux": [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _load_overlay_font(size: int):
|
||||
from PIL import ImageFont
|
||||
|
||||
platform = "win32" if sys.platform.startswith("win") else ("darwin" if sys.platform == "darwin" else "linux")
|
||||
for name in _FONT_CANDIDATES[platform]:
|
||||
path = name
|
||||
if platform == "win32" and not os.path.isabs(name):
|
||||
path = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", name)
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default(size=size)
|
||||
|
||||
|
||||
def _render_text_layer(width: int, height: int, ov: dict):
|
||||
"""Renders one overlay's text onto a transparent full-frame RGBA layer, so
|
||||
it can be alpha-composited over every frame in its time range without
|
||||
re-rendering the text per frame (same idea as ComfyUI core's TextOverlay)."""
|
||||
from PIL import ImageColor, ImageDraw
|
||||
|
||||
text = str(ov.get("text") or "").replace("\\n", "\n")
|
||||
layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
if not text.strip():
|
||||
return layer
|
||||
|
||||
size = max(8, round(float(ov.get("font_size") or 6.0) / 100.0 * height))
|
||||
font = _load_overlay_font(size)
|
||||
stroke = max(1, round(size * 0.06)) if ov.get("outline", True) else 0
|
||||
spacing = round(size * 0.2)
|
||||
try:
|
||||
color = ImageColor.getrgb(str(ov.get("color") or "#ffffff"))
|
||||
except ValueError:
|
||||
color = (255, 255, 255)
|
||||
fill = (*color[:3], 255)
|
||||
|
||||
anchor = str(ov.get("anchor") or "bottom-center").split("-")
|
||||
v = anchor[0] if anchor and anchor[0] in _ANCHOR_V else "bottom"
|
||||
h = anchor[1] if len(anchor) > 1 and anchor[1] in _ANCHOR_H else "center"
|
||||
|
||||
draw = ImageDraw.Draw(layer)
|
||||
box = draw.multiline_textbbox((0, 0), text, font=font, spacing=spacing, stroke_width=stroke, align=h)
|
||||
tw, th = box[2] - box[0], box[3] - box[1]
|
||||
margin = round(0.04 * min(width, height))
|
||||
x = {"left": margin, "center": (width - tw) / 2, "right": width - margin - tw}[h] - box[0]
|
||||
y = {"top": margin, "middle": (height - th) / 2, "bottom": height - margin - th}[v] - box[1]
|
||||
|
||||
if ov.get("background"):
|
||||
pad = round(size * 0.3)
|
||||
draw.rounded_rectangle(
|
||||
(x + box[0] - pad, y + box[1] - pad, x + box[2] + pad, y + box[3] + pad),
|
||||
radius=pad, fill=(0, 0, 0, 140),
|
||||
)
|
||||
draw.multiline_text((x, y), text, font=font, fill=fill, spacing=spacing, align=h,
|
||||
stroke_width=stroke, stroke_fill=(0, 0, 0, 255))
|
||||
return layer
|
||||
|
||||
|
||||
def _fit_layer(img, base_w: int, base_h: int, pip: dict):
|
||||
"""Scales a picture-in-picture frame to its target width (a fraction of the
|
||||
base frame's width, aspect preserved), applies opacity, and returns
|
||||
(layer, left, top) with the position of its top-left corner on the base
|
||||
frame (pip["x"]/["y"] are the layer's centre as fractions of the frame)."""
|
||||
layer = img.convert("RGBA")
|
||||
pw = max(2, int(round(float(pip["scale"]) * base_w)))
|
||||
ph = max(2, int(round(pw * layer.height / max(1, layer.width))))
|
||||
layer = layer.resize((pw, ph), _RESAMPLE)
|
||||
opacity = float(pip.get("opacity", 1.0))
|
||||
if opacity < 0.999:
|
||||
layer.putalpha(layer.getchannel("A").point(lambda a: int(a * opacity)))
|
||||
left = int(round(float(pip["x"]) * base_w - pw / 2))
|
||||
top = int(round(float(pip["y"]) * base_h - ph / 2))
|
||||
return layer, left, top
|
||||
|
||||
|
||||
class _PipSource:
|
||||
"""One overlay clip's frame source. Images are decoded once; videos are
|
||||
decoded sequentially (seek once to the in-file start, then only forward),
|
||||
since the base video is also processed strictly in time order. After a
|
||||
video's last frame it holds that frame until the overlay's end."""
|
||||
|
||||
def __init__(self, path: Path, pip: dict, base_w: int, base_h: int):
|
||||
import av # type: ignore
|
||||
|
||||
self.pip = pip
|
||||
self.base_w, self.base_h = base_w, base_h
|
||||
self.start = float(pip["start"])
|
||||
self.end = self.start + float(pip["length"])
|
||||
self.src_start = max(0.0, float(pip.get("src_start") or 0.0))
|
||||
self.container = None
|
||||
self._cache_key = None
|
||||
self._cache = None
|
||||
if pip.get("kind") == "image":
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
self.static = _fit_layer(im, base_w, base_h, pip)
|
||||
else:
|
||||
self.static = None
|
||||
self.container = av.open(str(path))
|
||||
self.stream = self.container.streams.video[0]
|
||||
if self.src_start > 0.05:
|
||||
self.container.seek(int(self.src_start * 1_000_000))
|
||||
self.gen = self.container.decode(self.stream)
|
||||
self.cur = None
|
||||
self.nxt = None
|
||||
self.done = False
|
||||
|
||||
def _time(self, frame) -> float:
|
||||
return float(frame.pts * self.stream.time_base) if frame.pts is not None else 0.0
|
||||
|
||||
def layer_at(self, t: float):
|
||||
"""(layer, left, top) for timeline time t, or None when inactive."""
|
||||
if not (self.start <= t < self.end):
|
||||
return None
|
||||
if self.static is not None:
|
||||
return self.static
|
||||
target = self.src_start + (t - self.start)
|
||||
while True:
|
||||
if self.nxt is None and not self.done:
|
||||
try:
|
||||
self.nxt = next(self.gen)
|
||||
except StopIteration:
|
||||
self.done = True
|
||||
if self.nxt is not None and self._time(self.nxt) <= target + 1e-3:
|
||||
self.cur, self.nxt = self.nxt, None
|
||||
continue
|
||||
break
|
||||
frame = self.cur or self.nxt
|
||||
if frame is None:
|
||||
return None
|
||||
key = id(frame)
|
||||
if key != self._cache_key:
|
||||
self._cache = _fit_layer(frame.to_image(), self.base_w, self.base_h, self.pip)
|
||||
self._cache_key = key
|
||||
return self._cache
|
||||
|
||||
def close(self):
|
||||
if self.container is not None:
|
||||
self.container.close()
|
||||
self.container = None
|
||||
|
||||
|
||||
def _paste_layer(base, layer, left: int, top: int):
|
||||
"""Alpha-composites `layer` onto the RGBA `base` at (left, top), clipping
|
||||
whatever falls outside the frame (a plain alpha_composite() dest can't be
|
||||
negative)."""
|
||||
x0, y0 = max(0, left), max(0, top)
|
||||
x1, y1 = min(base.width, left + layer.width), min(base.height, top + layer.height)
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return
|
||||
piece = layer.crop((x0 - left, y0 - top, x1 - left, y1 - top))
|
||||
region = base.crop((x0, y0, x1, y1))
|
||||
region.alpha_composite(piece)
|
||||
base.paste(region, (x0, y0))
|
||||
|
||||
|
||||
class VideoService:
|
||||
def _resolve_media_path(self, filename: str, subfolder: str, type_: str) -> Path:
|
||||
"""Resolves a ComfyUI-style {filename, subfolder, type} reference to an
|
||||
absolute path, guarding against path traversal outside the matching
|
||||
input/output/temp directory."""
|
||||
import folder_paths # type: ignore
|
||||
|
||||
base_dir = folder_paths.get_directory_by_type(type_)
|
||||
if not base_dir:
|
||||
raise ValueError(f"Invalid type: {type_}")
|
||||
base = Path(base_dir).resolve()
|
||||
target = (base / (subfolder or "") / filename).resolve()
|
||||
try:
|
||||
target.relative_to(base)
|
||||
except ValueError:
|
||||
raise ValueError("Access denied: path outside allowed directory")
|
||||
if not target.is_file():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return target
|
||||
|
||||
def save_frame_to_output(self, image_base64: str, filename_prefix: str = "video/Frame") -> dict:
|
||||
"""Saves a client-captured video frame (PNG data URL) into ComfyUI's own
|
||||
output folder — same auto-numbered pattern as Lab's index-image save
|
||||
(see LabPlanService.save_index_image_to_output)."""
|
||||
import folder_paths # type: ignore
|
||||
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def probe_video(self, filename: str, subfolder: str, type_: str) -> dict:
|
||||
"""Reads duration/dimensions/fps/audio presence via PyAV only (no ffmpeg
|
||||
subprocess) — used by the Video Edit tab right after a clip is added, so
|
||||
the timeline UI can show its length and the export-time resolution-match
|
||||
check has real numbers to compare against."""
|
||||
import av # type: ignore
|
||||
|
||||
src_path = self._resolve_media_path(filename, subfolder, type_)
|
||||
container = av.open(str(src_path))
|
||||
try:
|
||||
stream = container.streams.video[0]
|
||||
fps = float(stream.average_rate) if stream.average_rate else 0.0
|
||||
if stream.duration is not None and stream.time_base is not None:
|
||||
duration = float(stream.duration * stream.time_base)
|
||||
elif container.duration is not None:
|
||||
duration = float(container.duration) / 1_000_000
|
||||
else:
|
||||
duration = 0.0
|
||||
width = stream.codec_context.width
|
||||
height = stream.codec_context.height
|
||||
has_audio = len(container.streams.audio) > 0
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": fps,
|
||||
"has_audio": has_audio,
|
||||
}
|
||||
|
||||
def convert_to_gif(
|
||||
self,
|
||||
filename: str,
|
||||
subfolder: str,
|
||||
type_: str,
|
||||
start_time: float = 0.0,
|
||||
end_time=None,
|
||||
fps: float = 10.0,
|
||||
max_width=None,
|
||||
filename_prefix: str = "video/GIF",
|
||||
) -> dict:
|
||||
"""Decodes the given video (PyAV), samples it down to `fps`, optionally
|
||||
downscales to `max_width`, and encodes the result as an animated GIF
|
||||
(Pillow) into ComfyUI's own output folder."""
|
||||
import av # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
|
||||
if _RESAMPLE is None:
|
||||
raise RuntimeError("Pillow is not available")
|
||||
|
||||
src_path = self._resolve_media_path(filename, subfolder, type_)
|
||||
fps = max(0.1, float(fps))
|
||||
|
||||
container = av.open(str(src_path))
|
||||
try:
|
||||
stream = container.streams.video[0]
|
||||
source_fps = float(stream.average_rate) if stream.average_rate else 24.0
|
||||
step = max(1, round(source_fps / fps))
|
||||
|
||||
pil_frames = []
|
||||
idx = 0
|
||||
for frame in container.decode(stream):
|
||||
t = float(frame.pts * stream.time_base) if frame.pts is not None else None
|
||||
if t is not None:
|
||||
if t < start_time:
|
||||
idx += 1
|
||||
continue
|
||||
if end_time is not None and t > end_time:
|
||||
break
|
||||
if idx % step == 0:
|
||||
im = frame.to_image()
|
||||
if max_width and im.width > max_width:
|
||||
ratio = max_width / im.width
|
||||
im = im.resize((max_width, max(1, round(im.height * ratio))), _RESAMPLE)
|
||||
pil_frames.append(im.convert("RGB"))
|
||||
idx += 1
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
if not pil_frames:
|
||||
raise ValueError("No frames found in the specified range")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, out_filename, counter, out_subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{out_filename}_{counter:05}_.gif"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
|
||||
duration_ms = max(20, round(1000 / fps))
|
||||
pil_frames[0].save(
|
||||
save_path,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=pil_frames[1:],
|
||||
duration=duration_ms,
|
||||
loop=0,
|
||||
optimize=True,
|
||||
)
|
||||
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": len(pil_frames)}
|
||||
|
||||
def overlay_text_on_video(
|
||||
self,
|
||||
filename: str,
|
||||
subfolder: str,
|
||||
type_: str,
|
||||
overlays: list,
|
||||
filename_prefix: str = "video/wfm_edit",
|
||||
delete_source: bool = False,
|
||||
pips: list = None,
|
||||
) -> dict:
|
||||
"""Burns timed text overlays - and picture-in-picture overlay clips
|
||||
(video or image, placed/scaled/faded on the frame) - into a video
|
||||
(Video Edit tab, Phase 3 + overlay track).
|
||||
|
||||
ComfyUI core has no time-ranged video text node (core TextOverlay draws
|
||||
on every frame of an IMAGE batch, top/bottom only), and routing the
|
||||
whole timeline through IMAGE tensors would hold every decoded frame in
|
||||
memory at once — so this streams instead: decode one frame, composite
|
||||
any overlay whose [start, end) covers it, encode, repeat. Audio packets
|
||||
are remuxed untouched (no audio re-encode), so the soundtrack built by
|
||||
the export graph (original audio / BGM) survives this pass as-is.
|
||||
|
||||
overlays: [{text, start, end, font_size (% of frame height), color,
|
||||
anchor ("<top|middle|bottom>-<left|center|right>"), outline, background}]
|
||||
with start/end in seconds from the start of the video.
|
||||
|
||||
pips: [{filename, subfolder, type (input), kind ("video"|"image"),
|
||||
src_start (in-file start, s), start (timeline s), length (s),
|
||||
x, y (centre, fractions of the frame), scale (width as a fraction of
|
||||
the frame width), opacity (0..1)}]. Overlay audio is not mixed.
|
||||
Pips are drawn first (in list order), then text on top.
|
||||
"""
|
||||
import av # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
|
||||
src_path = self._resolve_media_path(filename, subfolder, type_)
|
||||
items = []
|
||||
for ov in overlays or []:
|
||||
try:
|
||||
start = max(0.0, float(ov.get("start") or 0.0))
|
||||
end = float(ov.get("end") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end > start and str(ov.get("text") or "").strip():
|
||||
items.append((start, end, ov))
|
||||
valid_pips = []
|
||||
for pip in pips or []:
|
||||
try:
|
||||
if float(pip["length"]) > 0 and float(pip["scale"]) > 0 and pip.get("filename"):
|
||||
valid_pips.append(pip)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not items and not valid_pips:
|
||||
raise ValueError("No valid text or overlay clips")
|
||||
# Resolve overlay files up front so a missing one fails before any encoding.
|
||||
pip_paths = [
|
||||
self._resolve_media_path(p["filename"], p.get("subfolder", ""), p.get("type", "input"))
|
||||
for p in valid_pips
|
||||
]
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, out_filename, counter, out_subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{out_filename}_{counter:05}_.mp4"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
|
||||
in_c = av.open(str(src_path))
|
||||
out_c = None
|
||||
pip_sources = []
|
||||
try:
|
||||
in_v = in_c.streams.video[0]
|
||||
in_a = in_c.streams.audio[0] if in_c.streams.audio else None
|
||||
width, height = in_v.codec_context.width, in_v.codec_context.height
|
||||
rate = in_v.average_rate or in_v.guessed_rate or 24
|
||||
|
||||
pip_sources = [_PipSource(path, pip, width, height) for path, pip in zip(pip_paths, valid_pips)]
|
||||
layers = [(s, e, _render_text_layer(width, height, ov)) for s, e, ov in items]
|
||||
|
||||
out_c = av.open(str(save_path), mode="w")
|
||||
out_v = out_c.add_stream("libx264", rate=rate)
|
||||
out_v.width = width
|
||||
out_v.height = height
|
||||
out_v.pix_fmt = "yuv420p"
|
||||
out_v.options = {"crf": "18", "preset": "medium"}
|
||||
out_a = out_c.add_stream_from_template(in_a) if in_a is not None else None
|
||||
|
||||
streams = [in_v] + ([in_a] if in_a is not None else [])
|
||||
first_pts = None
|
||||
index = 0
|
||||
for packet in in_c.demux(*streams):
|
||||
if packet.stream.type == "audio":
|
||||
if packet.dts is None:
|
||||
continue
|
||||
packet.stream = out_a
|
||||
out_c.mux(packet)
|
||||
continue
|
||||
for frame in packet.decode():
|
||||
if frame.pts is not None and first_pts is None:
|
||||
first_pts = frame.pts
|
||||
t = float((frame.pts - first_pts) * in_v.time_base) if frame.pts is not None else index / float(rate)
|
||||
active = [layer for s, e, layer in layers if s <= t < e]
|
||||
pip_layers = [pl for pl in (src.layer_at(t) for src in pip_sources) if pl is not None]
|
||||
if active or pip_layers:
|
||||
img = frame.to_image().convert("RGBA")
|
||||
for layer, left, top in pip_layers:
|
||||
_paste_layer(img, layer, left, top)
|
||||
for layer in active:
|
||||
img.alpha_composite(layer)
|
||||
out_frame = av.VideoFrame.from_image(img.convert("RGB"))
|
||||
else:
|
||||
out_frame = frame
|
||||
out_frame.pts = index
|
||||
out_frame.time_base = out_v.codec_context.time_base
|
||||
index += 1
|
||||
for out_packet in out_v.encode(out_frame):
|
||||
out_c.mux(out_packet)
|
||||
for out_packet in out_v.encode(None):
|
||||
out_c.mux(out_packet)
|
||||
except BaseException:
|
||||
if out_c is not None:
|
||||
out_c.close()
|
||||
out_c = None
|
||||
save_path.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
if out_c is not None:
|
||||
out_c.close()
|
||||
for src in pip_sources:
|
||||
src.close()
|
||||
in_c.close()
|
||||
|
||||
# The export graph's intermediate file is only deleted when the caller
|
||||
# explicitly asks and it's one of this tab's own "_pre" intermediates in
|
||||
# output — never an arbitrary user file.
|
||||
if delete_source and type_ == "output" and Path(filename).name.startswith("wfm_edit_pre"):
|
||||
try:
|
||||
src_path.unlink()
|
||||
except OSError as e:
|
||||
logger.warning("Could not delete intermediate %s: %s", src_path, e)
|
||||
|
||||
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": index}
|
||||
@@ -3,6 +3,35 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
# プロンプトを直接持つノードタイプ(CLIPTextEncode以外の複合ノード含む)
|
||||
_PROMPT_NODE_TYPES = {
|
||||
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner",
|
||||
"CLIPTextEncodeEditPlus", "TextEncodeQwenImageEditPlus", "TextEncodeMageFlowEdit",
|
||||
"SDXLPromptStyler", "SDXLPromptStylerAdvanced", "ImageMetadataPromptLoader",
|
||||
"WFS_PromptText", "ImpactWildcardEncode", "ImpactWildcardProcessor",
|
||||
}
|
||||
|
||||
# 画像を最終出力として保存するノードタイプ
|
||||
_OUTPUT_IMAGE_NODE_TYPES = {"SaveImage", "SaveImageAdvanced"}
|
||||
|
||||
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
|
||||
_CLIP_TYPE_TO_MODEL = {
|
||||
"flux": "Flux",
|
||||
"flux2": "Flux2",
|
||||
"newbie": "NewBie",
|
||||
"ovis": "Ovis",
|
||||
"qwen_image": "Qwen",
|
||||
"sd3": "SD3",
|
||||
"sd3.5": "SD3",
|
||||
"hidream_i": "HiDream",
|
||||
"hidream_e": "HiDream",
|
||||
"hidream": "HiDream",
|
||||
"wan": "Wan",
|
||||
"cosmos": "Cosmos",
|
||||
"lumina": "Lumina",
|
||||
"lumina2": "Z-IMAGE",
|
||||
}
|
||||
|
||||
|
||||
def _model_name_from_ui_node(node):
|
||||
"""Extract model filename from UI format node (lowercase)."""
|
||||
@@ -11,16 +40,65 @@ def _model_name_from_ui_node(node):
|
||||
return val.lower() if isinstance(val, str) else ""
|
||||
|
||||
|
||||
def _clip_type_from_ui_node(node):
|
||||
"""Extract CLIPLoader type string from widgets_values (lowercase)."""
|
||||
ntype = node.get("type", "")
|
||||
vals = node.get("widgets_values", [])
|
||||
if ntype == "CLIPLoader" and len(vals) > 1:
|
||||
return str(vals[1]).lower() if isinstance(vals[1], str) else ""
|
||||
if ntype in ("DualCLIPLoader", "TripleCLIPLoader") and len(vals) > 2:
|
||||
return str(vals[2]).lower() if isinstance(vals[2], str) else ""
|
||||
return ""
|
||||
|
||||
|
||||
def _model_name_from_api_node(node):
|
||||
"""Extract model filename from API format node (lowercase)."""
|
||||
inputs = node.get("inputs", {})
|
||||
for key in ("ckpt_name", "unet_name", "model_name"):
|
||||
for key in ("ckpt_name", "unet_name", "model_name", "clip_name1", "clip_name"):
|
||||
val = inputs.get(key, "")
|
||||
if isinstance(val, str) and val:
|
||||
return val.lower()
|
||||
return ""
|
||||
|
||||
|
||||
def _collect_all_ui_nodes(workflow_data):
|
||||
"""Yield all UI-format nodes including those inside definitions.subgraphs."""
|
||||
for node in workflow_data.get("nodes", []):
|
||||
yield node
|
||||
defs = workflow_data.get("definitions", {})
|
||||
if isinstance(defs, dict):
|
||||
for sg in defs.get("subgraphs", []):
|
||||
if isinstance(sg, dict):
|
||||
for node in sg.get("nodes", []):
|
||||
yield node
|
||||
|
||||
|
||||
def _detect_model_type_from_name(mn_base, model_types):
|
||||
"""Detect model type from a model filename (already lowercased basename)."""
|
||||
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
|
||||
model_types.add("SD1.5")
|
||||
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
|
||||
model_types.add("SDXL")
|
||||
elif re.search(r"flux[-_.]?2", mn_base):
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in mn_base:
|
||||
model_types.add("Flux")
|
||||
elif re.search(r"sd[._-]?3", mn_base):
|
||||
model_types.add("SD3")
|
||||
elif "qwen" in mn_base:
|
||||
model_types.add("Qwen")
|
||||
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
|
||||
model_types.add("Z-IMAGE")
|
||||
elif "newbie" in mn_base:
|
||||
model_types.add("NewBie")
|
||||
elif "ovis" in mn_base:
|
||||
model_types.add("Ovis")
|
||||
elif "hidream" in mn_base:
|
||||
model_types.add("HiDream")
|
||||
elif "wan" in mn_base and re.search(r"wan[-_.]?\d|wan[-_]video", mn_base):
|
||||
model_types.add("Wan")
|
||||
|
||||
|
||||
def analyze_workflow(workflow_data, filename=""):
|
||||
"""Analyze workflow JSON and return model types, input/output node counts."""
|
||||
model_types = set()
|
||||
@@ -36,23 +114,33 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
model_types.add("Qwen")
|
||||
if "zimage" in fn or "z-image" in fn or "zit" in fn:
|
||||
model_types.add("Z-IMAGE")
|
||||
if "flux2" in fn:
|
||||
if re.search(r"flux[-_.]?2", fn):
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in fn:
|
||||
model_types.add("Flux")
|
||||
if "sd3" in fn:
|
||||
model_types.add("SD3")
|
||||
if "newbie" in fn:
|
||||
model_types.add("NewBie")
|
||||
if "ovis" in fn:
|
||||
model_types.add("Ovis")
|
||||
if "hidream" in fn:
|
||||
model_types.add("HiDream")
|
||||
if "wan" in fn and re.search(r"wan[-_.]?\d|wan[-_]video", fn):
|
||||
model_types.add("Wan")
|
||||
|
||||
# Support both UI format (nodes array) and API format (node ID object)
|
||||
raw_nodes = workflow_data.get("nodes", None)
|
||||
if raw_nodes is not None:
|
||||
# UI / app format: collect all nodes including subgraph definitions
|
||||
node_iter = [
|
||||
(
|
||||
node.get("type", ""),
|
||||
node.get("title", "").lower(),
|
||||
_model_name_from_ui_node(node),
|
||||
_clip_type_from_ui_node(node),
|
||||
)
|
||||
for node in raw_nodes
|
||||
for node in _collect_all_ui_nodes(workflow_data)
|
||||
]
|
||||
else:
|
||||
node_iter = []
|
||||
@@ -62,33 +150,32 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
ntype = node.get("class_type", "")
|
||||
title = node.get("_meta", {}).get("title", "").lower()
|
||||
mn = _model_name_from_api_node(node)
|
||||
node_iter.append((ntype, title, mn))
|
||||
node_iter.append((ntype, title, mn, ""))
|
||||
|
||||
for ntype, title, mn in node_iter:
|
||||
for ntype, title, mn, clip_type in node_iter:
|
||||
if (
|
||||
"CheckpointLoader" in ntype
|
||||
or "UNETLoader" in ntype
|
||||
or "UnetLoader" in ntype # UnetLoaderGGUF 等を含む
|
||||
or "unet_name" in ntype
|
||||
):
|
||||
mn_base = os.path.basename(mn.replace("\\", "/"))
|
||||
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
|
||||
model_types.add("SD1.5")
|
||||
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
|
||||
model_types.add("SDXL")
|
||||
elif "flux2" in mn_base:
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in mn_base:
|
||||
model_types.add("Flux")
|
||||
elif re.search(r"sd[._-]?3", mn_base):
|
||||
model_types.add("SD3")
|
||||
elif "qwen" in mn_base:
|
||||
model_types.add("Qwen")
|
||||
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
|
||||
model_types.add("Z-IMAGE")
|
||||
_detect_model_type_from_name(mn_base, model_types)
|
||||
|
||||
# CLIPLoader 系: type フィールドまたはファイル名からモデル種別を検出
|
||||
# QuadrupleCLIPLoader 等、type フィールドを持たない新型ローダーにも対応
|
||||
if "CLIPLoader" in ntype:
|
||||
if clip_type:
|
||||
detected = _CLIP_TYPE_TO_MODEL.get(clip_type)
|
||||
if detected:
|
||||
model_types.add(detected)
|
||||
mn_base = os.path.basename(mn.replace("\\", "/"))
|
||||
if mn_base:
|
||||
_detect_model_type_from_name(mn_base, model_types)
|
||||
|
||||
if "sdxl" in title:
|
||||
model_types.add("SDXL")
|
||||
if "flux2" in title:
|
||||
if re.search(r"flux[-_.]?2", title):
|
||||
model_types.add("Flux2")
|
||||
elif "flux" in title:
|
||||
model_types.add("Flux")
|
||||
@@ -98,26 +185,34 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
model_types.add("Qwen")
|
||||
if "zimage" in title or "z-image" in title or "zit" in title:
|
||||
model_types.add("Z-IMAGE")
|
||||
if "newbie" in title:
|
||||
model_types.add("NewBie")
|
||||
if "ovis" in title:
|
||||
model_types.add("Ovis")
|
||||
if "hidream" in title:
|
||||
model_types.add("HiDream")
|
||||
|
||||
if ntype == "CLIPTextEncode" or "prompt" in title:
|
||||
if ntype in _PROMPT_NODE_TYPES or "prompt" in title:
|
||||
inputs["prompts"] += 1
|
||||
if ntype in ("LoadImage", "LoadImageMask"):
|
||||
inputs["images"] += 1
|
||||
if ntype == "LoadVideo" or "load video" in title:
|
||||
inputs["videos"] += 1
|
||||
if ntype == "SaveImage":
|
||||
if ntype in _OUTPUT_IMAGE_NODE_TYPES:
|
||||
outputs["images"] += 1
|
||||
if ntype in ("SaveVideo", "VH_VideoCombine"):
|
||||
outputs["videos"] += 1
|
||||
|
||||
# Detect workflow format
|
||||
if raw_nodes is not None:
|
||||
# App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
# App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
# A workflow that merely *contains* subgraphs (definitions.subgraphs) is still a
|
||||
# normal node graph (comfyui-workflow.js's convertUiToApi flattens subgraphs before
|
||||
# conversion), so it's treated as "ui" here too — must match detectFormat() in
|
||||
# static/js/comfyui-workflow.js or the App/UI badge shown to the user disagrees
|
||||
# with what GenerateUI actually does with the file.
|
||||
extra = workflow_data.get("extra", {})
|
||||
if (
|
||||
"definitions" in workflow_data
|
||||
or extra.get("linearMode") is True
|
||||
):
|
||||
if extra.get("linearMode") is True:
|
||||
wf_format = "app"
|
||||
elif filename and filename.lower().endswith(".app.json"):
|
||||
wf_format = "app"
|
||||
|
||||
@@ -4,7 +4,7 @@ import jinja2
|
||||
from aiohttp import web
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR, VIDEO_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +41,38 @@ async def serve_workflow_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Lab plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = LAB_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
async def serve_video_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Video plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = VIDEO_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowStudio:
|
||||
"""Main entry point for Workflow Studio plugin."""
|
||||
|
||||
@@ -57,20 +89,33 @@ class WorkflowStudio:
|
||||
# Dynamic workflow file serving (thumbnails etc.)
|
||||
app.router.add_get("/wfm_data/workflows/{filename}", serve_workflow_file)
|
||||
|
||||
# Dynamic Lab plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
|
||||
|
||||
# Dynamic Video plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/video_plan/{filename}", serve_video_plan_file)
|
||||
|
||||
# Main page
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes, video_routes, vram_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
ollama_routes.setup_routes(app)
|
||||
unsloth_routes.setup_routes(app)
|
||||
eagle_routes.setup_routes(app)
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
models_routes.setup_routes(app)
|
||||
gallery_routes.setup_routes(app)
|
||||
wildcard_routes.setup_routes(app)
|
||||
tagger_routes.setup_routes(app)
|
||||
gmic_routes.setup_routes(app)
|
||||
skill_routes.setup_routes(app)
|
||||
lab_routes.setup_routes(app)
|
||||
video_routes.setup_routes(app)
|
||||
vram_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
|
||||
version = "0.2.7"
|
||||
license = "MIT"
|
||||
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.8.2"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -10,7 +10,7 @@ dependencies = ["jinja2"]
|
||||
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "ketle-man"
|
||||
PublisherId = "statsu"
|
||||
DisplayName = "Workflow Studio"
|
||||
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/icon.svg"
|
||||
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/docs/thumb.png"
|
||||
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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
|
||||
|
||||
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
|
||||
python-dotenv>=1.0,<2.0
|
||||
|
||||
# -- Multi-layer PSD export (Gallery tab bulk action) --
|
||||
# Pure-Python, ships prebuilt wheels (no C compiler required on Windows).
|
||||
psd-tools>=1.10
|
||||
|
||||
# -- MP4 thumbnail generation (Gallery tab) --
|
||||
# Uses PyAV (av) to decode the first frame. ComfyUI core already requires av>=17.0.0,
|
||||
# so it is normally pre-installed in python_embeded and no action is needed here.
|
||||
# If missing, mp4 thumbnail generation simply fails and falls back to a placeholder
|
||||
# (Gallery itself keeps working). Not pinned here on purpose — see torch note above.
|
||||
# av>=17.0.0
|
||||
|
||||
# -- Gallery Semantic Search: local EmbeddingGemma 2 (optional) --
|
||||
# Uses onnxruntime (above) + tokenizers + huggingface_hub (both normally pre-installed with ComfyUI's
|
||||
# transformers). The model (~300 MB, q4) is downloaded to the data folder on first use.
|
||||
# GPU (NVIDIA/CUDA): replace onnxruntime with onnxruntime-gpu
|
||||
# pip uninstall onnxruntime onnxruntime-gpu
|
||||
# pip install onnxruntime-gpu
|
||||
# Local EmbeddingGemma 2 then indexes ~45x faster than on the CPU. DirectML is not supported.
|
||||
# tokenizers
|
||||
# huggingface_hub
|
||||
@@ -2,11 +2,45 @@
|
||||
Gallery Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── サブタブ (Output / Style-Prompt) ──────────────────────── */
|
||||
|
||||
.wfm-gallery-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-gallery-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-gallery-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-gallery-panel.active { display: flex; }
|
||||
|
||||
/* ── 3カラムレイアウト ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px));
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
@@ -219,6 +253,61 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* 動画ファイル(mp4)の再生アイコンバッジ(サムネイル中央) */
|
||||
.wfm-gallery-thumb-video-badge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* テーブルビューのお気に入りボタン */
|
||||
.wfm-gallery-table-fav-btn {
|
||||
background: none;
|
||||
@@ -378,6 +467,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;
|
||||
@@ -416,6 +514,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-image-prompt-text {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-gallery-tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -602,6 +714,119 @@
|
||||
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-action-btn-orange {
|
||||
background: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
.wfm-gallery-action-btn-orange:hover {
|
||||
background: #d97f06;
|
||||
border-color: #d97f06;
|
||||
}
|
||||
|
||||
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
|
||||
|
||||
.wfm-gallery-file-actions {
|
||||
@@ -642,8 +867,290 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-gallery-move-filter {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-gallery-move-modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ImagePrompt / Style_Catalog ギャラリー (右ペイン)
|
||||
============================================================ */
|
||||
|
||||
.wfm-imageprompt-detail-panel {
|
||||
width: 520px;
|
||||
min-width: 520px;
|
||||
max-width: 520px;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-left {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-section {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-prompt-box {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wfm-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row .wfm-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
background: rgba(79,142,247,0.15);
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove:hover { opacity: 1; }
|
||||
|
||||
.wfm-imageprompt-final-section {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-final-textarea {
|
||||
margin-top: 4px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* 音声ファイル: 波形画像+<audio> コントロール */
|
||||
.wfm-gallery-audio-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.wfm-gallery-audio-wave {
|
||||
object-fit: contain;
|
||||
max-height: 220px;
|
||||
background: #181c26;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wfm-gallery-audio-player {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 変換ダイアログ */
|
||||
.wfm-gallery-convert-modal {
|
||||
min-width: 360px;
|
||||
max-width: 480px;
|
||||
}
|
||||
.wfm-gallery-convert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.wfm-gallery-convert-row .wfm-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
.wfm-gallery-convert-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wfm-gallery-convert-note,
|
||||
.wfm-gallery-convert-status {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 音声: 詳細パネルのプレビュー枠は固定高さ(正方形)をやめ、波形+プレイヤーを収める */
|
||||
.wfm-gallery-detail-preview--audio {
|
||||
aspect-ratio: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding: 8px;
|
||||
}
|
||||
.wfm-gallery-detail-preview--audio .wfm-gallery-audio-wave {
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
/* ── セマンティック検索 ─────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-sem-toggle.active {
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 2行目(Similar / 更新)のボタン: Taggerの紫と区別できるティール */
|
||||
.wfm-sem-sim {
|
||||
background: #0d9488;
|
||||
border-color: #0d9488;
|
||||
color: #fff;
|
||||
}
|
||||
.wfm-sem-sim:hover {
|
||||
background: #0f766e;
|
||||
border-color: #0f766e;
|
||||
}
|
||||
/* 類似画像表示中(トグルON)は縁取りで状態を示す */
|
||||
#wfm-gallery-similar-btn.active {
|
||||
box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px #0d9488;
|
||||
}
|
||||
|
||||
.wfm-gallery-sem-row {
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.wfm-gallery-sem-count {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-score {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(13, 148, 136, 0.85);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-sem-modal { display: flex; flex-direction: column; gap: 10px; min-width: 420px; }
|
||||
.wfm-sem-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.wfm-sem-folder { font-family: monospace; font-size: 12px; word-break: break-all; opacity: 0.85; }
|
||||
.wfm-sem-hint { font-size: 11px; opacity: 0.7; margin: 0; }
|
||||
.wfm-sem-ok { color: #22c55e; }
|
||||
.wfm-sem-ng { color: #ef4444; }
|
||||
.wfm-sem-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.wfm-sem-bar { height: 8px; border-radius: 4px; background: var(--wfm-border, #444); overflow: hidden; }
|
||||
.wfm-sem-bar-fill { height: 100%; width: 0; background: #0d9488; transition: width 0.3s; }
|
||||
|
||||
/* フォルダツリー: 直下の画像が全件セマンティック索引済みのフォルダに付くドット */
|
||||
.wfm-gallery-tree-sem-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: 4px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* モーダルのヘルプボタンから飛んだ先の説明を一時的に強調 */
|
||||
.wfm-help-flash {
|
||||
animation: wfm-help-flash 2.5s ease-out;
|
||||
}
|
||||
@keyframes wfm-help-flash {
|
||||
0%, 60% { background: rgba(13, 148, 136, 0.35); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
.wfm-sem-local-opts { display: flex; flex-direction: column; gap: 10px; padding: 8px 10px; border: 1px solid var(--wfm-border, #444); border-radius: 6px; }
|
||||
|
||||
/* セマンティック検索モーダル: タブ */
|
||||
.wfm-sem-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--wfm-border, #444); }
|
||||
.wfm-sem-tab-btn {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: var(--wfm-text-secondary, #aaa);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wfm-sem-tab-btn:hover { color: var(--wfm-text, #fff); }
|
||||
.wfm-sem-tab-btn.active {
|
||||
color: var(--wfm-text, #fff);
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
border-color: var(--wfm-border, #444);
|
||||
margin-bottom: -1px;
|
||||
border-bottom: 1px solid var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
.wfm-sem-pane { display: flex; flex-direction: column; gap: 10px; min-height: 230px; }
|
||||
.wfm-sem-footer { justify-content: flex-end; border-top: 1px solid var(--wfm-border, #444); padding-top: 10px; }
|
||||
.wfm-sem-num { width: 80px; }
|
||||
.wfm-sem-danger { margin-left: auto; color: #ef4444; }
|
||||
|
||||
.wfm-sem-row .wfm-select { width: auto; min-width: 90px; }
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
/* =============================================
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
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-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ie-layers-header-row > 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,413 @@
|
||||
/* Lab tab — experimental I2I batch generation with per-column keyframes */
|
||||
|
||||
/* .wfm-gen-subtab-content.active defaults to flex-direction:row (main.css);
|
||||
Lab needs its Setting/Results nav stacked above the panel body. */
|
||||
#wfm-gen-subtab-lab.active {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-lab-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: calc(100% - 33px);
|
||||
}
|
||||
|
||||
.wfm-lab-panel.active { display: flex; }
|
||||
|
||||
.wfm-lab-planjson-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 4px 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------------- Setting layout: left | center (4 columns) | right ---------------- */
|
||||
|
||||
.wfm-lab-setting-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-pane:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-left { width: 220px; flex-shrink: 0; }
|
||||
.wfm-lab-center { flex: 1; padding: 0; }
|
||||
.wfm-lab-right { width: 260px; flex-shrink: 0; overflow-y: auto; }
|
||||
|
||||
.wfm-lab-side-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-plan-workflow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-secondary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-plan-workflow-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-note {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------- Keyframe columns ---------------- */
|
||||
|
||||
.wfm-lab-columns {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
row-gap: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-col-btns { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-col-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Model column header: C/L/V toggle that switches which single field its cells
|
||||
summarize (see _modelViewMode in lab-tab.js) — not part of the Plan data. */
|
||||
.wfm-lab-model-view-toggle { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-model-view-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-model-view-btn:hover { color: var(--wfm-text-primary); border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-model-view-btn.active {
|
||||
color: #fff;
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-lab-col-cells {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell {
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-cell:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-cell-index {
|
||||
font-weight: 600;
|
||||
color: var(--wfm-primary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-value {
|
||||
color: var(--wfm-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-revert .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Setting 1, not yet explicitly overridden: showing the live GenerateUI value instead of
|
||||
its own stored one (see _isLiveDisplay in lab-tab.js). Dashed border distinguishes it
|
||||
from a saved override (solid border) at a glance. */
|
||||
.wfm-lab-cell.wfm-lab-cell-live {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-live .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
/* This keyframe row is skipped entirely (see _resolveKeyframe's bypassed handling) —
|
||||
amber to read as "temporarily disabled", distinct from the neutral gray used for
|
||||
revert/live. Takes priority over both of those in _cellHtml. */
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed {
|
||||
border-color: var(--wfm-warning);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed .wfm-lab-cell-value {
|
||||
color: var(--wfm-warning);
|
||||
}
|
||||
|
||||
/* LoRA node-bypass (strength forced to 0 at apply-time) — red to distinguish from the
|
||||
amber row-level bypassed above; the row itself still applies, only the LoRA doesn't. */
|
||||
.wfm-lab-cell.wfm-lab-cell-node-bypassed {
|
||||
border-color: var(--wfm-danger);
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-node-bypassed .wfm-lab-cell-value {
|
||||
color: var(--wfm-danger);
|
||||
}
|
||||
|
||||
/* Single-click selection target for the column header's up/down reorder buttons —
|
||||
layered as a box-shadow (not border-color) so it never conflicts with the
|
||||
revert/live/bypassed/node-bypassed border-color signaling above. */
|
||||
.wfm-lab-cell.wfm-lab-cell-selected {
|
||||
box-shadow: 0 0 0 2px var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-lab-cell-applied {
|
||||
margin-top: 3px;
|
||||
padding-top: 3px;
|
||||
border-top: 1px dashed var(--wfm-border);
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
/* Shown instead of a keyframe list when the loaded workflow has no node this Model
|
||||
sub-column (checkpoint/lora/vae) could override — see _hasNodeForColumn in lab-tab.js. */
|
||||
.wfm-lab-cell-empty {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-prompt-line {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- Modal form (checkpoint/vae/prompt/ksampler edit) ---------------- */
|
||||
|
||||
.wfm-lab-modal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-btnrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-hint {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------------- Results layout ---------------- */
|
||||
|
||||
.wfm-lab-results-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-results-left {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-results-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-lab-results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
border: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-result-item { position: relative; }
|
||||
|
||||
.wfm-lab-result-index {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/* ============================================================
|
||||
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-th-select, td.wfm-tagger-db-select {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#wfm-tagger-db-th-select input, .wfm-tagger-db-select input {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr.selected {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-edit-panel {
|
||||
flex-shrink: 0;
|
||||
margin-top: 10px;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* ── ユーティリティ ─────────────────────────────────────────── */
|
||||
|
||||
#wfm-tab-tagger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--wfm-header-height, 56px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#wfm-tab-tagger.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wfm-placeholder {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Shared AI backend settings modal (Backend / Connection / Model / Generation / Unload / Save).
|
||||
* Used by the Prompt tab and Tagger tab, each with its own localStorage key so their
|
||||
* settings stay independent — mirrors the AI TOOL tab's own settings pane (ai-tab.js).
|
||||
*/
|
||||
|
||||
import { openModal, closeModal, showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, unslothProxy, unloadAiModel } from "./util.js";
|
||||
import { unloadAllOllamaModels } from "./vram-prepare.js";
|
||||
|
||||
function loadCfg(storageKey) {
|
||||
return readJsonStorage(storageKey);
|
||||
}
|
||||
|
||||
function saveCfg(storageKey, patch) {
|
||||
const data = { ...loadCfg(storageKey), ...patch };
|
||||
localStorage.setItem(storageKey, JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
// 保存済み設定を {backend, url, model, thinkingMode, maxTokens} の形で返す
|
||||
export function getAiBackendConfig(storageKey) {
|
||||
const s = loadCfg(storageKey);
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return {
|
||||
backend,
|
||||
url,
|
||||
model: s.model || "",
|
||||
thinkingMode: !!s.thinkingMode,
|
||||
maxTokens: Math.max(0, parseInt(s.maxTokens, 10) || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function isValidBackendUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBackendModels(url, backend) {
|
||||
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 if (backend === "unsloth") {
|
||||
const data = await unslothProxy(url, "/v1/models", "GET");
|
||||
return (data.data || []).map(m => m.id);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} storageKey localStorage key holding {backend, backendUrl, model, thinkingMode, maxTokens}
|
||||
* @param {string} title modal title
|
||||
* @param {() => void} [onSaved] called after Save persists the config (e.g. to refresh a status label)
|
||||
*/
|
||||
export function openAiBackendSettingsModal(storageKey, title, onSaved) {
|
||||
const saved = loadCfg(storageKey);
|
||||
const backend = saved.backend || "ollama";
|
||||
|
||||
const html = `
|
||||
<div class="wfm-ai-settings-container">
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsBackend")}</div>
|
||||
<div class="wfm-ai-backend-row">
|
||||
${[["ollama", "Ollama"], ["lmstudio", "LM Studio"], ["lemonade", "Lemonade"], ["unsloth", "Unsloth"]].map(([v, label]) => `
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-aism-backend" value="${v}" ${v === backend ? "checked" : ""}> ${label}
|
||||
</label>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsConnection")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label">URL</label>
|
||||
<input type="text" id="wfm-aism-url" class="wfm-input wfm-ai-url-input" value="${saved.backendUrl || getAiBackendDefaultUrl(backend)}">
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<button id="wfm-aism-test-btn" class="wfm-btn">${t("aiSettingsTestBtn")}</button>
|
||||
<button id="wfm-aism-unload-btn" class="wfm-btn">${t("aiUnloadModel")}</button>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<span id="wfm-aism-test-result" class="wfm-ai-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsModelSection")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<select id="wfm-aism-model" class="wfm-select wfm-ai-model-select">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button id="wfm-aism-refresh-btn" class="wfm-btn">${t("aiSettingsRefreshBtn")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsGenerationTitle")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-aism-thinking"> ${t("aiSettingsThinkingMode")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label">${t("aiSettingsMaxTokens")}</label>
|
||||
<input type="number" id="wfm-aism-max-tokens" class="wfm-input" min="0" step="1" placeholder="${t("aiSettingsMaxTokensPlaceholder")}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<button id="wfm-aism-save-btn" class="wfm-btn wfm-btn-primary">${t("aiSettingsSaveBtn")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(title, html);
|
||||
|
||||
const urlInput = document.getElementById("wfm-aism-url");
|
||||
const modelSelect = document.getElementById("wfm-aism-model");
|
||||
const thinkingCheckbox = document.getElementById("wfm-aism-thinking");
|
||||
const maxTokensInput = document.getElementById("wfm-aism-max-tokens");
|
||||
const testResultEl = document.getElementById("wfm-aism-test-result");
|
||||
|
||||
thinkingCheckbox.checked = !!saved.thinkingMode;
|
||||
if (saved.maxTokens) maxTokensInput.value = saved.maxTokens;
|
||||
|
||||
function currentBackend() {
|
||||
return document.querySelector("input[name='wfm-aism-backend']:checked")?.value || "ollama";
|
||||
}
|
||||
|
||||
async function populateModels(selectedModel) {
|
||||
const url = urlInput.value.trim();
|
||||
try {
|
||||
const models = await fetchBackendModels(url, currentBackend());
|
||||
modelSelect.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === selectedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
} catch {
|
||||
modelSelect.innerHTML = `<option value="">${t("noModelsFound")}</option>`;
|
||||
}
|
||||
}
|
||||
populateModels(saved.model || "");
|
||||
|
||||
document.querySelectorAll("input[name='wfm-aism-backend']").forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
testResultEl.textContent = "";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-refresh-btn")?.addEventListener("click", () => populateModels(modelSelect.value));
|
||||
|
||||
document.getElementById("wfm-aism-test-btn")?.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!isValidBackendUrl(url)) {
|
||||
testResultEl.textContent = t("aiToastInvalidUrlInput");
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
return;
|
||||
}
|
||||
testResultEl.textContent = t("aiStatusConnecting");
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-working";
|
||||
try {
|
||||
const models = await fetchBackendModels(url, currentBackend());
|
||||
testResultEl.textContent = `${t("aiStatusConnectOk")} (${models.length} ${t("aiModels")})`;
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-ok";
|
||||
await populateModels(modelSelect.value);
|
||||
} catch (err) {
|
||||
testResultEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-unload-btn")?.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
const model = modelSelect.value;
|
||||
// Ollama: unload every loaded model incl. decision models (same server step as Settings → VRAM Management)
|
||||
if (currentBackend() === "ollama") { await unloadAllOllamaModels(url); return; }
|
||||
if (!model) { showToast(t("aiUnloadNoModel"), "error"); return; }
|
||||
try {
|
||||
await unloadAiModel(url, currentBackend(), model);
|
||||
showToast(t("aiUnloadDone"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message === "UNSUPPORTED" ? t("aiUnloadUnsupported") : t("aiUnloadFailed") + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-save-btn")?.addEventListener("click", () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
saveCfg(storageKey, {
|
||||
backend: currentBackend(),
|
||||
backendUrl: url,
|
||||
model: modelSelect.value,
|
||||
thinkingMode: !!thinkingCheckbox.checked,
|
||||
maxTokens: Math.max(0, parseInt(maxTokensInput.value, 10) || 0),
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
closeModal();
|
||||
if (onSaved) onSaved();
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { initI18n, t } from "./i18n.js";
|
||||
import { getSettings } from "./util.js";
|
||||
|
||||
// Initialize i18n before anything else
|
||||
initI18n();
|
||||
@@ -41,11 +42,32 @@ export function showToast(message, type = "info", duration = 3000) {
|
||||
|
||||
export function openModal(title, contentHtml) {
|
||||
const overlay = document.getElementById("wfm-modal-overlay");
|
||||
const titleEl = document.getElementById("wfm-modal-title");
|
||||
let titleEl = document.getElementById("wfm-modal-title");
|
||||
const bodyEl = document.getElementById("wfm-modal-body");
|
||||
const titleInput = document.getElementById("wfm-modal-title-input");
|
||||
|
||||
if (!overlay || !titleEl || !bodyEl) return;
|
||||
|
||||
// This modal shell is shared by every caller (Workflow/Models/Nodes detail
|
||||
// modals, Lab's keyframe editor, etc.). Some callers (e.g. workflow-tab.js's
|
||||
// openDetailModal) inject extra header chrome after calling openModal() — a
|
||||
// favorite star button, and a click-to-rename listener + tooltip/cursor on the
|
||||
// title — but nothing ever tore that down again. The next caller to reuse this
|
||||
// same DOM inherited it as stale leftovers: a Lab cell modal could show a
|
||||
// favorite star wired to the PREVIOUS caller's workflow object, and clicking it
|
||||
// silently toggled that unrelated workflow's favorite state. Clean slate every
|
||||
// time, before any caller-specific chrome is added back on top.
|
||||
titleEl.parentElement?.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
|
||||
const freshTitleEl = titleEl.cloneNode(false); // drops any addEventListener from a prior caller
|
||||
freshTitleEl.removeAttribute("title");
|
||||
freshTitleEl.removeAttribute("style"); // also clears any leftover display:none from an interrupted rename
|
||||
titleEl.replaceWith(freshTitleEl);
|
||||
titleEl = freshTitleEl;
|
||||
if (titleInput) {
|
||||
titleInput.style.display = "none";
|
||||
titleInput.value = "";
|
||||
}
|
||||
|
||||
titleEl.textContent = title;
|
||||
bodyEl.innerHTML = contentHtml;
|
||||
overlay.style.display = "flex";
|
||||
@@ -90,7 +112,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", "image-edit": "tabImageEdit", video: "tabVideo", 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);
|
||||
@@ -112,9 +134,15 @@ function applyI18nToHtml() {
|
||||
if (listLoadBtn.disabled) listLoadBtn.title = t("selectCardFirst");
|
||||
}
|
||||
|
||||
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
|
||||
if (listLoadVideoBtn) {
|
||||
listLoadVideoBtn.textContent = t("loadInVideo");
|
||||
if (listLoadVideoBtn.disabled) listLoadVideoBtn.title = t("selectCardFirst");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -194,17 +222,111 @@ function applyI18nToHtml() {
|
||||
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
|
||||
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
|
||||
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
if (btn.dataset.detailTab === "prompt") btn.textContent = t("gallerySidePrompt");
|
||||
});
|
||||
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 galleryBulkPsd = document.getElementById("wfm-gallery-bulk-psd");
|
||||
if (galleryBulkPsd) galleryBulkPsd.textContent = t("galleryBulkPsd");
|
||||
const galleryBulkEditLayers = document.getElementById("wfm-gallery-bulk-edit-layers");
|
||||
if (galleryBulkEditLayers) galleryBulkEditLayers.textContent = t("galleryBulkEditLayers");
|
||||
const galleryBulkConvert = document.getElementById("wfm-gallery-bulk-convert");
|
||||
if (galleryBulkConvert) galleryBulkConvert.textContent = t("galleryBulkConvert");
|
||||
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");
|
||||
|
||||
// Gallery サブタブ (Output / ImagePrompt / Style_Catalog)
|
||||
const galleryOutputSubtab = document.getElementById("wfm-gallery-subtab-output");
|
||||
if (galleryOutputSubtab) galleryOutputSubtab.textContent = t("galleryTabOutput");
|
||||
const galleryImagePromptSubtab = document.getElementById("wfm-gallery-subtab-imageprompt");
|
||||
if (galleryImagePromptSubtab) galleryImagePromptSubtab.textContent = t("galleryTabImagePrompt");
|
||||
const galleryStyleCatalogSubtab = document.getElementById("wfm-gallery-subtab-styleCatalog");
|
||||
if (galleryStyleCatalogSubtab) galleryStyleCatalogSubtab.textContent = t("galleryTabStyleCatalog");
|
||||
const galleryMetadataSubtab = document.getElementById("wfm-gallery-subtab-metadata");
|
||||
if (galleryMetadataSubtab) galleryMetadataSubtab.textContent = t("galleryTabMetadata");
|
||||
|
||||
// ImagePrompt ギャラリーパネル
|
||||
const imagePromptSearch = document.getElementById("wfm-imageprompt-search");
|
||||
if (imagePromptSearch) imagePromptSearch.placeholder = t("gallerySearchPlaceholder");
|
||||
const imagePromptRefreshBtn = document.getElementById("wfm-imageprompt-refresh-btn");
|
||||
if (imagePromptRefreshBtn) imagePromptRefreshBtn.textContent = t("refresh");
|
||||
const imagePromptTreeTitle = document.getElementById("wfm-imageprompt-tree-title");
|
||||
if (imagePromptTreeTitle) imagePromptTreeTitle.textContent = t("galleryFolders");
|
||||
const imagePromptListPlaceholder = document.getElementById("wfm-imageprompt-list-placeholder");
|
||||
if (imagePromptListPlaceholder) imagePromptListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const imagePromptNoSelection = document.getElementById("wfm-imageprompt-no-selection");
|
||||
if (imagePromptNoSelection) imagePromptNoSelection.textContent = t("galleryNoSelection");
|
||||
const imagePromptSelectedPromptTitle = document.getElementById("wfm-imageprompt-selected-prompt-title");
|
||||
if (imagePromptSelectedPromptTitle) imagePromptSelectedPromptTitle.textContent = t("imagePromptSelectedPrompt");
|
||||
const imagePromptSelectedPrompt = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
if (imagePromptSelectedPrompt) imagePromptSelectedPrompt.placeholder = t("imagePromptNoPrompt");
|
||||
const imagePromptSavePromptBtn = document.getElementById("wfm-imageprompt-save-prompt-btn");
|
||||
if (imagePromptSavePromptBtn) imagePromptSavePromptBtn.textContent = t("save");
|
||||
const imagePromptAddBtn = document.getElementById("wfm-imageprompt-add-btn");
|
||||
if (imagePromptAddBtn) imagePromptAddBtn.textContent = t("imagePromptAddBtn");
|
||||
const imagePromptChipsTitle = document.getElementById("wfm-imageprompt-chips-title");
|
||||
if (imagePromptChipsTitle) imagePromptChipsTitle.textContent = t("imagePromptChipsTitle");
|
||||
const imagePromptClearBtn = document.getElementById("wfm-imageprompt-clear-btn");
|
||||
if (imagePromptClearBtn) imagePromptClearBtn.textContent = t("imagePromptClearBtn");
|
||||
const imagePromptFinalPromptTitle = document.getElementById("wfm-imageprompt-final-prompt-title");
|
||||
if (imagePromptFinalPromptTitle) imagePromptFinalPromptTitle.textContent = t("imagePromptFinalPromptTitle");
|
||||
const imagePromptCopyBtn = document.getElementById("wfm-imageprompt-copy-btn");
|
||||
if (imagePromptCopyBtn) imagePromptCopyBtn.textContent = t("copy");
|
||||
|
||||
// Style_Catalog ギャラリーパネル
|
||||
const scSearch = document.getElementById("wfm-stylecatalog-search");
|
||||
if (scSearch) scSearch.placeholder = t("stylecatalogSearchPlaceholder");
|
||||
const scRefreshBtn = document.getElementById("wfm-stylecatalog-refresh-btn");
|
||||
if (scRefreshBtn) scRefreshBtn.textContent = t("refresh");
|
||||
const scTreeTitle = document.getElementById("wfm-stylecatalog-tree-title");
|
||||
if (scTreeTitle) scTreeTitle.textContent = t("galleryFolders");
|
||||
const scListPlaceholder = document.getElementById("wfm-stylecatalog-list-placeholder");
|
||||
if (scListPlaceholder) scListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const scNoSelection = document.getElementById("wfm-stylecatalog-no-selection");
|
||||
if (scNoSelection) scNoSelection.textContent = t("galleryNoSelection");
|
||||
const scPositiveTitle = document.getElementById("wfm-stylecatalog-positive-title");
|
||||
if (scPositiveTitle) scPositiveTitle.textContent = t("stylecatalogPositive");
|
||||
const scCopyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
|
||||
if (scCopyPositiveBtn) scCopyPositiveBtn.textContent = t("copy");
|
||||
const scNegativeTitle = document.getElementById("wfm-stylecatalog-negative-title");
|
||||
if (scNegativeTitle) scNegativeTitle.textContent = t("stylecatalogNegative");
|
||||
const scCopyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
|
||||
if (scCopyNegativeBtn) scCopyNegativeBtn.textContent = t("copy");
|
||||
const scSelectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
|
||||
if (scSelectStyleBtn) scSelectStyleBtn.textContent = t("stylecatalogSelectStyleBtn");
|
||||
const scLoadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
|
||||
if (scLoadGenuiBtn) scLoadGenuiBtn.textContent = t("stylecatalogLoadGenuiBtn");
|
||||
const scOpenMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
|
||||
if (scOpenMetadataBtn) scOpenMetadataBtn.textContent = t("stylecatalogOpenMetadataBtn");
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
const presetsHeader = document.querySelector(".wfm-prompt-split-right .wfm-prompt-split-header");
|
||||
if (presetsHeader) presetsHeader.textContent = t("presetsSubtab");
|
||||
const ollamaRefreshBtn = document.getElementById("wfm-ollama-refresh-btn");
|
||||
if (ollamaRefreshBtn) ollamaRefreshBtn.textContent = t("refresh");
|
||||
const ollamaTestBtn = document.getElementById("wfm-ollama-test-btn");
|
||||
if (ollamaTestBtn) ollamaTestBtn.textContent = t("test");
|
||||
const ollamaSettingsBtn = document.getElementById("wfm-ollama-settings-btn");
|
||||
if (ollamaSettingsBtn) ollamaSettingsBtn.title = t("settings");
|
||||
const ollamaAttachBtn = document.getElementById("wfm-ollama-attach-btn");
|
||||
if (ollamaAttachBtn) ollamaAttachBtn.textContent = t("attachImage");
|
||||
const ollamaClearBtn = document.getElementById("wfm-ollama-clear-btn");
|
||||
@@ -224,6 +346,98 @@ function applyI18nToHtml() {
|
||||
const presetCopyNegBtn = document.getElementById("wfm-preset-copy-neg-btn");
|
||||
if (presetCopyNegBtn) presetCopyNegBtn.textContent = t("copyNegativePrompt");
|
||||
|
||||
// AI tab
|
||||
document.querySelectorAll(".wfm-ai-subtab-btn").forEach(btn => {
|
||||
if (btn.dataset.aiSubtab === "translate") btn.textContent = t("aiSubTabTranslate");
|
||||
if (btn.dataset.aiSubtab === "vlm") btn.textContent = t("aiSubTabVlm");
|
||||
if (btn.dataset.aiSubtab === "ai-settings") btn.textContent = t("aiSubTabSettings");
|
||||
});
|
||||
const aiLangMap = { ja: "aiLangJa", en: "aiLangEn", zh: "aiLangZh", free: "aiLangFree" };
|
||||
[document.getElementById("wfm-ai-src-lang"), document.getElementById("wfm-ai-dst-lang")].forEach(sel => {
|
||||
if (!sel) return;
|
||||
sel.querySelectorAll("option").forEach(opt => {
|
||||
const key = aiLangMap[opt.value];
|
||||
if (key) opt.textContent = t(key);
|
||||
});
|
||||
});
|
||||
const aiTransInput = document.getElementById("wfm-ai-trans-input");
|
||||
if (aiTransInput) aiTransInput.placeholder = t("aiTransInputPlaceholder");
|
||||
const aiTransOutput = document.getElementById("wfm-ai-trans-output");
|
||||
if (aiTransOutput) aiTransOutput.placeholder = t("aiTransOutputPlaceholder");
|
||||
const aiTransBtn = document.getElementById("wfm-ai-trans-btn");
|
||||
if (aiTransBtn) aiTransBtn.textContent = t("aiTranslateBtn");
|
||||
const aiTransCopyBtn = document.getElementById("wfm-ai-trans-copy-btn");
|
||||
if (aiTransCopyBtn) aiTransCopyBtn.textContent = t("aiCopyBtn");
|
||||
const aiVlmLabel = document.getElementById("wfm-ai-vlm-label");
|
||||
if (aiVlmLabel) aiVlmLabel.textContent = t("aiVlmDropLabel");
|
||||
const aiVlmTask = document.getElementById("wfm-ai-vlm-task");
|
||||
if (aiVlmTask) {
|
||||
aiVlmTask.querySelectorAll("option").forEach(opt => {
|
||||
if (opt.value === "describe") opt.textContent = t("aiVlmDescribe");
|
||||
if (opt.value === "prompt") opt.textContent = t("aiVlmPromptCreate");
|
||||
});
|
||||
}
|
||||
const aiVlmRunBtn = document.getElementById("wfm-ai-vlm-run");
|
||||
if (aiVlmRunBtn) aiVlmRunBtn.textContent = t("aiVlmRunBtn");
|
||||
const aiVlmResult = document.getElementById("wfm-ai-vlm-result");
|
||||
if (aiVlmResult) aiVlmResult.placeholder = t("aiVlmResultPlaceholder");
|
||||
const aiVlmCopyBtn = document.getElementById("wfm-ai-vlm-copy");
|
||||
if (aiVlmCopyBtn) aiVlmCopyBtn.textContent = t("aiCopyBtn");
|
||||
const aiBackendTitle = document.getElementById("wfm-ai-settings-backend-title");
|
||||
if (aiBackendTitle) aiBackendTitle.textContent = t("aiSettingsBackend");
|
||||
const aiConnTitle = document.getElementById("wfm-ai-settings-conn-title");
|
||||
if (aiConnTitle) aiConnTitle.textContent = t("aiSettingsConnection");
|
||||
const aiTestBtn = document.getElementById("wfm-ai-test-btn");
|
||||
if (aiTestBtn) aiTestBtn.textContent = t("aiSettingsTestBtn");
|
||||
const aiModelTitle = document.getElementById("wfm-ai-settings-model-title");
|
||||
if (aiModelTitle) aiModelTitle.textContent = t("aiSettingsModelSection");
|
||||
const aiModelSel = document.getElementById("wfm-ai-model-select");
|
||||
if (aiModelSel) {
|
||||
const firstOpt = aiModelSel.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = t("aiSettingsModelPlaceholder");
|
||||
}
|
||||
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
|
||||
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
|
||||
const aiGenerationTitle = document.getElementById("wfm-ai-settings-generation-title");
|
||||
if (aiGenerationTitle) aiGenerationTitle.textContent = t("aiSettingsGenerationTitle");
|
||||
const aiThinkingModeLabel = document.getElementById("wfm-ai-thinking-mode-label");
|
||||
if (aiThinkingModeLabel) aiThinkingModeLabel.textContent = t("aiSettingsThinkingMode");
|
||||
const aiMaxTokensLabel = document.getElementById("wfm-ai-max-tokens-label");
|
||||
if (aiMaxTokensLabel) aiMaxTokensLabel.textContent = t("aiSettingsMaxTokens");
|
||||
const aiMaxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (aiMaxTokensInput) aiMaxTokensInput.placeholder = t("aiSettingsMaxTokensPlaceholder");
|
||||
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
|
||||
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
|
||||
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
|
||||
if (aiFreeSrcLabel) aiFreeSrcLabel.textContent = t("aiSettingsInputLang");
|
||||
const aiFreeDstLabel = document.getElementById("wfm-ai-free-dst-label");
|
||||
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
|
||||
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
|
||||
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
|
||||
const aiChatImgGenLabel = document.getElementById("wfm-ai-chat-imggen-label-text");
|
||||
if (aiChatImgGenLabel) aiChatImgGenLabel.textContent = t("aiChatImgGenToggle");
|
||||
const aiChatGenTitle = document.getElementById("wfm-ai-settings-chatgen-title");
|
||||
if (aiChatGenTitle) aiChatGenTitle.textContent = t("aiSettingsChatGenTitle");
|
||||
const aiChatGenDedicatedLabel = document.getElementById("wfm-ai-chatgen-dedicated-label");
|
||||
if (aiChatGenDedicatedLabel) aiChatGenDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
const aiChatGenI2ITitle = document.getElementById("wfm-ai-settings-chatgen-i2i-title");
|
||||
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
|
||||
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
|
||||
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
const aiSkillSel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
if (aiSkillSel) {
|
||||
const firstOpt = aiSkillSel.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = `-- ${t("aiSkillNone")} --`;
|
||||
}
|
||||
const aiSkillManageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
|
||||
if (aiSkillManageBtn) aiSkillManageBtn.title = t("aiSkillManageBtnTitle");
|
||||
const aiSkillPanelTitle = document.querySelector(".wfm-ai-skill-panel-title");
|
||||
if (aiSkillPanelTitle) aiSkillPanelTitle.textContent = t("aiSkillPanelTitle");
|
||||
const aiSkillNewBtn = document.getElementById("wfm-ai-skill-new-btn");
|
||||
if (aiSkillNewBtn) aiSkillNewBtn.textContent = t("aiSkillNewBtn");
|
||||
const aiSkillContentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
if (aiSkillContentTA) aiSkillContentTA.placeholder = t("aiSkillContentPlaceholder");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
if (propTitle) propTitle.textContent = t("properties");
|
||||
@@ -260,38 +474,128 @@ function applyI18nToHtml() {
|
||||
"wfm-help-about-1": "helpAbout1", "wfm-help-about-2": "helpAbout2",
|
||||
"wfm-help-wf-1": "helpWf1", "wfm-help-wf-2": "helpWf2", "wfm-help-wf-3": "helpWf3",
|
||||
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5", "wfm-help-wf-6": "helpWf6",
|
||||
"wfm-help-wf-7": "helpWf7",
|
||||
"wfm-help-wf-7": "helpWf7", "wfm-help-wf-8": "helpWf8",
|
||||
"wfm-help-gen-1": "helpGen1", "wfm-help-gen-2": "helpGen2",
|
||||
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
|
||||
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
|
||||
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
|
||||
"wfm-help-gen-11": "helpGen11",
|
||||
"wfm-help-gen-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-gen-20": "helpGen20", "wfm-help-gen-21": "helpGen21",
|
||||
"wfm-help-gen-22": "helpGen22", "wfm-help-gen-23": "helpGen23",
|
||||
"wfm-help-gen-24": "helpGen24", "wfm-help-gen-25": "helpGen25",
|
||||
"wfm-help-gen-26": "helpGen26", "wfm-help-gen-27": "helpGen27",
|
||||
"wfm-help-gen-28": "helpGen28",
|
||||
"wfm-help-gen-29": "helpGen29",
|
||||
"wfm-help-gen-30": "helpGen30",
|
||||
"wfm-help-gen-31": "helpGen31",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
"wfm-help-feeder-imgloop-desc": "helpFeederImgloopDesc",
|
||||
"wfm-help-feeder-1": "helpFeeder1", "wfm-help-feeder-2": "helpFeeder2",
|
||||
"wfm-help-feeder-3": "helpFeeder3", "wfm-help-feeder-4": "helpFeeder4",
|
||||
"wfm-help-feeder-5": "helpFeeder5", "wfm-help-feeder-6": "helpFeeder6",
|
||||
"wfm-help-feeder-7": "helpFeeder7", "wfm-help-feeder-8": "helpFeeder8",
|
||||
"wfm-help-feeder-9": "helpFeeder9",
|
||||
"wfm-help-feeder-gal-title": "helpFeederGalTitle",
|
||||
"wfm-help-feeder-gal-desc": "helpFeederGalDesc",
|
||||
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
|
||||
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
|
||||
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
|
||||
"wfm-help-lab-title": "helpLabTitle",
|
||||
"wfm-help-lab-desc": "helpLabDesc",
|
||||
"wfm-help-lab-card-columns-title": "helpLabCardColumnsTitle",
|
||||
"wfm-help-lab-1": "helpLab1", "wfm-help-lab-2": "helpLab2",
|
||||
"wfm-help-lab-3": "helpLab3", "wfm-help-lab-4": "helpLab4",
|
||||
"wfm-help-lab-5": "helpLab5", "wfm-help-lab-6": "helpLab6",
|
||||
"wfm-help-lab-7": "helpLab7", "wfm-help-lab-8": "helpLab8",
|
||||
"wfm-help-lab-9": "helpLab9", "wfm-help-lab-10": "helpLab10",
|
||||
"wfm-help-lab-11": "helpLab11", "wfm-help-lab-12": "helpLab12",
|
||||
"wfm-help-lab-13": "helpLab13", "wfm-help-lab-14": "helpLab14",
|
||||
"wfm-help-lab-15": "helpLab15",
|
||||
"wfm-help-lab-16": "helpLab16", "wfm-help-lab-17": "helpLab17",
|
||||
"wfm-help-lab-18": "helpLab18", "wfm-help-lab-19": "helpLab19",
|
||||
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
|
||||
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
|
||||
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
|
||||
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
|
||||
"wfm-help-lab-plan-7": "helpLabPlan7", "wfm-help-lab-plan-8": "helpLabPlan8",
|
||||
"wfm-help-lab-plan-9": "helpLabPlan9", "wfm-help-lab-plan-10": "helpLabPlan10",
|
||||
"wfm-help-lab-plan-11": "helpLabPlan11",
|
||||
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
|
||||
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
|
||||
"wfm-help-lab-results-3": "helpLabResults3",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
|
||||
"wfm-help-prompt-9": "helpPrompt9",
|
||||
"wfm-help-prompt-card-style-title": "helpPromptCardStyleTitle",
|
||||
"wfm-help-prompt-10": "helpPrompt10", "wfm-help-prompt-11": "helpPrompt11",
|
||||
"wfm-help-prompt-12": "helpPrompt12", "wfm-help-prompt-13": "helpPrompt13",
|
||||
"wfm-help-prompt-14": "helpPrompt14",
|
||||
"wfm-help-prompt-card-toplevel-title": "helpPromptCardToplevel",
|
||||
"wfm-help-prompt-15": "helpPrompt15",
|
||||
"wfm-help-prompt-card-table-title": "helpPromptCardTable",
|
||||
"wfm-help-prompt-16": "helpPrompt16", "wfm-help-prompt-17": "helpPrompt17",
|
||||
"wfm-help-prompt-18": "helpPrompt18", "wfm-help-prompt-19": "helpPrompt19",
|
||||
"wfm-help-prompt-20": "helpPrompt20", "wfm-help-prompt-21": "helpPrompt21",
|
||||
"wfm-help-prompt-22": "helpPrompt22", "wfm-help-prompt-23": "helpPrompt23",
|
||||
"wfm-help-prompt-24": "helpPrompt24", "wfm-help-prompt-25": "helpPrompt25",
|
||||
"wfm-help-prompt-26": "helpPrompt26",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13", "wfm-help-settings-14": "helpSettings14",
|
||||
"wfm-help-settings-15": "helpSettings15", "wfm-help-settings-16": "helpSettings16",
|
||||
"wfm-help-settings-17": "helpSettings17",
|
||||
"wfm-help-settings-18": "helpSettings18", "wfm-help-settings-19": "helpSettings19",
|
||||
"wfm-help-settings-20": "helpSettings20", "wfm-help-settings-21": "helpSettings21",
|
||||
"wfm-help-settings-22": "helpSettings22",
|
||||
"wfm-help-settings-23": "helpSettings23", "wfm-help-settings-24": "helpSettings24", "wfm-help-settings-25": "helpSettings25",
|
||||
"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-gallery-27": "helpGallery27",
|
||||
"wfm-help-gallery-18": "helpGallery18", "wfm-help-gallery-19": "helpGallery19",
|
||||
"wfm-help-gallery-20": "helpGallery20", "wfm-help-gallery-21": "helpGallery21",
|
||||
"wfm-help-gallery-22": "helpGallery22", "wfm-help-gallery-23": "helpGallery23",
|
||||
"wfm-help-gallery-24": "helpGallery24", "wfm-help-gallery-25": "helpGallery25",
|
||||
"wfm-help-gallery-26": "helpGallery26",
|
||||
"wfm-help-gallery-28": "helpGallery28", "wfm-help-gallery-29": "helpGallery29",
|
||||
"wfm-help-gallery-30": "helpGallery30", "wfm-help-gallery-31": "helpGallery31",
|
||||
"wfm-help-gallery-32": "helpGallery32", "wfm-help-gallery-33": "helpGallery33", "wfm-help-gallery-34": "helpGallery34", "wfm-help-gallery-35": "helpGallery35", "wfm-help-gallery-36": "helpGallery36",
|
||||
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
|
||||
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
|
||||
"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",
|
||||
@@ -299,22 +603,202 @@ function applyI18nToHtml() {
|
||||
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
|
||||
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
|
||||
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-12": "helpSidepanel12",
|
||||
"wfm-help-sidepanel-13": "helpSidepanel13",
|
||||
"wfm-help-sidepanel-14": "helpSidepanel14", "wfm-help-sidepanel-15": "helpSidepanel15",
|
||||
"wfm-help-sidepanel-16": "helpSidepanel16",
|
||||
"wfm-help-sidepanel-17": "helpSidepanel17",
|
||||
"wfm-help-ai-title": "helpAiTitle",
|
||||
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"wfm-help-ai-8": "helpAi8",
|
||||
"wfm-help-ai-9": "helpAi9",
|
||||
"wfm-help-ai-card-layout-title": "helpAiCardLayout",
|
||||
"wfm-help-ai-card-translate-title": "helpAiCardTranslate",
|
||||
"wfm-help-ai-card-chat-title": "helpAiCardChat",
|
||||
"wfm-help-ai-card-svg-title": "helpAiCardSvg",
|
||||
"wfm-help-ai-card-skills-title": "helpAiCardSkills",
|
||||
"wfm-help-ai-card-skilltips-title": "helpAiCardSkillTips",
|
||||
"wfm-help-ai-card-tools-title": "helpAiCardTools",
|
||||
"wfm-help-ai-card-settings-title": "helpAiCardSettings",
|
||||
"wfm-help-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
"wfm-help-imageedit-text-title": "helpImageEditTextTitle",
|
||||
"wfm-help-imageedit-export-title": "helpImageEditExportTitle",
|
||||
"wfm-help-imageedit-kbd-title": "helpImageEditKbdTitle",
|
||||
"wfm-help-imageedit-1": "helpImageEdit1", "wfm-help-imageedit-2": "helpImageEdit2", "wfm-help-imageedit-3": "helpImageEdit3",
|
||||
"wfm-help-imageedit-4": "helpImageEdit4", "wfm-help-imageedit-5": "helpImageEdit5",
|
||||
"wfm-help-imageedit-6": "helpImageEdit6", "wfm-help-imageedit-7": "helpImageEdit7",
|
||||
"wfm-help-imageedit-8": "helpImageEdit8", "wfm-help-imageedit-9": "helpImageEdit9",
|
||||
"wfm-help-imageedit-10": "helpImageEdit10", "wfm-help-imageedit-11": "helpImageEdit11",
|
||||
"wfm-help-imageedit-12": "helpImageEdit12", "wfm-help-imageedit-13": "helpImageEdit13",
|
||||
"wfm-help-imageedit-14": "helpImageEdit14", "wfm-help-imageedit-15": "helpImageEdit15",
|
||||
"wfm-help-imageedit-16": "helpImageEdit16", "wfm-help-imageedit-17": "helpImageEdit17",
|
||||
"wfm-help-imageedit-18": "helpImageEdit18",
|
||||
"wfm-help-imageedit-19b": "helpImageEdit19b",
|
||||
"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-31": "helpImageEdit31", "wfm-help-imageedit-32": "helpImageEdit32",
|
||||
"wfm-help-imageedit-33": "helpImageEdit33", "wfm-help-imageedit-34": "helpImageEdit34",
|
||||
"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-mask-14": "helpImageEditMask14",
|
||||
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
|
||||
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
|
||||
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
|
||||
"wfm-help-imageedit-gmic-install-title": "helpImageEditGmicInstallTitle",
|
||||
"wfm-help-imageedit-gmic-install-1": "helpImageEditGmicInstall1",
|
||||
"wfm-help-imageedit-gmic-install-2": "helpImageEditGmicInstall2",
|
||||
"wfm-help-imageedit-gmic-install-3": "helpImageEditGmicInstall3",
|
||||
"wfm-help-imageedit-tabkbd-title": "helpImageEditTabKbdTitle",
|
||||
"wfm-help-imageedit-tabkbd-1": "helpImageEdit22", "wfm-help-imageedit-tabkbd-2": "helpImageEdit23",
|
||||
"wfm-help-imageedit-tabkbd-3": "helpImageEdit24", "wfm-help-imageedit-tabkbd-4": "helpImageEdit25",
|
||||
"wfm-help-imageedit-tabkbd-5": "helpImageEdit26", "wfm-help-imageedit-tabkbd-6": "helpImageEdit27",
|
||||
"wfm-help-imageedit-tabkbd-7": "helpImageEdit28", "wfm-help-imageedit-tabkbd-8": "helpImageEdit29",
|
||||
"wfm-help-shortcuts-title": "helpShortcutsTitle",
|
||||
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
|
||||
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
|
||||
"wfm-help-trouble-title": "helpTroubleTitle",
|
||||
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
|
||||
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
|
||||
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
|
||||
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
|
||||
"wfm-help-trouble-9": "helpTrouble9",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
"wfm-help-kofi-desc": "helpKofiDesc",
|
||||
"wfm-help-thanks": "helpThanks",
|
||||
// -- Help card headings (card-based redesign) --
|
||||
"wfm-help-about-card-getting-started-title": "helpAboutCardGettingStarted",
|
||||
"wfm-help-wf-title": "helpWfTitle",
|
||||
"wfm-help-wf-card-browse-title": "helpWfCardBrowse",
|
||||
"wfm-help-wf-card-groups-title": "helpWfCardGroups",
|
||||
"wfm-help-wf-card-panel-title": "helpWfCardPanel",
|
||||
"wfm-help-gen-title": "helpGenTitle",
|
||||
"wfm-help-gen-card-connect-title": "helpGenCardConnect",
|
||||
"wfm-help-gen-card-tabs-title": "helpGenCardTabs",
|
||||
"wfm-help-gen-card-seed-title": "helpGenCardSeed",
|
||||
"wfm-help-gen-card-batchsetup-title": "helpGenCardBatchSetup",
|
||||
"wfm-help-gen-card-batchtypes-title": "helpGenCardBatchTypes",
|
||||
"wfm-help-gen-card-lora-title": "helpGenCardLora",
|
||||
"wfm-help-prompt-title": "helpPromptTitle",
|
||||
"wfm-help-prompt-card-ai-title": "helpPromptCardAi",
|
||||
"wfm-help-prompt-card-presets-title": "helpPromptCardPresets",
|
||||
"wfm-help-prompt-card-wildcards-title": "helpPromptCardWildcards",
|
||||
"wfm-help-metadata-card-overview-title": "helpMetadataCardOverview",
|
||||
"wfm-help-metadata-card-extracted-title": "helpMetadataCardExtracted",
|
||||
"wfm-help-metadata-card-formats-title": "helpMetadataCardFormats",
|
||||
"wfm-help-settings-title": "helpSettingsTitle",
|
||||
"wfm-help-settings-card-general-title": "helpSettingsCardGeneral",
|
||||
"wfm-help-settings-card-connections-title": "helpSettingsCardConnections",
|
||||
"wfm-help-settings-card-workflow-title": "helpSettingsCardWorkflow",
|
||||
"wfm-help-settings-card-editor-title": "helpSettingsCardEditor",
|
||||
"wfm-help-settings-card-misc-title": "helpSettingsCardMisc",
|
||||
"wfm-help-nodes-title": "helpNodesTitle",
|
||||
"wfm-help-nodes-card-browse-title": "helpNodesCardBrowse",
|
||||
"wfm-help-nodes-card-groups-title": "helpNodesCardGroups",
|
||||
"wfm-help-nodes-card-panel-title": "helpNodesCardPanel",
|
||||
"wfm-help-gallery-card-browse-title": "helpGalleryCardBrowse",
|
||||
"wfm-help-gallery-card-folder-title": "helpGalleryCardFolder",
|
||||
"wfm-help-gallery-card-detail-title": "helpGalleryCardDetail",
|
||||
"wfm-help-gallery-card-actions-title": "helpGalleryCardActions",
|
||||
"wfm-help-gallery-card-formats-title": "helpGalleryCardFormats",
|
||||
"wfm-help-models-title": "helpModelsTitle",
|
||||
"wfm-help-models-card-browse-title": "helpModelsCardBrowse",
|
||||
"wfm-help-models-card-civitai-title": "helpModelsCardCivitai",
|
||||
"wfm-help-models-card-apply-title": "helpModelsCardApply",
|
||||
"wfm-help-models-card-groups-title": "helpModelsCardGroups",
|
||||
"wfm-help-models-card-bulk-title": "helpModelsCardBulk",
|
||||
"wfm-help-sidepanel-card-tabs-title": "helpSidepanelCardTabs",
|
||||
"wfm-help-sidepanel-card-dnd-title": "helpSidepanelCardDnd",
|
||||
"wfm-help-sidepanel-card-info-title": "helpSidepanelCardInfo",
|
||||
"wfm-help-sidepanel-card-ai-title": "helpSidepanelCardAi",
|
||||
"wfm-help-sidepanel-card-nav-title": "helpSidepanelCardNav",
|
||||
"wfm-help-tagger-card-overview-title": "helpTaggerCardOverview",
|
||||
"wfm-help-tagger-card-single-title": "helpTaggerCardSingle",
|
||||
"wfm-help-tagger-card-sending-title": "helpTaggerCardSending",
|
||||
"wfm-help-tagger-card-batch-title": "helpTaggerCardBatch",
|
||||
"wfm-help-imageedit-card-overview-title": "helpImageEditCardOverview",
|
||||
"wfm-help-shortcuts-card-general-title": "helpShortcutsCardGeneral",
|
||||
"wfm-help-trouble-card-connection-title": "helpTroubleCardConnection",
|
||||
"wfm-help-trouble-card-workflow-title": "helpTroubleCardWorkflow",
|
||||
"wfm-help-trouble-card-tagger-title": "helpTroubleCardTagger",
|
||||
"wfm-help-video-title": "helpVideoTitle",
|
||||
"wfm-help-video-desc": "helpVideoDesc",
|
||||
"wfm-help-video-1": "helpVideo1", "wfm-help-video-2": "helpVideo2",
|
||||
"wfm-help-video-3": "helpVideo3", "wfm-help-video-4": "helpVideo4",
|
||||
"wfm-help-video-5": "helpVideo5", "wfm-help-video-6": "helpVideo6",
|
||||
"wfm-help-video-7": "helpVideo7", "wfm-help-video-8": "helpVideo8",
|
||||
"wfm-help-video-9": "helpVideo9", "wfm-help-video-10": "helpVideo10",
|
||||
"wfm-help-video-11": "helpVideo11", "wfm-help-video-12": "helpVideo12",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
// Search box placeholder
|
||||
const helpSearch = document.getElementById("wfm-help-search");
|
||||
if (helpSearch) {
|
||||
helpSearch.placeholder = t("helpSearchPlaceholder");
|
||||
helpSearch.addEventListener("input", _onHelpSearch);
|
||||
}
|
||||
|
||||
// Jump-to-tab buttons
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.textContent = t("helpJumpToTab");
|
||||
});
|
||||
}
|
||||
|
||||
function _onHelpSearch(e) {
|
||||
const q = e.target.value.trim().toLowerCase();
|
||||
const navItems = document.querySelectorAll("[data-help-page]");
|
||||
if (!q) {
|
||||
navItems.forEach(btn => btn.classList.remove("search-hidden"));
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedPages = new Set();
|
||||
document.querySelectorAll(".wfm-help-page").forEach(page => {
|
||||
const pageId = page.id.replace("wfm-help-page-", "");
|
||||
if (page.textContent.toLowerCase().includes(q)) matchedPages.add(pageId);
|
||||
});
|
||||
|
||||
let firstBtn = null;
|
||||
navItems.forEach(btn => {
|
||||
const pageId = btn.dataset.helpPage;
|
||||
if (matchedPages.has(pageId)) {
|
||||
btn.classList.remove("search-hidden");
|
||||
if (!firstBtn) firstBtn = btn;
|
||||
} else {
|
||||
btn.classList.add("search-hidden");
|
||||
}
|
||||
});
|
||||
|
||||
if (firstBtn && !firstBtn.classList.contains("active")) firstBtn.click();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -352,24 +836,67 @@ import { initWorkflowTab } from "./workflow-tab.js";
|
||||
import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme } from "./settings-tab.js";
|
||||
import { initPromptTableTab } from "./prompt-table.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors, applyModelTabActiveColor } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
import { initMetadataTab } from "./metadata-tab.js";
|
||||
import { initAiTab } from "./ai-tab.js";
|
||||
import { initTaggerTab } from "./tagger-tab.js";
|
||||
import { imageEditTab } from "./image-edit-tab.js";
|
||||
import { initVideoTab } from "./video-tab.js";
|
||||
|
||||
// Apply saved theme immediately to prevent flash of default theme
|
||||
applyTheme(getSavedTheme());
|
||||
|
||||
// Apply saved textarea font size & JSON highlight colors
|
||||
try {
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
applyJsonColors(_s.jsonColors);
|
||||
applyModelTabActiveColor(_s.modelTabActiveColor);
|
||||
} catch {}
|
||||
|
||||
function initHelpTab() {
|
||||
document.querySelectorAll(".wfm-help-nav-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const page = btn.dataset.helpPage;
|
||||
document.querySelectorAll(".wfm-help-nav-item").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".wfm-help-page").forEach((p) => p.classList.remove("active"));
|
||||
document.getElementById(`wfm-help-page-${page}`)?.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Jump-to-tab buttons: switch to the real feature tab this help page documents
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelector(`.wfm-tab[data-tab="${btn.dataset.jumpTab}"]`)?.click();
|
||||
if (btn.dataset.jumpSubtab) {
|
||||
document.querySelector(`.wfm-gallery-subtab-btn[data-gallery-subtab="${btn.dataset.jumpSubtab}"]`)?.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
applyI18nToHtml();
|
||||
initTabs();
|
||||
initModal();
|
||||
initHelpTab();
|
||||
initSettingsTab(); // Settings first (applies saved URL)
|
||||
initWorkflowTab();
|
||||
initNodesTab();
|
||||
initModelsTab();
|
||||
initGenerateTab();
|
||||
initPromptTab();
|
||||
initPromptTableTab();
|
||||
initMetadataTab();
|
||||
initGalleryTab();
|
||||
initAiTab();
|
||||
initTaggerTab();
|
||||
imageEditTab.init();
|
||||
initVideoTab();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Handles communication with ComfyUI server (same origin)
|
||||
*/
|
||||
|
||||
import { prepareVramForGeneration } from "./vram-prepare.js";
|
||||
|
||||
function _uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
@@ -18,8 +20,15 @@ export const comfyUI = {
|
||||
connected: false,
|
||||
generating: false,
|
||||
currentPromptId: null,
|
||||
// trackProgress() 実行中の Promise を promptId -> reject関数 で保持。
|
||||
// WebSocket切断時に、待機中のPromiseを解決させずに放置してバッチループが
|
||||
// 無言のまま停止する(例: カタログ作成バッチが特定件数で止まる)のを防ぐ。
|
||||
_pendingTrackers: new Map(),
|
||||
currentWorkflow: null,
|
||||
currentAnalysis: null,
|
||||
// ワークフロー読み込み時点のLora Loader (LoraManager)ノードの loras/text 状態。
|
||||
// Stackグループチェックボックスのトグル時に、OFF側の「読み込み時の状態へ戻す」用に使う。
|
||||
loraManagerSnapshots: {},
|
||||
|
||||
updateUrl(url) {
|
||||
if (!url) {
|
||||
@@ -66,10 +75,18 @@ export const comfyUI = {
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.onopen = () => resolve(true);
|
||||
this.socket.onerror = () => resolve(false);
|
||||
this.socket.onclose = () => { this.socket = null; };
|
||||
this.socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this._rejectPendingTrackers(new Error("WebSocket disconnected"));
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_rejectPendingTrackers(err) {
|
||||
for (const rejectFn of this._pendingTrackers.values()) rejectFn(err);
|
||||
this._pendingTrackers.clear();
|
||||
},
|
||||
|
||||
// Node info fetching
|
||||
async fetchObjectInfo(nodeClass) {
|
||||
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
|
||||
@@ -90,11 +107,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 +149,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 +172,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 +195,18 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
// Generation
|
||||
async queuePrompt(workflow) {
|
||||
async queuePrompt(workflow, extraData = null) {
|
||||
// Settings tab "VRAM Management": unload Ollama models to free VRAM (no-op when off, never throws)
|
||||
await prepareVramForGeneration();
|
||||
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(() => ({}));
|
||||
@@ -184,12 +215,29 @@ export const comfyUI = {
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
trackProgress(promptId, progressCallback) {
|
||||
// timeoutMs: この時間内に完了/エラー/中断通知が来なければ強制的にreject。
|
||||
// WebSocketの取りこぼしやexecution_interrupted未処理でPromiseが永久に
|
||||
// ぶら下がり続け、バッチループが無言で停止するのを防ぐための安全弁。
|
||||
trackProgress(promptId, progressCallback, timeoutMs = 10 * 60 * 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error("WebSocket not connected"));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (isResolve, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.socket?.removeEventListener("message", handler);
|
||||
clearTimeout(timer);
|
||||
this._pendingTrackers.delete(promptId);
|
||||
if (isResolve) resolve(value); else reject(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(false, new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s (prompt_id=${promptId})`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
@@ -199,17 +247,19 @@ export const comfyUI = {
|
||||
}
|
||||
if (msg.type === "executing" && msg.data?.prompt_id === promptId) {
|
||||
if (msg.data.node === null) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
resolve();
|
||||
finish(true);
|
||||
}
|
||||
}
|
||||
if (msg.type === "execution_error" && msg.data?.prompt_id === promptId) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
reject(new Error(msg.data.exception_message || "Execution error"));
|
||||
finish(false, new Error(msg.data.exception_message || "Execution error"));
|
||||
}
|
||||
if (msg.type === "execution_interrupted" && msg.data?.prompt_id === promptId) {
|
||||
finish(false, new Error("Execution interrupted"));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
this.socket.addEventListener("message", handler);
|
||||
this._pendingTrackers.set(promptId, (err) => finish(false, err));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -228,7 +278,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) {
|
||||
@@ -258,7 +314,7 @@ export const comfyUI = {
|
||||
|
||||
// High-level generate
|
||||
async generate(workflow, options = {}) {
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError } = options;
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError, timeoutMs } = options;
|
||||
|
||||
this.generating = true;
|
||||
try {
|
||||
@@ -275,12 +331,12 @@ 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
|
||||
await this.trackProgress(result.prompt_id, onProgress);
|
||||
await this.trackProgress(result.prompt_id, onProgress, timeoutMs);
|
||||
|
||||
// Get history/results
|
||||
const history = await this.getHistory(result.prompt_id);
|
||||
@@ -288,6 +344,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 +354,23 @@ export const comfyUI = {
|
||||
images.push(img);
|
||||
}
|
||||
}
|
||||
if (nodeOutput.saved_svg) {
|
||||
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
|
||||
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
|
||||
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
|
||||
const joinIfCharArray = (v) =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
|
||||
? v.join("")
|
||||
: v;
|
||||
svgOutputs.push({
|
||||
filename: joinIfCharArray(nodeOutput.saved_svg),
|
||||
path: joinIfCharArray(nodeOutput.path) || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(images, seed);
|
||||
return { images, seed };
|
||||
return { images, seed, svgOutputs };
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
throw err;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Decision model client (Unsloth Decision API / Laya, Ollama 0.35+ / tev1・nimble・clef).
|
||||
*
|
||||
* Laya is a *decision* model, not a generator: it takes a state (text or any JSON) plus typed
|
||||
* questions and returns calibrated probabilities in one forward pass — no free-form output, so no
|
||||
* format hallucination, and results can be thresholded directly. Most decision models are text-only
|
||||
* (image-based judgments must first be turned into text — Tagger tags / VLM caption), but Ollama's
|
||||
* "clef" models accept images directly (decide()'s `images` param — base64, no `data:` prefix; use
|
||||
* supportsDecisionVision() first since a non-vision model errors out on an images payload). This
|
||||
* file is ComfyUI-Comic-Creator's decision-client.js origin (CC ported it 2026-09-30) and CC later
|
||||
* added vision support (2026-10, based on ComfyUI-LiveChatStream's judgeImage pattern) which is
|
||||
* ported back here — keep the two in sync going forward.
|
||||
*
|
||||
* Both backends speak the same TypeSafe-compatible POST /v1/systemone. Unsloth needs an API key
|
||||
* even on localhost (unless Keyless API access is on), so its requests go through the same
|
||||
* server-side proxy as the Unsloth chat backend (py/routes/unsloth_routes.py, key from .env).
|
||||
* Ollama needs no key and allows CORS, so it's called directly from the browser like the existing
|
||||
* Ollama LLM backend. Backend/URL/model/threshold come from the Settings tab's own
|
||||
* "Decision Model" section (wfm_decision_settings), independent of the AI TOOL / Tagger backends so
|
||||
* a decision model can run alongside whichever LLM/VLM those use.
|
||||
* API reference: https://unsloth.ai/docs/models/decision-laya,
|
||||
* https://github.com/ollama/ollama/releases/tag/v0.35.0
|
||||
*
|
||||
* Usage (threshold defaults to the Settings tab value when omitted):
|
||||
* import { decide, noul, choice, score, isYes, pickChoice } from "./decision-client.js";
|
||||
* const answers = await decide(promptText, {
|
||||
* genre: choice("Which genre fits best?", { portrait: "a person is the main subject", landscape: "scenery" }),
|
||||
* text: noul("Does the image contain rendered text?"),
|
||||
* nsfw: score("How sexually explicit is this?", ["none", "suggestive", "explicit"]),
|
||||
* });
|
||||
* const genre = pickChoice(answers.genre, 0.8); // { value, probability, confident }
|
||||
*/
|
||||
|
||||
import { unslothProxy, readJsonStorage } from "./util.js";
|
||||
|
||||
// Limits documented by Unsloth's Decision API.
|
||||
export const DECISION_LIMITS = { maxQuestions: 64, maxChoiceOptions: 255, maxScoreLevels: 10 };
|
||||
|
||||
// Unsloth: "laya" = the model picked in Unsloth's settings. Explicit variants: "laya-multilingual"
|
||||
// (default download, 100+ languages), "laya-english", "laya-typed-decisions" (structured data such
|
||||
// as JSON records — e.g. workflow node summaries).
|
||||
// Ollama: decision models are ordinary pulled models whose /api/tags capabilities include
|
||||
// "decision" — listed live by listDecisionModels(); `models` below are only pull suggestions
|
||||
// (tev1 = 4B / 4.5 GB, tev1:0.8b = 812 MB, nimble = 9B / 9.5 GB, clef = 27B / ~18GB vision-capable —
|
||||
// needs Ollama 0.35.1+, clef-flash(9B) is unreliable as of Ollama 0.40.1, see ollama/ollama#18769).
|
||||
export const DECISION_BACKENDS = {
|
||||
unsloth: {
|
||||
label: "Unsloth",
|
||||
defaultUrl: "http://localhost:8888",
|
||||
models: ["laya", "laya-multilingual", "laya-english", "laya-typed-decisions"],
|
||||
defaultModel: "laya",
|
||||
},
|
||||
ollama: {
|
||||
label: "Ollama",
|
||||
defaultUrl: "http://localhost:11434",
|
||||
models: ["tev1", "tev1:0.8b", "nimble", "clef"],
|
||||
defaultModel: "tev1",
|
||||
},
|
||||
};
|
||||
export const DECISION_MODELS = DECISION_BACKENDS.unsloth.models;
|
||||
export const DEFAULT_DECISION_MODEL = DECISION_BACKENDS.unsloth.defaultModel;
|
||||
|
||||
export const DECISION_SETTINGS_KEY = "wfm_decision_settings";
|
||||
const DEFAULT_SETTINGS = {
|
||||
backend: "unsloth",
|
||||
baseUrl: DECISION_BACKENDS.unsloth.defaultUrl,
|
||||
model: DEFAULT_DECISION_MODEL,
|
||||
threshold: 0.8,
|
||||
};
|
||||
|
||||
/** Saved Decision Model settings merged over the defaults. */
|
||||
export function getDecisionSettings() {
|
||||
const saved = readJsonStorage(DECISION_SETTINGS_KEY);
|
||||
const merged = { ...DEFAULT_SETTINGS, ...saved };
|
||||
const th = Number(merged.threshold);
|
||||
merged.threshold = th > 0 && th <= 1 ? th : DEFAULT_SETTINGS.threshold;
|
||||
if (!DECISION_BACKENDS[merged.backend]) merged.backend = DEFAULT_SETTINGS.backend;
|
||||
const backend = DECISION_BACKENDS[merged.backend];
|
||||
if (!merged.baseUrl) merged.baseUrl = backend.defaultUrl;
|
||||
// A model name from the other backend (e.g. "laya" after switching to Ollama) can't work.
|
||||
const isLaya = /^laya/.test(merged.model || "");
|
||||
if (!merged.model || (merged.backend === "ollama" && isLaya) || (merged.backend === "unsloth" && !isLaya)) {
|
||||
merged.model = backend.defaultModel;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model choices for the Settings dropdown. Unsloth: its fixed Laya names. Ollama: installed models
|
||||
* whose /api/tags capabilities include "decision" (throws if Ollama can't be reached). Returns
|
||||
* { models, installed } — installed is false for Unsloth (names aren't checked against the server).
|
||||
*/
|
||||
export async function listDecisionModels(backend, baseUrl) {
|
||||
if (backend !== "ollama") return { models: [...DECISION_BACKENDS.unsloth.models], installed: false };
|
||||
const res = await fetch(`${(baseUrl || DECISION_BACKENDS.ollama.defaultUrl).replace(/\/+$/, "")}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const models = (data.models || [])
|
||||
.filter((m) => Array.isArray(m.capabilities) && m.capabilities.includes("decision"))
|
||||
.map((m) => m.name)
|
||||
.sort();
|
||||
return { models, installed: true };
|
||||
}
|
||||
|
||||
export function saveDecisionSettings(patch) {
|
||||
const data = { ...getDecisionSettings(), ...patch };
|
||||
localStorage.setItem(DECISION_SETTINGS_KEY, JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- Question builders ----
|
||||
|
||||
/** Yes/no question — answer.noul is the probability of "yes" (0..1). */
|
||||
export function noul(instructions) {
|
||||
return { type: "noul", instructions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick-one question. criteria: an array of option names, or { option: description } — descriptions
|
||||
* noticeably improve accuracy (options share a token budget, so past ~20 described options the
|
||||
* descriptions get trimmed).
|
||||
*/
|
||||
export function choice(instructions, criteria) {
|
||||
return { type: "choice", instructions, criteria };
|
||||
}
|
||||
|
||||
/** Rating question. levels: ordered labels from lowest (0) to highest, 2..10 entries. */
|
||||
export function score(instructions, levels) {
|
||||
return { type: "score", instructions, criteria: levels };
|
||||
}
|
||||
|
||||
// ---- Request ----
|
||||
|
||||
|
||||
function _validateQuestions(questions) {
|
||||
const entries = Object.entries(questions || {});
|
||||
if (entries.length === 0) throw new Error("decide(): no questions given");
|
||||
if (entries.length > DECISION_LIMITS.maxQuestions) {
|
||||
throw new Error(`decide(): ${entries.length} questions exceeds the limit of ${DECISION_LIMITS.maxQuestions}`);
|
||||
}
|
||||
for (const [key, q] of entries) {
|
||||
if (!q || !["noul", "choice", "score"].includes(q.type)) {
|
||||
throw new Error(`decide(): question "${key}" has an unknown type`);
|
||||
}
|
||||
const n = Array.isArray(q.criteria) ? q.criteria.length : Object.keys(q.criteria || {}).length;
|
||||
if (q.type === "choice" && (n < 2 || n > DECISION_LIMITS.maxChoiceOptions)) {
|
||||
throw new Error(`decide(): choice "${key}" needs 2-${DECISION_LIMITS.maxChoiceOptions} options (got ${n})`);
|
||||
}
|
||||
if (q.type === "score" && (n < 2 || n > DECISION_LIMITS.maxScoreLevels)) {
|
||||
throw new Error(`decide(): score "${key}" needs 2-${DECISION_LIMITS.maxScoreLevels} levels (got ${n})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask typed questions about one state. Returns the raw `answers` object keyed like `questions`.
|
||||
* The first call after Unsloth starts loads the model (10-20 s); later calls are sub-second on CPU.
|
||||
*
|
||||
* @param {string|object} state Text or any JSON (e.g. { prompt, tags, model }).
|
||||
* @param {object} questions { key: noul(...) | choice(...) | score(...) }, max 64.
|
||||
* @param {object} [opts] { backend, model, baseUrl } — override the saved Decision Model settings.
|
||||
* @param {string[]} [images] Base64 image data (no `data:` prefix), shared by all questions.
|
||||
* Vision-capable Ollama models only (e.g. clef) — check with
|
||||
* supportsDecisionVision() first; a non-vision model errors out.
|
||||
*/
|
||||
export async function decide(state, questions, opts = {}, images = []) {
|
||||
_validateQuestions(questions);
|
||||
const settings = getDecisionSettings();
|
||||
const backend = opts.backend || settings.backend;
|
||||
const baseUrl = (opts.baseUrl || settings.baseUrl).replace(/\/+$/, "");
|
||||
const payload = { model: opts.model || settings.model, state, questions };
|
||||
if (images?.length) payload.images = images;
|
||||
let data;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${baseUrl}/v1/systemone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
data = await res.json().catch(() => ({}));
|
||||
// Ollama reports failures as { error } (e.g. 'model "tev1" not found, try pulling it first').
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
} else {
|
||||
data = await unslothProxy(baseUrl, "/v1/systemone", "POST", payload);
|
||||
}
|
||||
if (!data || typeof data.answers !== "object") throw new Error(data?.error || "Decision API returned no answers");
|
||||
return data.answers;
|
||||
}
|
||||
|
||||
/** Blob / data URL / image URL → base64 (no `data:` prefix), for decide()'s `images` param. */
|
||||
export async function imageToBase64(src) {
|
||||
const blob = src instanceof Blob ? src : await (await fetch(src)).blob();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result).split(",")[1] || "");
|
||||
r.onerror = () => reject(r.error);
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the configured decision model accepts images. Ollama only — decision models' /api/show
|
||||
* capabilities list only "decision" (not "vision") on some Ollama builds, so this also checks for an
|
||||
* image encoder (projector_info) as a fallback. Unsloth can't be checked this way, so it returns
|
||||
* null (unknown) — callers should try and fall back to text-only on failure. Returns false on error.
|
||||
*
|
||||
* @param {object} [opts] { backend, model, baseUrl } — same overrides as decide().
|
||||
*/
|
||||
export async function supportsDecisionVision(opts = {}) {
|
||||
const settings = getDecisionSettings();
|
||||
const backend = opts.backend || settings.backend;
|
||||
if (backend !== "ollama") return null;
|
||||
const baseUrl = (opts.baseUrl || settings.baseUrl).replace(/\/+$/, "");
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/show`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: opts.model || settings.model }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return (Array.isArray(data.capabilities) && data.capabilities.includes("vision"))
|
||||
|| !!(data.projector_info && Object.keys(data.projector_info).length);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run decide() over many states with a small concurrency cap (the model runs locally, so flooding
|
||||
* it only queues requests). Per-item failures are returned as { error } instead of rejecting all.
|
||||
*/
|
||||
export async function decideMany(states, questions, opts = {}) {
|
||||
const concurrency = Math.max(1, opts.concurrency || 2);
|
||||
const results = new Array(states.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < states.length) {
|
||||
const i = next++;
|
||||
try {
|
||||
results[i] = { answers: await decide(states[i], questions, opts) };
|
||||
} catch (e) {
|
||||
results[i] = { error: e.message };
|
||||
}
|
||||
opts.onProgress?.(i, results[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, states.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection check for the Settings tab: one yes/no question, timed. The first request after
|
||||
* Unsloth starts also loads the model (10-20 s), so callers should say that a slow first test is
|
||||
* normal.
|
||||
*/
|
||||
export async function testDecisionConnection(opts = {}) {
|
||||
const started = performance.now();
|
||||
const answers = await decide("A cat is sitting on a sofa.", {
|
||||
test: noul("Is there an animal in the text?"),
|
||||
}, opts);
|
||||
return { ms: Math.round(performance.now() - started), yes: answers.test?.noul };
|
||||
}
|
||||
|
||||
// ---- Answer helpers ----
|
||||
// Threshold on `probabilities` (or noul's value), not `confidence` — per Unsloth's docs, confidence
|
||||
// only says how peaked the distribution is and its formula differs from Jev's.
|
||||
|
||||
/** True when the yes-probability of a noul answer reaches `threshold`. */
|
||||
export function isYes(answer, threshold = getDecisionSettings().threshold) {
|
||||
return typeof answer?.noul === "number" && answer.noul >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most likely option of a choice answer. `confident` is false below `threshold` — callers should
|
||||
* then show the value as a suggestion for the user to confirm rather than apply it automatically.
|
||||
*/
|
||||
export function pickChoice(answer, threshold = getDecisionSettings().threshold) {
|
||||
const value = answer?.choice ?? null;
|
||||
const probability = value != null ? (answer.probabilities?.[value] ?? 0) : 0;
|
||||
return { value, probability, confident: value != null && probability >= threshold };
|
||||
}
|
||||
|
||||
/**
|
||||
* Most likely level of a score answer: { level (index from 0), label, value (expected score,
|
||||
* fractional), probability, confident }.
|
||||
*/
|
||||
export function pickScore(answer, threshold = getDecisionSettings().threshold) {
|
||||
const probs = answer?.probabilities || {};
|
||||
let level = null, probability = 0;
|
||||
for (const [k, p] of Object.entries(probs)) {
|
||||
if (p > probability) { level = Number(k); probability = p; }
|
||||
}
|
||||
return {
|
||||
level,
|
||||
label: level != null ? (answer.legend?.[String(level)] ?? null) : null,
|
||||
value: typeof answer?.score === "number" ? answer.score : null,
|
||||
probability,
|
||||
confident: level != null && probability >= threshold,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
/**
|
||||
* Feeder Tab - ImageFeeder node control (Image Loop mode)
|
||||
* + WFS_GalleryFeeder node control (Gallery mode)
|
||||
*/
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { FEEDER_GROUP, ensureFeederGroup } from "./gallery-tab.js";
|
||||
|
||||
// ── Module State ──────────────────────────────────────────────────
|
||||
const _s = {
|
||||
dir: "", // current folder relative path (loop mode)
|
||||
images: [], // filenames in current folder (loop mode)
|
||||
selected: new Set(),// selected filenames (loop mode)
|
||||
presets: {}, // {presetName: {directory, selected_files}}
|
||||
running: false, // feeder run loop active
|
||||
mode: localStorage.getItem("wfm_feeder_mode") || "loop", // "loop" | "gallery"
|
||||
};
|
||||
|
||||
// Gallery mode state
|
||||
const _gs = {
|
||||
groups: [], // [{name}]
|
||||
group: FEEDER_GROUP, // selected group name
|
||||
images: [], // absolute paths in selected group
|
||||
idx: 0, // current index
|
||||
};
|
||||
|
||||
// ── Node helpers ──────────────────────────────────────────────────
|
||||
|
||||
function _feederNodes() {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf) return [];
|
||||
return Object.entries(wf)
|
||||
.filter(([, n]) => n?.class_type === "ImageFeeder")
|
||||
.map(([id, n]) => ({ id, title: n._meta?.title || `ImageFeeder` }));
|
||||
}
|
||||
|
||||
function _galFeederNodes() {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf) return [];
|
||||
return Object.entries(wf)
|
||||
.filter(([, n]) => n?.class_type === "WFS_GalleryFeeder")
|
||||
.map(([id, n]) => ({ id, title: n._meta?.title || `GalleryFeeder` }));
|
||||
}
|
||||
|
||||
export function refreshFeederNodeList() {
|
||||
if (_s.mode === "gallery") {
|
||||
_refreshGalNodeList();
|
||||
} else {
|
||||
const sel = document.getElementById("wfm-feeder-node-sel");
|
||||
if (!sel) return;
|
||||
const nodes = _feederNodes();
|
||||
if (nodes.length === 0) {
|
||||
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
|
||||
return;
|
||||
}
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = nodes.map(n =>
|
||||
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
|
||||
).join("");
|
||||
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
|
||||
_loadFromNode(sel.value || nodes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
function _refreshGalNodeList() {
|
||||
const sel = document.getElementById("wfm-feeder-gal-node-sel");
|
||||
if (!sel) return;
|
||||
const nodes = _galFeederNodes();
|
||||
if (nodes.length === 0) {
|
||||
sel.innerHTML = `<option value="">-- No WFS_GalleryFeeder node --</option>`;
|
||||
return;
|
||||
}
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = nodes.map(n =>
|
||||
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
|
||||
).join("");
|
||||
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
|
||||
_loadFromGalNode(sel.value || nodes[0].id);
|
||||
}
|
||||
|
||||
function _loadFromNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf?.[nodeId]) return;
|
||||
const inp = wf[nodeId].inputs || {};
|
||||
|
||||
_setVal("wfm-feeder-dir-input", inp.directory || "");
|
||||
_setVal("wfm-feeder-sort", inp.sort_mode || "ascending");
|
||||
_setVal("wfm-feeder-index", inp.index ?? 0);
|
||||
_setVal("wfm-feeder-start", inp.start_index ?? 0);
|
||||
_setVal("wfm-feeder-end", inp.end_index ?? 0);
|
||||
_setVal("wfm-feeder-batch", inp.batch_size ?? 1);
|
||||
_setVal("wfm-feeder-seed", inp.seed ?? 0);
|
||||
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
|
||||
|
||||
_s.selected.clear();
|
||||
try {
|
||||
const arr = JSON.parse(inp.selected_files || "[]");
|
||||
if (Array.isArray(arr)) arr.forEach(f => _s.selected.add(f));
|
||||
} catch {}
|
||||
|
||||
const dir = inp.directory || "";
|
||||
if (_s.dir !== dir) {
|
||||
_s.dir = dir;
|
||||
_loadImages(dir);
|
||||
_highlightTreeRow(dir);
|
||||
} else {
|
||||
_refreshGridCbs();
|
||||
}
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _loadFromGalNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf?.[nodeId]) return;
|
||||
const inp = wf[nodeId].inputs || {};
|
||||
|
||||
const group = inp.group_name || FEEDER_GROUP;
|
||||
_setVal("wfm-feeder-gal-sort", inp.sort_mode || "filename_asc");
|
||||
_setVal("wfm-feeder-gal-index", inp.index ?? 0);
|
||||
_setVal("wfm-feeder-gal-seed", inp.seed ?? 0);
|
||||
_gs.idx = inp.index ?? 0;
|
||||
|
||||
if (_gs.group !== group) {
|
||||
_gs.group = group;
|
||||
_setGalGroupSelect(group);
|
||||
_loadGalImages(group);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyToWorkflow() {
|
||||
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!nodeId || !wf?.[nodeId]) {
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
_applyToNode(nodeId);
|
||||
showToast(t("appliedToWorkflow"), "success");
|
||||
}
|
||||
|
||||
function _applyGalToWorkflow() {
|
||||
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!nodeId || !wf?.[nodeId]) {
|
||||
showToast(t("feederGalNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
_applyToGalNode(nodeId);
|
||||
showToast(t("appliedToWorkflow"), "success");
|
||||
}
|
||||
|
||||
// ── Run Loop ──────────────────────────────────────────────────────
|
||||
|
||||
function _setRunUI(running) {
|
||||
const runBtn = document.getElementById("wfm-feeder-run-btn");
|
||||
const stopBtn = document.getElementById("wfm-feeder-stop-btn");
|
||||
if (runBtn) runBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
}
|
||||
|
||||
function _setGalRunUI(running) {
|
||||
const runBtn = document.getElementById("wfm-feeder-gal-run-btn");
|
||||
const stopBtn = document.getElementById("wfm-feeder-gal-stop-btn");
|
||||
if (runBtn) runBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
}
|
||||
|
||||
function _applyToNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
inp.directory = _s.dir;
|
||||
inp.sort_mode = document.getElementById("wfm-feeder-sort")?.value || "ascending";
|
||||
inp.index = parseInt(document.getElementById("wfm-feeder-index")?.value) || 0;
|
||||
inp.start_index = parseInt(document.getElementById("wfm-feeder-start")?.value) || 0;
|
||||
inp.end_index = parseInt(document.getElementById("wfm-feeder-end")?.value) || 0;
|
||||
inp.batch_size = parseInt(document.getElementById("wfm-feeder-batch")?.value) || 1;
|
||||
inp.seed = parseInt(document.getElementById("wfm-feeder-seed")?.value) || 0;
|
||||
inp.use_selection = document.getElementById("wfm-feeder-use-sel")?.checked ?? true;
|
||||
inp.selected_files = JSON.stringify([..._s.selected]);
|
||||
}
|
||||
|
||||
function _applyToGalNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
inp.group_name = _gs.group;
|
||||
inp.index = _gs.idx;
|
||||
inp.sort_mode = document.getElementById("wfm-feeder-gal-sort")?.value || "filename_asc";
|
||||
inp.seed = parseInt(document.getElementById("wfm-feeder-gal-seed")?.value) || 0;
|
||||
}
|
||||
|
||||
async function _startRun() {
|
||||
if (_s.running) return;
|
||||
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
|
||||
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
|
||||
|
||||
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
|
||||
|
||||
_s.running = true;
|
||||
_setRunUI(true);
|
||||
|
||||
const progressBar = document.getElementById("wfm-gen-progress-bar");
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
|
||||
let _lastSync = null;
|
||||
const _syncHandler = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "image_loop_node_sync" && String(msg.data?.node_id) === String(nodeId)) {
|
||||
_lastSync = msg.data;
|
||||
if (msg.data.thumbnail_path) {
|
||||
const p = msg.data.thumbnail_path;
|
||||
_showPreview(p, p.split("/").pop());
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
comfyUI.socket.addEventListener("message", _syncHandler);
|
||||
|
||||
let count = 0;
|
||||
try {
|
||||
while (_s.running) {
|
||||
_lastSync = null;
|
||||
_applyToNode(nodeId);
|
||||
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = `#${++count} 0%`;
|
||||
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
{ ...wf },
|
||||
{
|
||||
seedMode,
|
||||
seedValue,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `#${count} ${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seedEl = document.getElementById("wfm-gen-seed-value");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0 && resultImg) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
resultImg.src = URL.createObjectURL(blob);
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (!_s.running) break;
|
||||
|
||||
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
|
||||
if (control === "fixed") {
|
||||
// index を変えない
|
||||
} else if (_lastSync) {
|
||||
const indexEl = document.getElementById("wfm-feeder-index");
|
||||
if (indexEl) indexEl.value = _lastSync.next_index;
|
||||
if (control === "increment" && !_lastSync.has_next) {
|
||||
_s.running = false;
|
||||
showToast(t("feederComplete", count), "success");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (_s.running) showToast(t("feederRunError", err.message), "error");
|
||||
} finally {
|
||||
comfyUI.socket?.removeEventListener("message", _syncHandler);
|
||||
_s.running = false;
|
||||
_setRunUI(false);
|
||||
if (progressText) progressText.textContent = `Done (${count} generated)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _stopRun() {
|
||||
_s.running = false;
|
||||
try { await comfyUI.interrupt(); } catch {}
|
||||
_setRunUI(false);
|
||||
showToast(t("feederStopped"), "info");
|
||||
}
|
||||
|
||||
// ── Gallery Mode Run Loop ─────────────────────────────────────────
|
||||
|
||||
async function _startGalRun() {
|
||||
if (_s.running) return;
|
||||
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
|
||||
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
|
||||
|
||||
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
|
||||
showToast(t("feederGalNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
if (_gs.images.length === 0) {
|
||||
showToast(t("feederGalEmptyGroup"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
|
||||
|
||||
_s.running = true;
|
||||
_setGalRunUI(true);
|
||||
|
||||
const progressBar = document.getElementById("wfm-gen-progress-bar");
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
const indexEl = document.getElementById("wfm-feeder-gal-index");
|
||||
|
||||
const total = _gs.images.length;
|
||||
let count = 0;
|
||||
try {
|
||||
while (_s.running) {
|
||||
_applyToGalNode(nodeId);
|
||||
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = `#${++count} [${_gs.idx + 1}/${total}] 0%`;
|
||||
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
{ ...comfyUI.currentWorkflow },
|
||||
{
|
||||
seedMode,
|
||||
seedValue,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `#${count} [${_gs.idx + 1}/${total}] ${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seedEl = document.getElementById("wfm-gen-seed-value");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0 && resultImg) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
resultImg.src = URL.createObjectURL(blob);
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (!_s.running) break;
|
||||
|
||||
const control = document.getElementById("wfm-feeder-gal-control-after")?.value || "loop";
|
||||
const nextIdx = (_gs.idx + 1) % total;
|
||||
const isLast = _gs.idx === total - 1;
|
||||
|
||||
if (control === "fixed") {
|
||||
// idx を変えない
|
||||
} else if (control === "increment" && isLast) {
|
||||
_s.running = false;
|
||||
showToast(t("feederComplete", count), "success");
|
||||
break;
|
||||
} else {
|
||||
_gs.idx = nextIdx;
|
||||
if (indexEl) indexEl.value = _gs.idx;
|
||||
_highlightGalCard(_gs.images[_gs.idx]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (_s.running) showToast(t("feederRunError", err.message), "error");
|
||||
} finally {
|
||||
_s.running = false;
|
||||
_setGalRunUI(false);
|
||||
if (progressText) progressText.textContent = `Done (${count} generated)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _stopGalRun() {
|
||||
_s.running = false;
|
||||
try { await comfyUI.interrupt(); } catch {}
|
||||
_setGalRunUI(false);
|
||||
showToast(t("feederStopped"), "info");
|
||||
}
|
||||
|
||||
// ── Folder Tree (loop mode) ───────────────────────────────────────
|
||||
|
||||
async function _loadTree() {
|
||||
const container = document.getElementById("wfm-feeder-tree");
|
||||
if (!container) return;
|
||||
container.innerHTML = `<div class="wfm-feeder-tree-msg">Loading...</div>`;
|
||||
try {
|
||||
const res = await fetch("/image_loop/tree");
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_renderTree(data.tree || [], container);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="wfm-feeder-tree-msg">Cannot reach image-loop API</div>`;
|
||||
console.warn("[Feeder] tree load failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderTree(items, container, depth = 0) {
|
||||
if (depth === 0) {
|
||||
container.innerHTML = "";
|
||||
container.appendChild(_makeTreeRow("", "🏠 (root)", 0));
|
||||
}
|
||||
for (const item of items) {
|
||||
const icon = item.children?.length > 0 ? "📂" : "📁";
|
||||
container.appendChild(_makeTreeRow(item.path, `${icon} ${item.name}`, depth + 1));
|
||||
if (item.children?.length > 0) _renderTree(item.children, container, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function _makeTreeRow(path, label, depth) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "wfm-feeder-tree-row" + (_s.dir === path ? " active" : "");
|
||||
row.style.paddingLeft = `${8 + depth * 12}px`;
|
||||
row.dataset.path = path;
|
||||
row.textContent = label;
|
||||
row.title = path || "(root)";
|
||||
row.addEventListener("click", () => _selectDir(path));
|
||||
return row;
|
||||
}
|
||||
|
||||
function _selectDir(path) {
|
||||
if (_s.dir !== path) {
|
||||
_s.selected.clear();
|
||||
_s.dir = path;
|
||||
_setVal("wfm-feeder-dir-input", path);
|
||||
}
|
||||
_highlightTreeRow(path);
|
||||
_loadImages(path);
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _highlightTreeRow(path) {
|
||||
document.querySelectorAll(".wfm-feeder-tree-row").forEach(el => {
|
||||
el.classList.toggle("active", el.dataset.path === path);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image Grid (loop mode) ────────────────────────────────────────
|
||||
|
||||
async function _loadImages(dir) {
|
||||
const grid = document.getElementById("wfm-feeder-grid");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
|
||||
try {
|
||||
const url = "/image_loop/images" + (dir ? `?dir=${encodeURIComponent(dir)}` : "");
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_s.images = data.images || [];
|
||||
_renderGrid();
|
||||
} catch (err) {
|
||||
_s.images = [];
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
}
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _renderGrid() {
|
||||
const grid = document.getElementById("wfm-feeder-grid");
|
||||
if (!grid) return;
|
||||
if (_s.images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images found</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
for (const fname of _s.images) grid.appendChild(_makeCard(fname));
|
||||
}
|
||||
|
||||
function _makeCard(fname) {
|
||||
const relPath = _s.dir ? `${_s.dir}/${fname}` : fname;
|
||||
const checked = _s.selected.has(fname);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-feeder-card" + (checked ? " selected" : "");
|
||||
card.dataset.name = fname;
|
||||
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.className = "wfm-feeder-card-cb";
|
||||
cb.checked = checked;
|
||||
cb.addEventListener("change", e => {
|
||||
e.stopPropagation();
|
||||
if (cb.checked) _s.selected.add(fname);
|
||||
else _s.selected.delete(fname);
|
||||
card.classList.toggle("selected", cb.checked);
|
||||
_updateStatus();
|
||||
});
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "wfm-feeder-card-img";
|
||||
img.loading = "lazy";
|
||||
img.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
|
||||
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-feeder-card-name";
|
||||
name.textContent = fname;
|
||||
name.title = fname;
|
||||
|
||||
card.append(cb, img, name);
|
||||
card.addEventListener("click", e => {
|
||||
if (e.target === cb) return;
|
||||
_showPreview(relPath, fname);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function _refreshGridCbs() {
|
||||
document.querySelectorAll(".wfm-feeder-card").forEach(card => {
|
||||
const sel = _s.selected.has(card.dataset.name);
|
||||
card.classList.toggle("selected", sel);
|
||||
const cb = card.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.checked = sel;
|
||||
});
|
||||
}
|
||||
|
||||
function _selectAll() {
|
||||
_s.images.forEach(f => _s.selected.add(f));
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _deselectAll() {
|
||||
_s.images.forEach(f => _s.selected.delete(f));
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _updateStatus() {
|
||||
const el = document.getElementById("wfm-feeder-status");
|
||||
if (!el) return;
|
||||
const inCurrent = _s.images.filter(f => _s.selected.has(f)).length;
|
||||
el.textContent = `📂 ${_s.dir || "(root)"} | ${inCurrent} / ${_s.images.length} selected`;
|
||||
}
|
||||
|
||||
// ── Gallery Mode: Group & Image Grid ─────────────────────────────
|
||||
|
||||
async function _loadGalGroups() {
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/groups");
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_gs.groups = data.groups || [];
|
||||
_renderGalGroupSelect();
|
||||
} catch (err) {
|
||||
console.warn("[Feeder] loadGalGroups failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGalGroupSelect() {
|
||||
const sel = document.getElementById("wfm-feeder-gal-group");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = _gs.groups
|
||||
.map(g => `<option value="${g.name}"${g.name === _gs.group ? " selected" : ""}>${g.name}</option>`)
|
||||
.join("");
|
||||
if (!_gs.groups.find(g => g.name === _gs.group) && _gs.groups.length > 0) {
|
||||
_gs.group = _gs.groups[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
function _setGalGroupSelect(group) {
|
||||
const sel = document.getElementById("wfm-feeder-gal-group");
|
||||
if (sel) sel.value = group;
|
||||
}
|
||||
|
||||
async function _loadGalImages(group) {
|
||||
const grid = document.getElementById("wfm-feeder-gal-grid");
|
||||
const statusEl = document.getElementById("wfm-feeder-gal-status");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/groups/${encodeURIComponent(group)}/images`);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_gs.images = data.images || [];
|
||||
_gs.idx = 0;
|
||||
const idxEl = document.getElementById("wfm-feeder-gal-index");
|
||||
if (idxEl) idxEl.value = 0;
|
||||
_renderGalGrid();
|
||||
if (statusEl) statusEl.textContent = `${_gs.images.length} image(s) in "${group}"`;
|
||||
} catch (err) {
|
||||
_gs.images = [];
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGalGrid() {
|
||||
const grid = document.getElementById("wfm-feeder-gal-grid");
|
||||
if (!grid) return;
|
||||
if (_gs.images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images in this group</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
_gs.images.forEach((absPath, i) => grid.appendChild(_makeGalCard(absPath, i)));
|
||||
}
|
||||
|
||||
function _makeGalCard(absPath, idx) {
|
||||
const fname = absPath.replace(/\\/g, "/").split("/").pop();
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-feeder-card" + (idx === _gs.idx ? " selected" : "");
|
||||
card.dataset.path = absPath;
|
||||
card.dataset.idx = idx;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "wfm-feeder-card-img";
|
||||
img.loading = "lazy";
|
||||
img.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
|
||||
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-feeder-card-name";
|
||||
name.textContent = fname;
|
||||
name.title = fname;
|
||||
|
||||
card.append(img, name);
|
||||
card.addEventListener("click", () => {
|
||||
_gs.idx = idx;
|
||||
const idxEl = document.getElementById("wfm-feeder-gal-index");
|
||||
if (idxEl) idxEl.value = idx;
|
||||
_highlightGalCard(absPath);
|
||||
_showGalPreview(absPath, fname);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function _highlightGalCard(absPath) {
|
||||
document.querySelectorAll("#wfm-feeder-gal-grid .wfm-feeder-card").forEach(c => {
|
||||
c.classList.toggle("selected", c.dataset.path === absPath);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Preview Panel ────────────────────────────────────────────────
|
||||
|
||||
async function _showPreview(relPath, fname) {
|
||||
const imgEl = document.getElementById("wfm-feeder-preview-img");
|
||||
const nameEl = document.getElementById("wfm-feeder-preview-name");
|
||||
const infoEl = document.getElementById("wfm-feeder-preview-info");
|
||||
|
||||
if (imgEl) {
|
||||
imgEl.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
|
||||
imgEl.style.display = "block";
|
||||
}
|
||||
if (nameEl) nameEl.textContent = fname;
|
||||
if (infoEl) infoEl.textContent = "Loading...";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/image_loop/image_info?path=${encodeURIComponent(relPath)}`);
|
||||
if (res.ok && infoEl) {
|
||||
const d = await res.json();
|
||||
const kb = d.size_bytes ? (d.size_bytes / 1024).toFixed(1) + " KB" : "";
|
||||
infoEl.textContent = `${d.width ?? "?"}×${d.height ?? "?"} px\n${kb}`;
|
||||
}
|
||||
} catch { if (infoEl) infoEl.textContent = ""; }
|
||||
}
|
||||
|
||||
async function _showGalPreview(absPath, fname) {
|
||||
const imgEl = document.getElementById("wfm-feeder-preview-img");
|
||||
const nameEl = document.getElementById("wfm-feeder-preview-name");
|
||||
const infoEl = document.getElementById("wfm-feeder-preview-info");
|
||||
|
||||
if (imgEl) {
|
||||
imgEl.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
|
||||
imgEl.style.display = "block";
|
||||
}
|
||||
if (nameEl) nameEl.textContent = fname;
|
||||
if (infoEl) infoEl.textContent = "";
|
||||
}
|
||||
|
||||
// ── Presets (loop mode) ───────────────────────────────────────────
|
||||
|
||||
async function _loadPresets() {
|
||||
try {
|
||||
const res = await fetch("/image_feeder/presets");
|
||||
if (!res.ok) return;
|
||||
_s.presets = await res.json();
|
||||
} catch { _s.presets = {}; }
|
||||
_renderPresets();
|
||||
}
|
||||
|
||||
function _renderPresets() {
|
||||
const sel = document.getElementById("wfm-feeder-preset-sel");
|
||||
if (!sel) return;
|
||||
const names = Object.keys(_s.presets).sort();
|
||||
sel.innerHTML = `<option value="">-- Select preset --</option>` +
|
||||
names.map(n => `<option value="${n}">${n}</option>`).join("");
|
||||
}
|
||||
|
||||
async function _savePreset() {
|
||||
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
|
||||
if (!name) { showToast(t("enterPresetName"), "error"); return; }
|
||||
const sel = _s.images.filter(f => _s.selected.has(f));
|
||||
try {
|
||||
const res = await fetch("/image_feeder/presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(t("presetSavedName", name), "success");
|
||||
const nameInput = document.getElementById("wfm-feeder-preset-name");
|
||||
if (nameInput) nameInput.value = "";
|
||||
await _loadPresets();
|
||||
const selEl = document.getElementById("wfm-feeder-preset-sel");
|
||||
if (selEl) selEl.value = name;
|
||||
} else { showToast(t("saveFailed"), "error"); }
|
||||
} catch (err) { showToast(t("saveFailed", err.message), "error"); }
|
||||
}
|
||||
|
||||
async function _applyPreset() {
|
||||
const selEl = document.getElementById("wfm-feeder-preset-sel");
|
||||
const name = selEl?.value;
|
||||
if (!name || !_s.presets[name]) { showToast(t("selectPresetFirst"), "error"); return; }
|
||||
const preset = _s.presets[name];
|
||||
const dir = preset.directory || "";
|
||||
|
||||
_s.selected.clear();
|
||||
(preset.selected_files || []).forEach(f => _s.selected.add(f));
|
||||
_setVal("wfm-feeder-dir-input", dir);
|
||||
|
||||
if (_s.dir !== dir) {
|
||||
_s.dir = dir;
|
||||
_highlightTreeRow(dir);
|
||||
await _loadImages(dir);
|
||||
}
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
showToast(t("presetLoaded", name), "success");
|
||||
}
|
||||
|
||||
async function _deletePreset() {
|
||||
const name = document.getElementById("wfm-feeder-preset-sel")?.value;
|
||||
if (!name) return;
|
||||
try {
|
||||
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
showToast(t("presetDeleted", name), "info");
|
||||
await _loadPresets();
|
||||
} catch (err) { showToast(t("deleteFailed"), "error"); }
|
||||
}
|
||||
|
||||
// ── Mode Switch ───────────────────────────────────────────────────
|
||||
|
||||
function _setMode(mode) {
|
||||
_s.mode = mode;
|
||||
localStorage.setItem("wfm_feeder_mode", mode);
|
||||
|
||||
const loopBtn = document.getElementById("wfm-feeder-mode-loop");
|
||||
const galBtn = document.getElementById("wfm-feeder-mode-gallery");
|
||||
const loopSec = document.getElementById("wfm-feeder-loop-section");
|
||||
const galSec = document.getElementById("wfm-feeder-gal-section");
|
||||
const loopLib = document.getElementById("wfm-feeder-loop-library");
|
||||
const galLib = document.getElementById("wfm-feeder-gal-library");
|
||||
|
||||
const isGallery = mode === "gallery";
|
||||
|
||||
if (loopBtn) loopBtn.classList.toggle("wfm-btn-primary", !isGallery);
|
||||
if (galBtn) galBtn.classList.toggle("wfm-btn-primary", isGallery);
|
||||
if (loopSec) loopSec.style.display = isGallery ? "none" : "";
|
||||
if (galSec) galSec.style.display = isGallery ? "" : "none";
|
||||
if (loopLib) loopLib.style.display = isGallery ? "none" : "";
|
||||
if (galLib) galLib.style.display = isGallery ? "" : "none";
|
||||
|
||||
if (isGallery) {
|
||||
_loadGalGroups().then(() => _loadGalImages(_gs.group));
|
||||
_refreshGalNodeList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function _setVal(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = val ?? "";
|
||||
}
|
||||
function _setCheck(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.checked = !!val;
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function initFeederTab() {
|
||||
// Mode toggle
|
||||
document.getElementById("wfm-feeder-mode-loop")?.addEventListener("click", () => _setMode("loop"));
|
||||
document.getElementById("wfm-feeder-mode-gallery")?.addEventListener("click", () => _setMode("gallery"));
|
||||
|
||||
// ── Image Loop mode events ──
|
||||
document.getElementById("wfm-feeder-node-sel")?.addEventListener("change", e => {
|
||||
_loadFromNode(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-apply-btn")?.addEventListener("click", _applyToWorkflow);
|
||||
|
||||
document.getElementById("wfm-feeder-dir-input")?.addEventListener("change", e => {
|
||||
const val = e.target.value.trim();
|
||||
if (_s.dir !== val) {
|
||||
_s.dir = val;
|
||||
_highlightTreeRow(val);
|
||||
_loadImages(val);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-reload-tree")?.addEventListener("click", _loadTree);
|
||||
document.getElementById("wfm-feeder-sel-all")?.addEventListener("click", _selectAll);
|
||||
document.getElementById("wfm-feeder-desel-all")?.addEventListener("click", _deselectAll);
|
||||
|
||||
document.getElementById("wfm-feeder-preset-save")?.addEventListener("click", _savePreset);
|
||||
document.getElementById("wfm-feeder-preset-load")?.addEventListener("click", _applyPreset);
|
||||
document.getElementById("wfm-feeder-preset-del")?.addEventListener("click", _deletePreset);
|
||||
|
||||
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
|
||||
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
|
||||
|
||||
// ── Gallery mode events ──
|
||||
document.getElementById("wfm-feeder-gal-node-sel")?.addEventListener("change", e => {
|
||||
_loadFromGalNode(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-apply-btn")?.addEventListener("click", _applyGalToWorkflow);
|
||||
|
||||
document.getElementById("wfm-feeder-gal-group")?.addEventListener("change", e => {
|
||||
_gs.group = e.target.value;
|
||||
_gs.idx = 0;
|
||||
_loadGalImages(_gs.group);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-index")?.addEventListener("change", e => {
|
||||
_gs.idx = Math.max(0, parseInt(e.target.value) || 0);
|
||||
if (_gs.images[_gs.idx]) _highlightGalCard(_gs.images[_gs.idx]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-run-btn")?.addEventListener("click", _startGalRun);
|
||||
document.getElementById("wfm-feeder-gal-stop-btn")?.addEventListener("click", _stopGalRun);
|
||||
|
||||
// 初期化
|
||||
await ensureFeederGroup();
|
||||
_setMode(_s.mode);
|
||||
|
||||
if (_s.mode === "loop") {
|
||||
await Promise.all([_loadTree(), _loadPresets()]);
|
||||
_selectDir("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Gallery Tab - セマンティック検索 (Gemini Embedding 2)
|
||||
* - 検索ボックスのSemantic Searchトグル(ONでEnter実行の意味検索)
|
||||
* - 詳細パネルの「類似画像」トグル/更新ボタン
|
||||
* - インデックス作成モーダル(Semantic⚙)
|
||||
* 一覧の差し替えは gallery-tab.js の loadImages() が getSemanticMode() / fetchSemanticImages() を呼んで行う。
|
||||
*/
|
||||
|
||||
import { openModal, closeModal, showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
|
||||
const API = {
|
||||
status: (folder, recursive) => `/wfm/gallery/semantic/status?folder=${encodeURIComponent(folder)}&recursive=${recursive}`,
|
||||
index: "/wfm/gallery/semantic/index",
|
||||
cancel: "/wfm/gallery/semantic/cancel",
|
||||
search: "/wfm/gallery/semantic/search",
|
||||
similar: "/wfm/gallery/semantic/similar",
|
||||
clear: "/wfm/gallery/semantic/clear",
|
||||
folders: (root) => `/wfm/gallery/semantic/folders?root=${encodeURIComponent(root)}`,
|
||||
test: "/wfm/gallery/semantic/test",
|
||||
};
|
||||
|
||||
const LS_ON = "wfm_gallery_semantic_on";
|
||||
const LS_DIM = "wfm_gallery_semantic_dim";
|
||||
const LS_MODEL = "wfm_gallery_semantic_model";
|
||||
const LS_SOFT = "wfm_gallery_semantic_soft";
|
||||
const GEMINI = "gemini-embedding-2";
|
||||
const LOCAL = "embeddinggemma-2";
|
||||
const LS_SUB = "wfm_gallery_semantic_recursive";
|
||||
const LS_TAB = "wfm_gallery_semantic_tab";
|
||||
const LS_TH = "wfm_gallery_semantic_th";
|
||||
const LIMITS = [50, 100, 200, 500, 1000];
|
||||
|
||||
const sem = {
|
||||
on: false, // Semantic Searchトグル
|
||||
anchor: "", // 類似画像表示中の基準画像パス(空=類似表示OFF)
|
||||
anchorName: "",
|
||||
indexModel: GEMINI, // 現在のインデックスを作ったモデル(しきい値はモデルごとに保存する)
|
||||
};
|
||||
|
||||
let ctx = null; // { getSearch, getFolder, getSelected, reload, focusSearch }
|
||||
|
||||
function lsGet(key, fallback) {
|
||||
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
|
||||
}
|
||||
function lsSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch { /* 保存できなくても動作に影響しない */ }
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
let data = {};
|
||||
try { data = await res.json(); } catch { /* 本文なし */ }
|
||||
if (!res.ok) {
|
||||
throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { code: data.code || "" });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** APIエラーコードをi18nメッセージにする */
|
||||
export function semanticErrorMessage(e) {
|
||||
const known = ["no_api_key", "api_key_invalid", "index_empty", "dim_mismatch", "already_running",
|
||||
"folder_not_allowed", "unsupported_type", "network_error", "api_error", "local_error"];
|
||||
if (e?.code && known.includes(e.code)) {
|
||||
return t(`sem_err_${e.code}`, e.message);
|
||||
}
|
||||
return t("errorWithMsg", e?.message || String(e));
|
||||
}
|
||||
|
||||
// ── gallery-tab.js から使う公開API ────────────────────────────
|
||||
|
||||
/** 現在の一覧モード: {kind:"similar", path} | {kind:"text", query} | null(通常) */
|
||||
export function getSemanticMode() {
|
||||
if (sem.anchor) return { kind: "similar", path: sem.anchor, name: sem.anchorName };
|
||||
const q = (ctx?.getSearch() || "").trim();
|
||||
if (sem.on && q) return { kind: "text", query: q };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSemanticOn() {
|
||||
return sem.on;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表示条件(類似度のしきい値・最大件数)。スコアの水準はモデルで違うため、モデルごとに保存する。
|
||||
* similar: 類似画像検索の最小スコア / text: 文章検索の最小スコア(0=絞り込みなし)
|
||||
*/
|
||||
function loadThresholds(model) {
|
||||
let v = {};
|
||||
try { v = JSON.parse(lsGet(LS_TH, "{}"))[model] || {}; } catch { /* 壊れた値は既定に戻す */ }
|
||||
const num = x => { const n = parseFloat(x); return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0; };
|
||||
return { similar: num(v.similar), text: num(v.text), limit: LIMITS.includes(v.limit) ? v.limit : 200 };
|
||||
}
|
||||
|
||||
function saveThresholds(model, th) {
|
||||
let all = {};
|
||||
try { all = JSON.parse(lsGet(LS_TH, "{}")) || {}; } catch { all = {}; }
|
||||
all[model] = th;
|
||||
lsSet(LS_TH, JSON.stringify(all));
|
||||
}
|
||||
|
||||
function minScoreFor(mode) {
|
||||
const th = loadThresholds(sem.indexModel);
|
||||
return mode.kind === "similar" ? th.similar : th.text;
|
||||
}
|
||||
|
||||
/** モードに応じてサーバーから結果(スコア降順・score付き)を取得する */
|
||||
export async function fetchSemanticImages(mode, folder) {
|
||||
const th = loadThresholds(sem.indexModel);
|
||||
const common = { folder, recursive: true, limit: th.limit, min_score: minScoreFor(mode) };
|
||||
const data = mode.kind === "similar"
|
||||
? await postJson(API.similar, { path: mode.path, ...common })
|
||||
: await postJson(API.search, { query: mode.query, ...common });
|
||||
return data.images || [];
|
||||
}
|
||||
|
||||
/** 2行目の結果件数表示(意味検索/類似表示のときだけ文言を入れる。通常は空) */
|
||||
export function setSemanticCountText(text) {
|
||||
const el = document.getElementById("wfm-gallery-sem-count");
|
||||
if (el) el.textContent = text || "";
|
||||
}
|
||||
|
||||
/** 一覧件数ラベル */
|
||||
export function semanticCountLabel(mode, count) {
|
||||
const min = minScoreFor(mode);
|
||||
return mode.kind === "similar"
|
||||
? t("semCountSimilar", count, mode.name, min)
|
||||
: t("semCountText", count, min);
|
||||
}
|
||||
|
||||
let _dotRoot = "";
|
||||
let _dotSeq = 0;
|
||||
|
||||
/**
|
||||
* フォルダツリーの枚数バッジ横に、直下の画像が全件インデックス済みのフォルダへドットを付ける。
|
||||
* ツリー再構築後・インデックス作成後に呼ぶ(DOMはその場で更新し、ツリーは作り直さない)。
|
||||
*/
|
||||
export async function refreshSemanticDots(root) {
|
||||
if (root) _dotRoot = root;
|
||||
if (!_dotRoot) return;
|
||||
const seq = ++_dotSeq;
|
||||
let folders;
|
||||
try {
|
||||
const res = await fetch(API.folders(_dotRoot));
|
||||
if (!res.ok) return;
|
||||
folders = (await res.json()).folders || {};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (seq !== _dotSeq) return; // より新しい呼び出しがある
|
||||
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
|
||||
const label = item.querySelector(".wfm-gallery-tree-label");
|
||||
const badge = label?.querySelector(".wfm-gallery-tree-count");
|
||||
label?.querySelector(".wfm-gallery-tree-sem-dot")?.remove();
|
||||
const st = folders[(item.dataset.path || "").replace(/\\/g, "/")];
|
||||
if (badge && st && st.indexable > 0 && st.indexed >= st.indexable) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "wfm-gallery-tree-sem-dot";
|
||||
dot.title = t("semDotTitle");
|
||||
badge.after(dot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** フォルダ切替・フィルタクリア時に類似表示を解除する(再読込は呼び出し側) */
|
||||
export function exitSimilarMode() {
|
||||
sem.anchor = "";
|
||||
sem.anchorName = "";
|
||||
updateSimilarButtons();
|
||||
}
|
||||
|
||||
// ── トグル/類似ボタン ───────────────────────────────────────
|
||||
|
||||
function updateToggleUi() {
|
||||
const btn = document.getElementById("wfm-gallery-semantic-toggle");
|
||||
btn?.classList.toggle("active", sem.on);
|
||||
// ON のときだけ2行目(Similar / 更新 / 設定 / 件数)を表示する
|
||||
const row = document.getElementById("wfm-gallery-sem-row");
|
||||
if (row) row.style.display = sem.on ? "flex" : "none";
|
||||
const input = document.getElementById("wfm-gallery-search");
|
||||
if (input) input.placeholder = sem.on ? t("semSearchPlaceholder") : "Search images...";
|
||||
}
|
||||
|
||||
function updateSimilarButtons() {
|
||||
document.getElementById("wfm-gallery-similar-btn")?.classList.toggle("active", !!sem.anchor);
|
||||
}
|
||||
|
||||
async function toggleSimilar() {
|
||||
if (sem.anchor) {
|
||||
exitSimilarMode();
|
||||
ctx.reload();
|
||||
return;
|
||||
}
|
||||
await applySimilar();
|
||||
}
|
||||
|
||||
async function applySimilar() {
|
||||
const img = ctx.getSelected();
|
||||
if (!img) {
|
||||
showToast(t("gallerySelectImageFirst"), "error");
|
||||
return;
|
||||
}
|
||||
sem.anchor = img.path;
|
||||
sem.anchorName = img.filename;
|
||||
updateSimilarButtons();
|
||||
ctx.reload();
|
||||
}
|
||||
|
||||
// ── インデックス作成モーダル ─────────────────────────────────
|
||||
|
||||
function modalHtml() {
|
||||
const tab = ["index", "model", "search"].includes(lsGet(LS_TAB, "index")) ? lsGet(LS_TAB, "index") : "index";
|
||||
const pane = (id, body) => `<div class="wfm-sem-pane" data-sem-pane="${id}" style="${tab === id ? "" : "display:none;"}">${body}</div>`;
|
||||
const tabBtn = (id, label) =>
|
||||
`<button type="button" class="wfm-sem-tab-btn${tab === id ? " active" : ""}" data-sem-tab="${id}">${escapeHtml(label)}</button>`;
|
||||
const indexPane = `
|
||||
<div class="wfm-sem-status" id="wfm-sem-status">${escapeHtml(t("loading"))}</div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semScope"))}</b>
|
||||
<span id="wfm-sem-folder" class="wfm-sem-folder"></span></div>
|
||||
<label class="wfm-sem-row"><input type="checkbox" id="wfm-sem-recursive" ${lsGet(LS_SUB, "1") === "1" ? "checked" : ""}>
|
||||
${escapeHtml(t("semIncludeSub"))}</label>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semMode"))}</b>
|
||||
<label><input type="radio" name="wfm-sem-mode" value="missing" checked> ${escapeHtml(t("semModeMissing"))}</label>
|
||||
<label><input type="radio" name="wfm-sem-mode" value="all"> ${escapeHtml(t("semModeAll"))}</label></div>
|
||||
<div class="wfm-sem-progress" id="wfm-sem-progress" style="display:none;">
|
||||
<div class="wfm-sem-bar"><div class="wfm-sem-bar-fill" id="wfm-sem-bar-fill"></div></div>
|
||||
<div id="wfm-sem-progress-text" class="wfm-sem-hint"></div>
|
||||
</div>
|
||||
<div class="wfm-sem-actions">
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-sem-start">${escapeHtml(t("semStart"))}</button>
|
||||
<button class="wfm-btn" id="wfm-sem-cancel" disabled>${escapeHtml(t("semCancelJob"))}</button>
|
||||
<button class="wfm-btn wfm-sem-danger" id="wfm-sem-clear">${escapeHtml(t("semClear"))}</button>
|
||||
</div>`;
|
||||
const modelPane = `
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semModel"))}</b>
|
||||
<label><input type="radio" name="wfm-sem-model" value="${GEMINI}"> ${escapeHtml(t("semModelGemini"))}</label>
|
||||
<label><input type="radio" name="wfm-sem-model" value="${LOCAL}"> ${escapeHtml(t("semModelLocal"))}</label></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semDimension"))}</b>
|
||||
<select id="wfm-sem-dim" class="wfm-select"></select>
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semDimHint"))}</span></div>
|
||||
<div id="wfm-sem-local-opts" class="wfm-sem-local-opts" style="display:none;">
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semDevice"))}</b>
|
||||
<select id="wfm-sem-device" class="wfm-select">
|
||||
<option value="auto">${escapeHtml(t("semDeviceAuto"))}</option>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="cuda">GPU (CUDA)</option>
|
||||
</select>
|
||||
<span id="wfm-sem-device-info" class="wfm-sem-hint"></span></div>
|
||||
<div class="wfm-sem-row" id="wfm-sem-threads-row"><b>${escapeHtml(t("semThreads"))}</b>
|
||||
<select id="wfm-sem-threads" class="wfm-select"></select>
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semThreadsHint"))}</span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semSoft"))}</b>
|
||||
<select id="wfm-sem-soft" class="wfm-select">
|
||||
<option value="280">${escapeHtml(t("semSoftHigh"))}</option>
|
||||
<option value="140">${escapeHtml(t("semSoftStd"))}</option>
|
||||
<option value="70">${escapeHtml(t("semSoftLight"))}</option>
|
||||
</select>
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semSoftHint"))}</span></div>
|
||||
</div>
|
||||
<div class="wfm-sem-actions">
|
||||
<button class="wfm-btn" id="wfm-sem-test">${escapeHtml(t("semTest"))}</button>
|
||||
</div>
|
||||
<p class="wfm-sem-hint">${escapeHtml(t("semCostNote"))}</p>`;
|
||||
const searchPane = `
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThTarget"))}</b>
|
||||
<span id="wfm-sem-th-model" class="wfm-sem-folder"></span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThSimilar"))}</b>
|
||||
<input type="number" id="wfm-sem-th-similar" class="wfm-input wfm-sem-num" min="0" max="1" step="0.01" placeholder="0">
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semThSimilarHint"))}</span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThText"))}</b>
|
||||
<input type="number" id="wfm-sem-th-text" class="wfm-input wfm-sem-num" min="0" max="1" step="0.01" placeholder="0">
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semThTextHint"))}</span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThLimit"))}</b>
|
||||
<select id="wfm-sem-th-limit" class="wfm-select">${LIMITS.map(n => `<option value="${n}">${n}</option>`).join("")}</select></div>
|
||||
<p class="wfm-sem-hint">${escapeHtml(t("semThNote"))}</p>`;
|
||||
return `
|
||||
<div class="wfm-sem-modal">
|
||||
<div class="wfm-sem-tabs">${tabBtn("index", t("semTabIndex"))}${tabBtn("model", t("semTabModel"))}${tabBtn("search", t("semTabSearch"))}</div>
|
||||
${pane("index", indexPane)}${pane("model", modelPane)}${pane("search", searchPane)}
|
||||
<div class="wfm-sem-actions wfm-sem-footer">
|
||||
<button class="wfm-btn" id="wfm-sem-help" title="${escapeHtml(t("semHelpTitle"))}">?</button>
|
||||
<button class="wfm-btn" id="wfm-sem-close">${escapeHtml(t("close"))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function modalAlive() {
|
||||
const overlay = document.getElementById("wfm-modal-overlay");
|
||||
return !!document.getElementById("wfm-sem-status") && overlay && overlay.style.display !== "none";
|
||||
}
|
||||
|
||||
let _pollTimer = null;
|
||||
let _backends = {};
|
||||
let _sawRunning = false;
|
||||
|
||||
function selectedModel() {
|
||||
return document.querySelector('input[name="wfm-sem-model"]:checked')?.value || GEMINI;
|
||||
}
|
||||
|
||||
/** ローカルモデルの実行設定(デバイス/スレッド/画像精度)の初期値をサーバー保存値から設定する */
|
||||
function initLocalOpts(st) {
|
||||
const lo = st.local || {};
|
||||
const devSel = document.getElementById("wfm-sem-device");
|
||||
devSel.value = (lo.options && lo.options.device) || "auto";
|
||||
const cores = lo.devices?.cpu_count || 4;
|
||||
const cur = (lo.options && lo.options.threads) || lo.default_threads || Math.max(1, Math.floor(cores / 2));
|
||||
const set = new Set([1, 2, 4, 6, 8, 12, 16, 24, 32].filter(n => n <= cores).concat([cores, cur, lo.default_threads || 1]));
|
||||
document.getElementById("wfm-sem-threads").innerHTML = [...set].sort((a, b) => a - b)
|
||||
.map(n => `<option value="${n}">${n}${n === cores ? ` (${t("semThreadsAll")})` : ""}</option>`).join("");
|
||||
document.getElementById("wfm-sem-threads").value = String(cur);
|
||||
const soft = st.indexed_total && st.index_model === LOCAL ? st.index_soft : parseInt(lsGet(LS_SOFT, String(lo.default_soft || 280)), 10);
|
||||
document.getElementById("wfm-sem-soft").value = String([70, 140, 280].includes(soft) ? soft : 280);
|
||||
}
|
||||
|
||||
/** 検索タブ: しきい値の入力欄にインデックスのモデル用の保存値を入れる(入力中の値は上書きしない) */
|
||||
function fillThresholds(st) {
|
||||
const model = st.indexed_total ? st.index_model : selectedModel();
|
||||
const box = document.getElementById("wfm-sem-th-model");
|
||||
if (box) box.textContent = model + (st.indexed_total ? "" : ` ${t("semThNoIndex")}`);
|
||||
const pane = document.getElementById("wfm-sem-th-similar");
|
||||
if (!pane || pane.dataset.model === model) return;
|
||||
const th = loadThresholds(model);
|
||||
pane.value = th.similar || "";
|
||||
document.getElementById("wfm-sem-th-text").value = th.text || "";
|
||||
document.getElementById("wfm-sem-th-limit").value = String(th.limit);
|
||||
pane.dataset.model = model;
|
||||
}
|
||||
|
||||
/** 検索タブの入力を保存する。一覧が意味検索/類似表示中なら閉じるときに再読込する */
|
||||
let _thDirty = false;
|
||||
function saveThresholdInputs() {
|
||||
const model = document.getElementById("wfm-sem-th-similar")?.dataset.model;
|
||||
if (!model) return;
|
||||
const clamp = id => {
|
||||
const n = parseFloat(document.getElementById(id).value);
|
||||
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
||||
};
|
||||
saveThresholds(model, {
|
||||
similar: clamp("wfm-sem-th-similar"),
|
||||
text: clamp("wfm-sem-th-text"),
|
||||
limit: parseInt(document.getElementById("wfm-sem-th-limit").value, 10) || 200,
|
||||
});
|
||||
_thDirty = true;
|
||||
}
|
||||
|
||||
/** モーダルを閉じる共通処理 */
|
||||
function closeSemanticModal() {
|
||||
clearTimeout(_pollTimer);
|
||||
closeModal();
|
||||
refreshSemanticDots();
|
||||
if (_thDirty) {
|
||||
_thDirty = false;
|
||||
if (getSemanticMode()) ctx.reload(); // 新しいしきい値で一覧を取り直す
|
||||
}
|
||||
}
|
||||
|
||||
/** 検出した実行デバイスの表示(GPU版onnxruntimeが無いときの案内を含む) */
|
||||
function updateDeviceInfo(lo) {
|
||||
const dev = lo?.devices;
|
||||
if (!dev) return;
|
||||
const info = document.getElementById("wfm-sem-device-info");
|
||||
const cudaOpt = document.querySelector('#wfm-sem-device option[value="cuda"]');
|
||||
if (cudaOpt) cudaOpt.disabled = !dev.cuda;
|
||||
if (!dev.cuda && document.getElementById("wfm-sem-device").value === "cuda") {
|
||||
document.getElementById("wfm-sem-device").value = "auto";
|
||||
}
|
||||
if (info) info.textContent = dev.cuda ? t("semDeviceCudaOk", dev.ort_version) : t("semDeviceNoGpu", dev.ort_version);
|
||||
const dv = document.getElementById("wfm-sem-device").value;
|
||||
const useCpu = dv === "cpu" || (dv === "auto" && !dev.cuda);
|
||||
document.getElementById("wfm-sem-threads-row").style.display = useCpu ? "" : "none";
|
||||
}
|
||||
|
||||
/** 選択中モデルの次元選択肢を作り直す(preferredが選択肢にあればそれを選ぶ) */
|
||||
function fillDims(model, preferred) {
|
||||
const sel = document.getElementById("wfm-sem-dim");
|
||||
const info = _backends[model];
|
||||
if (!sel || !info) return;
|
||||
sel.innerHTML = info.dims.map(d => `<option value="${d}">${d}</option>`).join("");
|
||||
sel.value = String(info.dims.includes(preferred) ? preferred : info.default_dim);
|
||||
}
|
||||
|
||||
async function refreshModal() {
|
||||
if (!modalAlive()) return;
|
||||
const folder = ctx.getFolder();
|
||||
const recursive = document.getElementById("wfm-sem-recursive")?.checked ?? true;
|
||||
let st;
|
||||
try {
|
||||
const res = await fetch(API.status(folder, recursive));
|
||||
st = await res.json();
|
||||
if (!res.ok) throw new Error(st.error || `HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
document.getElementById("wfm-sem-status").textContent = t("errorWithMsg", e.message);
|
||||
return;
|
||||
}
|
||||
if (!modalAlive()) return;
|
||||
const job = st.job || {};
|
||||
_backends = st.backends || {};
|
||||
if (st.indexed_total) sem.indexModel = st.index_model;
|
||||
if (!document.getElementById("wfm-sem-dim").dataset.init) {
|
||||
// 初回のみ: 既存インデックスのモデル/次元、なければ前回の選択を初期値にする
|
||||
const initModel = st.indexed_total ? st.index_model : lsGet(LS_MODEL, GEMINI);
|
||||
const radio = document.querySelector(`input[name="wfm-sem-model"][value="${initModel}"]`)
|
||||
|| document.querySelector(`input[name="wfm-sem-model"][value="${GEMINI}"]`);
|
||||
radio.checked = true;
|
||||
fillDims(radio.value, st.indexed_total ? st.index_dim : parseInt(lsGet(LS_DIM, "0"), 10));
|
||||
initLocalOpts(st);
|
||||
document.getElementById("wfm-sem-dim").dataset.init = "1";
|
||||
}
|
||||
fillThresholds(st);
|
||||
const model = selectedModel();
|
||||
document.getElementById("wfm-sem-local-opts").style.display = model === LOCAL ? "" : "none";
|
||||
updateDeviceInfo(st.local);
|
||||
const ready = model === LOCAL ? true : !!st.has_key;
|
||||
const keyLine = model === LOCAL
|
||||
? (_backends[LOCAL]?.downloaded ? t("semLocalReady") : t("semLocalNeedDownload"))
|
||||
: (st.has_key ? t("semKeyOk") : t("semKeyMissing"));
|
||||
const idxLine = t("semIndexInfo", st.indexed_total, st.index_dim || "-");
|
||||
const scopeLine = st.scope_total != null ? t("semScopeInfo", st.scope_indexed, st.scope_total) : "";
|
||||
document.getElementById("wfm-sem-status").innerHTML =
|
||||
`<div class="${ready ? "wfm-sem-ok" : "wfm-sem-ng"}">${escapeHtml(keyLine)}</div>` +
|
||||
`<div>${escapeHtml(st.indexed_total ? st.index_model : "-")} / ${escapeHtml(idxLine)}</div>` +
|
||||
(scopeLine ? `<div>${escapeHtml(scopeLine)}</div>` : "");
|
||||
document.getElementById("wfm-sem-folder").textContent = folder || t("semNoFolder");
|
||||
|
||||
const prog = document.getElementById("wfm-sem-progress");
|
||||
const hasJob = job.running || job.finished;
|
||||
prog.style.display = hasJob ? "" : "none";
|
||||
if (hasJob) {
|
||||
const total = job.total || 0;
|
||||
const doneAll = (job.done || 0) + (job.failed || 0);
|
||||
document.getElementById("wfm-sem-bar-fill").style.width = total ? `${Math.min(100, (doneAll / total) * 100)}%` : "100%";
|
||||
let text = t("semProgress", job.done || 0, total, job.failed || 0);
|
||||
if (job.running && job.current) text += ` ${job.current}`;
|
||||
if (!job.running) {
|
||||
if (["no_api_key", "api_key_invalid", "local_error"].includes(job.error_code)) text += ` ${t(`sem_err_${job.error_code}`, job.error)}`;
|
||||
else text += ` ${job.cancelled ? t("semCancelled") : t("semFinished")}`;
|
||||
if (job.failed && job.error) text += ` (${job.error})`;
|
||||
}
|
||||
document.getElementById("wfm-sem-progress-text").textContent = text;
|
||||
}
|
||||
document.getElementById("wfm-sem-start").disabled = !!job.running || !ready;
|
||||
document.getElementById("wfm-sem-cancel").disabled = !job.running;
|
||||
document.getElementById("wfm-sem-clear").disabled = !!job.running;
|
||||
document.getElementById("wfm-sem-test").disabled = !ready;
|
||||
|
||||
clearTimeout(_pollTimer);
|
||||
if (job.running) {
|
||||
_pollTimer = setTimeout(refreshModal, 1000);
|
||||
_sawRunning = true;
|
||||
} else if (_sawRunning) {
|
||||
_sawRunning = false;
|
||||
refreshSemanticDots();
|
||||
}
|
||||
}
|
||||
|
||||
/** ヘルプタブのGallery > セマンティック検索の説明へ飛ぶ */
|
||||
function openSemanticHelp() {
|
||||
closeSemanticModal();
|
||||
document.querySelector('.wfm-tab[data-tab="help"]')?.click();
|
||||
document.querySelector('.wfm-help-nav-item[data-help-page="gallery"]')?.click();
|
||||
const target = document.getElementById("wfm-help-gallery-33");
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
target.classList.add("wfm-help-flash");
|
||||
setTimeout(() => target.classList.remove("wfm-help-flash"), 2500);
|
||||
}
|
||||
|
||||
function openSemanticModal() {
|
||||
if (!ctx.getFolder()) {
|
||||
showToast(t("semNoFolder"), "error");
|
||||
return;
|
||||
}
|
||||
openModal(t("semModalTitle"), modalHtml());
|
||||
const dimOf = () => parseInt(document.getElementById("wfm-sem-dim").value, 10) || 0;
|
||||
// ローカルモデル選択時のみ実行設定を送る(サーバー側で保存され、検索時のクエリ埋め込みにも使われる)
|
||||
const localOpts = () => selectedModel() === LOCAL ? {
|
||||
device: document.getElementById("wfm-sem-device").value,
|
||||
threads: parseInt(document.getElementById("wfm-sem-threads").value, 10) || 0,
|
||||
soft: parseInt(document.getElementById("wfm-sem-soft").value, 10) || 280,
|
||||
} : {};
|
||||
document.getElementById("wfm-sem-device").addEventListener("change", () => refreshModal());
|
||||
document.querySelectorAll('input[name="wfm-sem-model"]').forEach(r => r.addEventListener("change", () => {
|
||||
fillDims(selectedModel(), 0);
|
||||
refreshModal();
|
||||
}));
|
||||
|
||||
document.getElementById("wfm-sem-recursive").addEventListener("change", (e) => {
|
||||
lsSet(LS_SUB, e.target.checked ? "1" : "0");
|
||||
refreshModal();
|
||||
});
|
||||
document.getElementById("wfm-sem-close").addEventListener("click", closeSemanticModal);
|
||||
document.querySelectorAll(".wfm-sem-tab-btn").forEach(btn => btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".wfm-sem-tab-btn").forEach(b => b.classList.toggle("active", b === btn));
|
||||
document.querySelectorAll(".wfm-sem-pane").forEach(p => { p.style.display = p.dataset.semPane === btn.dataset.semTab ? "" : "none"; });
|
||||
lsSet(LS_TAB, btn.dataset.semTab);
|
||||
}));
|
||||
["wfm-sem-th-similar", "wfm-sem-th-text"].forEach(id => document.getElementById(id).addEventListener("change", saveThresholdInputs));
|
||||
document.getElementById("wfm-sem-th-limit").addEventListener("change", saveThresholdInputs);
|
||||
document.getElementById("wfm-sem-help").addEventListener("click", openSemanticHelp);
|
||||
document.getElementById("wfm-sem-start").addEventListener("click", async () => {
|
||||
const mode = document.querySelector('input[name="wfm-sem-mode"]:checked')?.value || "missing";
|
||||
if (mode === "all" && !confirm(t("semConfirmAll"))) return;
|
||||
lsSet(LS_DIM, String(dimOf()));
|
||||
lsSet(LS_MODEL, selectedModel());
|
||||
lsSet(LS_SOFT, document.getElementById("wfm-sem-soft").value);
|
||||
try {
|
||||
await postJson(API.index, {
|
||||
folder: ctx.getFolder(),
|
||||
recursive: document.getElementById("wfm-sem-recursive").checked,
|
||||
mode,
|
||||
model: selectedModel(),
|
||||
dim: dimOf(),
|
||||
...localOpts(),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(semanticErrorMessage(e), "error");
|
||||
}
|
||||
refreshModal();
|
||||
});
|
||||
document.getElementById("wfm-sem-cancel").addEventListener("click", async () => {
|
||||
await postJson(API.cancel, {}).catch(() => {});
|
||||
refreshModal();
|
||||
});
|
||||
document.getElementById("wfm-sem-test").addEventListener("click", async () => {
|
||||
try {
|
||||
const r = await postJson(API.test, { model: selectedModel(), dim: dimOf(), ...localOpts() });
|
||||
showToast(r.provider && r.provider !== "API" ? t("semTestOkProvider", r.dimensions, r.provider) : t("semTestOk", r.dimensions), "success");
|
||||
} catch (e) {
|
||||
showToast(semanticErrorMessage(e), "error");
|
||||
}
|
||||
});
|
||||
document.getElementById("wfm-sem-clear").addEventListener("click", async () => {
|
||||
if (!confirm(t("semConfirmClear"))) return;
|
||||
try {
|
||||
await postJson(API.clear, {});
|
||||
showToast(t("semCleared"), "success");
|
||||
refreshSemanticDots();
|
||||
} catch (e) {
|
||||
showToast(semanticErrorMessage(e), "error");
|
||||
}
|
||||
refreshModal();
|
||||
});
|
||||
refreshModal();
|
||||
}
|
||||
|
||||
// ── 初期化 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} context
|
||||
* getSearch(): 検索ボックスの文字列 / getFolder(): 選択中フォルダ / getSelected(): 選択中画像
|
||||
* reload(): 一覧再読込(loadImages)
|
||||
*/
|
||||
export function initSemantic(context) {
|
||||
ctx = context;
|
||||
sem.on = lsGet(LS_ON, "0") === "1";
|
||||
updateToggleUi();
|
||||
updateSimilarButtons();
|
||||
|
||||
document.getElementById("wfm-gallery-semantic-toggle")?.addEventListener("click", () => {
|
||||
sem.on = !sem.on;
|
||||
lsSet(LS_ON, sem.on ? "1" : "0");
|
||||
updateToggleUi();
|
||||
// OFFにしたら類似表示も解除する(2行目ごと消えるため)
|
||||
const wasSimilar = !!sem.anchor;
|
||||
if (!sem.on) exitSimilarMode();
|
||||
// 検索文字列があれば、モード切替後の方式で結果を取り直す
|
||||
if (wasSimilar && !sem.on || (ctx.getSearch() || "").trim()) ctx.reload();
|
||||
});
|
||||
fetch(API.status("", true)).then(r => r.json()).then(st => {
|
||||
if (st.indexed_total) sem.indexModel = st.index_model;
|
||||
}).catch(() => { /* 取得できなくても既定のモデル扱いで動作する */ });
|
||||
document.getElementById("wfm-gallery-semantic-settings-btn")?.addEventListener("click", openSemanticModal);
|
||||
document.getElementById("wfm-gallery-similar-btn")?.addEventListener("click", toggleSimilar);
|
||||
document.getElementById("wfm-gallery-similar-refresh-btn")?.addEventListener("click", applySimilar);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Image Edit Tab - Background Removal
|
||||
* Two backends: a lightweight in-browser model (@imgly/background-removal)
|
||||
* and BiRefNet via the Mask Editor One server endpoints.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class BgRemove {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(newLayer: object) => void} callbacks.onLayerAdded
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const resp = await fetch("/mask_editor/birefnet/status");
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
this.birefnetAvailable = json.loaded === true || json.model_found === true;
|
||||
} catch {
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const birefnetDisabled = this.birefnetAvailable ? "" : "disabled";
|
||||
const birefnetLabel = this.birefnetAvailable
|
||||
? "BiRefNet (Mask Editor One)"
|
||||
: "BiRefNet (Mask Editor One required)";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<label>Model</label>
|
||||
<select id="ie-bgremove-model" class="ie-opt-select">
|
||||
<option value="imgly">Lightweight (@imgly)</option>
|
||||
<option value="birefnet" ${birefnetDisabled}>${birefnetLabel}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-bgremove-new-layer" checked> New Layer
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-bgremove-btn">Remove BG</button>
|
||||
</div>
|
||||
<span id="ie-bgremove-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;"></span>
|
||||
`;
|
||||
document.getElementById("ie-bgremove-btn")?.addEventListener("click", () => this.apply());
|
||||
}
|
||||
|
||||
async apply() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
|
||||
const model = document.getElementById("ie-bgremove-model")?.value ?? "imgly";
|
||||
const asNew = document.getElementById("ie-bgremove-new-layer")?.checked ?? true;
|
||||
const statusEl = document.getElementById("ie-bgremove-status");
|
||||
const btn = document.getElementById("ie-bgremove-btn");
|
||||
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
setStatus("Starting...");
|
||||
|
||||
try {
|
||||
const dataUrl = layer.canvas.toDataURL("image/png");
|
||||
|
||||
let resultDataUrl;
|
||||
if (model === "imgly") {
|
||||
resultDataUrl = await this._removeImgly(dataUrl, setStatus);
|
||||
} else {
|
||||
resultDataUrl = await this._removeBiRefNet(dataUrl, setStatus);
|
||||
}
|
||||
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("Result image load failed"));
|
||||
i.src = resultDataUrl;
|
||||
});
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (asNew) {
|
||||
const newL = layerMgr.addLayer("image", layer.name + " (no bg)", {
|
||||
contentW: img.width, contentH: img.height,
|
||||
displayW: layer.displayW, displayH: layer.displayH,
|
||||
x: layer.x, y: layer.y,
|
||||
});
|
||||
newL.ctx.drawImage(img, 0, 0);
|
||||
layerMgr.setActive(newL.id);
|
||||
this._cb.onLayerAdded(newL);
|
||||
} else {
|
||||
layer.canvas.width = img.width;
|
||||
layer.canvas.height = img.height;
|
||||
layer.ctx = layer.canvas.getContext("2d");
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
setStatus("Done!");
|
||||
setTimeout(() => setStatus(""), 3000);
|
||||
showToast("Background removed", "success");
|
||||
|
||||
} catch (err) {
|
||||
setStatus("Error: " + err.message);
|
||||
showToast("BG remove failed: " + err.message, "error");
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _removeImgly(dataUrl, onStatus) {
|
||||
if (!window._wfmImglyRemoveBg) {
|
||||
onStatus("Loading model...");
|
||||
const mod = await import("https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022");
|
||||
window._wfmImglyRemoveBg = mod.removeBackground;
|
||||
}
|
||||
onStatus("Processing...");
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const resultBlob = await window._wfmImglyRemoveBg(blob, {
|
||||
publicPath: "https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/",
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(resultBlob);
|
||||
});
|
||||
}
|
||||
|
||||
async _removeBiRefNet(dataUrl, onStatus) {
|
||||
const NODE_ID = "wfs_bgremove";
|
||||
|
||||
// 1. アクティブレイヤー画像を Mask Editor One のキャッシュに登録
|
||||
onStatus("Sending image...");
|
||||
const storeResp = await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
|
||||
});
|
||||
if (!storeResp.ok) throw new Error("Failed to cache image for BiRefNet");
|
||||
|
||||
// 2. BiRefNet 推論実行
|
||||
onStatus("Running BiRefNet...");
|
||||
const resp = await fetch("/mask_editor/birefnet/remove_bg", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID }),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
|
||||
// 3. グレースケールマスク (白=前景) → RGBA PNG に変換して返す
|
||||
onStatus("Compositing...");
|
||||
return await this._applyMaskToImage(dataUrl, json.mask_b64);
|
||||
}
|
||||
|
||||
async _applyMaskToImage(imageB64, maskB64) {
|
||||
const loadImage = src => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed: " + src.slice(0, 40)));
|
||||
img.src = src;
|
||||
});
|
||||
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
|
||||
|
||||
const w = origImg.naturalWidth;
|
||||
const h = origImg.naturalHeight;
|
||||
|
||||
// マスク: グレースケール輝度値 → アルファチャンネル (RGBA 白マスク)
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = w;
|
||||
maskCanvas.height = h;
|
||||
const mc = maskCanvas.getContext("2d");
|
||||
mc.drawImage(maskImg, 0, 0, w, h);
|
||||
const maskData = mc.getImageData(0, 0, w, h);
|
||||
const md = maskData.data;
|
||||
for (let i = 0; i < md.length; i += 4) {
|
||||
md[i + 3] = md[i]; // 輝度 → アルファ
|
||||
md[i] = md[i + 1] = md[i + 2] = 255;
|
||||
}
|
||||
mc.putImageData(maskData, 0, 0);
|
||||
|
||||
// 元画像にマスクを destination-in で適用
|
||||
const out = document.createElement("canvas");
|
||||
out.width = w;
|
||||
out.height = h;
|
||||
const ctx = out.getContext("2d");
|
||||
ctx.drawImage(origImg, 0, 0, w, h);
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
ctx.drawImage(maskCanvas, 0, 0, w, h);
|
||||
|
||||
return out.toDataURL("image/png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Image Edit Tab - Blur / Mosaic Tool
|
||||
* Whole-layer blur/mosaic buttons plus a drag-to-select rectangle mode
|
||||
* that applies blur or mosaic to just the dragged region.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
function _applyMosaicToRegion(ctx, x, y, w, h, size) {
|
||||
if (w <= 0 || h <= 0 || size < 1) return;
|
||||
const imgData = ctx.getImageData(x, y, w, h);
|
||||
const d = imgData.data;
|
||||
for (let py = 0; py < h; py += size) {
|
||||
for (let px = 0; px < w; px += size) {
|
||||
const i = (py * w + px) * 4;
|
||||
const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3];
|
||||
for (let by = py; by < Math.min(py + size, h); by++) {
|
||||
for (let bx = px; bx < Math.min(px + size, w); bx++) {
|
||||
const j = (by * w + bx) * 4;
|
||||
d[j] = r; d[j + 1] = g; d[j + 2] = b; d[j + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, x, y);
|
||||
}
|
||||
|
||||
export class BlurTool {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => number} callbacks.getZoom
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.rectMode = null; // null | 'blur' | 'mosaic'
|
||||
this._dragging = false;
|
||||
this._dragStart = null;
|
||||
this._dragCur = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.rectMode = null;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) {
|
||||
overlay.style.cursor = "";
|
||||
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const blurOn = this.rectMode === "blur";
|
||||
const mosaicOn = this.rectMode === "mosaic";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Whole:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Blur</label>
|
||||
<input type="range" id="ie-whole-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-whole-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-blur-apply">Apply</button>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Mosaic</label>
|
||||
<input type="range" id="ie-whole-mosaic" min="5" max="100" value="20" style="width:70px;">
|
||||
<span id="ie-whole-mosaic-val" style="font-size:11px;min-width:22px;">20</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-mosaic-apply">Apply</button>
|
||||
</div>
|
||||
<div style="width:1px;height:22px;background:var(--wfm-border);margin:0 6px;flex-shrink:0;"></div>
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Rect:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${blurOn ? " ie-opt-active" : ""}" id="ie-rect-blur-toggle"
|
||||
style="${blurOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Blur: ${blurOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-rect-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${mosaicOn ? " ie-opt-active" : ""}" id="ie-rect-mosaic-toggle"
|
||||
style="${mosaicOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Mosaic: ${mosaicOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-mosaic" min="5" max="50" value="15" style="width:70px;">
|
||||
<span id="ie-rect-mosaic-val" style="font-size:11px;min-width:22px;">15</span>px
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("ie-whole-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-blur-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeBlur(parseInt(document.getElementById("ie-whole-blur").value));
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeMosaic(parseInt(document.getElementById("ie-whole-mosaic").value));
|
||||
});
|
||||
document.getElementById("ie-rect-blur-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "blur" ? null : "blur";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "mosaic" ? null : "mosaic";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
}
|
||||
|
||||
onMouseDown(pos) {
|
||||
if (!this.rectMode) return;
|
||||
this._dragging = true;
|
||||
this._dragStart = { x: pos.x, y: pos.y };
|
||||
this._dragCur = { x: pos.x, y: pos.y };
|
||||
}
|
||||
|
||||
onMouseMove(pos) {
|
||||
if (!this._dragging) return;
|
||||
this._dragCur = pos;
|
||||
this._drawPreview();
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._applyRectEffect();
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
applyWholeBlur(amount) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
applyWholeMosaic(size) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
_applyMosaicToRegion(layer.ctx, 0, 0, layer.canvas.width, layer.canvas.height, size);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
// canvas座標 (cx,cy) → layer.canvas 座標への逆変換
|
||||
_canvasToLayerCoords(layer, cx, cy) {
|
||||
const centerX = layer.x + layer.displayW / 2;
|
||||
const centerY = layer.y + layer.displayH / 2;
|
||||
const dx = cx - centerX, dy = cy - centerY;
|
||||
const angle = -(layer.rotation || 0) * Math.PI / 180;
|
||||
const rdx = dx * Math.cos(angle) - dy * Math.sin(angle);
|
||||
const rdy = dx * Math.sin(angle) + dy * Math.cos(angle);
|
||||
const scaleX = layer.displayW / layer.canvas.width;
|
||||
const scaleY = layer.displayH / layer.canvas.height;
|
||||
let lx = rdx / scaleX + layer.canvas.width / 2;
|
||||
let ly = rdy / scaleY + layer.canvas.height / 2;
|
||||
if (layer.flipX) lx = layer.canvas.width - lx;
|
||||
if (layer.flipY) ly = layer.canvas.height - ly;
|
||||
return { x: lx, y: ly };
|
||||
}
|
||||
|
||||
_drawPreview() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (!overlay) return;
|
||||
const ctx = overlay.getContext("2d");
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (!s || !c) return;
|
||||
const x = Math.min(s.x, c.x), y = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
const zoom = this._cb.getZoom();
|
||||
ctx.strokeStyle = this.rectMode === "blur" ? "#4af" : "#fa4";
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
ctx.setLineDash([4 / zoom, 2 / zoom]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
_applyRectEffect() {
|
||||
const layer = this._cb.getLayerManager()?.activeLayer;
|
||||
if (!layer || !this._dragStart || !this._dragCur) return;
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (Math.abs(c.x - s.x) < 3 || Math.abs(c.y - s.y) < 3) return;
|
||||
|
||||
const minX = Math.min(s.x, c.x), minY = Math.min(s.y, c.y);
|
||||
const maxX = Math.max(s.x, c.x), maxY = Math.max(s.y, c.y);
|
||||
const p1 = this._canvasToLayerCoords(layer, minX, minY);
|
||||
const p2 = this._canvasToLayerCoords(layer, maxX, maxY);
|
||||
|
||||
const lx = Math.round(Math.max(0, Math.min(p1.x, p2.x)));
|
||||
const ly = Math.round(Math.max(0, Math.min(p1.y, p2.y)));
|
||||
const lw = Math.round(Math.min(layer.canvas.width - lx, Math.abs(p2.x - p1.x)));
|
||||
const lh = Math.round(Math.min(layer.canvas.height - ly, Math.abs(p2.y - p1.y)));
|
||||
if (lw <= 0 || lh <= 0) return;
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (this.rectMode === "blur") {
|
||||
const amount = parseInt(document.getElementById("ie-rect-blur")?.value ?? "10");
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = layer.canvas.width; tmp.height = layer.canvas.height;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.drawImage(tmp, lx, ly, lw, lh, lx, ly, lw, lh);
|
||||
} else {
|
||||
const size = parseInt(document.getElementById("ie-rect-mosaic")?.value ?? "15");
|
||||
_applyMosaicToRegion(layer.ctx, lx, ly, lw, lh, size);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Pen pressure (0–1, from PointerEvent.pressure; mouse reports 0.5).
|
||||
// pressure=0.5 maps to a 1.0x multiplier so mouse behavior is unchanged.
|
||||
this.pressureEnabled = true;
|
||||
this._lastPressure = 0.5;
|
||||
|
||||
// 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, pressure = 0.5) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y, pressure);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y, pressure = 0.5) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this._drawing) {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Pressure response ──────────────────────────────────────────────
|
||||
|
||||
_effectiveSize(pressure) {
|
||||
if (!this.pressureEnabled) return this.brushSize;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(1, this.brushSize * (0.3 + p * 1.4)); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
_effectiveOpacity(pressure) {
|
||||
if (!this.pressureEnabled) return this.opacity;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(0, Math.min(1, this.opacity * (0.4 + p * 1.2))); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
// ── 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, pressure = 0.5) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
let size = this._effectiveSize(pressure);
|
||||
if (this.sizeJitter) {
|
||||
size = Math.max(1, size * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
|
||||
if (this.rotationJitter) {
|
||||
const savedAngle = this.angle;
|
||||
this.angle = Math.random() * 360;
|
||||
this._paintImageBrush(x, y, size);
|
||||
this.angle = savedAngle;
|
||||
} else {
|
||||
this._paintImageBrush(x, y, size);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
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, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp(size) {
|
||||
size = Math.max(1, Math.round(size));
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === size &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampColor === this.color
|
||||
) return this._stamp;
|
||||
|
||||
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, pressure = 0.5) {
|
||||
const size = this._effectiveSize(pressure);
|
||||
const stamp = this._getStamp(size);
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = this._effectiveOpacity(pressure);
|
||||
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, p0 = 0.5, p1 = 0.5) {
|
||||
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, p0 + (p1 - p0) * 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, size = this.brushSize) {
|
||||
const stamp = this._getImageStamp(Math.round(size));
|
||||
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,222 @@
|
||||
/**
|
||||
* Image Edit Tab - File Export
|
||||
* Composites the visible layers (respecting mask-apply clipping) into a
|
||||
* flat canvas and offers three destinations: local PNG download, WFS
|
||||
* Gallery, and the selected node's image widget on the ComfyUI canvas
|
||||
* (Send to LI node).
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { Layer } from "./LayerManager.js";
|
||||
|
||||
export class FileExport {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => {w: number, h: number}} callbacks.getCanvasSize
|
||||
* @param {() => string} callbacks.getBaseName
|
||||
* @param {(ctx: CanvasRenderingContext2D, drawCanvas: HTMLCanvasElement, maskLayer: object, targetLayer: object, showOverlay: boolean) => void} callbacks.renderMaskedLayer
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
}
|
||||
|
||||
_buildCompositeCanvas() {
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return canvas;
|
||||
this._compositeForExport(canvas, layerMgr);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// 保存用合成: maskApply=true のクリッピングを適用、マスクオーバーレイは除外
|
||||
_compositeForExport(target, layerMgr) {
|
||||
const ctx = target.getContext("2d");
|
||||
ctx.clearRect(0, 0, target.width, target.height);
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (!layer.visible) continue;
|
||||
if (maskedIndices.has(i)) continue;
|
||||
if (layer.type === "mask") {
|
||||
if (layer.maskApply && i + 1 < layers.length) {
|
||||
this._cb.renderMaskedLayer(ctx, target, layer, layers[i + 1], false);
|
||||
}
|
||||
// maskApply=false のマスクはエクスポートに含めない
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用レイヤー配列を構築: 各レイヤーを個別のcanvas(ドキュメント全体サイズ)に
|
||||
// 回転・反転・拡縮のみベイクして描画する。opacity/blendModeはピクセルに焼き込まず
|
||||
// PSDレイヤー属性として渡すためメタデータのまま残す。
|
||||
// maskApply=true のクリッピングマスクは対象レイヤーと合成して1枚にベイクする
|
||||
// (PSDの本物のレイヤーマスクとしては書き出さない簡略化)。
|
||||
_buildLayerExportList(layerMgr, w, h) {
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const exportLayers = [];
|
||||
// 背面→前面の順(PSD側で後から追加したレイヤーほど上に重なるため、この順で送る)
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (maskedIndices.has(i)) continue; // マスク側の処理でまとめて扱う
|
||||
|
||||
if (layer.type === "mask") {
|
||||
if (!(layer.maskApply && layer.visible && i + 1 < layers.length)) continue;
|
||||
const target = layers[i + 1];
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
this._cb.renderMaskedLayer(canvas.getContext("2d"), canvas, layer, target, false);
|
||||
exportLayers.push({
|
||||
name: target.name || "Layer",
|
||||
visible: target.visible,
|
||||
opacity: 1,
|
||||
blendMode: "source-over",
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.save();
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
exportLayers.push({
|
||||
name: layer.name || "Layer",
|
||||
visible: layer.visible,
|
||||
opacity: layer.opacity,
|
||||
blendMode: layer.blendMode,
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
}
|
||||
return exportLayers;
|
||||
}
|
||||
|
||||
async saveAsPsd() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const exportLayers = this._buildLayerExportList(layerMgr, w, h);
|
||||
if (exportLayers.length === 0) { showToast("No layers to export", "error"); return; }
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/export-psd-layers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width: w, height: h, layers: exportLayers }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + ".psd";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("PSD saved", "success");
|
||||
} catch (err) {
|
||||
showToast(`PSD save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
savePng() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const a = document.createElement("a");
|
||||
a.href = canvas.toDataURL("image/png");
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + "-output.png";
|
||||
a.click();
|
||||
showToast("PNG saved", "success");
|
||||
}
|
||||
|
||||
async saveToGallery() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// デフォルトファイル名: wfs-image-YYYYMMDDHHmmss
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const defaultName = `wfs-image-${ts}`;
|
||||
|
||||
const filename = window.prompt("Save to Gallery — file name (without extension):", defaultName);
|
||||
if (filename === null) return; // キャンセル
|
||||
const safeName = filename.trim() || defaultName;
|
||||
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const imageData = canvas.toDataURL("image/png");
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: safeName, imageData }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
showToast(`Saved to Gallery: ${safeName}.png`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Gallery save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the composite via /upload/image, then (via window.opener,
|
||||
// since WFS runs in its own window — same cross-window pattern as
|
||||
// wfmReceiveWorkflow/wfmOpenMaskEditorForNode) writes the resulting
|
||||
// filename into the "image" widget of the currently selected ComfyUI
|
||||
// node (falling back to the first node in the graph with one), mirroring
|
||||
// the chat_TE custom node's "Send to workflow" button.
|
||||
async sendToWorkflow() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
|
||||
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
|
||||
return;
|
||||
}
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("overwrite", "true");
|
||||
try {
|
||||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||||
const data = await r.json();
|
||||
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
|
||||
window.opener.wfmSendImageToSelectedNode(filename);
|
||||
showToast(`Sent "${filename}" to LI node`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Send to LI node failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Image Edit Tab - Fill Tool (bucket fill)
|
||||
* Flood-fills a contiguous same-color region on the active layer canvas,
|
||||
* either with a solid color or a linear/radial gradient.
|
||||
* Gradient ramp math (evalGradient / hex<->rgb) is ported from
|
||||
* comfyUI-particle-pixijs's particle_engine.js.
|
||||
*/
|
||||
|
||||
function _hexToRgb01(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || "#000000");
|
||||
if (!m) return { r: 0, g: 0, b: 0 };
|
||||
return { r: parseInt(m[1], 16) / 255, g: parseInt(m[2], 16) / 255, b: parseInt(m[3], 16) / 255 };
|
||||
}
|
||||
|
||||
function _rgb01ToHex({ r, g, b }) {
|
||||
const h = v => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0");
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
function _lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
export class FillTool {
|
||||
constructor() {
|
||||
// fill settings
|
||||
this.tolerance = 32; // 0-255
|
||||
this.fillMode = "solid"; // "solid" | "gradient"
|
||||
this.color = "#ff0000"; // solid fill color
|
||||
this.opacity = 1.0;
|
||||
|
||||
// gradient settings
|
||||
this.gradientShape = "linear"; // "linear" | "radial"
|
||||
this.gradientAngleDeg = 0; // linear direction
|
||||
this.gradientStrength = 1.0; // 0.2-3.0, length/radius multiplier
|
||||
this.gradientStops = [
|
||||
{ pos: 0.0, color: "#ffffff" },
|
||||
{ pos: 1.0, color: "#888888" },
|
||||
];
|
||||
this.selectedStopIdx = 0;
|
||||
|
||||
// internal state
|
||||
this._active = false;
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
// ── color ramp evaluation (ported from particle_engine.js evalGradient) ──
|
||||
|
||||
static evalGradient(stops, t) {
|
||||
if (!stops || stops.length === 0) return { r: 0, g: 0, b: 0 };
|
||||
const sorted = [...stops].sort((a, b) => a.pos - b.pos);
|
||||
if (t <= sorted[0].pos) return _hexToRgb01(sorted[0].color);
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (t >= last.pos) return _hexToRgb01(last.color);
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i], b = sorted[i + 1];
|
||||
if (t >= a.pos && t <= b.pos) {
|
||||
const span = b.pos - a.pos;
|
||||
const localT = span > 0 ? (t - a.pos) / span : 0;
|
||||
const ca = _hexToRgb01(a.color), cb = _hexToRgb01(b.color);
|
||||
return {
|
||||
r: _lerp(ca.r, cb.r, localT),
|
||||
g: _lerp(ca.g, cb.g, localT),
|
||||
b: _lerp(ca.b, cb.b, localT),
|
||||
};
|
||||
}
|
||||
}
|
||||
return _hexToRgb01(last.color);
|
||||
}
|
||||
|
||||
/** Preview swatch color (hex) of the ramp at position t. */
|
||||
evalGradientHex(t) {
|
||||
return _rgb01ToHex(FillTool.evalGradient(this.gradientStops, t));
|
||||
}
|
||||
|
||||
// ── stop editing helpers (ported from particle_widget.js addStop/removeStop/selectStop) ──
|
||||
|
||||
addStop() {
|
||||
const sorted = [...this.gradientStops].sort((a, b) => a.pos - b.pos);
|
||||
let bestGapStart = 0, bestGapSize = 0;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].pos - sorted[i].pos;
|
||||
if (gap > bestGapSize) { bestGapSize = gap; bestGapStart = sorted[i].pos; }
|
||||
}
|
||||
const pos = sorted.length < 2 ? 0.5 : bestGapStart + bestGapSize / 2;
|
||||
const color = _rgb01ToHex(FillTool.evalGradient(this.gradientStops, pos));
|
||||
this.gradientStops.push({ pos, color });
|
||||
this.selectedStopIdx = this.gradientStops.length - 1;
|
||||
}
|
||||
|
||||
removeStop(idx = this.selectedStopIdx) {
|
||||
if (this.gradientStops.length <= 1) return;
|
||||
this.gradientStops.splice(idx, 1);
|
||||
this.selectedStopIdx = Math.max(0, Math.min(this.selectedStopIdx, this.gradientStops.length - 1));
|
||||
}
|
||||
|
||||
selectStop(idx) {
|
||||
this.selectedStopIdx = idx;
|
||||
}
|
||||
|
||||
// ── flood fill ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flood-fills the contiguous region matching the pixel at (x, y).
|
||||
* (x, y) are canvas-pixel coordinates (already converted via DrawTool.getCanvasPos).
|
||||
*/
|
||||
onMouseDown(x, y) {
|
||||
if (!this._active || !this._ctx) return;
|
||||
const w = this._canvas.width, h = this._canvas.height;
|
||||
const px = Math.floor(x), py = Math.floor(y);
|
||||
if (px < 0 || py < 0 || px >= w || py >= h) return;
|
||||
|
||||
const imgData = this._ctx.getImageData(0, 0, w, h);
|
||||
const data = imgData.data;
|
||||
|
||||
const mask = this._buildFloodMask(data, w, h, px, py, this.tolerance);
|
||||
if (!mask) return;
|
||||
|
||||
if (this.fillMode === "gradient") {
|
||||
this._fillMaskWithGradient(data, w, h, mask.pixels, mask.bbox);
|
||||
} else {
|
||||
this._fillMaskWithSolid(data, mask.pixels, this.color, this.opacity);
|
||||
}
|
||||
|
||||
this._ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
/** Scanline stack-based flood fill. Returns { pixels: Uint8Array(w*h), bbox } or null. */
|
||||
_buildFloodMask(data, w, h, startX, startY, tolerance) {
|
||||
const idx0 = (startY * w + startX) * 4;
|
||||
const tr = data[idx0], tg = data[idx0 + 1], tb = data[idx0 + 2], ta = data[idx0 + 3];
|
||||
const tol2 = tolerance * tolerance;
|
||||
|
||||
const matches = (i) => {
|
||||
const dr = data[i] - tr, dg = data[i + 1] - tg, db = data[i + 2] - tb, da = data[i + 3] - ta;
|
||||
return (dr * dr + dg * dg + db * db + da * da) <= tol2 * 4;
|
||||
};
|
||||
|
||||
const pixels = new Uint8Array(w * h);
|
||||
const stack = [[startX, startY]];
|
||||
let minX = startX, maxX = startX, minY = startY, maxY = startY;
|
||||
|
||||
while (stack.length) {
|
||||
let [x, y] = stack.pop();
|
||||
while (x >= 0 && matches((y * w + x) * 4)) x--;
|
||||
x++;
|
||||
let spanUp = false, spanDown = false;
|
||||
while (x < w && matches((y * w + x) * 4) && !pixels[y * w + x]) {
|
||||
pixels[y * w + x] = 1;
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
|
||||
if (y > 0) {
|
||||
const above = matches((( y - 1) * w + x) * 4) && !pixels[(y - 1) * w + x];
|
||||
if (above && !spanUp) { stack.push([x, y - 1]); spanUp = true; }
|
||||
else if (!above) spanUp = false;
|
||||
}
|
||||
if (y < h - 1) {
|
||||
const below = matches(((y + 1) * w + x) * 4) && !pixels[(y + 1) * w + x];
|
||||
if (below && !spanDown) { stack.push([x, y + 1]); spanDown = true; }
|
||||
else if (!below) spanDown = false;
|
||||
}
|
||||
x++;
|
||||
}
|
||||
}
|
||||
return { pixels, bbox: { minX, minY, maxX, maxY } };
|
||||
}
|
||||
|
||||
_fillMaskWithSolid(data, pixels, colorHex, opacity) {
|
||||
const { r, g, b } = _hexToRgb01(colorHex);
|
||||
const cr = r * 255, cg = g * 255, cb = b * 255;
|
||||
const alpha = Math.max(0, Math.min(1, opacity));
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
if (!pixels[i]) continue;
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], cr, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], cg, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], cb, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable with any {pixels, bbox} mask (flood fill today; rect/ellipse selections in the future). */
|
||||
_fillMaskWithGradient(data, w, h, pixels, bbox) {
|
||||
const cx = (bbox.minX + bbox.maxX) / 2, cy = (bbox.minY + bbox.maxY) / 2;
|
||||
const diagW = bbox.maxX - bbox.minX, diagH = bbox.maxY - bbox.minY;
|
||||
const diag = Math.max(1, Math.hypot(diagW, diagH));
|
||||
const strength = Math.max(0.2, Math.min(3.0, this.gradientStrength));
|
||||
const alpha = Math.max(0, Math.min(1, this.opacity));
|
||||
|
||||
const angleRad = this.gradientAngleDeg * Math.PI / 180;
|
||||
const dirX = Math.cos(angleRad), dirY = Math.sin(angleRad);
|
||||
const halfLen = (diag / 2) * strength;
|
||||
const radius = (diag / 2) * strength;
|
||||
|
||||
for (let y = bbox.minY; y <= bbox.maxY; y++) {
|
||||
for (let x = bbox.minX; x <= bbox.maxX; x++) {
|
||||
const i = y * w + x;
|
||||
if (!pixels[i]) continue;
|
||||
const dx = x - cx, dy = y - cy;
|
||||
let t;
|
||||
if (this.gradientShape === "radial") {
|
||||
t = Math.hypot(dx, dy) / radius;
|
||||
} else {
|
||||
t = (dx * dirX + dy * dirY) / (halfLen * 2) + 0.5;
|
||||
}
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const c = FillTool.evalGradient(this.gradientStops, t);
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], c.r * 255, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], c.g * 255, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], c.b * 255, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||