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()
|
||||
@@ -19,6 +19,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/on_doc_edit.py\"",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,19 @@
|
||||
# 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=
|
||||
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9 # 1.0.1
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
@@ -6,11 +6,9 @@ __pycache__/
|
||||
*.egg-info/
|
||||
data/
|
||||
|
||||
# graphify: keep the knowledge graph itself under version control,
|
||||
# but exclude the run-by-run cost/token log, the local extraction
|
||||
# cache (both churn every rebuild and have no value to other clones),
|
||||
# and the machine-local interpreter/scan-root pointers.
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/.graphify_python
|
||||
graphify-out/.graphify_root
|
||||
# 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
|
||||
|
||||
@@ -1,3 +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
|
||||
|
||||
@@ -200,6 +200,8 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
|
||||
`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.
|
||||
@@ -209,3 +211,17 @@ Rules:
|
||||
- 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
|
||||
|
||||
@@ -7,6 +7,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- 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
|
||||
|
||||
@@ -22,7 +23,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,29 +31,29 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
| :-------------------------------------: | :---------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Prompt Input Assistance | Gen UI Feeder |
|
||||
| :----------------------------------------------------------: | :---------------------------------------: |
|
||||
|  |  |
|
||||
| Prompt Input Assistance | GenUI Batch |
|
||||
| :----------------------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| GenUI LoRA Stack | GenUI Batch |
|
||||
| :---------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Models Multi-select Menu | Top Bar |
|
||||
| :-----------------------------------------------------------: | :---------------------------: |
|
||||
|  |  |
|
||||
| Top Bar | Image Edit Tab |
|
||||
| :----------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| WS Library | Library Information |
|
||||
| :----------------------------------: | :---------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Tagger Tab | Image Edit Tab |
|
||||
| :-------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
| Video Tab | AI TOOL Tab |
|
||||
| :----------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Customize | |
|
||||
| :--------------------------------: | :-: |
|
||||
|  | |
|
||||
| Video Tab - Edit | Video Tab - Asset |
|
||||
| :----------------------------------: | :-----------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Lab Tab | Lab Index Image |
|
||||
| :--------------------------------: | :------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,9 +70,12 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **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; **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>
|
||||
|
||||
@@ -89,7 +93,8 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
<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
|
||||
- **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
|
||||
@@ -98,12 +103,14 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **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; 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
|
||||
- **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
|
||||
@@ -113,7 +120,12 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **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
|
||||
|
||||
</details>
|
||||
|
||||
@@ -146,17 +158,19 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Lab subtab (v0.3.87 – v0.4.2)</h3></summary>
|
||||
<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** — Model, Prompt, KSampler each hold an 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 — except the Model column, which resolves each of its three fields independently (see below); 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) — the old separate Checkpoint and VAE columns merged into one Model column; a `[C][L][V]` toggle in its header picks which single field its cells summarize and its edit modal shows — switching the toggle never touches the other two fields, Save only updates whichever one is currently shown — and each button lights up the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type; the three fields carry forward independently across keyframes, so a keyframe that only sets a LoRA doesn't reset Checkpoint/VAE back to the workflow default — it keeps whatever an earlier keyframe last set until a later one explicitly changes it (a field left blank in the edit modal shows "(inherits: ...)" instead of "(workflow default)" when there's something to inherit); "revert to #1's setting" still resets all three fields at once. 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
|
||||
- **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)
|
||||
@@ -166,6 +180,10 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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>
|
||||
|
||||
@@ -173,7 +191,8 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<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** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
- **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
|
||||
@@ -189,21 +208,18 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Metadata Tab (v0.3.8)</h3></summary>
|
||||
|
||||
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
|
||||
- **File drop** — drop a ComfyUI-generated PNG / WebP or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
|
||||
- **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
|
||||
- **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/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
|
||||
- **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>
|
||||
|
||||
@@ -217,15 +233,18 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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
|
||||
- **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
|
||||
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
|
||||
- ~~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`
|
||||
- **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
|
||||
@@ -235,31 +254,50 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<details>
|
||||
<summary><h3>Gallery Tab (v0.3.44)</h3></summary>
|
||||
|
||||
- **Output / Style-Prompt sub-tabs** (v0.3.94) — the Gallery tab has two sub-tabs at the top: **Output** (the image browser described below) and **Style/Prompt** (a separate visual prompt-building library — see its own section below)
|
||||
- **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..., Export, and Delete File from the multi-select bar
|
||||
- **Download** — hover over the image preview in the detail panel to reveal a download icon (⬇); click to download the single image
|
||||
- **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); Delete File
|
||||
- **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; **`__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
|
||||
- **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)
|
||||
- **Load GenUI button** — 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, Load 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
|
||||
- **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 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
|
||||
- **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)** — rightmost toolbar button; resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
|
||||
- **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
|
||||
- **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>
|
||||
@@ -288,6 +326,26 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
|
||||
</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>
|
||||
|
||||
@@ -337,11 +395,12 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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
|
||||
- **Ollama VLM** — optional vision model alongside WD Tagger; set API URL and select model (↻ to refresh list); results from both models are merged into one comma-separated tag string
|
||||
- **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 and Ollama settings; 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
|
||||
- **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>
|
||||
@@ -349,25 +408,55 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<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
|
||||
- **Loading images** — drag & drop onto the canvas, Upload button, 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
|
||||
- **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), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **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
|
||||
- **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)
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header 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%); **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 Workflow**) — 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
|
||||
- **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
|
||||
- **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 to Input** (uploads to ComfyUI input folder for use in Load Image nodes); **Send to Workflow** (v0.4.0) — uploads the same way, 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
|
||||
- **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), Delete (remove selected layer when 2+ exist)
|
||||
- **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>
|
||||
|
||||
@@ -379,6 +468,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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
|
||||
@@ -386,10 +476,12 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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 always requires an API key, even for local access; 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; 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
|
||||
- **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
|
||||
@@ -402,20 +494,22 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **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 or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
|
||||
- **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) or "Copy & Send Canvas" (Gallery tab JSON panel) sends the workflow directly to the ComfyUI canvas via `window.opener` (UI and API formats both supported); if Workflow Studio was opened from a bookmark instead of the ComfyUI toolbar, UI-format workflows fall back to title drag (panel title highlights blue with a green ● — drag it onto the canvas to load)
|
||||
- **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
|
||||
@@ -505,9 +599,15 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **[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); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 181 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 |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -1,745 +0,0 @@
|
||||
# Graph Report - ComfyUI-Workflow-Studio (2026-08-20)
|
||||
|
||||
## Corpus Check
|
||||
- 140 files · ~0 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 2627 nodes · 5787 edges · 171 communities (119 shown, 52 thin omitted)
|
||||
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.66)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- Models Tab Frontend
|
||||
- Models API Routes
|
||||
- Tagger API Routes
|
||||
- Image Edit Mask Tools (Code)
|
||||
- Prompt Tab Frontend
|
||||
- Gallery API Routes
|
||||
- Gallery Tab Frontend & Toasts
|
||||
- Image Edit Layer & Draw Tools
|
||||
- Tagger/Prompt i18n Helpers
|
||||
- GenerateUI Feeder Tab
|
||||
- Nodes API Routes
|
||||
- Settings API Routes
|
||||
- App Shell & i18n Init
|
||||
- Nodes Tab Frontend
|
||||
- GenerateUI Lab Tab
|
||||
- Metadata Tab Frontend
|
||||
- Node Sets Menu & Sidebar
|
||||
- AI Tool Tab Frontend
|
||||
- Generate Tab Frontend & Modals
|
||||
- Workflow Tab Frontend & Storage
|
||||
- Wildcard Service & Routes
|
||||
- CivitAI Service & Data Import/Export
|
||||
- GenerateUI Tab & AI Chat Features
|
||||
- ComfyUI Client & Editor
|
||||
- DEVLOG v0.1.x Early Features
|
||||
- AI Chat/VLM Sidebar Helpers
|
||||
- AI TOOL Tab Backends
|
||||
- Node Sets Menu UI Components
|
||||
- Node Sets Draggable Items
|
||||
- Workflow API Routes
|
||||
- Gallery Metadata Store
|
||||
- Workflow Service Metadata/Import
|
||||
- CLAUDE.md Node Dev Rules
|
||||
- Lab Tab & PNG Metadata Utilities
|
||||
- Lab Plan Service & Routes
|
||||
- Skill Service & Routes
|
||||
- Image Edit Select Tool
|
||||
- Image Edit Draw Tool
|
||||
- Image Edit Layer Manager Core
|
||||
- CivitAI Service
|
||||
- Image Edit Mask Tool
|
||||
- Gallery Service Core
|
||||
- Eagle Routes Backend
|
||||
- Style Catalog Tab
|
||||
- Image Prompt Tab
|
||||
- SDXL 3D Pose Editor DWPose Workflow
|
||||
- Gallery Service Core
|
||||
- Image Edit Tab & Mask Editor One
|
||||
- App Shell & Nodes Tab Ecosystem
|
||||
- Node Sets Menu Model Fetch
|
||||
- Image Edit Background Removal
|
||||
- ComfyUI Workflow Utilities
|
||||
- Generate Tab Frontend & Modals
|
||||
- Batch Group UI & Security Fixes
|
||||
- DEVLOG Early History & Manager Registration
|
||||
- App Shell & ComfyUI Ecosystem Refs
|
||||
- GenerateUI Batch & Workflow Editor Core
|
||||
- Image Edit Blur Tool (BlurTool.js)
|
||||
- Image Edit Shape Tool
|
||||
- Tagger Tab & Gallery Bulk Actions
|
||||
- Image Edit Layer Core & Bug Fixes
|
||||
- G'MIC Routes
|
||||
- Ollama Routes
|
||||
- Gallery Metadata/Service Bridge
|
||||
- Image Edit Text Tool
|
||||
- Mask Editor One BiRefNet/SAM3 Integration
|
||||
- Eagle Routes Backend
|
||||
- Prompts API Routes/Service
|
||||
- Workflow Analyzer
|
||||
- Gallery Tab Core & Feeder Group
|
||||
- AI TOOL Backend & Model Tab Updates
|
||||
- Tagger Tab Implementation & Security Fixes
|
||||
- Eagle Routes Backend
|
||||
- Prompts API Routes/Service
|
||||
- Metadata & Nodes Tab Core
|
||||
- AI Tool Tab Frontend
|
||||
- Node Sets Menu & Sidebar
|
||||
- Gallery Feeder Extension (Frontend)
|
||||
- Gallery Speed & Bulk Features
|
||||
- Style Catalog & Batch Loop Core
|
||||
- Metadata Tab Node Support History
|
||||
- Image Edit Background Removal (BgRemove.js)
|
||||
- Gallery Feeder Node (Backend)
|
||||
- App Shell & i18n Init
|
||||
- Generate Tab Frontend & Modals
|
||||
- Image Edit Inpaint/I2I Actions
|
||||
- Image Edit SAM3 Segmentation
|
||||
- Node Sets Metadata Extraction
|
||||
- Node Sets Prompt Extraction
|
||||
- Inpaint Basic Workflow
|
||||
- Gallery Feeder & Batch Features
|
||||
- Gallery Service Background Indexing
|
||||
- Settings/CivitAI Config Resolution
|
||||
- Gallery Service Vault Helpers
|
||||
- GenerateUI Lab Tab
|
||||
- Style Prompt Seed Import Tool
|
||||
- FireRed 3D Pose Image Edit Workflow
|
||||
- Chat Image Generation & Inpaint Bridge
|
||||
- Subgraph Workflow Support & Fixes
|
||||
- KREA-2 & Lab Tab Extensions
|
||||
- LoRA Pane Evolution & Help Tab
|
||||
- Image Edit File Export (FileExport.js)
|
||||
- Image Edit G'MIC Integration
|
||||
- App Shell & i18n Init
|
||||
- DWPose Image Feeder Workflow
|
||||
- AI Tab & RAW JSON Search History
|
||||
- Prompt Extraction Bug Fixes
|
||||
- Comic Creator Bridge & LoRA Batch Fixes
|
||||
- Settings/CivitAI Config Resolution
|
||||
- Node Sets Menu Model Fetch
|
||||
- G'MIC Integration & Security Fixes
|
||||
- Comic Creator Integration Bridges
|
||||
- GenerateUI Model Tab GGUF/Embedding Support
|
||||
- Bypass Node Handling & Advanced Sampling
|
||||
- Prompt Text Node
|
||||
- Gallery Metadata/Service Bridge
|
||||
- Gallery Service Vault Helpers
|
||||
- PNG Metadata Extractor
|
||||
- AI Skills & Help Redesign
|
||||
- Prompt Extraction Bug Fixes
|
||||
- AI TOOL Chat & Wildcard Features
|
||||
- Config & Service Modules
|
||||
- Group Feature Path Bug Fixes
|
||||
- SVG Generation & Display Support
|
||||
- Send to Canvas Feature
|
||||
- Module Split Refactor (v0.4.0)
|
||||
- CivitAI Detail Panel History
|
||||
- GenerateUI Layout & JSON Highlight
|
||||
- Workflow Tab Bug Fixes
|
||||
- CivitAI Batch Fetch Optimization
|
||||
- DEVLOG JSON Highlighting & Subgraph
|
||||
- Mask Tool 5 Sub-tools
|
||||
- Prestartup Script
|
||||
- Workflow Service Dir Update
|
||||
- Project Banner Asset
|
||||
- Project Icon Asset
|
||||
- SDXL 2D Pose Editor I2I Workflow
|
||||
- Tagger Tab Screenshot
|
||||
- Models Tab Screenshot
|
||||
- Topbar Icon Strip Screenshot
|
||||
- GenerateUI LoRA Stack Screenshot
|
||||
- Favicon Asset
|
||||
- FireRed 2D Pose Image Edit Workflow
|
||||
- Flux.2 Klein 3D Pose Image Edit Workflow
|
||||
- SDXL DWPose ControlNet Workflow
|
||||
- SDXL From Metadata Workflow
|
||||
- SDXL I2I FaceDetailer Workflow
|
||||
- SDXL Image Feeder I2I Workflow
|
||||
- SDXL T2I FaceDetailer Workflow
|
||||
- Default View Setting
|
||||
- Latent Resolution Presets
|
||||
- Send to Canvas Toast Fix
|
||||
- ZIT/Lumina2 Prompt Fix
|
||||
- GenerateUI Batch Tab Screenshot
|
||||
- Multiple Select Menu Screenshot
|
||||
- Image Edit Tab Screenshot
|
||||
- Workflow Tab Screenshot
|
||||
- Prompt Input Assistance Screenshot
|
||||
- GenerateUI Feeder Screenshot
|
||||
- Library Panel LoRA Info Screenshot
|
||||
- Library Panel Prompts Screenshot
|
||||
- Settings Tab Screenshot
|
||||
- Project Thumbnail Asset
|
||||
- Project Manifest (pyproject.toml)
|
||||
- Main Stylesheet
|
||||
- SD1.5 T2I Basic Workflow
|
||||
- SDXL T2I Basic Workflow Screenshot
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `t()` - 174 edges
|
||||
2. `showToast()` - 138 edges
|
||||
3. `escapeHtml()` - 57 edges
|
||||
4. `ImageEditTab` - 53 edges
|
||||
5. `GalleryService` - 45 edges
|
||||
6. `initPromptTab()` - 34 edges
|
||||
7. `setup_routes()` - 32 edges
|
||||
8. `initSettingsTab()` - 31 edges
|
||||
9. `add()` - 31 edges
|
||||
10. `renderModelGrid()` - 28 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `Sidepanel Information (I) Tab` --semantically_similar_to--> `Metadata Tab` [INFERRED] [semantically similar]
|
||||
templates/index.html → README.md
|
||||
- `Batch Type: Checkpoint` --shares_data_with--> `Models Tab` [EXTRACTED]
|
||||
templates/index.html → README.md
|
||||
- `Workflow Tab` --shares_data_with--> `Badge System (shared)` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
- `Settings Tab` --conceptually_related_to--> `Settings: Connections (ComfyUI/Ollama/Eagle)` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
- `Settings Tab` --conceptually_related_to--> `Settings: Workflow Defaults` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
|
||||
## Import Cycles
|
||||
- 3-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
- 3-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/image-prompt-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/MaskEditorOneBridge.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BlurTool.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/GmicIntegration.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/FileExport.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/InpaintI2IActions.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BgRemove.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/Sam3Segmentation.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 5-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
- 5-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Gallery Feeder System** — devlog_gallery_feeder_node, devlog_gallery_feeder_canvas_controls, devlog_feeder_group_protection, devlog_feeder_tab_gallery_mode [EXTRACTED 0.90]
|
||||
- **LoRA Pane Iterative Evolution (v0.3.29-v0.3.33)** — devlog_v0_3_29_lora_section_integration, devlog_v0_3_30_lora_trigger_bugfix, devlog_v0_3_31_lora_single_stack_tabs, devlog_v0_3_32_loramanager_detection, devlog_v0_3_33_lora_pane_bugfix [EXTRACTED 0.90]
|
||||
- **Shared Badge System Across Workflow and Models Tabs** — templates_index_badge_system, readme_workflow_tab, readme_models_tab [EXTRACTED 1.00]
|
||||
- **ComfyUI Comic Creator Bidirectional Bridge Functions** — devlog_comic_creator_integration, devlog_v0_3_69_send_cc_button, devlog_i2i_bridge_receive_image, devlog_v0_3_76_i2i_run_bridge, devlog_v0_3_72_comic_creator_inpaint_entrypoint, devlog_v0_3_80_comic_creator_bridge [EXTRACTED 1.00]
|
||||
- **Mask Editor One Integration Features (BiRefNet/SAM3/ABR/Icon Fix/Connect Button)** — devlog_mask_editor_one_custom_node, devlog_birefnet_bg_remove_integration, devlog_sam3_segmentation_integration, devlog_mask_tool_abr_brush_support, devlog_bg_image_b64_field_rename_fix, devlog_v0_4_0_mask_editor_one_connect_button [EXTRACTED 1.00]
|
||||
- **Feeder Group Image Queueing Flow** — readme_feeder_group, readme_gallery_tab, templates_index_feeder_gallery_mode, templates_index_wfs_galleryfeeder_node [EXTRACTED 1.00]
|
||||
- **node_sets_menu.js Sidepanel Evolution Across Versions** — devlog_v0_1_7_nodes_tab, devlog_v0_1_8_wf_node_library, devlog_v0_1_9_sidepanel_improvements, devlog_v0_2_0_prompt_presets, devlog_v0_2_3_badge_unification_genui_model, devlog_v0_2_5_category_package_theme [EXTRACTED 1.00]
|
||||
- **AI TOOL Tab multi-backend support** — readme_ai_tool_tab, readme_ollama, readme_lm_studio, readme_lemonade_server, readme_unsloth [EXTRACTED 1.00]
|
||||
- **Feeder generation-loop ecosystem** — readme_feeder_subtab, readme_image_loop_mode, readme_gallery_mode, readme_wfs_galleryfeeder_node, readme_feeder_group [EXTRACTED 1.00]
|
||||
- **GenerateUI 6-tab layout** — readme_generateui, readme_feeder_subtab, readme_batch_tab, readme_lab_subtab [EXTRACTED 1.00]
|
||||
- **py/routes API layer (per-tab route modules)** — readme_workflow_routes_py, readme_models_routes_py, readme_nodes_routes_py, readme_settings_routes_py, readme_gallery_routes_py [EXTRACTED 1.00]
|
||||
- **GenerateUI Style System (shared named-Style registry)** — readme_style_manager, readme_generateui, readme_batch_tab, readme_style_catalog_gallery_subtab, readme_create_catalog [EXTRACTED 1.00]
|
||||
- **Release & Registry Publish Flow** — claude_release_procedure, github_workflows_publish_trigger, readme_version_badge, github_workflows_publish_action [EXTRACTED 1.00]
|
||||
- **Shared Style File System Across Tabs** — templates_index_style_system, templates_index_style_feature, templates_index_style_manager_prompt, templates_index_batch_style_type, templates_index_create_catalog_feature, templates_index_gallery_stylecatalog_panel [EXTRACTED 1.00]
|
||||
- **Path Traversal Protection Pattern (_safe_path)** — devlog_v0_1_6_path_traversal_fix, devlog_v0_3_3_wildcard_panel, devlog_v0_2_8_data_dir_export_import [INFERRED 0.70]
|
||||
- **Shared Badge Palette System (Workflow/Models tabs)** — devlog_v0_2_3_badge_unification_genui_model, devlog_v0_3_0_model_group_display, devlog_v0_2_9_model_enable_disable [INFERRED 0.80]
|
||||
- **Batch Reserved Group System** — devlog_reserved_groups_mechanism, devlog_models_batch_group_ui, devlog_prompt_workflow_batch_group_ui, devlog_v0_3_24 [INFERRED 0.80]
|
||||
- **CivitAI Integration Pipeline** — devlog_v0_3_17, devlog_v0_3_19, devlog_v0_3_20, devlog_v0_3_21 [INFERRED 0.80]
|
||||
- **ComfyUI Custom Node Robustness Patterns** — claude_is_changed_caching, claude_validate_inputs_pitfall, claude_hidden_inputs, claude_lazy_inputs, claude_safe_node_loading [INFERRED 0.85]
|
||||
- **Recurring Dev/Runtime Deployment Sync Failures Across Versions** — devlog_dev_deploy_sync_reminder, devlog_v0_3_69_send_cc_button, devlog_v0_3_70_ollama_404_prompt_weight [INFERRED 0.85]
|
||||
- **LoraManager Integration** — devlog_v0_3_17, devlog_v0_3_25, devlog_v0_3_26, devlog_lora_manager_format [INFERRED 0.85]
|
||||
- **Image Edit Tab Tool Suite** — devlog_v0_3_53_image_edit_tab, devlog_v0_3_54_shape_tool, devlog_v0_3_57_blur_tool, devlog_v0_3_57_bgremove_tool, devlog_v0_3_59_mask_tool, devlog_gmic_qt_filter_tool [INFERRED 0.90]
|
||||
- **Mask Editor One Inpainting Pipeline** — comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_load_checkpoint_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_mask_editor_one_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_positive_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_negative_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_vae_encode_for_inpainting_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_save_image_node [INFERRED]
|
||||
|
||||
## Communities (171 total, 52 thin omitted)
|
||||
|
||||
### Community 0 - "Models Tab Frontend"
|
||||
Cohesion: 0.10
|
||||
Nodes (77): badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange(), openBadgeEditModal(), renderBadgeFilter() (+69 more)
|
||||
|
||||
### Community 1 - "Models API Routes"
|
||||
Cohesion: 0.06
|
||||
Nodes (55): handle_change_preview(), handle_civitai_batch(), handle_civitai_cache(), handle_civitai_fetch(), handle_delete_models(), handle_get_disabled(), handle_get_filepath(), handle_get_groups() (+47 more)
|
||||
|
||||
### Community 2 - "Tagger API Routes"
|
||||
Cohesion: 0.07
|
||||
Nodes (29): Connection, Image, handle_batch_start(), handle_batch_status(), handle_batch_stop(), handle_db_delete(), handle_db_export(), handle_db_list() (+21 more)
|
||||
|
||||
### Community 3 - "Image Edit Mask Tools (Code)"
|
||||
Cohesion: 0.05
|
||||
Nodes (6): MASK_TEXT_FONTS, MaskAlphaTool, MaskColorTool, MaskShapeTool, MaskTextTool, MaskVectorTool
|
||||
|
||||
### Community 4 - "Prompt Tab Frontend"
|
||||
Cohesion: 0.08
|
||||
Nodes (59): refreshStylesList(), addChatMessage(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), applyToGenerateUI(), attachFile(), chatWithAi() (+51 more)
|
||||
|
||||
### Community 5 - "Gallery API Routes"
|
||||
Cohesion: 0.10
|
||||
Nodes (54): add_to_group(), bulk_favorite(), bulk_group(), clear_group_images(), create_folder_route(), create_group(), delete_folder_route(), delete_group() (+46 more)
|
||||
|
||||
### Community 6 - "Gallery Tab Frontend & Toasts"
|
||||
Cohesion: 0.10
|
||||
Nodes (53): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+45 more)
|
||||
|
||||
### Community 8 - "Tagger/Prompt i18n Helpers"
|
||||
Cohesion: 0.11
|
||||
Nodes (50): _appendSvgPreview(), closeModal(), showToast(), _applyDefaultCheckpointIfEnabled(), handleGenerate(), loadWorkflowIntoEditor(), openCatalogCreateModal(), saveCurrentWorkflow() (+42 more)
|
||||
|
||||
### Community 9 - "GenerateUI Feeder Tab"
|
||||
Cohesion: 0.10
|
||||
Nodes (50): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+42 more)
|
||||
|
||||
### Community 10 - "Nodes API Routes"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): handle_create_set(), handle_delete_set(), handle_export_set(), handle_get_groups(), handle_get_metadata(), handle_list_sets(), handle_save_groups(), handle_save_metadata() (+20 more)
|
||||
|
||||
### Community 11 - "Settings API Routes"
|
||||
Cohesion: 0.09
|
||||
Nodes (44): _apply_import_bundle(), _build_export_bundle(), _find_style(), _get_comfyui_output_dir(), handle_create_style(), handle_delete_style(), handle_export(), handle_get() (+36 more)
|
||||
|
||||
### Community 12 - "App Shell & i18n Init"
|
||||
Cohesion: 0.09
|
||||
Nodes (43): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+35 more)
|
||||
|
||||
### Community 13 - "Nodes Tab Frontend"
|
||||
Cohesion: 0.12
|
||||
Nodes (42): buildPromptItem(), _applyNodeSelectionToDOM(), bulkNodeAddToGroup(), bulkNodeCreateAndAddToGroup(), bulkNodeRemoveFromGroup(), bulkNodeSetFavorite(), categoryBadgeHtml(), createNodeCard() (+34 more)
|
||||
|
||||
### Community 14 - "GenerateUI Lab Tab"
|
||||
Cohesion: 0.12
|
||||
Nodes (41): openModal(), _applyPlanData(), _applyStyleToText(), _buildIndexImageDataUrl(), _buildPlanData(), _buildWorkflowForIteration(), _cellHtml(), _cellLabel() (+33 more)
|
||||
|
||||
### Community 15 - "Metadata Tab Frontend"
|
||||
Cohesion: 0.11
|
||||
Nodes (39): buildLoRAItem(), buildModelItem(), collectAllNodes(), collectUnique(), extractAllMetadata(), fromWorkflow(), extractCheckpoints(), extractDiffusionModels() (+31 more)
|
||||
|
||||
### Community 16 - "Node Sets Menu & Sidebar"
|
||||
Cohesion: 0.09
|
||||
Nodes (38): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+30 more)
|
||||
|
||||
### Community 17 - "AI Tool Tab Frontend"
|
||||
Cohesion: 0.12
|
||||
Nodes (38): appendChatBubble(), _appendSkillSaveButton(), buildTranslationMessages(), cleanTranslationOutput(), _extractSkillBlock(), _extractSvgCode(), fetchModels(), generateImageFromChat() (+30 more)
|
||||
|
||||
### Community 18 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.09
|
||||
Nodes (37): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _ckptBatch, _ckptState (+29 more)
|
||||
|
||||
### Community 19 - "Workflow Tab Frontend & Storage"
|
||||
Cohesion: 0.13
|
||||
Nodes (34): badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows(), filterWorkflows() (+26 more)
|
||||
|
||||
### Community 20 - "Wildcard Service & Routes"
|
||||
Cohesion: 0.12
|
||||
Nodes (21): handle_create_link(), handle_delete(), handle_get_content(), handle_link_status(), handle_list(), handle_remove_link(), handle_save(), Application (+13 more)
|
||||
|
||||
### Community 21 - "CivitAI Service & Data Import/Export"
|
||||
Cohesion: 0.06
|
||||
Nodes (36): CivitAI, CivitAI Integration, py/services/civitai_service.py, Data Management (export/import), G'MIC-Qt Integration, Highlight color (v0.3.98), static/js/i18n.js, static/js/json-highlight.js (+28 more)
|
||||
|
||||
### Community 22 - "GenerateUI Tab & AI Chat Features"
|
||||
Cohesion: 0.07
|
||||
Nodes (36): ComfyUI-Impact-Pack (external), Eagle Integration, GenerateUI Tab, Lab Subtab, Wildcard Integration (Settings), Alt+Click Apply & Generate, Batch Type: Checkpoint, Batch Type: Prompt (+28 more)
|
||||
|
||||
### Community 23 - "ComfyUI Client & Editor"
|
||||
Cohesion: 0.09
|
||||
Nodes (26): _applyLoraToNode(), _attachPromptWeightControl(), _buildLoraManagerSyntax(), _buildLoraSyntax(), _buildPresetOptions(), _compositeImageWithMask(), _I2I_PLACEHOLDER_DEFAULT, _imageInputToDataURL() (+18 more)
|
||||
|
||||
### Community 24 - "DEVLOG v0.1.x Early Features"
|
||||
Cohesion: 0.08
|
||||
Nodes (30): Feeder Tab (GenerateUI), Gallery タブ追加 & 全タブ Group UI 統一, node_sets_menu.js (ComfyUI Sidepanel Library), プロジェクトリネーム & アイコン適用(Workflow-Manager→Workflow-Studio), プロンプトタブ 中国語↔英語翻訳追加, テーマカスタマイズ機能・設定タブ改善, トップバーボタンアイコン修正・Appモードバッジ調査, v0.1.0 リリース(GitHub公開・ComfyUI Manager登録) (+22 more)
|
||||
|
||||
### Community 25 - "AI Chat/VLM Sidebar Helpers"
|
||||
Cohesion: 0.11
|
||||
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
|
||||
|
||||
### Community 26 - "AI TOOL Tab Backends"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): static/js/ai-tab.js, AI TOOL Tab, Lemonade Server, LM Studio, Ollama, py/routes/ollama_routes.py, Prompt Tab, static/js/prompt-tab.js (+20 more)
|
||||
|
||||
### Community 27 - "Node Sets Menu UI Components"
|
||||
Cohesion: 0.14
|
||||
Nodes (27): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), NODE_SETS_TOOLTIP, attachTopMenuButton(), beforeRegisterNodeDef(), captureCanvasSnapshot(), compareVersions() (+19 more)
|
||||
|
||||
### Community 28 - "Node Sets Draggable Items"
|
||||
Cohesion: 0.16
|
||||
Nodes (28): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), esc(), extractPackageName(), getNodeCategory(), getNodePackage(), getWfBadges() (+20 more)
|
||||
|
||||
### Community 29 - "Workflow API Routes"
|
||||
Cohesion: 0.15
|
||||
Nodes (26): handle_analyze(), handle_change_thumbnail(), handle_delete(), handle_import(), handle_list(), handle_metadata(), handle_raw(), handle_reanalyze_all() (+18 more)
|
||||
|
||||
### Community 30 - "Gallery Metadata Store"
|
||||
Cohesion: 0.13
|
||||
Nodes (9): GalleryMetadataStore, Path, グループ名を変更し、全画像のgroupsフィールドも更新する, gallery_metadata.json の構造: { "images": { "<abs_path>": { "favorite": bool,…, 存在しないパスをグループから一括削除する(1回の保存で完結), グループメンバーのパスをsetで返す(高速フィルタ用), グループ内の全画像を除外する(グループ自体は残す), 画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ) (+1 more)
|
||||
|
||||
### Community 31 - "Workflow Service Metadata/Import"
|
||||
Cohesion: 0.13
|
||||
Nodes (13): Get raw workflow JSON content., Save metadata (tags, memo, summary, etc.) for a workflow., Manages workflow files and metadata., Import workflow files. Returns list of results., Rename workflow and its associated thumbnail., Delete workflow JSON and associated thumbnail., Re-analyze a workflow and save results to metadata., Re-analyze all workflows and update metadata. (+5 more)
|
||||
|
||||
### Community 32 - "CLAUDE.md Node Dev Rules"
|
||||
Cohesion: 0.10
|
||||
Nodes (25): CLAUDE.md Development Guidelines, Bounded Module-level Cache Pattern, ComfyUI Restart Required After Python Change, Pre-Commit Checklist, Loose Dependency Pinning Policy, Source Repo vs custom_nodes Deploy Sync, folder_paths-based File Path Handling, Hidden Inputs (UNIQUE_ID/PROMPT/EXTRA_PNGINFO) (+17 more)
|
||||
|
||||
### Community 33 - "Lab Tab & PNG Metadata Utilities"
|
||||
Cohesion: 0.09
|
||||
Nodes (25): wfm:apply-and-generate Custom Event (Circular Import Avoidance), embedPngTextChunk() iTXt PNG Chunk Embedding (util.js), extractAllMetadata() Exported Reusable Prompt Extraction, Gallery Root Folder Label Simplification ("[root]"), Lab Index Image Save-to-Output + Eagle Auto-Save, Lab Keyframe Data Model (atIteration/value/revertToBase), Lab Plan JSON "Open Workflow" Button (Dynamic Import), Lab Tab Plan JSON Sub-tab (Refresh/Apply to Setting) (+17 more)
|
||||
|
||||
### Community 34 - "Lab Plan Service & Routes"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): handle_delete(), handle_get_content(), handle_list(), handle_save(), handle_save_index_to_output(), Application, Request, Response (+6 more)
|
||||
|
||||
### Community 35 - "Skill Service & Routes"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): handle_delete(), handle_get_content(), handle_list(), handle_save(), Application, Request, Response, AI skill (.md system prompt) file management API routes. (+8 more)
|
||||
|
||||
### Community 39 - "CivitAI Service"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): CivitaiService, Build request headers, optionally including Bearer token., Calculate SHA256 hash of a file., Fetch model version info from CivitAI by SHA256 hash (GET). 429/5xx…, POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。 レスポンスの…, Extract relevant fields from CivitAI API response., Batch fetch CivitAI info for multiple model files. Phase 1: SHA256…, Clear cache for a specific hash or all. (+3 more)
|
||||
|
||||
### Community 41 - "Gallery Service Core"
|
||||
Cohesion: 0.15
|
||||
Nodes (8): Path, パスが許可ルート配下かチェック(パストラバーサル防止)。 Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの…, ワークフローを抽出する。 優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow], 複数画像のグループ追加 / 削除を一括処理する。action は "add" または "remove"。, 選択フォルダ内に新しいサブフォルダを作成する, 画像ファイルを別フォルダへ移動する(複数対応), 画像のPathオブジェクトを返す(ルートで使用), 縮小サムネイルのPathを返す。 ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。…
|
||||
|
||||
### Community 42 - "Eagle Routes Backend"
|
||||
Cohesion: 0.14
|
||||
Nodes (20): _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test(), Application, Request, Response (+12 more)
|
||||
|
||||
### Community 43 - "Style Catalog Tab"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): selectStyleByName(), _wireLabValueFilter(), activateStyleCatalogTab(), API, apiFetch(), copyText(), createThumbCard(), debounce() (+12 more)
|
||||
|
||||
### Community 44 - "Image Prompt Tab"
|
||||
Cohesion: 0.21
|
||||
Nodes (20): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+12 more)
|
||||
|
||||
### Community 45 - "SDXL 3D Pose Editor DWPose Workflow"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): CLIP Text Encode Negative Prompt Node, CLIP Text Encode Positive Prompt Node, Inpaint with Mask Editor One (Workflow Screenshot), Load Checkpoint Node, Mask Editor One Node, Save Image Node, VAE Encode (for Inpainting) Node, 3D Pose Editor Node (comfyui-vrm-pose-editor) (+12 more)
|
||||
|
||||
### Community 46 - "Gallery Service Core"
|
||||
Cohesion: 0.10
|
||||
Nodes (7): GalleryService, 許可するルートパスを更新する(Settings変更時に呼ぶ), ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ), フォルダ内の未キャッシュ画像をバックグラウンドでインデックスする。 フォルダロード時に呼び出す。前回のインデックス処理は自動キャンセル。, outputフォルダのフォルダツリーを返す, ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を 返す。無ければ作成する。, Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を 返す。無ければ作成する。
|
||||
|
||||
### Community 47 - "Image Edit Tab & Mask Editor One"
|
||||
Cohesion: 0.13
|
||||
Nodes (20): Image Edit Tab, Mask Editor One Integration, Mask slot for inpainting (v0.3.71), G'MIC-Qt Standalone, Help Tab, ABR Brush Library (Mask Editor One), Image Edit BG Remove Tool, Image Edit Blur Tool (Whole/Rect Blur & Mosaic) (+12 more)
|
||||
|
||||
### Community 48 - "App Shell & Nodes Tab Ecosystem"
|
||||
Cohesion: 0.13
|
||||
Nodes (19): ComfyUI-Workflow-Studio README, Canvas Snapshot Feature, ComfyUI-Custom-Scripts (external), Node Sets, web/comfyui/node_sets_menu.js, py/routes/nodes_routes.py, py/services/nodes_service.py, Nodes Tab (+11 more)
|
||||
|
||||
### Community 49 - "Node Sets Menu Model Fetch"
|
||||
Cohesion: 0.22
|
||||
Nodes (19): add(), fetchWorkflowRaw(), getCanvasDropPos(), handleInfoFile(), installCanvasDropHandler(), loadWorkflowOnCanvas(), placeClipTextEncodeNode(), placeLoraMgrNode() (+11 more)
|
||||
|
||||
### Community 50 - "Image Edit Background Removal"
|
||||
Cohesion: 0.18
|
||||
Nodes (7): comfyUI, comfyEditor, comfyWorkflow, MaskEditorOneBridge, fitToCanvas(), TOOL_DEFS, TEXT_FONTS
|
||||
|
||||
### Community 51 - "ComfyUI Workflow Utilities"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): _CONTROL_AFTER_GENERATE, _findInjectedWidgetIndex(), _flattenSubgraphs(), _getDynamicComboSubNames(), _getWidgetInputNames(), _getWidgetInputTypes(), _isExtraWidgetValue(), _isLinkedWidgetName() (+5 more)
|
||||
|
||||
### Community 52 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.20
|
||||
Nodes (18): _buildSimpleGroupList(), _getGroupSelCount(), _getGroupSelCountFrom(), initBatchTab(), _loadBatchCheckpointGroups(), _loadBatchLoraGroups(), _loadPromptGroupsForBatch(), _loadWorkflowGroupsForBatch() (+10 more)
|
||||
|
||||
### Community 53 - "Batch Group UI & Security Fixes"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): GenerateUI Batch 4-Type Group Selection (unreleased), Batch Queue Sampler/Scheduler Columns, LoraManager Data Format (__value__ / text), LORA SYNTAX innerHTML Display Bug, Models Batch Group Registration UI (unreleased), Prompt/Workflow Batch Group Registration UI (unreleased), RESERVED_GROUPS Mechanism, SSL CERT_NONE MitM Vulnerability (+9 more)
|
||||
|
||||
### Community 54 - "DEVLOG Early History & Manager Registration"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): comfyui-image-feeder (external node), __Feeder__ reserved group, Feeder Subtab, static/js/feeder-tab.js, web/comfyui/gallery_feeder_extension.js, py/nodes/gallery_feeder_node.py, Gallery mode (Feeder), Help & Support Tab (+9 more)
|
||||
|
||||
### Community 55 - "App Shell & ComfyUI Ecosystem Refs"
|
||||
Cohesion: 0.12
|
||||
Nodes (16): static/js/app.js, ComfyUI, static/js/comfyui-client.js, ComfyUI-Lora-Manager (external), ComfyUI Manager, ComfyUI-Workflow-Studio, py/config.py, data/ (fallback data dir) (+8 more)
|
||||
|
||||
### Community 56 - "GenerateUI Batch & Workflow Editor Core"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): Batch tab (v0.3.18), Bypass/Mute node handling (v0.3.75), static/js/comfyui-editor.js, Create Catalog / Catalog buttons (v0.3.95), Eagle, py/routes/eagle_routes.py, Emphasis weight editing (v0.3.70), Fooocus (external, by lllyasviel) (+8 more)
|
||||
|
||||
### Community 59 - "Tagger Tab & Gallery Bulk Actions"
|
||||
Cohesion: 0.16
|
||||
Nodes (15): prestartup_script.py Early Env Setup, Tagger Tab, static/js/tagger-tab.js, Unsloth Backend (AI TOOL Tab), Tagger Tab requirements.txt, onnxruntime (WD Tagger / SwinV2), piexif (JPEG EXIF tag writing), python-dotenv (Unsloth .env loading) (+7 more)
|
||||
|
||||
### Community 60 - "Image Edit Layer Core & Bug Fixes"
|
||||
Cohesion: 0.16
|
||||
Nodes (15): async Event Handler e.currentTarget Null-After-Await Pattern, Draw/Mask Layer Disappearing Bug Fix, G'MIC-Qt Filter Tool Integration, Image Edit Layer Lock Feature, LayerManager.js (Layer Compositing Core), Image Edit Save to Gallery Feature, MaskTool.js, Models Tab Overlay Button Not-Updating Bug Fix (+7 more)
|
||||
|
||||
### Community 61 - "G'MIC Routes"
|
||||
Cohesion: 0.21
|
||||
Nodes (14): _gmic_run_gui(), handle_open(), handle_result(), handle_status(), Application, Request, Response, G'MIC-Qt integration API routes. (+6 more)
|
||||
|
||||
### Community 62 - "Ollama Routes"
|
||||
Cohesion: 0.23
|
||||
Nodes (14): _get_ollama_config(), handle_chat(), handle_models(), handle_test(), Application, Request, Response, Ollama API proxy routes. (+6 more)
|
||||
|
||||
### Community 63 - "Gallery Metadata/Service Bridge"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): Gallery Metadata Store - ギャラリー画像のメタデータ永続化, _clean_vault_tags(), _decode_image_data_url(), _parse_vault_yaml_leaves(), Gallery Service - outputフォルダの画像管理、メタデータ閲覧, data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。 ヘッダが無ければPNG扱い(旧互換)。, OSで使えない文字を除去し、拡張子が無ければ付与する, 任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。 Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。 (+3 more)
|
||||
|
||||
### Community 65 - "Mask Editor One BiRefNet/SAM3 Integration"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): store_image API bg_image_b64 Field Rename Compatibility Fix, BiRefNet BG Remove (Mask Editor One Integration), Mask Editor One (comfyui-mask-editor-one Custom Node), Mask Layer Add/Subtract Composite Mode (toggleOperation), MaskTool.js ABR Brush Support (spacing/angle/jitter), Plan: Image Edit Extension — ABR Brush + Mask Editor One Integration, SAM3 Text-Prompt Segmentation (Mask Editor One Integration), Top Bar Icon Disappearance Fix (MutationObserver, top_menu_extension.js) (+6 more)
|
||||
|
||||
### Community 66 - "Eagle Routes Backend"
|
||||
Cohesion: 0.23
|
||||
Nodes (11): Request, Response, Serve the main SPA page., Serve files from the current workflows directory (dynamic path)., Serve files (index thumbnails) from the Lab plan directory., Main entry point for Workflow Studio plugin., Register all routes with ComfyUI's server., serve_index_page() (+3 more)
|
||||
|
||||
### Community 67 - "Prompts API Routes/Service"
|
||||
Cohesion: 0.25
|
||||
Nodes (13): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+5 more)
|
||||
|
||||
### Community 68 - "Workflow Analyzer"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): analyze_workflow(), _clip_type_from_ui_node(), _collect_all_ui_nodes(), _detect_model_type_from_name(), _model_name_from_api_node(), _model_name_from_ui_node(), Workflow analyzer - detects model types and counts input/output nodes., Analyze workflow JSON and return model types, input/output node counts. (+5 more)
|
||||
|
||||
### Community 69 - "Gallery Tab Core & Feeder Group"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): ComfyUI-Gallery (external, by PanicTitan), Folder tree root label (v0.3.90), py/services/gallery_metadata.py, py/routes/gallery_routes.py, py/services/gallery_service.py, Gallery Tab, static/js/gallery-tab.js, static/js/image-prompt-tab.js (+6 more)
|
||||
|
||||
### Community 70 - "AI TOOL Backend & Model Tab Updates"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): AI_BACKEND_DEFAULT_URLS / getAiBackendDefaultUrl() Helper (util.js), GenerateUI Model Tab Active Highlight Color Feature, ponyxlWildcardsVault Format Live (Un-imported) Support, reasoning_content Field Handling Bug Fix (Unsloth), SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist), AI TOOL Thinking Mode / Max Tokens Setting, Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention), Unsloth Server-Side Proxy (unsloth_routes.py) (+5 more)
|
||||
|
||||
### Community 71 - "Tagger Tab Implementation & Security Fixes"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): Gallery Detail Panel UI Improvement (Tab/Action Row Split), Gallery Tag Save Bug Fix (tags.forEach not a function), Path Traversal Security Fix (Tagger + Gallery), Security Fix: XSS Escaping + Path Validation (2026-06-20), Tagger Batch .txt Output Feature, Tagger Single GenUI:P Button, TaggerService / TaggerDbService (Backend), Tagger Tab New Implementation (+5 more)
|
||||
|
||||
### Community 72 - "Eagle Routes Backend"
|
||||
Cohesion: 0.19
|
||||
Nodes (12): _get_api_key(), handle_proxy(), _is_allowed_base_url(), Application, Request, Response, Unsloth API proxy routes. Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop…, Return the Unsloth API key from the environment (.env), or None. (+4 more)
|
||||
|
||||
### Community 73 - "Prompts API Routes/Service"
|
||||
Cohesion: 0.28
|
||||
Nodes (3): PromptsService, Prompt presets management service., Manages prompt presets (CRUD, categories, favorites).
|
||||
|
||||
### Community 74 - "Metadata & Nodes Tab Core"
|
||||
Cohesion: 0.17
|
||||
Nodes (13): CLIPTextEncodeEditPlus edit+ support (v0.3.92), static/js/comfyui-workflow.js, Metadata Tab, static/js/metadata-tab.js, py/services/png_extractor.py, UI-to-API conversion, py/services/workflow_analyzer.py, Gallery Bulk Actions (Group/Favorite/Compare/Move/Export/Delete) (+5 more)
|
||||
|
||||
### Community 75 - "AI Tool Tab Frontend"
|
||||
Cohesion: 0.26
|
||||
Nodes (12): _applyGenOptions(), callChat(), callLLM(), callVLM(), _clearToolsImage(), fileToBase64(), _formatMessagesForBackend(), initVlmTab() (+4 more)
|
||||
|
||||
### Community 76 - "Node Sets Menu & Sidebar"
|
||||
Cohesion: 0.22
|
||||
Nodes (13): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), injectStyles() (+5 more)
|
||||
|
||||
### Community 77 - "Gallery Feeder Extension (Frontend)"
|
||||
Cohesion: 0.36
|
||||
Nodes (11): beforeRegisterNodeDef(), fetchGroupImages(), getInputWidget(), getOrCreate(), _ns, setWidgetValue(), startLoop(), stopLoop() (+3 more)
|
||||
|
||||
### Community 78 - "Gallery Speed & Bulk Features"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Background Daemon Thread Indexing Pattern (asyncio.to_thread + Cancelable Scan), Select All Button + Gallery Bulk Bar i18n, Bulk API Endpoint Pattern (Single Request vs N Parallel), v0.3.44 Gallery Image Compare Mode, v0.3.44 Gallery Prompt Full-Text Search, v0.3.44 Gallery Speedup (Server Thumbnails + Infinite Scroll), v0.3.45 Gallery Shift+Click Range Selection, v0.3.48 Gallery Image Download / Bulk ZIP Export (+2 more)
|
||||
|
||||
### Community 79 - "Style Catalog & Batch Loop Core"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Catalog Creation "Don't keep copy in Output" Option, Style/Prompt to ImagePrompt Full Rename, Prompt Tab Style Management Sub-tab (CRUD), Reuse Existing Batch/Selection UI Before Building New, _runBatchLoop() Generic Batch Loop, Backend save_image_to_folder() Generalization, Style Catalog Gallery Sub-tab, trackProgress() Timeout & execution_interrupted Handling Fix (+2 more)
|
||||
|
||||
### Community 80 - "Metadata Tab Node Support History"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): CLIPLoader Widget Name Bug (clip_name1 to clip_name), model-and-prompt-from-metadata Custom Node Pack, Top Bar Icon Not Showing Bug, v0.3.10: Information Tab Canvas Drag Support, v0.3.11: Metadata Tab Node Type Expansion + Settings Text Size, v0.3.27: model-and-prompt-from-metadata Node Support, v0.3.6: Metadata Tab Addition, v0.3.7: Metadata Tab Flux2/Qwen/Z-Image Support (+2 more)
|
||||
|
||||
### Community 82 - "Gallery Feeder Node (Backend)"
|
||||
Cohesion: 0.27
|
||||
Nodes (4): Path, WFS_GalleryFeeder – Feeds images from a gallery group into a workflow., Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。, WFS_GalleryFeeder
|
||||
|
||||
### Community 83 - "App Shell & i18n Init"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize()
|
||||
|
||||
### Community 84 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.24
|
||||
Nodes (6): initGenerateTab(), updateStatus(), highlightJSON(), syncJsonHighlight(), syncScroll(), refreshLabLiveDefaults()
|
||||
|
||||
### Community 87 - "Node Sets Metadata Extraction"
|
||||
Cohesion: 0.36
|
||||
Nodes (10): _collectAllNodes(), _collectUnique(), fromWorkflow(), _extractCheckpoints(), _extractDiffusionModels(), _extractLoRAs(), _extractMarkdownNoteModels(), addU() (+2 more)
|
||||
|
||||
### Community 88 - "Node Sets Prompt Extraction"
|
||||
Cohesion: 0.33
|
||||
Nodes (10): _extractPrompts(), _extractPromptsAPI(), _extractPromptsFromNodeSet(), _extractPromptsLiteGraph(), _isPromptStylerNode(), _isSamplerNode(), _isTextEncoderNode(), _resolveEditPlusText() (+2 more)
|
||||
|
||||
### Community 89 - "Inpaint Basic Workflow"
|
||||
Cohesion: 0.53
|
||||
Nodes (9): CLIP Text Encode (Prompt) - negative conditioning, CLIP Text Encode (Prompt) - positive conditioning, KSampler node (ddim sampler, 30 steps, cfg 4.0, denoise 1.00), Load Checkpoint node (hayochamixIDXL_v11+_bl checkpoint), Load Image node (clipspace painted mask input), Save Image node (ComfyUI filename prefix), VAE Decode node, VAE Encode (for Inpainting) node with grow_mask_by (+1 more)
|
||||
|
||||
### Community 90 - "Gallery Feeder & Batch Features"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): __Feeder__ Reserved Group Protection + F Button + Seed Bug Fix, Feeder Tab Gallery Mode (vs Image Loop Mode), Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync), WFS_GalleryFeeder ComfyUI Canvas Controls Extension, WFS_GalleryFeeder Node (Gallery Feeder Feature), Models Tab Thumbnail Update Bug Fix (Cache/Corrupt-File/Disabled-Model), v0.3.28 Batch Tab Sampler/Scheduler Support, v0.3.56 Style Feature (GenerateUI Toolbar & Batch Tab) (+1 more)
|
||||
|
||||
### Community 91 - "Gallery Service Background Indexing"
|
||||
Cohesion: 0.25
|
||||
Nodes (5): Event, バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。 10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。, PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す, PNGのtEXtチャンクからメタデータを抽出, JPEGのEXIF/commentからメタデータを抽出(簡易)
|
||||
|
||||
### Community 92 - "Settings/CivitAI Config Resolution"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over…, Merge updates into existing settings., Manages application settings (data/settings.json)., SettingsService
|
||||
|
||||
### Community 93 - "Gallery Service Vault Helpers"
|
||||
Cohesion: 0.22
|
||||
Nodes (4): 画像のプロンプトテキストを返す。 1. 画像と同名の .txt サイドカーがあればそれを使う 2. 無ければ ponyxlWildcardsVault 形式…, image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ…, ponyxlWildcardsVault形式のフォールバック解決。 thumbnails_option2 の "{leaf}.preview3.ext"…, 画像と同名の.txtサイドカーにプロンプトテキストを保存する
|
||||
|
||||
### Community 94 - "GenerateUI Lab Tab"
|
||||
Cohesion: 0.36
|
||||
Nodes (9): _coreGenerate(), _expandWildcardsInWorkflow(), _annotatedImageRef(), _maybeSaveIndexImageOnRun(), _runLabBatch(), _setRunUiState(), _waitIfPaused(), getEagleSettings() (+1 more)
|
||||
|
||||
### Community 95 - "Style Prompt Seed Import Tool"
|
||||
Cohesion: 0.36
|
||||
Nodes (8): clean_tags(), import_category(), main(), parse_yaml_leaves(), Path, Style/Prompt ギャラリー用シードデータ投入スクリプト。 comfyui_prompt_gallery / prompt_builder_proto…, comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。 「key:」行の直後が「-…, 1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)
|
||||
|
||||
### Community 96 - "FireRed 3D Pose Image Edit Workflow"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): 3D Pose Editor node, FireRed-Image-Edit-1.1_fp8mixed_comfy.safetensors, FireRed-Image-Edit-1.0-Lightning-8steps-v1.0.safetensors (LoRA), Image Edit (FireRed Image Edit 1.1) node, qwen_2.5_vl_7b_fp8_scaled.safetensors (CLIP), qwen_image_vae.safetensors (VAE), Save Image node, 3D Pose Editor to Image Edit (Fire Red) Workflow
|
||||
|
||||
### Community 97 - "Chat Image Generation & Inpaint Bridge"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): comfyEditor.applyImageToSlot() Method, "Use Dedicated Workflow" Toggle Pattern (Inpaint/Chat Image Gen Shared Design), generate_image Tool Call (LLM Tool Calling to ComfyUI Generation), tagger_settings.json Missing from Data Export Bug Fix, v0.3.62 Send GenUI Image Button + tagger_settings.json Export Fix, v0.3.71 Inpaint Feature (GenerateUI Image Tab + Image Edit Inpaint Tool), v0.3.73 AI TOOL Chat Tool Calling Image Generation + Checkpoint Safety, v0.3.74 AI TOOL Chat Image Attachment + I2I Bridge
|
||||
|
||||
### Community 98 - "Subgraph Workflow Support & Fixes"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): detectFormat() App vs UI Judgment Criteria Fix (linearMode), DynamicCombo (COMFY_DYNAMICCOMBO_V3) Flat Dot-Key Input Fix, _flattenSubgraphs() Outer Widget Value Injection Into Inner Nodes, Subgraph Fan-out Fix: inputPortTargets Array + _wfmLiteralInputs, v0.3.78 GenerateUI/Metadata Tab Subgraph Workflow Support (Mage-Flow), v0.3.84 Ernie T2I "source" Validation Error / Subgraph Fan-out Bug, workflow_analyzer.py P:/I:/→ Count and Format Badge Fix, Workflow Tab Summarize: Structured Data Instead of Raw JSON Truncation
|
||||
|
||||
### Community 99 - "KREA-2 & Lab Tab Extensions"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): DynamicCombo Multi-Slot Consumption Bug (sampling_mode), GenerateUI Input Image Placeholder Feature (Color/Image mode), KREA-2 Turbo Workflow Support, Lab Tab Bypass Checkbox (Per-Keyframe Row Disable), Lab Tab Clear / Get from Previous Buttons, Lab Tab Keyframe #1 Live Default Reflection, Role Propagation Bug: PreviewAny / StringConcatenate Relay Nodes, v0.3.97 KREA-2 Workflow Support + Lab Tab Extension + Input Image Placeholder
|
||||
|
||||
### Community 100 - "LoRA Pane Evolution & Help Tab"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): GenerateUI Reset Workflow Button + Input Tab Tabify (2026-06-08), Help Tab Search Feature + Font Enlargement + Gallery-mode i18n, Help Tab Sidebar Layout (2-Column), v0.3.29 LoRA Section Integration (Single+Stack Merge), v0.3.30 LoRA Stack Trigger Words Bug Fix, v0.3.31 LoRA Pane Single/Stack Tab Split, v0.3.32 Lora Loader (LoraManager) LoRA Detection Support, v0.3.33 LoRA Pane / Workflow Parse Bug Fixes
|
||||
|
||||
### Community 103 - "App Shell & i18n Init"
|
||||
Cohesion: 0.43
|
||||
Nodes (7): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), _joinBytes(), _n2b(), _pngCrc32()
|
||||
|
||||
### Community 104 - "DWPose Image Feeder Workflow"
|
||||
Cohesion: 0.52
|
||||
Nodes (7): DWPose Image Feeder Workflow, Apply ControlNet Node, DWPose Estimator Node (comfyui-controlnet-aux), Image Feeder Node (comfyui-image-feeder), KSampler Node, Load Checkpoint Node (animagineXLRealistic_v6), Load ControlNet Model Node (OpenPoseXL2)
|
||||
|
||||
### Community 105 - "AI Tab & RAW JSON Search History"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): AI TOOL Tab English Localization, RAW JSON Panel Search Feature, v0.3.12: README Screenshot Refresh + workflow_analyzer Extension, v0.3.13: AI Tab (A) Addition, v0.3.14: AI TOOL Tab 3-Pane Redesign + RAW JSON Colors, v0.3.15: Sample Workflow Bundling + CivitAI Auto Preview, v0.3.16: CivitAI Preview Fallback Display
|
||||
|
||||
### Community 106 - "Prompt Extraction Bug Fixes"
|
||||
Cohesion: 0.33
|
||||
Nodes (7): ComfySwitchNode Branch Resolution (Prompt Enhancement Toggle), GenerateUI vs Metadata Tab: Separate Non-Synced Analysis Logic, node_sets_menu.js Local Duplication Pattern (web/comfyui/ import restriction), v0.3.79 Side Panel I-Tab Mage-Flow Subgraph Support, v0.3.82 Metadata Tab / Side Panel I-Tab Prompt Extraction Bug (LongCat/Boogu/HiDream E1/FireRed), v0.3.83 Ernie Image Support + Link-Sourced Prompt Pattern Fix, WFS_PromptText Node GenerateUI Prompt Tab Support + Drop Title Fix
|
||||
|
||||
### Community 107 - "Comic Creator Bridge & LoRA Batch Fixes"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): Development Repo vs Runtime custom_nodes Folder Sync Reminder, _wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS), LoRA Batch Path Separator Mismatch Fix + resolveLoraName(), LoRA Batch Execution Prompt Auto-Reflection + Restore, Prompt Weight Ctrl+Up/Down Editing (A1111-style), v0.3.69 Gallery to Comic Creator "Send CC" Button, v0.3.70 Ollama 404 Fix + Prompt Revert Button + Weight Ctrl+Arrow Editing
|
||||
|
||||
### Community 108 - "Settings/CivitAI Config Resolution"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): _get_ssl_context(), _make_ssl_context(), CivitAI API integration service., Return an SSL context with CA verification. 1. certifi CA bundle (available in…, Download an image from URL and save to save_path. Returns True on success.
|
||||
|
||||
### Community 109 - "Node Sets Menu Model Fetch"
|
||||
Cohesion: 0.57
|
||||
Nodes (7): createModelItem(), fetchModelList(), matchesModelSearch(), renderModelAll(), renderModelByType(), renderModelFavorites(), renderModelGroups()
|
||||
|
||||
### Community 110 - "G'MIC Integration & Security Fixes"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): Draw/Mask Brush Circular Cursor + Off-canvas Drag Support, gmic_qt.exe Argument Order Fix (-o output input), handle_result Path Traversal Fix (gmic/result endpoint), gmic_routes.py (New Backend for G'MIC-Qt Job Handling), v0.3.60 Image Edit Filter Tool (G'MIC-Qt Integration), v0.3.61 G'MIC Bug Fix + Security + Draw/Mask Brush Operability
|
||||
|
||||
### Community 111 - "Comic Creator Integration Bridges"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): ComfyUI Comic Creator (External iframe-embedded Custom Node), _runInpaintWithImages() / runInpaintExternal() Shared Inpaint Logic, v0.3.72 Comic Creator External Inpaint Entry Point, v0.3.76 Comic Creator I2I Run Bridge (_wfmReceiveI2IRunRequest), v0.3.80 Comic Creator Semi-auto Manga Bridge (LLM Prompt Draft + Batch Gen), v0.3.85 Generate UI Bridge (T2I) Default Workflow + Negative Prompt
|
||||
|
||||
### Community 112 - "GenerateUI Model Tab GGUF/Embedding Support"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): GenUI Model LoRA Reset-on-Subtab-Switch Bug Fix, v0.3.51 Embedding GenUI PP/NP Support, v0.3.51 GenerateUI Hypernetwork Support, v0.3.51 Models Tab Side Panel GenUI Model Button, Hypernetwork/Embedding Not-Displayed Bug (V3 object_info Format), v0.3.58 GenerateUI GGUF Diffusion Model / Text Encoder Support
|
||||
|
||||
### Community 113 - "Bypass Node Handling & Advanced Sampling"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): GenerateUI Raw JSON Bypass/Mute Node Indicator, Reroute Node missing_node_type 400 Error Fix (_resolveBypassSource extension), _resolveBypassSource() Bypass Pass-through Link Resolution, _simulateWidgetValues / _stripLegacyLinkedWidgetValues Common Logic Rewrite, v0.3.75 Bypass (mode:4) Node Workflow Generation Failure Fix, v0.3.81 Flux.2 Klein / LongCat / Boogu / HiDream E1 / FireRed Advanced Sampling Support
|
||||
|
||||
### Community 114 - "Prompt Text Node"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): WFS_PromptText – Prompt preset node with positive/negative STRING outputs., Outputs positive and negative prompt strings., WFS_PromptText
|
||||
|
||||
### Community 116 - "Gallery Service Vault Helpers"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): os.scandir() で画像ファイルを1回のシステムコールで列挙。 キャッシュがあればそれを返す。 Returns: [(name,…, os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。 ImagePrompt/Style…, 指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)
|
||||
|
||||
### Community 117 - "PNG Metadata Extractor"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): extract_png_workflow(), Extract ComfyUI workflow JSON from PNG metadata., Extract ComfyUI workflow JSON from PNG byte data. Returns dict or None., Workflow CRUD and metadata management service.
|
||||
|
||||
### Community 118 - "AI Skills & Help Redesign"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): AI Skills Mechanism (Selectable System Prompt Library), Help Tab Card-Format Redesign (16 Pages), Skill Creator (Interactive Skill Creation Meta-skill), SVG Icon Generator Skill (Shape Decomposition Prompting), v0.3.79 AI TOOL Chat Skills Mechanism + SVG Generation Quality Improvement
|
||||
|
||||
### Community 119 - "Prompt Extraction Bug Fixes"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Checkpoint Silent Substitution Bug (getLastCheckpointSubstitutions), CLIPTextEncodeEditPlus Metadata Extraction Gap, convertUiToApi forceInput Widget Misclassification (Root Cause), LoRA Stack: Use Stack Group Checkbox, v0.3.92 convertUiToApi forceInput Bug + LoRA Stack Improvements
|
||||
|
||||
### Community 120 - "AI TOOL Chat & Wildcard Features"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): v0.3.41 ImpactWildcardEncode/Processor Prompt Detection Bug Fix, v0.3.40 AI TOOL Tab Chat Pane, v0.3.40 AI TOOL Wildcard Generation Feature, v0.3.46 Prompt Tab AI Assistant LM Studio Support, v0.3.49 A1111-style Wildcard Expansion in GenerateUI
|
||||
|
||||
### Community 121 - "Config & Service Modules"
|
||||
Cohesion: 0.40
|
||||
Nodes (3): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Settings management service.
|
||||
|
||||
### Community 122 - "Group Feature Path Bug Fixes"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): Windows Path Separator Normalization Pattern, v0.3.45 Group Orphan Entry Auto Cleanup, v0.3.47 Models Tab File-Move Group State Fix, v0.3.55 Group Feature Bug Fixes (All Tabs)
|
||||
|
||||
### Community 123 - "SVG Generation & Display Support"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): AI TOOL Chat Workflow-less SVG Generation/Display/Gallery Save, v0.3.77 Gallery Tab SVG File Display/Management Support, v0.3.78 Eagle Auto-Save SVG Support
|
||||
|
||||
### Community 124 - "Send to Canvas Feature"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): API-Format Workflow Loads Empty on Canvas Bug Fix, Send to Canvas Feature (Workflow Tab / Gallery Tab Title DnD), Send to Canvas via window.opener Direct Load
|
||||
|
||||
### Community 125 - "Module Split Refactor (v0.4.0)"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Callback Injection Pattern (One-way Dependency), Graphify Community Cohesion Analysis, v0.4.0 image-edit-tab.js / models-tab.js Low-Cohesion Module Split
|
||||
|
||||
### Community 126 - "CivitAI Detail Panel History"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): CivitAI modelUrl Broken Fallback Bug, v0.3.20: CivitAI Detail Panel Enhancement, v0.3.21: CivitAI Host Setting + Sample Subtab
|
||||
|
||||
### Community 127 - "GenerateUI Layout & JSON Highlight"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): JSONシンタックスハイライト, Raw JSON同期修正, v0.2.4 GenerateUIタブ レイアウト再設計
|
||||
|
||||
### Community 128 - "Workflow Tab Bug Fixes"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): v0.3.23: Two Bug Fixes, Workflow Batch 404 Endpoint Bug, Workflow Rename Duplicate Listener Bug
|
||||
|
||||
## Ambiguous Edges - Review These
|
||||
- `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` → `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: references
|
||||
- `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` → `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: semantically_similar_to
|
||||
- `Models Tab Overlay Button Not-Updating Bug Fix` → `v0.3.54 Image Edit Shape Tool` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: conceptually_related_to
|
||||
- `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` → `v0.3.28 Batch Tab Sampler/Scheduler Support` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: conceptually_related_to
|
||||
|
||||
## Knowledge Gaps
|
||||
- **311 isolated node(s):** `AI_BACKEND_DEFAULT_URLS`, `state`, `LANGUAGE_OPTIONS`, `LANGUAGES`, `SUMMARY_LANGUAGE_OPTIONS` (+306 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **52 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **What is the exact relationship between `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` and `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()`?**
|
||||
_Edge tagged AMBIGUOUS (relation: references) - confidence is low._
|
||||
- **What is the exact relationship between `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` and `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)`?**
|
||||
_Edge tagged AMBIGUOUS (relation: semantically_similar_to) - confidence is low._
|
||||
- **What is the exact relationship between `Models Tab Overlay Button Not-Updating Bug Fix` and `v0.3.54 Image Edit Shape Tool`?**
|
||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||
- **What is the exact relationship between `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` and `v0.3.28 Batch Tab Sampler/Scheduler Support`?**
|
||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||
- **Why does `showToast()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `Image Edit Layer & Draw Tools`, `GenerateUI Feeder Tab`, `App Shell & i18n Init`, `Nodes Tab Frontend`, `GenerateUI Lab Tab`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `Workflow Tab Frontend & Storage`, `Image Edit Layer Manager Core`, `Style Catalog Tab`, `Image Prompt Tab`, `Image Edit Background Removal`, `Image Edit Blur Tool (BlurTool.js)`, `AI Tool Tab Frontend`, `Image Edit Background Removal (BgRemove.js)`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Image Edit SAM3 Segmentation`, `GenerateUI Lab Tab`, `Image Edit File Export (FileExport.js)`, `Image Edit G'MIC Integration`?**
|
||||
_High betweenness centrality (0.042) - this node is a cross-community bridge._
|
||||
- **Why does `t()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `GenerateUI Feeder Tab`, `AI Tool Tab Frontend`, `App Shell & i18n Init`, `Image Prompt Tab`, `GenerateUI Lab Tab`, `Style Catalog Tab`, `Metadata Tab Frontend`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Nodes Tab Frontend`, `Workflow Tab Frontend & Storage`, `ComfyUI Client & Editor`, `GenerateUI Lab Tab`?**
|
||||
_High betweenness centrality (0.023) - this node is a cross-community bridge._
|
||||
- **Why does `GalleryService` connect `Gallery Service Core` to `Gallery API Routes`, `Gallery Service Core`, `Gallery Metadata/Service Bridge`, `Gallery Service Vault Helpers`, `Gallery Service Background Indexing`, `Gallery Service Vault Helpers`, `Gallery Metadata Store`, `Gallery Metadata/Service Bridge`?**
|
||||
_High betweenness centrality (0.018) - this node is a cross-community bridge._
|
||||
@@ -1,842 +0,0 @@
|
||||
{
|
||||
"__init__.py": {
|
||||
"mtime": 1781939762.254596,
|
||||
"seen": 1787194338.8008406,
|
||||
"ast_hash": "72d9ef06026dff7be74da81ff98a306a",
|
||||
"semantic_hash": "72d9ef06026dff7be74da81ff98a306a"
|
||||
},
|
||||
"prestartup_script.py": {
|
||||
"mtime": 1787036267.332648,
|
||||
"seen": 1787194338.800846,
|
||||
"ast_hash": "7b5d482ef21a480d1b38356e8d82970b",
|
||||
"semantic_hash": "7b5d482ef21a480d1b38356e8d82970b"
|
||||
},
|
||||
"py/__init__.py": {
|
||||
"mtime": 1773797715.629029,
|
||||
"seen": 1787194338.8008478,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/config.py": {
|
||||
"mtime": 1786245317.3352194,
|
||||
"seen": 1787194338.800849,
|
||||
"ast_hash": "b013c27bb7cd9a79449961539fdcd30f",
|
||||
"semantic_hash": "b013c27bb7cd9a79449961539fdcd30f"
|
||||
},
|
||||
"py/nodes/__init__.py": {
|
||||
"mtime": 1774423045.6646452,
|
||||
"seen": 1787194338.8008502,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/nodes/gallery_feeder_node.py": {
|
||||
"mtime": 1781943269.5205867,
|
||||
"seen": 1787194338.8008513,
|
||||
"ast_hash": "d37c520b0f76e70e60ec7c72db904bf4",
|
||||
"semantic_hash": "d37c520b0f76e70e60ec7c72db904bf4"
|
||||
},
|
||||
"py/nodes/prompt_text.py": {
|
||||
"mtime": 1774423045.6669831,
|
||||
"seen": 1787194338.8008525,
|
||||
"ast_hash": "43cf16f182b9eda5fade2b52c2439372",
|
||||
"semantic_hash": "43cf16f182b9eda5fade2b52c2439372"
|
||||
},
|
||||
"py/routes/__init__.py": {
|
||||
"mtime": 1773797716.663036,
|
||||
"seen": 1787194338.8008535,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/routes/eagle_routes.py": {
|
||||
"mtime": 1785464911.9023821,
|
||||
"seen": 1787194338.800855,
|
||||
"ast_hash": "edd32039935a892317c55b434c9839ab",
|
||||
"semantic_hash": "edd32039935a892317c55b434c9839ab"
|
||||
},
|
||||
"py/routes/gallery_routes.py": {
|
||||
"mtime": 1786682156.6899943,
|
||||
"seen": 1787194338.800856,
|
||||
"ast_hash": "59e4c5d34ee2329ab9e0a66f841d1676",
|
||||
"semantic_hash": "59e4c5d34ee2329ab9e0a66f841d1676"
|
||||
},
|
||||
"py/routes/gmic_routes.py": {
|
||||
"mtime": 1782544784.03979,
|
||||
"seen": 1787194338.8008568,
|
||||
"ast_hash": "b909c65af6590c0ca058136cb0b9234e",
|
||||
"semantic_hash": "b909c65af6590c0ca058136cb0b9234e"
|
||||
},
|
||||
"py/routes/lab_routes.py": {
|
||||
"mtime": 1786322410.571655,
|
||||
"seen": 1787194338.8008578,
|
||||
"ast_hash": "48e94f71b11f2e494bb98bb7113e2baa",
|
||||
"semantic_hash": "48e94f71b11f2e494bb98bb7113e2baa"
|
||||
},
|
||||
"py/routes/models_routes.py": {
|
||||
"mtime": 1782289767.1073005,
|
||||
"seen": 1787194338.8008592,
|
||||
"ast_hash": "067aa9c89085fa3e077fdfacbb6be2cd",
|
||||
"semantic_hash": "067aa9c89085fa3e077fdfacbb6be2cd"
|
||||
},
|
||||
"py/routes/nodes_routes.py": {
|
||||
"mtime": 1774255599.0841086,
|
||||
"seen": 1787194338.8008602,
|
||||
"ast_hash": "dc5f7b12dce346631ff945c31bf03ac9",
|
||||
"semantic_hash": "dc5f7b12dce346631ff945c31bf03ac9"
|
||||
},
|
||||
"py/routes/ollama_routes.py": {
|
||||
"mtime": 1784126582.1613884,
|
||||
"seen": 1787194338.800861,
|
||||
"ast_hash": "80552c0676f7bbd4209eb95cf7a03f5b",
|
||||
"semantic_hash": "80552c0676f7bbd4209eb95cf7a03f5b"
|
||||
},
|
||||
"py/routes/prompts_routes.py": {
|
||||
"mtime": 1774423045.6696556,
|
||||
"seen": 1787194338.8008618,
|
||||
"ast_hash": "53b5641f3ad6f65e8ae665601c4739d7",
|
||||
"semantic_hash": "53b5641f3ad6f65e8ae665601c4739d7"
|
||||
},
|
||||
"py/routes/settings_routes.py": {
|
||||
"mtime": 1786681377.025748,
|
||||
"seen": 1787194338.8008628,
|
||||
"ast_hash": "bb2f332c04474ee39319c2d0b8107db9",
|
||||
"semantic_hash": "bb2f332c04474ee39319c2d0b8107db9"
|
||||
},
|
||||
"py/routes/skill_routes.py": {
|
||||
"mtime": 1785634791.033795,
|
||||
"seen": 1787194338.800864,
|
||||
"ast_hash": "3318c506163c720c5412ec0f8ff3a28a",
|
||||
"semantic_hash": "3318c506163c720c5412ec0f8ff3a28a"
|
||||
},
|
||||
"py/routes/tagger_routes.py": {
|
||||
"mtime": 1781503496.8879485,
|
||||
"seen": 1787194338.8008647,
|
||||
"ast_hash": "9c0559b594a69354882da0b1643e11f0",
|
||||
"semantic_hash": "9c0559b594a69354882da0b1643e11f0"
|
||||
},
|
||||
"py/routes/unsloth_routes.py": {
|
||||
"mtime": 1787036726.267709,
|
||||
"seen": 1787194338.8008657,
|
||||
"ast_hash": "93d4de97937f472f9618a35a0b4211d6",
|
||||
"semantic_hash": "93d4de97937f472f9618a35a0b4211d6"
|
||||
},
|
||||
"py/routes/wildcard_routes.py": {
|
||||
"mtime": 1777370036.4121041,
|
||||
"seen": 1787194338.8008666,
|
||||
"ast_hash": "1d0ea981a44ffab77d62c214951f4536",
|
||||
"semantic_hash": "1d0ea981a44ffab77d62c214951f4536"
|
||||
},
|
||||
"py/routes/workflow_routes.py": {
|
||||
"mtime": 1773896239.095392,
|
||||
"seen": 1787194338.8008673,
|
||||
"ast_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3",
|
||||
"semantic_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3"
|
||||
},
|
||||
"py/services/__init__.py": {
|
||||
"mtime": 1773797717.739297,
|
||||
"seen": 1787194338.800868,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/services/civitai_service.py": {
|
||||
"mtime": 1780233122.7587051,
|
||||
"seen": 1787194338.8008692,
|
||||
"ast_hash": "3b4a2a118bc56dd27e768a2819b401e7",
|
||||
"semantic_hash": "3b4a2a118bc56dd27e768a2819b401e7"
|
||||
},
|
||||
"py/services/gallery_metadata.py": {
|
||||
"mtime": 1786170828.9941547,
|
||||
"seen": 1787194338.80087,
|
||||
"ast_hash": "9d02a71d56b7ca2a88cdf8c56fadf912",
|
||||
"semantic_hash": "9d02a71d56b7ca2a88cdf8c56fadf912"
|
||||
},
|
||||
"py/services/gallery_service.py": {
|
||||
"mtime": 1786638406.9058151,
|
||||
"seen": 1787194338.800871,
|
||||
"ast_hash": "c8b9858f015f7f4a934c4b630e905107",
|
||||
"semantic_hash": "c8b9858f015f7f4a934c4b630e905107"
|
||||
},
|
||||
"py/services/lab_plan_service.py": {
|
||||
"mtime": 1786322396.0192728,
|
||||
"seen": 1787194338.8008716,
|
||||
"ast_hash": "5d2c928c077ad7b788f15419b39ef51f",
|
||||
"semantic_hash": "5d2c928c077ad7b788f15419b39ef51f"
|
||||
},
|
||||
"py/services/models_service.py": {
|
||||
"mtime": 1782393318.5989842,
|
||||
"seen": 1787194338.8008728,
|
||||
"ast_hash": "0885cc6852199fa45271192ea0d6713a",
|
||||
"semantic_hash": "0885cc6852199fa45271192ea0d6713a"
|
||||
},
|
||||
"py/services/nodes_service.py": {
|
||||
"mtime": 1774255599.0862236,
|
||||
"seen": 1787194338.8008735,
|
||||
"ast_hash": "8819590ffdc136fb2912d9c5696e26e0",
|
||||
"semantic_hash": "8819590ffdc136fb2912d9c5696e26e0"
|
||||
},
|
||||
"py/services/png_extractor.py": {
|
||||
"mtime": 1773797967.5810304,
|
||||
"seen": 1787194338.8008747,
|
||||
"ast_hash": "1282b6032d78cba2a4bbbfb876126bc1",
|
||||
"semantic_hash": "1282b6032d78cba2a4bbbfb876126bc1"
|
||||
},
|
||||
"py/services/prompts_service.py": {
|
||||
"mtime": 1774423045.6711988,
|
||||
"seen": 1787194338.8008757,
|
||||
"ast_hash": "79a98e220d2b556106e8530054547155",
|
||||
"semantic_hash": "79a98e220d2b556106e8530054547155"
|
||||
},
|
||||
"py/services/settings_service.py": {
|
||||
"mtime": 1773798498.9270506,
|
||||
"seen": 1787194338.8008769,
|
||||
"ast_hash": "dade5ea667a2c6408434ad3061c76db7",
|
||||
"semantic_hash": "dade5ea667a2c6408434ad3061c76db7"
|
||||
},
|
||||
"py/services/skill_service.py": {
|
||||
"mtime": 1785637306.8654232,
|
||||
"seen": 1787194338.8008778,
|
||||
"ast_hash": "5ebe579995c1c674e4219e58b3bd5855",
|
||||
"semantic_hash": "5ebe579995c1c674e4219e58b3bd5855"
|
||||
},
|
||||
"py/services/tagger_db_service.py": {
|
||||
"mtime": 1781503496.8949444,
|
||||
"seen": 1787194338.8008785,
|
||||
"ast_hash": "de0ca007db5159484e8ff3b8a36a3b84",
|
||||
"semantic_hash": "de0ca007db5159484e8ff3b8a36a3b84"
|
||||
},
|
||||
"py/services/tagger_service.py": {
|
||||
"mtime": 1781503496.8969479,
|
||||
"seen": 1787194338.8008797,
|
||||
"ast_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9",
|
||||
"semantic_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9"
|
||||
},
|
||||
"py/services/wildcard_service.py": {
|
||||
"mtime": 1777380000.0054882,
|
||||
"seen": 1787194338.8008807,
|
||||
"ast_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd",
|
||||
"semantic_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd"
|
||||
},
|
||||
"py/services/workflow_analyzer.py": {
|
||||
"mtime": 1785474766.4736228,
|
||||
"seen": 1787194338.8008814,
|
||||
"ast_hash": "937d6b02a6235795cb22ceb39e81f719",
|
||||
"semantic_hash": "937d6b02a6235795cb22ceb39e81f719"
|
||||
},
|
||||
"py/services/workflow_service.py": {
|
||||
"mtime": 1775439760.4805496,
|
||||
"seen": 1787194338.8008823,
|
||||
"ast_hash": "a2e47c0b8780ec7ca292310b16733a8c",
|
||||
"semantic_hash": "a2e47c0b8780ec7ca292310b16733a8c"
|
||||
},
|
||||
"py/wfm.py": {
|
||||
"mtime": 1787036294.0749958,
|
||||
"seen": 1787194338.8008835,
|
||||
"ast_hash": "cb1c96536f67edb380d1010b6165d74d",
|
||||
"semantic_hash": "cb1c96536f67edb380d1010b6165d74d"
|
||||
},
|
||||
"pyproject.toml": {
|
||||
"mtime": 1787193198.5117335,
|
||||
"seen": 1787194338.8008842,
|
||||
"ast_hash": "de0f37033a118144dc86368704dc7a34",
|
||||
"semantic_hash": "de0f37033a118144dc86368704dc7a34"
|
||||
},
|
||||
"static/js/ai-tab.js": {
|
||||
"mtime": 1787041206.037751,
|
||||
"seen": 1787194338.8008852,
|
||||
"ast_hash": "4103e9c646b3011bf5ce42d77487497c",
|
||||
"semantic_hash": "4103e9c646b3011bf5ce42d77487497c"
|
||||
},
|
||||
"static/js/app.js": {
|
||||
"mtime": 1787192745.6783228,
|
||||
"seen": 1787194338.8008862,
|
||||
"ast_hash": "b73d44820900bb125886ea51cf879475",
|
||||
"semantic_hash": "b73d44820900bb125886ea51cf879475"
|
||||
},
|
||||
"static/js/comfyui-client.js": {
|
||||
"mtime": 1786676208.2330563,
|
||||
"seen": 1787194338.8008873,
|
||||
"ast_hash": "bb546a6ae7bd35d1c16fee311681be73",
|
||||
"semantic_hash": "bb546a6ae7bd35d1c16fee311681be73"
|
||||
},
|
||||
"static/js/comfyui-editor.js": {
|
||||
"mtime": 1786975595.7088158,
|
||||
"seen": 1787194338.800888,
|
||||
"ast_hash": "2e378bbfd83ae94e3e068b48bec8676c",
|
||||
"semantic_hash": "2e378bbfd83ae94e3e068b48bec8676c"
|
||||
},
|
||||
"static/js/comfyui-workflow.js": {
|
||||
"mtime": 1786930771.5730827,
|
||||
"seen": 1787194338.8008888,
|
||||
"ast_hash": "c5f980df6dddaa9e5374d7ddc2643098",
|
||||
"semantic_hash": "c5f980df6dddaa9e5374d7ddc2643098"
|
||||
},
|
||||
"static/js/feeder-tab.js": {
|
||||
"mtime": 1781939989.0796032,
|
||||
"seen": 1787194338.80089,
|
||||
"ast_hash": "dcd5977c5fe691b87e2379cd0e018a12",
|
||||
"semantic_hash": "dcd5977c5fe691b87e2379cd0e018a12"
|
||||
},
|
||||
"static/js/gallery-tab.js": {
|
||||
"mtime": 1786638406.9234293,
|
||||
"seen": 1787194338.800891,
|
||||
"ast_hash": "a5469191f6f41638aa43decce8ee252c",
|
||||
"semantic_hash": "a5469191f6f41638aa43decce8ee252c"
|
||||
},
|
||||
"static/js/generate-tab.js": {
|
||||
"mtime": 1786932592.3725004,
|
||||
"seen": 1787194338.8008919,
|
||||
"ast_hash": "58f05ad49f87dfd8cff67290e739c3a8",
|
||||
"semantic_hash": "58f05ad49f87dfd8cff67290e739c3a8"
|
||||
},
|
||||
"static/js/i18n.js": {
|
||||
"mtime": 1787192763.662967,
|
||||
"seen": 1787194338.8008926,
|
||||
"ast_hash": "b626aca2093efe87d899d57bc02ce80d",
|
||||
"semantic_hash": "b626aca2093efe87d899d57bc02ce80d"
|
||||
},
|
||||
"static/js/image-edit-tab.js": {
|
||||
"mtime": 1787192013.6171837,
|
||||
"seen": 1787194338.8008935,
|
||||
"ast_hash": "bda888263a8f3aa325c0da5b688d75cb",
|
||||
"semantic_hash": "bda888263a8f3aa325c0da5b688d75cb"
|
||||
},
|
||||
"static/js/image-edit/DrawTool.js": {
|
||||
"mtime": 1782717315.6436062,
|
||||
"seen": 1787194338.8008964,
|
||||
"ast_hash": "a97a6a97ae28c804a7c5fa50448a2c87",
|
||||
"semantic_hash": "a97a6a97ae28c804a7c5fa50448a2c87"
|
||||
},
|
||||
"static/js/image-edit/LayerManager.js": {
|
||||
"mtime": 1782717943.772222,
|
||||
"seen": 1787194338.8009,
|
||||
"ast_hash": "596cd1906208fe72e1c2953d4d7f7135",
|
||||
"semantic_hash": "596cd1906208fe72e1c2953d4d7f7135"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneTools.js": {
|
||||
"mtime": 1782719665.7021136,
|
||||
"seen": 1787194338.8009021,
|
||||
"ast_hash": "b6b16c43ce528f7c9921eab0f4eda335",
|
||||
"semantic_hash": "b6b16c43ce528f7c9921eab0f4eda335"
|
||||
},
|
||||
"static/js/image-edit/MaskTool.js": {
|
||||
"mtime": 1782699721.4711535,
|
||||
"seen": 1787194338.8009028,
|
||||
"ast_hash": "116469f33829c5b2e6270f4b278b2b9c",
|
||||
"semantic_hash": "116469f33829c5b2e6270f4b278b2b9c"
|
||||
},
|
||||
"static/js/image-edit/SelectTool.js": {
|
||||
"mtime": 1782368193.2572634,
|
||||
"seen": 1787194338.800905,
|
||||
"ast_hash": "cb8db6bd5ee742b15f3beee4a5173377",
|
||||
"semantic_hash": "cb8db6bd5ee742b15f3beee4a5173377"
|
||||
},
|
||||
"static/js/image-edit/ShapeTool.js": {
|
||||
"mtime": 1782375200.0253434,
|
||||
"seen": 1787194338.800906,
|
||||
"ast_hash": "36d00bf6b80ebea13314d2853528c44f",
|
||||
"semantic_hash": "36d00bf6b80ebea13314d2853528c44f"
|
||||
},
|
||||
"static/js/image-edit/TextTool.js": {
|
||||
"mtime": 1782368193.2592669,
|
||||
"seen": 1787194338.800907,
|
||||
"ast_hash": "88f9e60251c2f78957e42c33ed5196f8",
|
||||
"semantic_hash": "88f9e60251c2f78957e42c33ed5196f8"
|
||||
},
|
||||
"static/js/image-prompt-tab.js": {
|
||||
"mtime": 1786638406.9362252,
|
||||
"seen": 1787194338.8009079,
|
||||
"ast_hash": "cf39285a30e3f8b79f6f51dfe13f86b5",
|
||||
"semantic_hash": "cf39285a30e3f8b79f6f51dfe13f86b5"
|
||||
},
|
||||
"static/js/json-highlight.js": {
|
||||
"mtime": 1773834925.1977189,
|
||||
"seen": 1787194338.8009088,
|
||||
"ast_hash": "e83e7a7a507d160b9cd503235a79135c",
|
||||
"semantic_hash": "e83e7a7a507d160b9cd503235a79135c"
|
||||
},
|
||||
"static/js/lab-tab.js": {
|
||||
"mtime": 1786933679.1300104,
|
||||
"seen": 1787194338.80091,
|
||||
"ast_hash": "f04c1d830856c01cc03945857d39bd9b",
|
||||
"semantic_hash": "f04c1d830856c01cc03945857d39bd9b"
|
||||
},
|
||||
"static/js/metadata-tab.js": {
|
||||
"mtime": 1786929497.4063816,
|
||||
"seen": 1787194338.800911,
|
||||
"ast_hash": "989f56e4b0eff94923fc73c75a6ce756",
|
||||
"semantic_hash": "989f56e4b0eff94923fc73c75a6ce756"
|
||||
},
|
||||
"static/js/models-tab.js": {
|
||||
"mtime": 1787145102.2314196,
|
||||
"seen": 1787194338.8009121,
|
||||
"ast_hash": "dcee8a68713cd300d0862b1c376d442f",
|
||||
"semantic_hash": "dcee8a68713cd300d0862b1c376d442f"
|
||||
},
|
||||
"static/js/nodes-tab.js": {
|
||||
"mtime": 1782619197.3828287,
|
||||
"seen": 1787194338.8009202,
|
||||
"ast_hash": "3467d4682a8c20a2b209b5db224ce6e2",
|
||||
"semantic_hash": "3467d4682a8c20a2b209b5db224ce6e2"
|
||||
},
|
||||
"static/js/prompt-tab.js": {
|
||||
"mtime": 1786681377.2302153,
|
||||
"seen": 1787194338.8009212,
|
||||
"ast_hash": "9c66fbb4c615e61c3b3ef1220ab20253",
|
||||
"semantic_hash": "9c66fbb4c615e61c3b3ef1220ab20253"
|
||||
},
|
||||
"static/js/settings-tab.js": {
|
||||
"mtime": 1786975595.7637193,
|
||||
"seen": 1787194338.8009222,
|
||||
"ast_hash": "b88c87057ac42e7cbca22cdf1dc522f7",
|
||||
"semantic_hash": "b88c87057ac42e7cbca22cdf1dc522f7"
|
||||
},
|
||||
"static/js/style-catalog-tab.js": {
|
||||
"mtime": 1786678976.6511285,
|
||||
"seen": 1787194338.8009229,
|
||||
"ast_hash": "a2956544bf800ac34f8084b8acd0714b",
|
||||
"semantic_hash": "a2956544bf800ac34f8084b8acd0714b"
|
||||
},
|
||||
"static/js/tagger-tab.js": {
|
||||
"mtime": 1781503496.933307,
|
||||
"seen": 1787194338.8009238,
|
||||
"ast_hash": "5e1c858a13e95148eeac1d5b5be5e4f3",
|
||||
"semantic_hash": "5e1c858a13e95148eeac1d5b5be5e4f3"
|
||||
},
|
||||
"static/js/util.js": {
|
||||
"mtime": 1787036300.7369778,
|
||||
"seen": 1787194338.8009248,
|
||||
"ast_hash": "29774ed440f2b60a708c70db1b3ae208",
|
||||
"semantic_hash": "29774ed440f2b60a708c70db1b3ae208"
|
||||
},
|
||||
"static/js/workflow-tab.js": {
|
||||
"mtime": 1785474064.011484,
|
||||
"seen": 1787194338.8009257,
|
||||
"ast_hash": "65caea21e8e02d81f2e85c75c7231dca",
|
||||
"semantic_hash": "65caea21e8e02d81f2e85c75c7231dca"
|
||||
},
|
||||
"tools/import_style_prompt_seed.py": {
|
||||
"mtime": 1786616195.405381,
|
||||
"seen": 1787194338.8009267,
|
||||
"ast_hash": "02997888c17a6061b7910cddfdd4d89a",
|
||||
"semantic_hash": "02997888c17a6061b7910cddfdd4d89a"
|
||||
},
|
||||
"web/comfyui/gallery_feeder_extension.js": {
|
||||
"mtime": 1781943835.232655,
|
||||
"seen": 1787194338.8009274,
|
||||
"ast_hash": "0048b031e64a433e29a5f7112e82012d",
|
||||
"semantic_hash": "0048b031e64a433e29a5f7112e82012d"
|
||||
},
|
||||
"web/comfyui/node_sets_menu.js": {
|
||||
"mtime": 1787192023.3791485,
|
||||
"seen": 1787194338.8009284,
|
||||
"ast_hash": "81c0d1363ee69d952ad16a294c6e8a8d",
|
||||
"semantic_hash": "81c0d1363ee69d952ad16a294c6e8a8d"
|
||||
},
|
||||
"web/comfyui/top_menu_extension.js": {
|
||||
"mtime": 1782696309.9055538,
|
||||
"seen": 1787194338.8009293,
|
||||
"ast_hash": "2c74498722b46d42bcc15f61ec35dfc4",
|
||||
"semantic_hash": "2c74498722b46d42bcc15f61ec35dfc4"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).json": {
|
||||
"mtime": 1779256606.5720088,
|
||||
"seen": 1787194338.8009303,
|
||||
"ast_hash": "52801ad2b42eca5e3a5f8fc51332d2c3",
|
||||
"semantic_hash": "52801ad2b42eca5e3a5f8fc51332d2c3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).json": {
|
||||
"mtime": 1779257129.3572881,
|
||||
"seen": 1787194338.8009312,
|
||||
"ast_hash": "8260ca467693c21c497f98113abf37f9",
|
||||
"semantic_hash": "8260ca467693c21c497f98113abf37f9"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).json": {
|
||||
"mtime": 1779257864.2496417,
|
||||
"seen": 1787194338.8009322,
|
||||
"ast_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e",
|
||||
"semantic_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.json": {
|
||||
"mtime": 1779255324.1402953,
|
||||
"seen": 1787194338.8009331,
|
||||
"ast_hash": "282ce14b11b8b88ddc91e1d318756c0d",
|
||||
"semantic_hash": "282ce14b11b8b88ddc91e1d318756c0d"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.json": {
|
||||
"mtime": 1784801838.7836998,
|
||||
"seen": 1787194338.8009338,
|
||||
"ast_hash": "43e83496dec4856ab9d97c8b950148ec",
|
||||
"semantic_hash": "43e83496dec4856ab9d97c8b950148ec"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.json": {
|
||||
"mtime": 1784801946.0336668,
|
||||
"seen": 1787194338.800935,
|
||||
"ast_hash": "ac0a865c1dacd2b30c489f08f8b4df4d",
|
||||
"semantic_hash": "ac0a865c1dacd2b30c489f08f8b4df4d"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.json": {
|
||||
"mtime": 1779253098.1947012,
|
||||
"seen": 1787194338.800936,
|
||||
"ast_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51",
|
||||
"semantic_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.json": {
|
||||
"mtime": 1779256158.4671156,
|
||||
"seen": 1787194338.800937,
|
||||
"ast_hash": "cfe237775d6fb844ac726639e3ae928b",
|
||||
"semantic_hash": "cfe237775d6fb844ac726639e3ae928b"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.json": {
|
||||
"mtime": 1779255768.6302803,
|
||||
"seen": 1787194338.800938,
|
||||
"ast_hash": "0e1955da91149ba6280d39db7a54db6f",
|
||||
"semantic_hash": "0e1955da91149ba6280d39db7a54db6f"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.json": {
|
||||
"mtime": 1779255037.8698418,
|
||||
"seen": 1787194338.800939,
|
||||
"ast_hash": "d670222d71df92e262ce8cb7299fd531",
|
||||
"semantic_hash": "d670222d71df92e262ce8cb7299fd531"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.json": {
|
||||
"mtime": 1779254360.9712114,
|
||||
"seen": 1787194338.8009398,
|
||||
"ast_hash": "463fca53dd0a4030555ff639661f2772",
|
||||
"semantic_hash": "463fca53dd0a4030555ff639661f2772"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.json": {
|
||||
"mtime": 1779253668.0067616,
|
||||
"seen": 1787194338.8009408,
|
||||
"ast_hash": "e2c677ee6670e37b466829c43ff9cd96",
|
||||
"semantic_hash": "e2c677ee6670e37b466829c43ff9cd96"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.json": {
|
||||
"mtime": 1779254714.0149894,
|
||||
"seen": 1787194338.8009415,
|
||||
"ast_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79",
|
||||
"semantic_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.json": {
|
||||
"mtime": 1779253180.3940487,
|
||||
"seen": 1787194338.8009427,
|
||||
"ast_hash": "beacbe857606b44d13aa2fc2f4872b76",
|
||||
"semantic_hash": "beacbe857606b44d13aa2fc2f4872b76"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.json": {
|
||||
"mtime": 1779253367.7975926,
|
||||
"seen": 1787194338.8009434,
|
||||
"ast_hash": "0706c31d93f980b581e0e6eb376c0bcc",
|
||||
"semantic_hash": "0706c31d93f980b581e0e6eb376c0bcc"
|
||||
},
|
||||
"templates/index.html": {
|
||||
"mtime": 1787192740.1538603,
|
||||
"seen": 1787194338.8009465,
|
||||
"ast_hash": "0b51d18f3fa91d4be929d72a499e669f",
|
||||
"semantic_hash": "0b51d18f3fa91d4be929d72a499e669f"
|
||||
},
|
||||
"assets/banner.svg": {
|
||||
"mtime": 1776081443.419675,
|
||||
"seen": 1787114579.030294,
|
||||
"ast_hash": "059ec317eaba9e353d220d883477927a",
|
||||
"semantic_hash": "059ec317eaba9e353d220d883477927a"
|
||||
},
|
||||
"assets/icon.svg": {
|
||||
"mtime": 1776081443.4219253,
|
||||
"seen": 1787114579.030295,
|
||||
"ast_hash": "9689aa0178236c19d5240250e08a303e",
|
||||
"semantic_hash": "9689aa0178236c19d5240250e08a303e"
|
||||
},
|
||||
"docs/10_GenUI_Batch.png": {
|
||||
"mtime": 1781057457.561953,
|
||||
"seen": 1787114579.0302958,
|
||||
"ast_hash": "351c1647aa5becfb4a2dbfa7da043b24",
|
||||
"semantic_hash": "351c1647aa5becfb4a2dbfa7da043b24"
|
||||
},
|
||||
"docs/11_multiple_select_menu.png": {
|
||||
"mtime": 1781057528.0140572,
|
||||
"seen": 1787114579.0302966,
|
||||
"ast_hash": "b3082324b8200a4eac8868f94ec4e6c7",
|
||||
"semantic_hash": "b3082324b8200a4eac8868f94ec4e6c7"
|
||||
},
|
||||
"docs/12_Tagger.png": {
|
||||
"mtime": 1782551031.7107866,
|
||||
"seen": 1787114579.030297,
|
||||
"ast_hash": "42806369c4c08140c87daea348b6fc0f",
|
||||
"semantic_hash": "42806369c4c08140c87daea348b6fc0f"
|
||||
},
|
||||
"docs/13_ImageEdit.png": {
|
||||
"mtime": 1782550946.6263509,
|
||||
"seen": 1787114579.030298,
|
||||
"ast_hash": "25a313806ba3c2396c9361b42599b063",
|
||||
"semantic_hash": "25a313806ba3c2396c9361b42599b063"
|
||||
},
|
||||
"docs/1_workflowtab.png": {
|
||||
"mtime": 1779078100.5980048,
|
||||
"seen": 1787114579.0302987,
|
||||
"ast_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297",
|
||||
"semantic_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297"
|
||||
},
|
||||
"docs/2_modelstab.png": {
|
||||
"mtime": 1779077971.8302422,
|
||||
"seen": 1787114579.0302994,
|
||||
"ast_hash": "2c89e877f01e8de3151e655f305df6aa",
|
||||
"semantic_hash": "2c89e877f01e8de3151e655f305df6aa"
|
||||
},
|
||||
"docs/3_PromptInputAssistance.png": {
|
||||
"mtime": 1779077804.0445502,
|
||||
"seen": 1787114579.0303004,
|
||||
"ast_hash": "6634ec20d561648ce223807d2fb010cc",
|
||||
"semantic_hash": "6634ec20d561648ce223807d2fb010cc"
|
||||
},
|
||||
"docs/4_GenUI_feeder.png": {
|
||||
"mtime": 1779077729.1484556,
|
||||
"seen": 1787114579.0303009,
|
||||
"ast_hash": "e6b3607b7c53a2f601aff487c278a3fb",
|
||||
"semantic_hash": "e6b3607b7c53a2f601aff487c278a3fb"
|
||||
},
|
||||
"docs/5_topbar.png": {
|
||||
"mtime": 1779076905.6012611,
|
||||
"seen": 1787114579.0303018,
|
||||
"ast_hash": "bafc74e7452f83ae9c585aa11f76f208",
|
||||
"semantic_hash": "bafc74e7452f83ae9c585aa11f76f208"
|
||||
},
|
||||
"docs/6_ws_library.png": {
|
||||
"mtime": 1781057870.2308166,
|
||||
"seen": 1787114579.0303028,
|
||||
"ast_hash": "6645a6c3d9ea09d75d34255c76cfc68b",
|
||||
"semantic_hash": "6645a6c3d9ea09d75d34255c76cfc68b"
|
||||
},
|
||||
"docs/7_library_Infomation.png": {
|
||||
"mtime": 1779076410.2629254,
|
||||
"seen": 1787114579.0303032,
|
||||
"ast_hash": "acb54f299c244fd41457f8fe4ccfc1d6",
|
||||
"semantic_hash": "acb54f299c244fd41457f8fe4ccfc1d6"
|
||||
},
|
||||
"docs/8_Customize.png": {
|
||||
"mtime": 1779077388.8494852,
|
||||
"seen": 1787114579.030304,
|
||||
"ast_hash": "c9f979d40753ce41c015b2d594007658",
|
||||
"semantic_hash": "c9f979d40753ce41c015b2d594007658"
|
||||
},
|
||||
"docs/9_GenUI_LoraStack.png": {
|
||||
"mtime": 1781057821.3736062,
|
||||
"seen": 1787114579.030305,
|
||||
"ast_hash": "e2b08e5f2059554d305563c63286bd74",
|
||||
"semantic_hash": "e2b08e5f2059554d305563c63286bd74"
|
||||
},
|
||||
"docs/thumb.png": {
|
||||
"mtime": 1778457744.7955222,
|
||||
"seen": 1787114579.0303059,
|
||||
"ast_hash": "92a0dcf2c3457777f2945f6df2dff017",
|
||||
"semantic_hash": "92a0dcf2c3457777f2945f6df2dff017"
|
||||
},
|
||||
"static/favicon.svg": {
|
||||
"mtime": 1773877378.0368707,
|
||||
"seen": 1787114579.0303063,
|
||||
"ast_hash": "0cd547c25c922d632890be287d6084ff",
|
||||
"semantic_hash": "0cd547c25c922d632890be287d6084ff"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).png": {
|
||||
"mtime": 1779256606.562932,
|
||||
"seen": 1787114579.0303073,
|
||||
"ast_hash": "79b9d1e5e1303b523349b539d3dc1af3",
|
||||
"semantic_hash": "79b9d1e5e1303b523349b539d3dc1af3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).png": {
|
||||
"mtime": 1779257129.3453796,
|
||||
"seen": 1787114579.0303097,
|
||||
"ast_hash": "02c572ab1c659d71dfd56e6b2b0334a5",
|
||||
"semantic_hash": "02c572ab1c659d71dfd56e6b2b0334a5"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).png": {
|
||||
"mtime": 1779257864.2375357,
|
||||
"seen": 1787114579.0303104,
|
||||
"ast_hash": "bb03ed86c7d4460a3568a4a8f90e5f40",
|
||||
"semantic_hash": "bb03ed86c7d4460a3568a4a8f90e5f40"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.png": {
|
||||
"mtime": 1779255324.1356418,
|
||||
"seen": 1787114579.0303113,
|
||||
"ast_hash": "e5db6889f59078e4ead6b89b8c07e91e",
|
||||
"semantic_hash": "e5db6889f59078e4ead6b89b8c07e91e"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.png": {
|
||||
"mtime": 1784801838.7777002,
|
||||
"seen": 1787114579.0303123,
|
||||
"ast_hash": "79321edb85aed7c0a8c777accc005486",
|
||||
"semantic_hash": "79321edb85aed7c0a8c777accc005486"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.png": {
|
||||
"mtime": 1784801946.027666,
|
||||
"seen": 1787114579.030313,
|
||||
"ast_hash": "bdd62facd6a8176c50e92264994dbbc2",
|
||||
"semantic_hash": "bdd62facd6a8176c50e92264994dbbc2"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.png": {
|
||||
"mtime": 1779253098.1897035,
|
||||
"seen": 1787114579.0303137,
|
||||
"ast_hash": "9582bb698439fff1a748315145538777",
|
||||
"semantic_hash": "9582bb698439fff1a748315145538777"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.png": {
|
||||
"mtime": 1779256158.4621174,
|
||||
"seen": 1787114579.0303144,
|
||||
"ast_hash": "bb7b9e26eae992abe62cd50299da9cb2",
|
||||
"semantic_hash": "bb7b9e26eae992abe62cd50299da9cb2"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.png": {
|
||||
"mtime": 1779255768.6191878,
|
||||
"seen": 1787114579.0303156,
|
||||
"ast_hash": "ac0fc8184ff7e42389fd0d562daa88c4",
|
||||
"semantic_hash": "ac0fc8184ff7e42389fd0d562daa88c4"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.png": {
|
||||
"mtime": 1779255037.8606594,
|
||||
"seen": 1787114579.0303164,
|
||||
"ast_hash": "564f58d85a0b960154bd0a30ea111599",
|
||||
"semantic_hash": "564f58d85a0b960154bd0a30ea111599"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.png": {
|
||||
"mtime": 1779254360.9671092,
|
||||
"seen": 1787114579.030317,
|
||||
"ast_hash": "98dfdb3ba374b0952c06995843f45171",
|
||||
"semantic_hash": "98dfdb3ba374b0952c06995843f45171"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.png": {
|
||||
"mtime": 1779253668.0007572,
|
||||
"seen": 1787114579.0303178,
|
||||
"ast_hash": "7285bb2a06aaf8dc7c5b37b84261be61",
|
||||
"semantic_hash": "7285bb2a06aaf8dc7c5b37b84261be61"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.png": {
|
||||
"mtime": 1779254714.0099926,
|
||||
"seen": 1787114579.0303185,
|
||||
"ast_hash": "13d4e7f87a87fd4c9a78427b38800cc6",
|
||||
"semantic_hash": "13d4e7f87a87fd4c9a78427b38800cc6"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.png": {
|
||||
"mtime": 1779253180.3890488,
|
||||
"seen": 1787114579.0303195,
|
||||
"ast_hash": "9d8dda899602b8d74c6b23350e58a0d0",
|
||||
"semantic_hash": "9d8dda899602b8d74c6b23350e58a0d0"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.png": {
|
||||
"mtime": 1779253367.7925913,
|
||||
"seen": 1787114579.0303202,
|
||||
"ast_hash": "51cae742b39316023cf5332f1d794854",
|
||||
"semantic_hash": "51cae742b39316023cf5332f1d794854"
|
||||
},
|
||||
"static/js/image-edit/BgRemove.js": {
|
||||
"mtime": 1787126632.8601232,
|
||||
"seen": 1787194338.8008947,
|
||||
"ast_hash": "add6a58068bdadc0c7641202f36ddb27",
|
||||
"semantic_hash": "add6a58068bdadc0c7641202f36ddb27"
|
||||
},
|
||||
"static/js/image-edit/BlurTool.js": {
|
||||
"mtime": 1787126263.0542421,
|
||||
"seen": 1787194338.8008955,
|
||||
"ast_hash": "1e52bc3757110a271ec17d923784a91b",
|
||||
"semantic_hash": "1e52bc3757110a271ec17d923784a91b"
|
||||
},
|
||||
"static/js/image-edit/FileExport.js": {
|
||||
"mtime": 1787192008.741717,
|
||||
"seen": 1787194338.8008974,
|
||||
"ast_hash": "cf645b775e537ad276c9e070c7849349",
|
||||
"semantic_hash": "cf645b775e537ad276c9e070c7849349"
|
||||
},
|
||||
"static/js/image-edit/GmicIntegration.js": {
|
||||
"mtime": 1787125777.3889306,
|
||||
"seen": 1787194338.8008983,
|
||||
"ast_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca",
|
||||
"semantic_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca"
|
||||
},
|
||||
"static/js/image-edit/InpaintI2IActions.js": {
|
||||
"mtime": 1787127273.4351766,
|
||||
"seen": 1787194338.800899,
|
||||
"ast_hash": "9ea57922985b63d4ec8eb44219a8341b",
|
||||
"semantic_hash": "9ea57922985b63d4ec8eb44219a8341b"
|
||||
},
|
||||
"static/js/image-edit/Sam3Segmentation.js": {
|
||||
"mtime": 1787126893.774897,
|
||||
"seen": 1787194338.800904,
|
||||
"ast_hash": "98c3a330ae7ea3369c00168c54c66d53",
|
||||
"semantic_hash": "98c3a330ae7ea3369c00168c54c66d53"
|
||||
},
|
||||
".github/workflows/publish.yml": {
|
||||
"mtime": 1778460176.5699222,
|
||||
"seen": 1787130571.383575,
|
||||
"ast_hash": "a83dab23e8d09fdc8626689e96dfeb0e",
|
||||
"semantic_hash": "a83dab23e8d09fdc8626689e96dfeb0e"
|
||||
},
|
||||
"CLAUDE.md": {
|
||||
"mtime": 1786327833.871171,
|
||||
"seen": 1787130571.3835762,
|
||||
"ast_hash": "c36546d2d007859555904d89e55a8ed7",
|
||||
"semantic_hash": "c36546d2d007859555904d89e55a8ed7"
|
||||
},
|
||||
"DEVLOG.md": {
|
||||
"mtime": 1787193273.171442,
|
||||
"seen": 1787194338.8009443,
|
||||
"ast_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4",
|
||||
"semantic_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1787193228.4769182,
|
||||
"seen": 1787194338.8009455,
|
||||
"ast_hash": "858c9d43205808ad6a111defeb66859c",
|
||||
"semantic_hash": "858c9d43205808ad6a111defeb66859c"
|
||||
},
|
||||
"requirements.txt": {
|
||||
"mtime": 1787036261.1817627,
|
||||
"seen": 1787130571.3835788,
|
||||
"ast_hash": "5942e1febf9611227a9145b3f9a91c6e",
|
||||
"semantic_hash": "5942e1febf9611227a9145b3f9a91c6e"
|
||||
},
|
||||
"static/js/models/badges.js": {
|
||||
"mtime": 1787143926.3653927,
|
||||
"seen": 1787194338.800913,
|
||||
"ast_hash": "0c1901a90a12f1b23df416d9eef11e1d",
|
||||
"semantic_hash": "0c1901a90a12f1b23df416d9eef11e1d"
|
||||
},
|
||||
"static/js/models/detail-panel.js": {
|
||||
"mtime": 1787144898.6748104,
|
||||
"seen": 1787194338.8009143,
|
||||
"ast_hash": "9907a4af0524b35d80e3b9fc7925324e",
|
||||
"semantic_hash": "9907a4af0524b35d80e3b9fc7925324e"
|
||||
},
|
||||
"static/js/models/filters.js": {
|
||||
"mtime": 1787144201.7602522,
|
||||
"seen": 1787194338.8009152,
|
||||
"ast_hash": "afd754f68d01f64c51995929b3841e56",
|
||||
"semantic_hash": "afd754f68d01f64c51995929b3841e56"
|
||||
},
|
||||
"static/js/models/grid-view.js": {
|
||||
"mtime": 1787144745.5522888,
|
||||
"seen": 1787194338.8009164,
|
||||
"ast_hash": "492e91fff2b8743a5243f85a736eb689",
|
||||
"semantic_hash": "492e91fff2b8743a5243f85a736eb689"
|
||||
},
|
||||
"static/js/models/helpers.js": {
|
||||
"mtime": 1787144193.741004,
|
||||
"seen": 1787194338.8009179,
|
||||
"ast_hash": "1c7b796f2507f55403fa5d11f304580a",
|
||||
"semantic_hash": "1c7b796f2507f55403fa5d11f304580a"
|
||||
},
|
||||
"static/js/models/selection-bulk.js": {
|
||||
"mtime": 1787144787.0092065,
|
||||
"seen": 1787194338.8009186,
|
||||
"ast_hash": "d445ff4dc4e9810afea73ff53c76b05b",
|
||||
"semantic_hash": "d445ff4dc4e9810afea73ff53c76b05b"
|
||||
},
|
||||
"static/js/models/state.js": {
|
||||
"mtime": 1787143674.705312,
|
||||
"seen": 1787194338.8009193,
|
||||
"ast_hash": "172ced5fdc4c96095f2760d7ef12d77d",
|
||||
"semantic_hash": "172ced5fdc4c96095f2760d7ef12d77d"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneBridge.js": {
|
||||
"mtime": 1787187872.7481277,
|
||||
"seen": 1787194338.8009012,
|
||||
"ast_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a",
|
||||
"semantic_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,51 @@ 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
|
||||
@@ -18,7 +63,7 @@ if ENV_FILE.exists():
|
||||
except ImportError:
|
||||
print(
|
||||
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
|
||||
"(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}")
|
||||
|
||||
@@ -71,6 +71,8 @@ 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"
|
||||
|
||||
@@ -9,6 +9,8 @@ import urllib.error
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.url_guard import require_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -92,7 +94,7 @@ def _eagle_add_from_path(eagle_url, file_path, 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"))
|
||||
|
||||
|
||||
@@ -127,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"))
|
||||
|
||||
|
||||
@@ -138,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"))
|
||||
|
||||
|
||||
@@ -146,7 +148,7 @@ 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", [])
|
||||
@@ -193,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",
|
||||
|
||||
@@ -10,10 +10,12 @@ 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:
|
||||
@@ -55,8 +57,22 @@ def _init_allowed_root() -> None:
|
||||
_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)
|
||||
@@ -81,6 +97,7 @@ def setup_routes(app: web.Application):
|
||||
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)
|
||||
@@ -88,6 +105,148 @@ def setup_routes(app: web.Application):
|
||||
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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -163,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)
|
||||
|
||||
@@ -175,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)
|
||||
|
||||
@@ -197,7 +365,10 @@ async def serve_thumb(request: web.Request) -> web.Response:
|
||||
except ValueError:
|
||||
width = 256
|
||||
|
||||
thumb_path = _service.serve_thumbnail(path, width)
|
||||
# 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)
|
||||
|
||||
@@ -219,6 +390,21 @@ async def serve_thumb(request: web.Request) -> web.Response:
|
||||
# バルク操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
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:
|
||||
@@ -399,7 +585,7 @@ async def create_group(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_RESERVED_GROUPS = {"__Feeder__"}
|
||||
_RESERVED_GROUPS = {"__Feeder__", "__Video Assets__", "__Video Temp__"}
|
||||
|
||||
|
||||
async def rename_group(request: web.Request) -> web.Response:
|
||||
@@ -568,3 +754,97 @@ async def export_images_zip(request: web.Request) -> web.Response:
|
||||
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)
|
||||
|
||||
@@ -82,9 +82,9 @@ async def handle_open(request: web.Request) -> web.Response:
|
||||
# Retrieve setting for gmic path
|
||||
data = _settings.load()
|
||||
gmic_exe = data.get("gmic_qt_path", "")
|
||||
if not os.path.exists(gmic_exe):
|
||||
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 the path in settings."
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure an absolute .exe path in settings."
|
||||
}, status=400)
|
||||
|
||||
ext = ".png"
|
||||
|
||||
@@ -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)
|
||||
@@ -139,14 +140,20 @@ 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,
|
||||
@@ -166,6 +173,10 @@ async def handle_civitai_fetch(request: web.Request) -> web.Response:
|
||||
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,
|
||||
@@ -244,69 +255,159 @@ 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)
|
||||
preview_saved_count = 0
|
||||
for model_name, result in results.items():
|
||||
civitai = result.get("civitai")
|
||||
if not civitai or not civitai.get("images"):
|
||||
continue
|
||||
if _service.find_preview_image(model_type, model_name):
|
||||
continue
|
||||
model_file = None
|
||||
for d in dirs:
|
||||
p = d / model_name
|
||||
if p.is_file():
|
||||
model_file = p
|
||||
break
|
||||
if not model_file:
|
||||
continue
|
||||
preview_path = model_file.parent / (model_file.stem + ".preview.png")
|
||||
if CivitaiService.download_image(civitai["images"][0], preview_path):
|
||||
preview_saved_count += 1
|
||||
logger.info("Auto-saved preview for %s from CivitAI", model_name)
|
||||
|
||||
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 = {
|
||||
@@ -314,6 +415,7 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
"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,
|
||||
}
|
||||
@@ -325,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", "")
|
||||
@@ -632,7 +750,8 @@ async def handle_list_model_files(request: web.Request) -> web.Response:
|
||||
if not model_type or model_type not in MODEL_TYPE_FOLDER_KEYS:
|
||||
return web.json_response({"error": "invalid type"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_model_files, model_type)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -83,7 +86,7 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
body = await request.json()
|
||||
cfg = _get_ollama_config()
|
||||
model = body.get("model") or cfg["model"]
|
||||
base_url = (body.get("url") or cfg["url"]).rstrip("/")
|
||||
base_url = require_backend_url(body.get("url") or cfg["url"])
|
||||
messages = body.get("messages", [])
|
||||
|
||||
def _chat():
|
||||
@@ -100,7 +103,7 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
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__)
|
||||
|
||||
@@ -24,6 +28,7 @@ _DATA_FILES = [
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
"tagger_settings.json",
|
||||
"civitai_cache.json",
|
||||
]
|
||||
|
||||
|
||||
@@ -33,10 +38,14 @@ 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)
|
||||
@@ -115,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:
|
||||
@@ -248,6 +298,254 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
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():
|
||||
|
||||
@@ -18,8 +18,8 @@ _db = TaggerDbService(TAGGER_DB_FILE)
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/tagger/models", handle_models)
|
||||
app.router.add_post("/wfm/tagger/predict", handle_predict)
|
||||
app.router.add_get("/wfm/tagger/ollama/models", handle_ollama_models)
|
||||
app.router.add_post("/wfm/tagger/ollama/predict", handle_ollama_predict)
|
||||
app.router.add_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)
|
||||
@@ -27,6 +27,7 @@ def setup_routes(app: web.Application):
|
||||
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)
|
||||
@@ -61,25 +62,29 @@ async def handle_predict(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_models(request: web.Request) -> web.Response:
|
||||
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.ollama_models, api_url)
|
||||
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_ollama_predict(request: web.Request) -> web.Response:
|
||||
async def handle_vlm_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.ollama_predict,
|
||||
_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:
|
||||
@@ -96,7 +101,8 @@ async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
bool(body.get("use_ollama", False)),
|
||||
body.get("ollama_api", "http://127.0.0.1:11434"),
|
||||
body.get("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)),
|
||||
@@ -104,6 +110,8 @@ async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
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:
|
||||
@@ -161,6 +169,27 @@ async def handle_db_delete(request: web.Request) -> web.Response:
|
||||
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:
|
||||
|
||||
@@ -5,7 +5,13 @@ 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.
|
||||
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
|
||||
@@ -13,19 +19,19 @@ import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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"}
|
||||
_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 to loopback
|
||||
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
|
||||
# server (SSRF). Port is unrestricted.
|
||||
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
# 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():
|
||||
@@ -33,13 +39,12 @@ def _get_api_key():
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def _is_allowed_base_url(base_url):
|
||||
"""Only relay (and attach the API key) to a loopback host."""
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
# 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):
|
||||
@@ -48,9 +53,10 @@ def setup_routes(app: web.Application):
|
||||
|
||||
|
||||
async def handle_proxy(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible / Decision API.
|
||||
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions" | "/v1/systemone",
|
||||
method: "GET" | "POST", payload }
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
@@ -63,25 +69,20 @@ async def handle_proxy(request: web.Request) -> web.Response:
|
||||
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_base_url(base_url):
|
||||
return web.json_response({
|
||||
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
|
||||
}, 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()
|
||||
if not api_key:
|
||||
return web.json_response({
|
||||
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI.",
|
||||
}, status=401)
|
||||
|
||||
def _fetch():
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
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 urllib.request.urlopen(req, timeout=120) as resp:
|
||||
with _guarded_urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
@@ -90,7 +91,11 @@ async def handle_proxy(request: web.Request) -> web.Response:
|
||||
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)
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -56,6 +56,38 @@ _MAX_RETRIES = 3
|
||||
_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."""
|
||||
|
||||
@@ -117,14 +149,16 @@ class CivitaiService:
|
||||
# ── ハッシュ計算 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def calculate_sha256(file_path, chunk_size=65536):
|
||||
"""Calculate SHA256 hash of a file."""
|
||||
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
|
||||
@@ -142,8 +176,11 @@ class CivitaiService:
|
||||
"""
|
||||
sha256_lower = sha256_hash.lower()
|
||||
cache = self._load_cache()
|
||||
if sha256_lower in cache:
|
||||
return cache[sha256_lower]
|
||||
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.upper()}"
|
||||
|
||||
@@ -198,6 +235,7 @@ class CivitaiService:
|
||||
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]
|
||||
@@ -239,6 +277,7 @@ class CivitaiService:
|
||||
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:
|
||||
@@ -247,8 +286,11 @@ class CivitaiService:
|
||||
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
|
||||
|
||||
@@ -338,64 +380,295 @@ class CivitaiService:
|
||||
|
||||
# ── バッチフェッチ ────────────────────────────────────────
|
||||
|
||||
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 1: SHA256 計算("hashing")
|
||||
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 } }
|
||||
"""
|
||||
results = {}
|
||||
total = len(model_files)
|
||||
cache = self._load_cache()
|
||||
known = known or {}
|
||||
finished = 0
|
||||
cache_dirty = False
|
||||
|
||||
# Phase 1: ハッシュ計算
|
||||
hashes_needed = [] # [(model_name, sha256_lower, original_index)]
|
||||
hashes_needed = [] # [(model_name, sha256_lower)]
|
||||
to_hash = [] # [(model_name, file_path, stat)]
|
||||
|
||||
for i, (model_name, file_path) in enumerate(model_files):
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "hashing")
|
||||
|
||||
sha256 = self.calculate_sha256(file_path)
|
||||
if not sha256:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
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 + 1, total, model_name, "not_found")
|
||||
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
|
||||
|
||||
sha256_lower = sha256.lower()
|
||||
results[model_name] = {"sha256": sha256_lower, "civitai": None}
|
||||
sha = self._known_hash(known.get(model_name), st)
|
||||
if sha:
|
||||
_resolved(model_name, sha, st, new_hash=False)
|
||||
continue
|
||||
|
||||
if sha256_lower in cache:
|
||||
results[model_name]["civitai"] = cache[sha256_lower]
|
||||
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(i + 1, total, model_name, "cached")
|
||||
else:
|
||||
hashes_needed.append((model_name, sha256_lower, i))
|
||||
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(len(results), total, "", "fetching")
|
||||
progress_callback(finished, total, "", "fetching")
|
||||
|
||||
# 同一ハッシュが複数モデルに対応する場合を考慮
|
||||
sha256_to_entries: dict[str, list] = {}
|
||||
for name, sha256_lower, idx in hashes_needed:
|
||||
sha256_to_entries.setdefault(sha256_lower, []).append((name, idx))
|
||||
sha256_to_names: dict[str, list] = {}
|
||||
for name, sha256_lower in hashes_needed:
|
||||
sha256_to_names.setdefault(sha256_lower, []).append(name)
|
||||
|
||||
batch_results = self._batch_fetch_post(list(sha256_to_entries.keys()))
|
||||
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())
|
||||
|
||||
for sha256_lower, info in batch_results.items():
|
||||
for name, idx in sha256_to_entries.get(sha256_lower, []):
|
||||
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(idx + 1, total, name, status)
|
||||
progress_callback(finished, total, name, status)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "", "done")
|
||||
@@ -407,10 +680,25 @@ class CivitaiService:
|
||||
@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 urlopen(req, timeout=timeout, context=_get_ssl_context()) as resp:
|
||||
data = resp.read()
|
||||
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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -38,6 +39,9 @@ class GalleryMetadataStore:
|
||||
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()
|
||||
|
||||
@@ -53,17 +57,56 @@ 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):
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
# 呼び出し元(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("\\", "/")
|
||||
@@ -83,6 +126,24 @@ class GalleryMetadataStore:
|
||||
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"}
|
||||
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]:
|
||||
@@ -191,17 +252,33 @@ class GalleryMetadataStore:
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
|
||||
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 key.startswith(folder_key) and key not in existing_paths
|
||||
if _in_scope(key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
|
||||
@@ -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,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -41,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:
|
||||
@@ -111,6 +206,12 @@ 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)
|
||||
@@ -131,9 +232,7 @@ class ModelsService:
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
@@ -145,17 +244,16 @@ class ModelsService:
|
||||
self._scan_cache[model_type] = (now, names)
|
||||
return names
|
||||
|
||||
def list_model_files(self, model_type: str) -> list:
|
||||
def list_model_files(self, model_type: str, extra_only: bool = False) -> list:
|
||||
"""モデルタイプのモデルファイル名をソートして返す(拡張子付き、/区切り)。
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。
|
||||
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 d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
name = f.name
|
||||
if name.endswith(_DISABLED_SUFFIX):
|
||||
name = name[: -len(_DISABLED_SUFFIX)]
|
||||
@@ -280,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):
|
||||
@@ -291,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.
|
||||
|
||||
@@ -311,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
|
||||
@@ -436,11 +561,10 @@ class ModelsService:
|
||||
errors.append({"model": model_name, "error": "Cannot determine root directory"})
|
||||
continue
|
||||
|
||||
# Destination directory — verify it stays within root_dir after resolution
|
||||
# Destination directory — verify it stays within root_dir (`..`/絶対パスは拒否。
|
||||
# フォルダのシンボリックリンク/ジャンクション配下への移動は許可する)
|
||||
dest_dir = root_dir / dest_subdir if dest_subdir else root_dir
|
||||
try:
|
||||
dest_dir.resolve().relative_to(root_dir.resolve())
|
||||
except ValueError:
|
||||
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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -5,6 +5,7 @@ import io
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,6 +86,20 @@ class TaggerDbService:
|
||||
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(
|
||||
|
||||
@@ -5,11 +5,15 @@ 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
|
||||
@@ -18,6 +22,10 @@ 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):
|
||||
@@ -185,19 +193,33 @@ class TaggerService:
|
||||
def _predict_onnx(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
|
||||
shape = data["input_shape"]
|
||||
fmt = "NHWC"
|
||||
size = (224, 224)
|
||||
size = 224
|
||||
if len(shape) == 4:
|
||||
if shape[1] == 3 and isinstance(shape[2], int) and shape[2] > 0:
|
||||
fmt = "NCHW"
|
||||
size = (int(shape[3]), int(shape[2]))
|
||||
size = int(shape[2])
|
||||
elif shape[3] == 3 and isinstance(shape[1], int) and shape[1] > 0:
|
||||
fmt = "NHWC"
|
||||
size = (int(shape[2]), int(shape[1]))
|
||||
size = int(shape[1])
|
||||
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
arr = np.array(img.resize(size, Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
|
||||
# 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)
|
||||
@@ -223,39 +245,123 @@ class TaggerService:
|
||||
tags.append(label.replace("character:", ""))
|
||||
return tags
|
||||
|
||||
# ── Ollama VLM ────────────────────────────────────────────
|
||||
# ── VLM Tagging (Ollama / LM Studio / Lemonade / Unsloth) ──
|
||||
|
||||
def ollama_models(self, api_url: str) -> list:
|
||||
# キー未設定で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:
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
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["name"] for m in data.get("models", []))
|
||||
return sorted(m["id"] for m in data.get("data", []))
|
||||
except Exception as e:
|
||||
logger.error("ollama_models: %s", 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 ollama_predict(self, image_b64: str, api_url: str, model: str, prompt: str, max_tags: int = 40) -> dict:
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096},
|
||||
}).encode("utf-8")
|
||||
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:
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
content = data.get("message", {}).get("content", "")
|
||||
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("ollama_predict: %s", 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)}
|
||||
|
||||
# ── ファイルメタデータ書込 ────────────────────────────────
|
||||
@@ -334,8 +440,9 @@ class TaggerService:
|
||||
self._batch_state["stop"] = True
|
||||
|
||||
def batch_start(self, folder: str, model_name: str, threshold: float, char_threshold: float,
|
||||
use_ollama: bool, ollama_api: str, ollama_model: str, ollama_prompt: str,
|
||||
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service) -> dict:
|
||||
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)
|
||||
@@ -370,7 +477,8 @@ class TaggerService:
|
||||
|
||||
vlm_tags = ""
|
||||
if use_ollama and ollama_model:
|
||||
res2 = self.ollama_predict(b64, ollama_api, ollama_model, ollama_prompt, ollama_max_tags)
|
||||
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]))
|
||||
|
||||
@@ -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}
|
||||
@@ -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, LAB_PLAN_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR, VIDEO_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +57,22 @@ async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -76,11 +92,14 @@ class WorkflowStudio:
|
||||
# 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, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_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)
|
||||
@@ -96,5 +115,7 @@ class WorkflowStudio:
|
||||
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, 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.4.2"
|
||||
license = "MIT"
|
||||
version = "0.8.2"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
|
||||
@@ -22,3 +22,24 @@ piexif>=1.1.3
|
||||
|
||||
# -- 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
|
||||
|
||||
@@ -290,6 +290,24 @@
|
||||
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;
|
||||
@@ -465,43 +483,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-gallery-preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.wfm-gallery-preview-wrapper:hover .wfm-gallery-preview-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-download-btn {
|
||||
background: var(--wfm-accent, #4f8ef7);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.wfm-gallery-download-btn:hover {
|
||||
background: var(--wfm-accent-hover, #3f7fe7);
|
||||
}
|
||||
|
||||
/* タブコンテンツ */
|
||||
.wfm-gallery-detail-tab-content {
|
||||
flex: 1;
|
||||
@@ -836,6 +817,16 @@
|
||||
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 {
|
||||
@@ -876,6 +867,13 @@
|
||||
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;
|
||||
@@ -992,3 +990,167 @@
|
||||
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; }
|
||||
|
||||
@@ -311,8 +311,8 @@
|
||||
|
||||
.ie-layers-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 5px 6px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
@@ -323,7 +323,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-layers-header > span {
|
||||
.ie-layers-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ie-layers-header-row > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,23 @@
|
||||
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;
|
||||
@@ -278,6 +295,16 @@
|
||||
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;
|
||||
|
||||
@@ -653,8 +653,8 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-lora-bypass-btn,
|
||||
.wfm-lora-refresh-btn {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
@@ -662,8 +662,21 @@ body {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.wfm-lora-bypass-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.wfm-lora-bypass-btn:hover,
|
||||
.wfm-lora-refresh-btn:hover { opacity: 1; }
|
||||
|
||||
/* Bypass is active for the current LoRA target node: strength forced to 0 (see
|
||||
comfyui-editor.js renderLoraPane / _loraNodeBypass). */
|
||||
.wfm-lora-bypass-btn.active {
|
||||
opacity: 1;
|
||||
background: var(--wfm-danger);
|
||||
border-color: var(--wfm-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-gen-lora-col-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -2374,6 +2387,248 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Prompt タブ: Form / Table 切り替え */
|
||||
.wfm-prompt-toplevel-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-prompt-toplevel-tab {
|
||||
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-prompt-toplevel-tab:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-prompt-toplevel-tab.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-prompt-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-prompt-panel.active { display: flex; }
|
||||
|
||||
/* Table パネル内サブタブ (Presets / Wildcards / Style) */
|
||||
.wfm-prompt-table-subnav {
|
||||
display: flex;
|
||||
background: var(--wfm-surface);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-prompt-table-subtab {
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wfm-prompt-table-subtab:hover { color: var(--wfm-text); }
|
||||
.wfm-prompt-table-subtab.active { color: var(--wfm-text); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-prompt-table-pane {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-prompt-table-pane.active { display: flex; }
|
||||
|
||||
.wfm-prompt-table-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-prompt-table-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 編集可能テーブル (Presets / Wildcards / Style 共通) */
|
||||
.wfm-prompt-edit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.wfm-prompt-edit-table th,
|
||||
.wfm-prompt-edit-table td {
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
padding: 4px 6px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.wfm-prompt-edit-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--wfm-surface);
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
.wfm-prompt-edit-table tbody tr:hover td {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
.wfm-prompt-edit-input,
|
||||
.wfm-prompt-edit-textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--wfm-text);
|
||||
font-size: 12px;
|
||||
padding: 3px 5px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wfm-prompt-edit-textarea {
|
||||
min-height: 40px;
|
||||
resize: vertical;
|
||||
}
|
||||
.wfm-prompt-edit-input:hover,
|
||||
.wfm-prompt-edit-textarea:hover {
|
||||
border-color: var(--wfm-border);
|
||||
}
|
||||
.wfm-prompt-edit-input:focus,
|
||||
.wfm-prompt-edit-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--wfm-primary);
|
||||
background: var(--wfm-bg);
|
||||
}
|
||||
@keyframes wfm-prompt-cell-flash {
|
||||
0% { background: rgba(74, 158, 255, 0.3); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
.wfm-prompt-edit-cell-saved {
|
||||
animation: wfm-prompt-cell-flash 0.7s ease;
|
||||
}
|
||||
.wfm-prompt-edit-fav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--wfm-text-secondary);
|
||||
padding: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wfm-prompt-edit-fav-btn.active { color: #ffd700; }
|
||||
.wfm-prompt-edit-batch-btn.active { color: #f0c040; }
|
||||
.wfm-prompt-edit-row-new td { background: rgba(74, 158, 255, 0.06); }
|
||||
.wfm-prompt-edit-file-badge {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.wfm-prompt-edit-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ダブルクリックで開くプロンプト編集モーダル (Presets / Wildcards / Style 共通) */
|
||||
.wfm-prompt-modal-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.wfm-prompt-modal-textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.wfm-prompt-modal-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* #(自動採番)列: クリックで行選択 → 上部の Delete ボタンで一括削除 */
|
||||
.wfm-prompt-edit-num-cell {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-prompt-edit-num-cell:hover {
|
||||
color: var(--wfm-text);
|
||||
background: var(--wfm-surface-2, var(--wfm-surface));
|
||||
}
|
||||
.wfm-prompt-edit-table tbody tr.wfm-prompt-edit-row-selected td {
|
||||
background: rgba(255, 69, 58, 0.14);
|
||||
}
|
||||
.wfm-prompt-edit-table tbody tr.wfm-prompt-edit-row-selected .wfm-prompt-edit-num-cell {
|
||||
color: var(--wfm-danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
.wfm-prompt-edit-num-cell-new {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
.wfm-prompt-edit-inline-add-btn,
|
||||
.wfm-prompt-edit-inline-cancel-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 3px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wfm-prompt-edit-inline-add-btn { color: var(--wfm-primary); }
|
||||
.wfm-prompt-edit-inline-add-btn:hover { background: var(--wfm-primary); color: #fff; }
|
||||
.wfm-prompt-edit-inline-cancel-btn { color: var(--wfm-text-secondary); }
|
||||
.wfm-prompt-edit-inline-cancel-btn:hover { color: var(--wfm-danger); }
|
||||
|
||||
/* Presets Group テーブル: プリセットバッジ */
|
||||
.wfm-prompt-edit-preset-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 10px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
font-size: 11px;
|
||||
margin: 2px 3px 2px 0;
|
||||
}
|
||||
.wfm-prompt-edit-badge-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 10px;
|
||||
padding: 1px 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wfm-prompt-edit-badge-remove:hover { color: var(--wfm-danger); }
|
||||
|
||||
/* Ollama Assistant */
|
||||
.wfm-ollama-assistant {
|
||||
display: flex;
|
||||
@@ -3236,7 +3491,8 @@ body {
|
||||
|
||||
.wfm-help-page {
|
||||
display: none;
|
||||
max-width: 760px;
|
||||
/* 上部タブバー(AI TOOLタブの右端)あたりまで広げ、折り返しを減らす。狭い画面では領域幅に収まる */
|
||||
max-width: 1076px;
|
||||
}
|
||||
|
||||
.wfm-help-page.active {
|
||||
@@ -5018,15 +5274,12 @@ body {
|
||||
Metadata Tab
|
||||
============================================ */
|
||||
|
||||
#wfm-tab-metadata {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-metadata-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 1fr;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -5829,3 +6082,79 @@ body {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Workflow detail modal — GenerateUI compatibility check row */
|
||||
.wfm-compat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wfm-compat-result {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
.wfm-compat-badge {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg);
|
||||
color: var(--wfm-text);
|
||||
cursor: help;
|
||||
}
|
||||
.wfm-compat-ok { border-color: #3a8a3a; }
|
||||
.wfm-compat-warn { border-color: #c08a20; }
|
||||
.wfm-compat-error { border-color: var(--wfm-danger, #c04040); }
|
||||
.wfm-compat-skip { opacity: 0.6; }
|
||||
.wfm-compat-badge:hover::after,
|
||||
.wfm-compat-badge:focus::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 1000;
|
||||
width: max-content;
|
||||
max-width: min(520px, 80vw);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-secondary, var(--wfm-bg));
|
||||
color: var(--wfm-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
text-align: left;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Side panel — format + GenerateUI compatibility badges row (below the title) */
|
||||
.wfm-side-compat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 10px 6px;
|
||||
min-height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-side-compat-row .wfm-format-badge:empty { display: none; }
|
||||
/* The side panel clips overflow, so anchor tips to the whole row and span the panel width */
|
||||
.wfm-side-compat-row { position: relative; }
|
||||
.wfm-compat-result--right .wfm-compat-badge { position: static; }
|
||||
.wfm-compat-result--right .wfm-compat-badge:hover::after,
|
||||
.wfm-compat-result--right .wfm-compat-badge:focus::after {
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 100%;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
.wfm-compat-row .wfm-format-badge:empty { display: none; }
|
||||
|
||||
@@ -250,6 +250,14 @@
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml, unloadAiModel } from "./util.js";
|
||||
import { unloadAllOllamaModels } from "./vram-prepare.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
@@ -456,6 +457,22 @@ function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Unload model
|
||||
document.getElementById("wfm-ai-unload-btn")?.addEventListener("click", async () => {
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
const url = urlInput?.value?.trim() || "";
|
||||
const model = document.getElementById("wfm-ai-model-select")?.value || "";
|
||||
// Ollama: unload every loaded model incl. decision models (same server step as Settings → VRAM Management)
|
||||
if (backend === "ollama") { await unloadAllOllamaModels(url); return; }
|
||||
if (!model) { showToast(t("aiUnloadNoModel"), "error"); return; }
|
||||
try {
|
||||
await unloadAiModel(url, backend, model);
|
||||
showToast(t("aiUnloadDone"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message === "UNSUPPORTED" ? t("aiUnloadUnsupported") : t("aiUnloadFailed") + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Model refresh
|
||||
document.getElementById("wfm-ai-model-refresh-btn")?.addEventListener("click", () => refreshModels());
|
||||
|
||||
@@ -548,12 +565,22 @@ const IMAGE_GEN_TOOLS = [{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_image",
|
||||
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image.",
|
||||
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image. All parameters besides prompt are optional — omit any the user didn't specify and the workflow's own current setting is kept.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prompt: { type: "string", description: "Positive prompt describing the desired image." },
|
||||
negative_prompt: { type: "string", description: "What to avoid in the image. Optional." },
|
||||
steps: { type: "integer", description: "Sampling steps. Optional." },
|
||||
cfg: { type: "number", description: "Classifier-free guidance scale. Optional." },
|
||||
sampler_name: { type: "string", description: "ComfyUI sampler name (e.g. euler, dpmpp_2m, ddim). Optional." },
|
||||
scheduler: { type: "string", description: "ComfyUI scheduler name (e.g. normal, karras, simple). Optional." },
|
||||
denoise: { type: "number", description: "Denoise strength 0-1 (mainly relevant for image-to-image). Optional." },
|
||||
seed: { type: "integer", description: "Fixed seed. Omit for a random seed each time. Optional." },
|
||||
width: { type: "integer", description: "Output image width in pixels. Ignored if the workflow has a Resolution Selector node — use aspect_ratio instead for those. Optional." },
|
||||
height: { type: "integer", description: "Output image height in pixels. Ignored if the workflow has a Resolution Selector node — use aspect_ratio instead for those. Optional." },
|
||||
aspect_ratio: { type: "string", enum: ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"], description: "Aspect ratio preset. Only applied if the workflow has a Resolution Selector node (feeding width/height from a ratio + megapixel target); ignored otherwise. Optional." },
|
||||
batch_size: { type: "integer", description: "Number of images to generate in this single call. Optional." },
|
||||
},
|
||||
required: ["prompt"],
|
||||
},
|
||||
@@ -635,7 +662,7 @@ async function _loadDedicatedChatGenWorkflow(filename) {
|
||||
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
|
||||
}
|
||||
|
||||
async function generateImageFromChat(prompt, negativePrompt) {
|
||||
async function generateImageFromChat(prompt, negativePrompt, params = {}) {
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
|
||||
|
||||
const settings = loadAiSettings();
|
||||
@@ -660,12 +687,24 @@ async function generateImageFromChat(prompt, negativePrompt) {
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
|
||||
|
||||
const result = await window._wfmGenerateTab.generate(workflow);
|
||||
const { steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size, aspect_ratio } = params;
|
||||
let resolvedAspectRatio = null;
|
||||
const resSelector = analysis.resolution_selector_nodes?.[0];
|
||||
if (aspect_ratio && resSelector) {
|
||||
resolvedAspectRatio = await comfyEditor.resolveAspectRatioOption(resSelector.type, aspect_ratio);
|
||||
}
|
||||
comfyEditor.setGenerationParams({
|
||||
steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size,
|
||||
resolvedAspectRatio, workflow, analysis,
|
||||
});
|
||||
|
||||
const result = await window._wfmGenerateTab.generate(workflow, seed != null ? { seedValue: seed } : {});
|
||||
const images = result?.images || [];
|
||||
if (images.length === 0) throw new Error(t("aiChatNoImageProduced"));
|
||||
|
||||
const img = images[0];
|
||||
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
return images.map((img) =>
|
||||
`/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`
|
||||
);
|
||||
}
|
||||
|
||||
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
|
||||
@@ -966,6 +1005,14 @@ function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
img.className = "wfm-ai-chat-img";
|
||||
div.appendChild(img);
|
||||
}
|
||||
if (opts.imageUrls?.length) {
|
||||
opts.imageUrls.forEach((url) => {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.className = "wfm-ai-chat-img";
|
||||
div.appendChild(img);
|
||||
});
|
||||
}
|
||||
if (opts.attachThumb) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.attachThumb;
|
||||
@@ -1055,8 +1102,8 @@ function initChatTab() {
|
||||
statusEl.textContent = t("aiStatusGeneratingImage");
|
||||
const args = _parseToolArgs(toolCall);
|
||||
try {
|
||||
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
|
||||
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
|
||||
const imageUrls = await generateImageFromChat(args.prompt, args.negative_prompt, args);
|
||||
appendChatBubble(messagesEl, "assistant", "", { imageUrls });
|
||||
} catch (genErr) {
|
||||
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
|
||||
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
|
||||
|
||||
@@ -42,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";
|
||||
@@ -91,7 +112,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", "image-edit": "tabImageEdit", settings: "tabSettings", help: "tabHelp", ai: "tabAi", tagger: "tabTagger" };
|
||||
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);
|
||||
@@ -113,6 +134,12 @@ 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("sendToCanvas");
|
||||
@@ -195,6 +222,7 @@ 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");
|
||||
@@ -217,6 +245,12 @@ function applyI18nToHtml() {
|
||||
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");
|
||||
@@ -229,6 +263,8 @@ function applyI18nToHtml() {
|
||||
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");
|
||||
@@ -289,10 +325,8 @@ function applyI18nToHtml() {
|
||||
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");
|
||||
@@ -440,7 +474,7 @@ 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",
|
||||
@@ -455,6 +489,9 @@ function applyI18nToHtml() {
|
||||
"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",
|
||||
@@ -480,6 +517,8 @@ function applyI18nToHtml() {
|
||||
"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",
|
||||
@@ -499,6 +538,15 @@ function applyI18nToHtml() {
|
||||
"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",
|
||||
@@ -506,6 +554,12 @@ function applyI18nToHtml() {
|
||||
"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",
|
||||
@@ -525,11 +579,15 @@ function applyI18nToHtml() {
|
||||
"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-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",
|
||||
@@ -577,7 +635,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-12": "helpImageEdit12", "wfm-help-imageedit-13": "helpImageEdit13",
|
||||
"wfm-help-imageedit-14": "helpImageEdit14", "wfm-help-imageedit-15": "helpImageEdit15",
|
||||
"wfm-help-imageedit-16": "helpImageEdit16", "wfm-help-imageedit-17": "helpImageEdit17",
|
||||
"wfm-help-imageedit-18": "helpImageEdit18", "wfm-help-imageedit-19": "helpImageEdit19",
|
||||
"wfm-help-imageedit-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",
|
||||
@@ -585,6 +643,8 @@ function applyI18nToHtml() {
|
||||
"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",
|
||||
@@ -686,6 +746,14 @@ function applyI18nToHtml() {
|
||||
"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);
|
||||
@@ -768,6 +836,7 @@ 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 { 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";
|
||||
@@ -775,6 +844,7 @@ 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());
|
||||
@@ -802,6 +872,9 @@ function initHelpTab() {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -817,11 +890,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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;
|
||||
@@ -194,6 +196,8 @@ export const comfyUI = {
|
||||
|
||||
// Generation
|
||||
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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { comfyUI } from "./comfyui-client.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { showToast } from "./app.js";
|
||||
|
||||
// ── Latent Image preset state ─────────────────────────────
|
||||
const _LATENT_PRESET_KEY = "wfm_latent_presets";
|
||||
@@ -156,6 +157,16 @@ function _refreshPresetSelect(customPresets) {
|
||||
let _stackStrengths = {};
|
||||
// { modelFullPath: boolean } — true = active (default)
|
||||
let _stackActive = {};
|
||||
// ── LoRA node-bypass state (GenerateUI Model tab header toggle) ───────────
|
||||
// Force the LoRA node's strength to 0 without touching the actual ComfyUI node's mode —
|
||||
// currentWorkflow is already-converted API format (see comfyui-workflow.js
|
||||
// convertUiToApi), where mode has no effect at execution time, so a real Bypass(mode:4)
|
||||
// can't be reproduced here; zeroing strength is the behavioral equivalent.
|
||||
// { nodeId: boolean } — true = currently bypassed
|
||||
let _loraNodeBypass = {};
|
||||
// { nodeId: { loraName, strModel, strClip } | { loras, text } } — pre-bypass values to
|
||||
// restore when toggled back off. Shape depends on isLoraManager at bypass-time.
|
||||
let _loraBypassSnapshot = {};
|
||||
// { id: string|null, selStart: number, selEnd: number }
|
||||
let _lastPromptFocus = { id: null, selStart: 0, selEnd: 0 };
|
||||
|
||||
@@ -742,6 +753,63 @@ export const comfyEditor = {
|
||||
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
|
||||
},
|
||||
|
||||
// Toggles the GenerateUI Model tab's LoRA column-header bypass button: forces the
|
||||
// current target LoRA node's strength to 0 (both tabs' strength fields, whichever
|
||||
// is currently in currentWorkflow), or restores whatever was there before. Targets
|
||||
// the same node Single/Stack Apply default to (first LoraManager, else first LoRA
|
||||
// node) so one button covers both tabs without needing its own node picker.
|
||||
toggleLoraNodeBypass(analysis) {
|
||||
const loraNodes = analysis?.lora_nodes || [];
|
||||
let nodeId = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
|
||||
let isLoraManager = !!loraNodes.find((n) => String(n.id) === String(nodeId))?.is_lora_manager;
|
||||
if (nodeId == null) {
|
||||
nodeId = Object.keys(comfyUI.currentWorkflow || {}).find((id) => comfyUI.currentWorkflow[id]?.inputs && "lora_name" in comfyUI.currentWorkflow[id].inputs);
|
||||
isLoraManager = false;
|
||||
}
|
||||
const wfNode = nodeId != null ? comfyUI.currentWorkflow?.[nodeId] : null;
|
||||
if (!wfNode) {
|
||||
showToast(t("modelsGenUINoNode", "LoRA"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loraNodeBypass[nodeId]) {
|
||||
// Restore whatever was set before bypassing
|
||||
const snap = _loraBypassSnapshot[nodeId];
|
||||
if (snap) {
|
||||
if (isLoraManager) {
|
||||
wfNode.inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras || [])) };
|
||||
wfNode.inputs.text = snap.text ?? "";
|
||||
} else {
|
||||
wfNode.inputs.lora_name = snap.lora_name;
|
||||
wfNode.inputs.strength_model = snap.strength_model;
|
||||
wfNode.inputs.strength_clip = snap.strength_clip;
|
||||
}
|
||||
}
|
||||
delete _loraNodeBypass[nodeId];
|
||||
delete _loraBypassSnapshot[nodeId];
|
||||
} else {
|
||||
// Snapshot current values, then zero the strength (LoraManager: every
|
||||
// entry in its loras list, keeping the list itself intact so un-bypassing
|
||||
// restores the exact same Stack — not just a single-LoRA fallback).
|
||||
if (isLoraManager) {
|
||||
const loras = Array.isArray(wfNode.inputs.loras?.__value__) ? wfNode.inputs.loras.__value__ : [];
|
||||
_loraBypassSnapshot[nodeId] = { loras: JSON.parse(JSON.stringify(loras)), text: wfNode.inputs.text ?? "" };
|
||||
wfNode.inputs.loras = { __value__: loras.map((l) => ({ ...l, strength: 0, clipStrength: 0 })) };
|
||||
wfNode.inputs.text = "";
|
||||
} else {
|
||||
_loraBypassSnapshot[nodeId] = {
|
||||
lora_name: wfNode.inputs.lora_name,
|
||||
strength_model: wfNode.inputs.strength_model,
|
||||
strength_clip: wfNode.inputs.strength_clip,
|
||||
};
|
||||
wfNode.inputs.strength_model = 0;
|
||||
wfNode.inputs.strength_clip = 0;
|
||||
}
|
||||
_loraNodeBypass[nodeId] = true;
|
||||
}
|
||||
_syncRawJson();
|
||||
},
|
||||
|
||||
async renderLoraPane(analysis, containerId, opts = {}) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
@@ -751,6 +819,15 @@ export const comfyEditor = {
|
||||
const loras = this.models.loras || [];
|
||||
const defaultStackTarget = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
|
||||
const hasLoraManager = loraNodes.some((n) => n.is_lora_manager);
|
||||
|
||||
// Sync the column-header bypass button (lives outside this container's
|
||||
// innerHTML, in the static wfm-gen-lora-col-header) to whatever the target
|
||||
// node's current bypass state is.
|
||||
const bypassBtn = document.getElementById("wfm-lora-bypass-toggle");
|
||||
if (bypassBtn) {
|
||||
bypassBtn.classList.toggle("active", defaultStackTarget != null && !!_loraNodeBypass[defaultStackTarget]);
|
||||
bypassBtn.disabled = loraNodes.length === 0;
|
||||
}
|
||||
// Use Stack Group チェックボックスの現在状態を先読み(Stack表示の取得元判定に使う)。
|
||||
// resetStackMode(新規読み込み)時は常にOFF扱い。
|
||||
const stackModeChecked = hasLoraManager && !opts.resetStackMode && (el.querySelector("#wfm-lora-stack-mode-toggle")?.checked ?? false);
|
||||
@@ -759,6 +836,10 @@ export const comfyEditor = {
|
||||
// Stackグループ使用チェックボックスをOFFへリセットする(既存のGenUI状態への影響を避けるため
|
||||
// タブ切替やStack再読込などの単なる再描画では行わない)。
|
||||
if (opts.resetStackMode) {
|
||||
// New workflow loaded — bypass state/snapshots belonged to the previous
|
||||
// workflow's node IDs, so they can't be meaningfully carried forward.
|
||||
_loraNodeBypass = {};
|
||||
_loraBypassSnapshot = {};
|
||||
comfyUI.loraManagerSnapshots = {};
|
||||
for (const n of loraNodes) {
|
||||
if (!n.is_lora_manager) continue;
|
||||
@@ -1084,6 +1165,11 @@ export const comfyEditor = {
|
||||
const strModel = parseFloat(document.getElementById("wfm-lora-str-model")?.value) || 1.0;
|
||||
const strClip = parseFloat(document.getElementById("wfm-lora-str-clip")?.value) || 1.0;
|
||||
_applyLoraToNode(nodeId, select.value, strModel, strClip, node?.is_lora_manager);
|
||||
// An explicit Apply overwrites whatever bypass had zeroed — clear it so the
|
||||
// header button (and a later un-bypass) don't stomp this new value.
|
||||
delete _loraNodeBypass[nodeId];
|
||||
delete _loraBypassSnapshot[nodeId];
|
||||
document.getElementById("wfm-lora-bypass-toggle")?.classList.remove("active");
|
||||
_syncRawJson();
|
||||
|
||||
// Sync to Positive prompt
|
||||
@@ -1216,6 +1302,11 @@ export const comfyEditor = {
|
||||
comfyUI.currentWorkflow[nodeId].inputs.strength_clip = _stackStrengths[first]?.c ?? 1.0;
|
||||
}
|
||||
}
|
||||
// An explicit Apply overwrites whatever bypass had zeroed — clear it so the
|
||||
// header button (and a later un-bypass) don't stomp this new value.
|
||||
delete _loraNodeBypass[nodeId];
|
||||
delete _loraBypassSnapshot[nodeId];
|
||||
document.getElementById("wfm-lora-bypass-toggle")?.classList.remove("active");
|
||||
_syncRawJson();
|
||||
|
||||
// Sync Stack LORA SYNTAX + TRIGGER WORDS to Positive prompt
|
||||
@@ -1291,6 +1382,9 @@ export const comfyEditor = {
|
||||
// (e.g. Flux2Scheduler has no named "scheduler" combo) so the field is disabled here.
|
||||
const schedulerAvailable = !!sampler && sampler.schedulerNodeId != null;
|
||||
const denoiseAvailable = !!sampler && sampler.denoiseNodeId != null;
|
||||
// Advanced samplers fed by a dedicated sampler node without a sampler_name combo
|
||||
// (e.g. Ming Image's SamplerLCM) have nothing for the Sampler dropdown to write to.
|
||||
const samplerNameAvailable = !!sampler && (!sampler.advanced || sampler.samplerNodeId != null);
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
|
||||
@@ -1303,7 +1397,7 @@ export const comfyEditor = {
|
||||
data-steps-node-id="${sampler.stepsNodeId ?? sampler.id}"
|
||||
data-cfg-key="${sampler.cfgKey || "cfg"}"
|
||||
data-cfg-node-id="${sampler.cfgNodeId ?? sampler.id}"
|
||||
data-sampler-node-id="${sampler.samplerNodeId ?? sampler.id}"
|
||||
data-sampler-node-id="${samplerNameAvailable ? (sampler.samplerNodeId ?? sampler.id) : ""}"
|
||||
data-scheduler-node-id="${sampler.schedulerNodeId ?? ""}"
|
||||
data-denoise-node-id="${sampler.denoiseNodeId ?? ""}">
|
||||
<div class="wfm-form-group">
|
||||
@@ -1320,8 +1414,10 @@ export const comfyEditor = {
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Sampler</label>
|
||||
<select class="wfm-select" id="wfm-settings-sampler-name">
|
||||
${this.models.samplers.map((s) => `<option value="${s}" ${s === sampler.sampler_name ? "selected" : ""}>${s}</option>`).join("")}
|
||||
<select class="wfm-select" id="wfm-settings-sampler-name" ${samplerNameAvailable ? "" : "disabled"}>
|
||||
${samplerNameAvailable
|
||||
? this.models.samplers.map((s) => `<option value="${s}" ${s === sampler.sampler_name ? "selected" : ""}>${s}</option>`).join("")
|
||||
: `<option value="">N/A</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
@@ -1355,6 +1451,12 @@ export const comfyEditor = {
|
||||
<label>Batch Size</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-batch" value="${latent.batch_size ?? 1}" min="1" max="64">
|
||||
</div>
|
||||
${latent.isVideoLatent ? `
|
||||
<div class="wfm-form-group">
|
||||
<label>Length (frames)</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-latent-length" value="${latent.length ?? ""}" placeholder="${latent.length === undefined ? "linked" : ""}" step="1" min="1">
|
||||
</div>
|
||||
` : ""}
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-latent-apply">Apply</button>
|
||||
${latent.width === undefined || latent.height === undefined ? `<p class="wfm-placeholder" style="margin-top:6px;">Width/Height is linked to another node (e.g. auto-follows the input image) — leave blank to keep it linked.</p>` : ""}
|
||||
<div style="margin-top:10px;border-top:1px solid var(--wfm-border);padding-top:10px;">
|
||||
@@ -1393,7 +1495,7 @@ export const comfyEditor = {
|
||||
if (stepsEl?.value !== "") write(ds.stepsNodeId, "steps", parseInt(stepsEl.value) || 20);
|
||||
const cfgEl = document.getElementById("wfm-settings-cfg");
|
||||
if (cfgEl?.value !== "") write(ds.cfgNodeId, ds.cfgKey || "cfg", parseFloat(cfgEl.value) || 7);
|
||||
write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
|
||||
if (ds.samplerNodeId) write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
|
||||
if (ds.schedulerNodeId) write(ds.schedulerNodeId, "scheduler", document.getElementById("wfm-settings-scheduler")?.value);
|
||||
const denoiseEl = document.getElementById("wfm-settings-denoise");
|
||||
if (ds.denoiseNodeId && denoiseEl?.value !== "") write(ds.denoiseNodeId, "denoise", parseFloat(denoiseEl.value) || 1.0);
|
||||
@@ -1411,6 +1513,8 @@ export const comfyEditor = {
|
||||
if (wEl?.value !== "") inputs.width = parseInt(wEl.value) || 512;
|
||||
if (hEl?.value !== "") inputs.height = parseInt(hEl.value) || 512;
|
||||
inputs.batch_size = parseInt(document.getElementById("wfm-settings-batch")?.value) || 1;
|
||||
const lenEl = document.getElementById("wfm-settings-latent-length");
|
||||
if (lenEl && lenEl.value !== "") inputs.length = parseInt(lenEl.value) || 1;
|
||||
_syncRawJson();
|
||||
});
|
||||
|
||||
@@ -1990,6 +2094,75 @@ export const comfyEditor = {
|
||||
if (!workflow) _syncRawJson();
|
||||
},
|
||||
|
||||
// Resolves a user/LLM-supplied aspect ratio (e.g. "3:2" or the full COMBO label
|
||||
// "3:2 (Photo)") against a ResolutionSelector-type node's actual options via
|
||||
// /object_info — the workflow analysis only knows the node's CURRENT value, not its
|
||||
// full option list, so a bare "3:2" needs matching against whatever labeled string
|
||||
// that node's aspect_ratio COMBO actually accepts. Falls back to the raw input if
|
||||
// the node type can't be queried or nothing matches, so ComfyUI's own validation
|
||||
// surfaces a clear error instead of this silently doing nothing.
|
||||
async resolveAspectRatioOption(nodeType, requested) {
|
||||
if (!requested) return null;
|
||||
const ratioMatch = String(requested).match(/(\d+)\s*:\s*(\d+)/);
|
||||
const wantedRatio = ratioMatch ? `${ratioMatch[1]}:${ratioMatch[2]}` : String(requested).trim();
|
||||
try {
|
||||
const info = await comfyUI.fetchObjectInfo(nodeType);
|
||||
const options = info?.[nodeType]?.input?.required?.aspect_ratio?.[1]?.options || [];
|
||||
if (options.includes(requested)) return requested;
|
||||
return options.find((o) => o.startsWith(wantedRatio)) || requested;
|
||||
} catch {
|
||||
return requested;
|
||||
}
|
||||
},
|
||||
|
||||
// AI TOOL Chat's generate_image tool call — writes whichever of KSampler
|
||||
// (steps/cfg/sampler_name/scheduler/denoise/seed), Latent Image (width/height/
|
||||
// batch_size), and Resolution Selector (resolvedAspectRatio) fields the LLM
|
||||
// supplied onto the given workflow/analysis (or comfyUI.currentWorkflow/
|
||||
// currentAnalysis by default). Mirrors lab-tab.js's _buildWorkflowForIteration
|
||||
// KSampler branch — same per-field node-id indirection (stepsNodeId/cfgNodeId/...
|
||||
// falling back to sampler.id) for Advanced-Sampling workflows (Flux.1/2, SD3.5,
|
||||
// Chroma...) that spread these across several nodes.
|
||||
// seed is returned rather than written directly — the caller passes it through to
|
||||
// comfyUI.generate()'s seedMode/seedValue instead, same as every other seed source.
|
||||
// resolvedAspectRatio must already be one of the node's actual option strings (see
|
||||
// resolveAspectRatioOption above) — passed in rather than resolved here because
|
||||
// that lookup is async and this stays a plain synchronous writer.
|
||||
setGenerationParams({ steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size, resolvedAspectRatio, workflow, analysis } = {}) {
|
||||
const wf = workflow || comfyUI.currentWorkflow;
|
||||
const an = analysis || comfyUI.currentAnalysis;
|
||||
if (!wf || !an) return { seed: null };
|
||||
|
||||
const sampler = an.sampler_nodes?.[0];
|
||||
if (sampler) {
|
||||
if (steps != null) { const id = sampler.stepsNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.steps = steps; }
|
||||
if (cfg != null) { const id = sampler.cfgNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.cfg = cfg; }
|
||||
if (sampler_name) { const id = sampler.samplerNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.sampler_name = sampler_name; }
|
||||
if (scheduler && sampler.schedulerNodeId != null && wf[sampler.schedulerNodeId]) {
|
||||
wf[sampler.schedulerNodeId].inputs.scheduler = scheduler;
|
||||
}
|
||||
if (denoise != null && sampler.denoiseNodeId != null && wf[sampler.denoiseNodeId]) {
|
||||
wf[sampler.denoiseNodeId].inputs.denoise = denoise;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution Selector takes priority over direct width/height — writing width/
|
||||
// height straight onto the EmptyLatentImage would just clobber whatever the
|
||||
// selector computed, silently ignoring the requested aspect ratio.
|
||||
const resSelector = an.resolution_selector_nodes?.[0];
|
||||
const latent = an.latent_nodes?.[0];
|
||||
if (resolvedAspectRatio && resSelector && wf[resSelector.id]) {
|
||||
wf[resSelector.id].inputs.aspect_ratio = resolvedAspectRatio;
|
||||
} else if (latent && wf[latent.id]) {
|
||||
if (width != null) wf[latent.id].inputs.width = width;
|
||||
if (height != null) wf[latent.id].inputs.height = height;
|
||||
}
|
||||
if (latent && wf[latent.id] && batch_size != null) wf[latent.id].inputs.batch_size = batch_size;
|
||||
|
||||
if (!workflow) _syncRawJson();
|
||||
return { seed: seed ?? null };
|
||||
},
|
||||
|
||||
syncToWorkflow() {
|
||||
// Sync prompt texts before generation
|
||||
const posSelect = document.getElementById("wfm-prompt-pos-target");
|
||||
|
||||
@@ -9,6 +9,9 @@ let _objectInfoCache = null;
|
||||
// ckpt_name に対して発生した場合の記録。呼び出し元が読み込み直後に確認して警告表示できるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastCheckpointSubstitutions = [];
|
||||
// 同じフォールバックを全COMBO入力(LoRA・VAE・画像など)について記録したもの。
|
||||
// Workflowタブの「GenerateUI互換チェック」が、無警告で差し替わる値を一覧するのに使う。
|
||||
let _lastComboSubstitutions = [];
|
||||
|
||||
// convertUiToApi() で除外された Bypass(mode:4) / Mute(mode:2) ノードの記録。
|
||||
// API形式には mode 情報が残らないため、呼び出し元がRaw JSON表示時に
|
||||
@@ -75,6 +78,35 @@ async function _loadObjectInfo() {
|
||||
* Get ordered widget (non-link) input names for a node class from object_info.
|
||||
* These correspond to widgets_values in UI-format nodes.
|
||||
*/
|
||||
// Scalar widget types object_info can declare for a required/optional input.
|
||||
const _WIDGET_TYPE_SET = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", "COMFY_DYNAMICCOMBO_V3"]);
|
||||
|
||||
/**
|
||||
* Resolve a possibly-compound object_info type spec (e.g. LTXVEmptyLatentAudio's frame_rate:
|
||||
* "FLOAT,INT", a MultiType declaration meaning "widget defaults to FLOAT but also accepts an INT
|
||||
* link") down to the single scalar widget type it actually occupies a widgets_values slot as.
|
||||
* Preferring the explicit widgetType hint some MultiType specs carry, else the first comma-
|
||||
* separated segment. Returns null for link-only types (MODEL, CLIP, LATENT, etc.) or arrays that
|
||||
* aren't combo option lists.
|
||||
*
|
||||
* Without this, a plain `upper === "INT" || ...` exact-match check silently drops any
|
||||
* comma-joined MultiType widget from the name/type lists entirely — which corrupts every widget
|
||||
* declared after it in the node's schema order in _simulateWidgetValues (see its own docstring),
|
||||
* exactly the class of bug that produced e.g. LTXVEmptyLatentAudio's batch_size reading
|
||||
* frame_rate's stale value instead of its own.
|
||||
*/
|
||||
function _resolveWidgetType(type, spec) {
|
||||
if (Array.isArray(type)) return "COMBO";
|
||||
if (typeof type !== "string") return null;
|
||||
const explicit = spec?.[1]?.widgetType;
|
||||
if (typeof explicit === "string" && _WIDGET_TYPE_SET.has(explicit.toUpperCase())) return explicit.toUpperCase();
|
||||
for (const part of type.split(",")) {
|
||||
const upper = part.trim().toUpperCase();
|
||||
if (_WIDGET_TYPE_SET.has(upper)) return upper;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _getWidgetInputNames(objectInfo, classType) {
|
||||
const info = objectInfo[classType];
|
||||
if (!info) return [];
|
||||
@@ -89,31 +121,16 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// (e.g. CLIPTextEncodeEditPlus's text1/text2). Never has a widgets_values entry.
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
// Link types are strings like "MODEL", "CLIP", "CONDITIONING", "LATENT", "IMAGE", etc.
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", or an array of choices
|
||||
if (Array.isArray(type)) {
|
||||
// Combo/enum widget
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
|
||||
}
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", an array of choices, or a
|
||||
// comma-joined MultiType union of those (e.g. "FLOAT,INT") — see _resolveWidgetType.
|
||||
if (_resolveWidgetType(type, spec)) names.push(name);
|
||||
}
|
||||
|
||||
// Optional inputs that are widgets
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
if (_resolveWidgetType(type, spec)) names.push(name);
|
||||
}
|
||||
|
||||
return names;
|
||||
@@ -133,26 +150,14 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
const resolved = _resolveWidgetType(type, spec);
|
||||
if (resolved) types.push(resolved);
|
||||
}
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
const resolved = _resolveWidgetType(type, spec);
|
||||
if (resolved) types.push(resolved);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
@@ -289,15 +294,26 @@ function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInt
|
||||
const allWidgetInputs = (remappedNode.inputs || []).filter((i) => i.widget);
|
||||
if (!widgetsValues || allWidgetInputs.length === 0) return;
|
||||
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
remappedNode.widgets_values = _normalizeLinkedWidgetValues(objectInfo, remappedNode.type, widgetsValues, isLinkedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return widgets_values with the entries belonging to linked widgets removed when the array is in
|
||||
* the "legacy full" format (every widget present, linked ones included — what the current ComfyUI
|
||||
* frontend actually saves for a widget converted to a wired input, e.g. EmptyLatentImage
|
||||
* [1024, 2048, 1] with width/height fed by GetImageSize/PrimitiveInt). Returns the array unchanged
|
||||
* when it's already in the modern format (linked widgets contribute zero entries).
|
||||
*/
|
||||
function _normalizeLinkedWidgetValues(objectInfo, nodeType, widgetsValues, isLinkedName) {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, nodeType);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, nodeType);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, nodeType, name, selectedKey);
|
||||
|
||||
// If skipping linked widgets already accounts for every entry, widgets_values is already in
|
||||
// the modern (no dangling linked entries) format — nothing to strip.
|
||||
const skipSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
if (skipSim.consumed === widgetsValues.length) return;
|
||||
if (skipSim.consumed === widgetsValues.length) return widgetsValues;
|
||||
|
||||
// Otherwise it's "legacy full": recompute with every widget present to find exactly which
|
||||
// indices the linked ones occupy — including any control_after_generate extra a linked
|
||||
@@ -309,9 +325,7 @@ function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInt
|
||||
for (const i of fullSim.consumedIndices[name] || []) linkedIdx.add(i);
|
||||
}
|
||||
}
|
||||
if (linkedIdx.size > 0) {
|
||||
remappedNode.widgets_values = widgetsValues.filter((_, i) => !linkedIdx.has(i));
|
||||
}
|
||||
return linkedIdx.size > 0 ? widgetsValues.filter((_, i) => !linkedIdx.has(i)) : widgetsValues;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -606,6 +620,15 @@ export const comfyWorkflow = {
|
||||
return _lastCheckpointSubstitutions;
|
||||
},
|
||||
|
||||
getLastComboSubstitutions() {
|
||||
return _lastComboSubstitutions;
|
||||
},
|
||||
|
||||
// Cached /object_info (same snapshot convertUiToApi() uses)
|
||||
async getObjectInfo() {
|
||||
return _loadObjectInfo();
|
||||
},
|
||||
|
||||
getLastBypassedNodes() {
|
||||
return _lastBypassedNodes;
|
||||
},
|
||||
@@ -614,8 +637,33 @@ export const comfyWorkflow = {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
// Forces the next convertUiToApi() call to refetch /object_info instead of
|
||||
// reusing the cached snapshot. Needed whenever a file gets uploaded mid-session
|
||||
// after the cache was already populated (e.g. Video Plan's block-chaining
|
||||
// uploads an extracted frame between batch iterations) — a stale cache's
|
||||
// LoadImage "image" COMBO choices wouldn't include the just-uploaded filename
|
||||
// yet, so the COMBO-mismatch fallback a few lines below (silently substituting
|
||||
// choices[0] when a value isn't in the known list — see the file-level comment
|
||||
// at the top) would replace it with an unrelated pre-existing file instead.
|
||||
invalidateObjectInfoCache() {
|
||||
_objectInfoCache = null;
|
||||
},
|
||||
|
||||
// opts.readOnly: 表示用の読み取り変換。COMBO値の差し替えを行わず、Workflowタブの互換性
|
||||
// チェックが参照する _last* 記録も変更しない(Metadataタブなど表示目的の呼び出し用)。
|
||||
async convertUiToApi(workflow, opts = {}) {
|
||||
if (!opts.readOnly) return comfyWorkflow._convertUiToApiImpl(workflow, opts);
|
||||
const saved = [_lastCheckpointSubstitutions, _lastComboSubstitutions, _lastBypassedNodes, _lastMutedNodes];
|
||||
try {
|
||||
return await comfyWorkflow._convertUiToApiImpl(workflow, opts);
|
||||
} finally {
|
||||
[_lastCheckpointSubstitutions, _lastComboSubstitutions, _lastBypassedNodes, _lastMutedNodes] = saved;
|
||||
}
|
||||
},
|
||||
|
||||
async _convertUiToApiImpl(workflow, opts = {}) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastComboSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
_lastMutedNodes = [];
|
||||
if (!workflow.nodes || !workflow.links) return {};
|
||||
@@ -678,7 +726,14 @@ export const comfyWorkflow = {
|
||||
});
|
||||
|
||||
// 2. Map widgets_values to widget input names using object_info
|
||||
const widgets = node.widgets_values || [];
|
||||
let widgets = node.widgets_values || [];
|
||||
// Widgets converted to wired inputs (e.g. EmptyLatentImage width/height fed by
|
||||
// GetImageSize) usually still keep their value in widgets_values — strip those so the
|
||||
// loop below, which assumes linked widgets contribute zero entries, doesn't shift every
|
||||
// later widget (otherwise batch_size silently becomes the stale width value).
|
||||
if (Array.isArray(widgets) && widgets.length > 0 && linkedSlotNames.size > 0 && !node.properties?.__lm_widget_ids) {
|
||||
widgets = _normalizeLinkedWidgetValues(objectInfo, node.type, widgets, (name) => linkedSlotNames.has(name));
|
||||
}
|
||||
if (widgets.length > 0) {
|
||||
// Lora Loader (LoraManager): use __lm_widget_ids from node properties
|
||||
// widgets_values: [autocomplete_meta_obj, text_str, loras_array]
|
||||
@@ -764,7 +819,7 @@ export const comfyWorkflow = {
|
||||
const spec = allInputDefs[name];
|
||||
if (spec) {
|
||||
const choices = Array.isArray(spec[0]) ? spec[0] : null;
|
||||
if (choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (!opts.readOnly && choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (name === "ckpt_name") {
|
||||
_lastCheckpointSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
@@ -773,6 +828,14 @@ export const comfyWorkflow = {
|
||||
replaced: choices[0],
|
||||
});
|
||||
}
|
||||
_lastComboSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
type: node.type,
|
||||
title: node.title || node.type,
|
||||
input: name,
|
||||
original: val,
|
||||
replaced: choices[0],
|
||||
});
|
||||
val = choices[0];
|
||||
}
|
||||
}
|
||||
@@ -886,6 +949,7 @@ export const comfyWorkflow = {
|
||||
prompt_nodes: [],
|
||||
sampler_nodes: [],
|
||||
latent_nodes: [],
|
||||
resolution_selector_nodes: [],
|
||||
lora_nodes: [],
|
||||
vae_nodes: [],
|
||||
save_nodes: [],
|
||||
@@ -961,6 +1025,14 @@ export const comfyWorkflow = {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// LTXVDualCFGGuider (LTX-2.5's video+audio guider) — positive/negative both
|
||||
// commonly point at the *same* LTXVConditioning node (different output slots),
|
||||
// so this only seeds the role on that node; LTXVConditioning below splits it
|
||||
// back out per-slot onto its own positive/negative inputs.
|
||||
if (ct === "LTXVDualCFGGuider") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
|
||||
@@ -1037,6 +1109,13 @@ export const comfyWorkflow = {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// LTXVConditioning (LTX-2.5) — same shape as InstructPixToPixConditioning:
|
||||
// separate positive/negative inputs feeding separate outputs consumed by
|
||||
// LTXVDualCFGGuider, so each role must route only to its own input.
|
||||
if (ct === "LTXVConditioning") {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — used by Ernie
|
||||
// Image's prompt-enhancement toggle to pick between the raw PrimitiveStringMultiline
|
||||
// text and an LLM-generated (TextGenerate) rewrite. The rewrite can't be known
|
||||
@@ -1055,7 +1134,7 @@ export const comfyWorkflow = {
|
||||
// TextEncodeQwenImageEditPlus/TextEncodeQwenImageEdit — propagate role through its
|
||||
// linked "prompt" input when prompt-enhancement wiring feeds it via a switch chain
|
||||
// instead of a literal string (e.g. Krea-2's image-reference workflow).
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit" || ct === "TextEncodeMingImageEdit") {
|
||||
const tv = inputs.prompt;
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
@@ -1137,7 +1216,11 @@ export const comfyWorkflow = {
|
||||
cfg: typeof guiderInputs[guiderCfgKey] === "number" ? guiderInputs[guiderCfgKey] : undefined,
|
||||
cfgNodeId: guiderCfgKey in guiderInputs ? guiderId : null,
|
||||
cfgKey: guiderCfgKey,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
// samplerNodeId is null when the SAMPLER source has no sampler_name combo at all
|
||||
// (e.g. Ming Image's SamplerLCM, a dedicated sampler node with its own
|
||||
// s_noise knobs instead of KSamplerSelect) — the Settings UI disables the field.
|
||||
sampler_name: samplerSelInputs.sampler_name,
|
||||
samplerNodeId: "sampler_name" in samplerSelInputs ? samplerSelId : null,
|
||||
scheduler: undefined, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
@@ -1240,7 +1323,11 @@ export const comfyWorkflow = {
|
||||
// the base variant takes one — both share the same "prompt" widget). Only add when
|
||||
// "prompt" is a direct string; if linked (e.g. Krea-2's prompt-enhancement wiring),
|
||||
// the upstream literal node is resolved instead via the role propagation above.
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
// TextEncodeMingImageEdit (Ming Image) has the same single "prompt" widget plus up to
|
||||
// 8 optional images.image_N reference inputs (none wired = pure T2I); its role comes
|
||||
// from the sampler/guider wiring — Ming's layer-decomposition workflows use a second,
|
||||
// empty-prompt instance of it as the KSampler negative.
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit" || ct === "TextEncodeMingImageEdit") {
|
||||
const textVal = inputs.prompt;
|
||||
if (typeof textVal === "string") {
|
||||
result.prompt_nodes.push({
|
||||
@@ -1266,6 +1353,28 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// TextEncodeQwenImage21 (Qwen Image 2.1 unified T2I/Edit encoder) — prompt +
|
||||
// negative_prompt in one node (fixed roles, same shape as TextEncodeBooguEdit).
|
||||
// Also carries a "resolution" pixel-budget int (not width/height) and up to 10
|
||||
// optional images.image_N reference inputs (image_1 is the edit target when wired;
|
||||
// with no images connected the node behaves as pure T2I). The reference images
|
||||
// themselves need no special handling here — they arrive as ordinary LoadImage
|
||||
// nodes already picked up generically by the load_image_nodes pass below.
|
||||
if (ct === "TextEncodeQwenImage21") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
@@ -1335,12 +1444,28 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Video all-in-one nodes (e.g. MiniMaxH3ImageToVideo) — hold their own "prompt"
|
||||
// widget directly instead of going through a separate CLIPTextEncode, and have
|
||||
// no negative counterpart. Matched generically by class name (ImageToVideo /
|
||||
// TextToVideo) rather than a fixed list so future models sharing this pattern
|
||||
// (WAN, HunyuanVideo, etc.) work without a code change — same detection metadata-tab.js
|
||||
// and node_sets_menu.js already use for prompt extraction (see extractPromptsAPI).
|
||||
if ((ct.includes("ImageToVideo") || ct.includes("TextToVideo")) && typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Latent nodes ---
|
||||
// EmptyFlux2LatentImage (Flux.2 templates) often has width/height wired to a
|
||||
// GetImageSize node (auto-follows the reference image) rather than a direct number —
|
||||
// leave those as undefined, same treatment as TextEncodeMageFlowEdit below, so the
|
||||
// settings-tab UI shows "linked" instead of a stale default and Apply doesn't clobber it.
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage") {
|
||||
// EmptyQwenImageLayeredLatentImage (Qwen-Image-Layered / Ming Image layer decomposition)
|
||||
// has the same width/height/batch_size shape plus a "layers" count, left as-is.
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage"
|
||||
|| ct === "EmptyQwenImageLayeredLatentImage") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
@@ -1349,6 +1474,35 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Video latent nodes (Wan2.2's EmptyHunyuanLatentVideo, LTX-2.5's
|
||||
// EmptyLTXVLatentVideo) — same width/height/batch_size shape as EmptyLatentImage
|
||||
// plus a frame-count "length" input, usually linked to a duration*fps expression
|
||||
// (see video-tab.js's own Wan2.2/LTX-2.5 handling) rather than a literal number —
|
||||
// left undefined in that case so the settings-tab UI shows "linked".
|
||||
if (ct === "EmptyHunyuanLatentVideo" || ct === "EmptyLTXVLatentVideo") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title, isVideoLatent: true,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
length: typeof inputs.length === "number" ? inputs.length : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ResolutionSelector (ComfyUI core, comfy_extras.nodes_resolution): typically
|
||||
// feeds an EmptyLatentImage's width/height rather than being a latent node
|
||||
// itself. aspect_ratio is a fixed-choice COMBO (e.g. "3:2 (Photo)") — its exact
|
||||
// option strings aren't known here, only the node's current value, so callers
|
||||
// that want to set a new one (AI TOOL Chat's generate_image) must resolve
|
||||
// against /object_info at call time (see comfyui-editor.js resolveAspectRatioOption).
|
||||
if (ct === "ResolutionSelector") {
|
||||
result.resolution_selector_nodes.push({
|
||||
id, type: ct, title,
|
||||
aspect_ratio: inputs.aspect_ratio,
|
||||
megapixels: inputs.megapixels,
|
||||
});
|
||||
}
|
||||
|
||||
// TextEncodeMageFlowEdit also carries width/height/batch_size directly
|
||||
// (it builds the latent internally instead of a separate EmptyLatentImage node).
|
||||
// width/height are commonly wired to a ResolutionSelector-style helper node
|
||||
@@ -1504,6 +1658,19 @@ export const comfyWorkflow = {
|
||||
return result;
|
||||
},
|
||||
|
||||
// True if this analysis found a video all-in-one node (see analyzeWorkflow's
|
||||
// ImageToVideo/TextToVideo prompt_nodes branch). Callers use this to pick a longer
|
||||
// generation timeout (video generation runs far longer than a still image) — see
|
||||
// generate-tab.js _coreGenerate and lab-tab.js _runLabBatch.
|
||||
isVideoWorkflow(analysis) {
|
||||
if ((analysis?.prompt_nodes || []).some((n) => /ImageToVideo|TextToVideo/.test(n.type))) return true;
|
||||
// LTX-2.5/Wan2.2: no all-in-one *ToVideo node — detected instead via their
|
||||
// EmptyHunyuanLatentVideo/EmptyLTXVLatentVideo latent node (see analyzeWorkflow)
|
||||
// or a SaveVideo/CreateVideo node anywhere in the flattened graph.
|
||||
if ((analysis?.latent_nodes || []).some((n) => n.isVideoLatent)) return true;
|
||||
return (analysis?.all_nodes || []).some((n) => n.type === "SaveVideo" || n.type === "CreateVideo");
|
||||
},
|
||||
|
||||
applyParams(workflow, params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
// Parse key format: nodeId.paramName
|
||||
@@ -1566,6 +1733,9 @@ function _getWidgetMapping(nodeType) {
|
||||
ImpactWildcardProcessor: ["wildcard_text"],
|
||||
ImpactWildcardEncode: ["wildcard_text"],
|
||||
WFS_PromptText: ["positive", "negative"],
|
||||
TextEncodeQwenImage21: ["prompt", "negative_prompt", "resolution"],
|
||||
TextEncodeMingImageEdit: ["prompt"],
|
||||
EmptyQwenImageLayeredLatentImage: ["width", "height", "layers", "batch_size"],
|
||||
};
|
||||
return mappings[nodeType] || null;
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
|
||||
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
|
||||
import { initLabTab, refreshLabLiveDefaults } from "./lab-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
|
||||
import { isVideoFilename } from "./video-utils.js";
|
||||
|
||||
// ============================================
|
||||
// Gallery Metadata - ワークフロー保存
|
||||
@@ -164,6 +165,17 @@ async function saveCurrentWorkflow() {
|
||||
nameEl.dataset.filename = filename;
|
||||
}
|
||||
}
|
||||
|
||||
// このファイル名が「デフォルトワークフロー」(workflow-tab.jsの「デフォルトに設定」で
|
||||
// localStorageにスナップショットされたAPI形式JSON、ページ起動時に自動読込される)に
|
||||
// 一致する場合、保存内容と食い違ったまま残らないようスナップショットも更新する。
|
||||
// 更新しないと、ここで上書き保存しても次回リロード時に古いモデル等が復活する。
|
||||
const settings = getSettings();
|
||||
if (settings.defaultWorkflow === filename) {
|
||||
settings.defaultWorkflowData = workflow;
|
||||
localStorage.setItem("wfm_settings", JSON.stringify(settings));
|
||||
}
|
||||
|
||||
showToast(t("savedAs", filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("saveFailed", err.message), "error");
|
||||
@@ -281,6 +293,13 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
|
||||
refreshFeederNodeList();
|
||||
|
||||
// Lab subtab may already be the active one (e.g. it was active when this workflow
|
||||
// was loaded) — the subtab-click handler that normally triggers this refresh never
|
||||
// fires in that case, leaving Setting 1's live-reflected cells showing the previous
|
||||
// workflow until the user clicks a cell or revisits the subtab.
|
||||
const activeSubtab = document.querySelector(".wfm-gen-subtab-btn.active")?.dataset.subtab;
|
||||
if (activeSubtab === "lab") refreshLabLiveDefaults();
|
||||
|
||||
if (ckptWarning) {
|
||||
showToast(ckptWarning, "error");
|
||||
} else {
|
||||
@@ -1443,26 +1462,38 @@ export async function _expandWildcardsInWorkflow(workflow) {
|
||||
// Generation (core — throws on error)
|
||||
// ============================================
|
||||
|
||||
async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
async function _coreGenerate(silent = false, workflowOverride = null, genOptions = {}) {
|
||||
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 resultVideo = document.getElementById("wfm-gen-result-video");
|
||||
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
|
||||
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
// genOptions.seedValue lets a caller (e.g. AI TOOL Chat's generate_image tool)
|
||||
// force a specific seed on an override workflow without touching the GenerateUI
|
||||
// seed fields — those still drive normal (non-override) generation as before.
|
||||
const seedMode = genOptions.seedValue != null ? "fixed" : (document.getElementById("wfm-gen-seed-mode")?.value || "random");
|
||||
const seedValue = genOptions.seedValue ?? (parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1);
|
||||
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = "Starting...";
|
||||
|
||||
// Style must be applied *before* wildcard expansion so a __name__ reference written
|
||||
// inside a Style's prompt/negative_prompt also gets expanded, not just wildcards
|
||||
// already present in the base workflow's prompt nodes.
|
||||
const baseWorkflow = workflowOverride || comfyUI.currentWorkflow;
|
||||
const workflowExpanded = await _expandWildcardsInWorkflow({ ...baseWorkflow });
|
||||
const workflowForGenerate = _applyStyleToWorkflow(workflowExpanded);
|
||||
const workflowStyled = _applyStyleToWorkflow({ ...baseWorkflow });
|
||||
const workflowForGenerate = await _expandWildcardsInWorkflow(workflowStyled);
|
||||
// Video workflows (e.g. MiniMax H3) run far longer than a still image — use a longer
|
||||
// timeout so a multi-minute generation doesn't get force-rejected mid-run (see
|
||||
// comfyWorkflow.isVideoWorkflow and video-tab.js's own 30-minute timeout).
|
||||
const isVideo = comfyWorkflow.isVideoWorkflow(comfyUI.currentAnalysis);
|
||||
const { images, seed, svgOutputs } = await comfyUI.generate(
|
||||
workflowForGenerate,
|
||||
{
|
||||
seedMode,
|
||||
seedValue,
|
||||
timeoutMs: isVideo ? 30 * 60 * 1000 : undefined,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
|
||||
@@ -1508,23 +1539,36 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
});
|
||||
}
|
||||
} else if (outputImages.length > 0) {
|
||||
// Video outputs (SaveVideo/PreviewVideo, e.g. MiniMax H3) come back through the
|
||||
// same {"images":[...]} shape as still images — dispatch to <video> vs <img>
|
||||
// per-item by extension rather than assuming the whole batch is one media type.
|
||||
const showMedia = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (isVideoFilename(filename)) {
|
||||
if (resultVideo) { resultVideo.src = url; resultVideo.style.display = "block"; }
|
||||
if (resultImg) resultImg.style.display = "none";
|
||||
} else {
|
||||
if (resultImg) { resultImg.src = url; resultImg.style.display = "block"; }
|
||||
if (resultVideo) { resultVideo.pause(); resultVideo.style.display = "none"; }
|
||||
}
|
||||
};
|
||||
|
||||
const blob = await comfyUI.getImageBlob(outputImages[0]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (resultImg) {
|
||||
resultImg.src = url;
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
showMedia(blob, outputImages[0].filename);
|
||||
|
||||
if (resultThumbs && outputImages.length > 1) {
|
||||
resultThumbs.innerHTML = "";
|
||||
for (let i = 0; i < outputImages.length; i++) {
|
||||
const b = i === 0 ? blob : await comfyUI.getImageBlob(outputImages[i]);
|
||||
const u = i === 0 ? url : URL.createObjectURL(b);
|
||||
const thumb = document.createElement("img");
|
||||
const filename = outputImages[i].filename;
|
||||
const u = URL.createObjectURL(b);
|
||||
const isVid = isVideoFilename(filename);
|
||||
const thumb = document.createElement(isVid ? "video" : "img");
|
||||
thumb.src = u;
|
||||
if (isVid) thumb.muted = true;
|
||||
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
|
||||
thumb.addEventListener("click", () => {
|
||||
resultImg.src = u;
|
||||
showMedia(b, filename);
|
||||
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
|
||||
thumb.classList.add("active");
|
||||
});
|
||||
@@ -1972,9 +2016,11 @@ async function _runBatchGenerate() {
|
||||
// ============================================
|
||||
|
||||
// workflowOverride: 明示的に渡された場合、comfyUI.currentWorkflow の代わりにこのワークフローで
|
||||
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行から利用)。
|
||||
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行や
|
||||
// AI TOOL Chatのgenerate_imageツールから利用)。
|
||||
// genOptions: 現状 seedValue のみ(指定時は_coreGenerateがseedMode "fixed"扱いにする)。
|
||||
// ボタンのclickハンドラから呼ばれる場合は第一引数がMouseEventになるため、その場合は無視する。
|
||||
async function handleGenerate(workflowOverride = null) {
|
||||
async function handleGenerate(workflowOverride = null, genOptions = {}) {
|
||||
if (workflowOverride instanceof Event) workflowOverride = null;
|
||||
|
||||
if (!workflowOverride && !comfyUI.currentWorkflow) {
|
||||
@@ -2003,7 +2049,7 @@ async function handleGenerate(workflowOverride = null) {
|
||||
await _runBatchGenerate();
|
||||
} else {
|
||||
try {
|
||||
return await _coreGenerate(false, workflowOverride);
|
||||
return await _coreGenerate(false, workflowOverride, genOptions);
|
||||
} catch (err) {
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
if (progressText) progressText.textContent = "Error";
|
||||
@@ -2146,6 +2192,10 @@ export async function initGenerateTab() {
|
||||
// Lab has its own Run/Results panel — hide the shared Generate/Seed/Batch column
|
||||
const rightCol = document.querySelector(".wfm-gen-right");
|
||||
if (rightCol) rightCol.style.display = (target === "lab") ? "none" : "";
|
||||
// Save button writes the currently loaded workflow as-is — not meaningful while
|
||||
// browsing Batch/Lab (which operate on workflow copies / their own Plan files)
|
||||
const saveBtn = document.getElementById("wfm-gen-save-btn");
|
||||
if (saveBtn) saveBtn.style.display = (target === "batch" || target === "lab") ? "none" : "";
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2156,6 +2206,14 @@ export async function initGenerateTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass button in LoRA column header — forces the target LoRA node's strength to
|
||||
// 0 (or restores it), independent of the Single/Stack tabs' own Apply buttons.
|
||||
document.getElementById("wfm-lora-bypass-toggle")?.addEventListener("click", () => {
|
||||
if (!comfyUI.currentAnalysis) return;
|
||||
comfyEditor.toggleLoraNodeBypass(comfyUI.currentAnalysis);
|
||||
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
|
||||
});
|
||||
|
||||
// Input inner tab (Prompt / Image)
|
||||
document.querySelectorAll(".wfm-input-inner-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -2233,10 +2291,11 @@ export async function initGenerateTab() {
|
||||
});
|
||||
searchOverlay.innerHTML = html + "\n";
|
||||
|
||||
// Scroll editor to current match
|
||||
// Scroll editor to current match. フォーカス/selectionRangeは意図的に設定しない
|
||||
// (テキストエリアにカーソルが当たった状態になり、検索直後のキー入力が選択範囲を
|
||||
// 上書きして不意にJSONを編集してしまうため)。ハイライトはsearchOverlayの<mark>
|
||||
// 表示のみで行い、編集はユーザーが明示的にエディタをクリックした場合に限る。
|
||||
const pos = matchPositions[currentMatchIndex];
|
||||
editor.focus();
|
||||
editor.setSelectionRange(pos, pos + term.length);
|
||||
const lineNum = (text.slice(0, pos).match(/\n/g) || []).length;
|
||||
const lineH = parseFloat(getComputedStyle(editor).lineHeight) || 18;
|
||||
editor.scrollTop = Math.max(0, lineH * lineNum - editor.clientHeight / 2);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DrawTool } from "./image-edit/DrawTool.js";
|
||||
import { TextTool, TEXT_FONTS } from "./image-edit/TextTool.js";
|
||||
import { SelectTool } from "./image-edit/SelectTool.js";
|
||||
import { ShapeTool } from "./image-edit/ShapeTool.js";
|
||||
import { FillTool } from "./image-edit/FillTool.js";
|
||||
import { MaskTool } from "./image-edit/MaskTool.js";
|
||||
import { MaskColorTool, MaskAlphaTool, MaskTextTool, MaskVectorTool, MaskShapeTool, MASK_TEXT_FONTS } from "./image-edit/MaskEditorOneTools.js";
|
||||
import { GmicIntegration } from "./image-edit/GmicIntegration.js";
|
||||
@@ -28,6 +29,7 @@ const TOOL_DEFS = [
|
||||
{ id: "draw", icon: "✏", label: "Draw", ready: true },
|
||||
{ id: "text", icon: "T", label: "Text", ready: true },
|
||||
{ id: "shape", icon: "□", label: "Shape", ready: true },
|
||||
{ id: "fill", icon: "🪣", label: "Fill", ready: true },
|
||||
{ id: "mask", icon: "🎭", label: "Mask", ready: true },
|
||||
{ id: "blur", icon: "≈", label: "Blur", ready: true },
|
||||
{ id: "filter", icon: "★", label: "Filter", ready: true },
|
||||
@@ -63,6 +65,7 @@ class ImageEditTab {
|
||||
this._editingTextLayer = null;
|
||||
this._initialized = false;
|
||||
this._shapeTool = new ShapeTool();
|
||||
this._fillTool = new FillTool();
|
||||
// Blur ツール
|
||||
this._blurTool = new BlurTool({
|
||||
getLayerManager: () => this._layerMgr,
|
||||
@@ -241,6 +244,11 @@ class ImageEditTab {
|
||||
if (this._activeTool === "text") this._textTool?.deactivate();
|
||||
if (this._activeTool === "select") this._selectTool?.deactivate();
|
||||
if (this._activeTool === "shape") this._shapeTool?.deactivate();
|
||||
if (this._activeTool === "fill") {
|
||||
this._fillTool?.deactivate();
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.style.cursor = "";
|
||||
}
|
||||
if (this._activeTool === "mask") this._deactivateMaskSubtool();
|
||||
if (this._activeTool === "filter") {
|
||||
this._gmic.abort();
|
||||
@@ -248,8 +256,8 @@ class ImageEditTab {
|
||||
if (this._activeTool === "blur") {
|
||||
this._blurTool.deactivate();
|
||||
}
|
||||
// Draw/Mask/Inpaint以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw" && toolId !== "inpaint") {
|
||||
// Draw/Mask/Fill/Inpaint以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw" && toolId !== "fill" && toolId !== "inpaint") {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
if (pane) pane.style.display = "none";
|
||||
}
|
||||
@@ -282,6 +290,11 @@ class ImageEditTab {
|
||||
this._shapeTool.setCanvas(overlayCanvas);
|
||||
this._shapeTool.activate();
|
||||
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
|
||||
} else if (this._activeTool === "fill" && this._fillTool) {
|
||||
const activeLayer = this._layerMgr?.activeLayer;
|
||||
if (activeLayer) this._fillTool.setCanvas(activeLayer.canvas);
|
||||
this._fillTool.activate();
|
||||
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
|
||||
} else if (this._activeTool === "mask") {
|
||||
this._initMaskEditorOneTools();
|
||||
this._activateMaskSubtool();
|
||||
@@ -344,6 +357,10 @@ class ImageEditTab {
|
||||
el.innerHTML = "";
|
||||
this._renderDrawProps();
|
||||
|
||||
} else if (toolId === "fill" && this._fillTool) {
|
||||
el.innerHTML = "";
|
||||
this._renderFillProps();
|
||||
|
||||
} else if (toolId === "text" && this._textTool) {
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
@@ -968,6 +985,271 @@ class ImageEditTab {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fill Tool (bucket fill) props ─────────────────────────────────────
|
||||
|
||||
_renderFillProps() {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
const body = document.getElementById("ie-props-body");
|
||||
const title = document.getElementById("ie-props-title");
|
||||
if (!pane || !body) return;
|
||||
pane.style.display = "flex";
|
||||
if (title) title.textContent = "Fill";
|
||||
|
||||
const t = this._fillTool;
|
||||
const isGradient = t.fillMode === "gradient";
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label>Mode</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${!isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-solid" style="flex:1;">Solid</button>
|
||||
<button class="wfm-btn wfm-btn-sm${isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-gradient" style="flex:1;">Gradient</button>
|
||||
</div>
|
||||
</div>
|
||||
${!isGradient ? `
|
||||
<div class="ie-props-row">
|
||||
<label>Color</label>
|
||||
<input type="color" id="ie-fill-color" value="${t.color}"
|
||||
style="width:36px;height:24px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;flex-shrink:0;">
|
||||
</div>` : `
|
||||
<div class="ie-props-row">
|
||||
<label>Shape</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${t.gradientShape === "linear" ? " ie-opt-active" : ""}" id="ie-fill-shape-linear" style="flex:1;">Linear</button>
|
||||
<button class="wfm-btn wfm-btn-sm${t.gradientShape === "radial" ? " ie-opt-active" : ""}" id="ie-fill-shape-radial" style="flex:1;">Radial</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row" style="align-items:flex-start;">
|
||||
<label>Ramp</label>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;flex:1;min-width:0;">
|
||||
<canvas id="ie-fill-ramp" width="130" height="24" style="display:block;width:130px;height:24px;border:1px solid var(--wfm-border);border-radius:3px;cursor:pointer;"></canvas>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<input type="color" id="ie-fill-stop-color" value="${t.gradientStops[t.selectedStopIdx]?.color ?? "#ffffff"}"
|
||||
style="width:28px;height:22px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-fill-stop-add" title="Add stop">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-fill-stop-remove" title="Remove stop">-</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row" style="align-items:flex-start;">
|
||||
<label>Direction</label>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<canvas id="ie-fill-dir" width="70" height="70" style="width:70px;height:70px;border:1px solid var(--wfm-border);border-radius:3px;cursor:crosshair;"></canvas>
|
||||
<span id="ie-fill-strength-lbl" style="font-size:11px;color:var(--wfm-text-secondary);">${t.gradientStrength.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="ie-props-row">
|
||||
<label>Tolerance</label>
|
||||
<input type="range" id="ie-fill-tolerance" min="0" max="255" value="${t.tolerance}">
|
||||
<span id="ie-fill-tolerance-lbl">${t.tolerance}</span>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Opacity</label>
|
||||
<input type="range" id="ie-fill-opacity" min="1" max="100" value="${Math.round(t.opacity * 100)}">
|
||||
<span id="ie-fill-opacity-lbl">${Math.round(t.opacity * 100)}%</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("ie-fill-mode-solid")?.addEventListener("click", () => {
|
||||
t.fillMode = "solid";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-mode-gradient")?.addEventListener("click", () => {
|
||||
t.fillMode = "gradient";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-color")?.addEventListener("input", e => { t.color = e.target.value; });
|
||||
document.getElementById("ie-fill-tolerance")?.addEventListener("input", e => {
|
||||
t.tolerance = parseInt(e.target.value);
|
||||
document.getElementById("ie-fill-tolerance-lbl").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-fill-opacity")?.addEventListener("input", e => {
|
||||
t.opacity = parseInt(e.target.value) / 100;
|
||||
document.getElementById("ie-fill-opacity-lbl").textContent = e.target.value + "%";
|
||||
});
|
||||
|
||||
if (isGradient) {
|
||||
document.getElementById("ie-fill-shape-linear")?.addEventListener("click", () => {
|
||||
t.gradientShape = "linear";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-shape-radial")?.addEventListener("click", () => {
|
||||
t.gradientShape = "radial";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-color")?.addEventListener("input", e => {
|
||||
t.gradientStops[t.selectedStopIdx].color = e.target.value;
|
||||
this._drawFillGradientRamp();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-add")?.addEventListener("click", () => {
|
||||
t.addStop();
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-remove")?.addEventListener("click", () => {
|
||||
t.removeStop();
|
||||
this._renderFillProps();
|
||||
});
|
||||
this._setupFillGradientRamp();
|
||||
this._setupFillGradientDir();
|
||||
}
|
||||
}
|
||||
|
||||
/** カラーランプ(複数ストップの線形プレビュー)の描画とドラッグ操作。particle_widget.jsのカラーランプUIを移植。 */
|
||||
_drawFillGradientRamp() {
|
||||
const canvas = document.getElementById("ie-fill-ramp");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const t = this._fillTool;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const barH = h - 10; // 下部にハンドル用の余白を確保
|
||||
const grad = ctx.createLinearGradient(0, 0, w, 0);
|
||||
[...t.gradientStops].sort((a, b) => a.pos - b.pos).forEach(s => grad.addColorStop(s.pos, s.color));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, barH);
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.strokeRect(0.5, 0.5, w - 1, barH - 1);
|
||||
|
||||
t.gradientStops.forEach((s, i) => {
|
||||
// ハンドル(三角形)がcanvas端で切れて見えなくならないようクランプする
|
||||
const x = Math.max(5, Math.min(w - 5, s.pos * w));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, h);
|
||||
ctx.lineTo(x - 5, barH);
|
||||
ctx.lineTo(x + 5, barH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = i === t.selectedStopIdx ? "#0077ff" : "#999";
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
_setupFillGradientRamp() {
|
||||
const canvas = document.getElementById("ie-fill-ramp");
|
||||
if (!canvas) return;
|
||||
this._drawFillGradientRamp();
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
const t = this._fillTool;
|
||||
const hitTestStop = mx => {
|
||||
let best = -1, bestDist = 9;
|
||||
t.gradientStops.forEach((s, i) => {
|
||||
const d = Math.abs(s.pos * canvas.width - mx);
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
});
|
||||
return best;
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", e => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||
const idx = hitTestStop(mx);
|
||||
if (idx < 0) return;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
t.selectStop(idx);
|
||||
// ドラッグ中onMove/onUpが参照し続けるcanvas要素が入れ替わらないよう、
|
||||
// DOM全体を再構築する_renderFillProps()は使わず、選択状態の見た目だけを部分更新する
|
||||
this._drawFillGradientRamp();
|
||||
const colorInput = document.getElementById("ie-fill-stop-color");
|
||||
if (colorInput) colorInput.value = t.gradientStops[idx].color;
|
||||
|
||||
const onMove = ev => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const x = (ev.clientX - r.left) * (canvas.width / r.width);
|
||||
t.gradientStops[t.selectedStopIdx].pos = Math.max(0, Math.min(1, x / canvas.width));
|
||||
this._drawFillGradientRamp();
|
||||
};
|
||||
const onUp = () => {
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
/** グラデーション方向(linear)/半径(radial)を指定するミニコンパスUI。particle_widget.jsの矢印方向ハンドルを移植。 */
|
||||
_drawFillGradientDir() {
|
||||
const canvas = document.getElementById("ie-fill-dir");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
|
||||
const maxR = Math.min(w, h) / 2 - 6;
|
||||
const t = this._fillTool;
|
||||
const distFrac = (Math.max(0.2, Math.min(3.0, t.gradientStrength)) - 0.2) / 2.8;
|
||||
const dist = 6 + distFrac * (maxR - 6);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = "#888";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 4, cy); ctx.lineTo(cx + 4, cy);
|
||||
ctx.moveTo(cx, cy - 4); ctx.lineTo(cx, cy + 4);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.strokeStyle = "#0077ff";
|
||||
if (t.gradientShape === "radial") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dist, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
this._fillDirHandlePos = { x: cx + dist, y: cy };
|
||||
} else {
|
||||
const rad = t.gradientAngleDeg * Math.PI / 180;
|
||||
const ex = cx + Math.cos(rad) * dist, ey = cy + Math.sin(rad) * dist;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy); ctx.lineTo(ex, ey);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
this._fillDirHandlePos = { x: ex, y: ey };
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(this._fillDirHandlePos.x, this._fillDirHandlePos.y, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ffcc00";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
_setupFillGradientDir() {
|
||||
const canvas = document.getElementById("ie-fill-dir");
|
||||
if (!canvas) return;
|
||||
this._drawFillGradientDir();
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
const t = this._fillTool;
|
||||
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
|
||||
const maxR = Math.min(w, h) / 2 - 6;
|
||||
|
||||
const applyDrag = (mx, my) => {
|
||||
const dx = mx - cx, dy = my - cy;
|
||||
const dist = Math.max(6, Math.min(maxR, Math.hypot(dx, dy)));
|
||||
t.gradientStrength = 0.2 + ((dist - 6) / (maxR - 6)) * 2.8;
|
||||
if (t.gradientShape === "linear") t.gradientAngleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
this._drawFillGradientDir();
|
||||
const lbl = document.getElementById("ie-fill-strength-lbl");
|
||||
if (lbl) lbl.textContent = t.gradientStrength.toFixed(1);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", e => {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
applyDrag((e.clientX - rect.left) * (w / rect.width), (e.clientY - rect.top) * (h / rect.height));
|
||||
const onMove = ev => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
applyDrag((ev.clientX - r.left) * (w / r.width), (ev.clientY - r.top) * (h / r.height));
|
||||
};
|
||||
const onUp = () => {
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mask Editor One 追加ツール: 初期化・切り替え ──────────────────────────
|
||||
|
||||
_initMaskEditorOneTools() {
|
||||
@@ -1107,12 +1389,19 @@ class ImageEditTab {
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
document.getElementById("ie-open-psd-input")?.addEventListener("change", e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) this._loadPsdFile(file);
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
document.getElementById("ie-new-btn")?.addEventListener("click", () => this._newCanvas());
|
||||
document.getElementById("ie-close-btn")?.addEventListener("click", () => this._closeDocument());
|
||||
document.getElementById("ie-undo-btn")?.addEventListener("click", () => this._undo());
|
||||
document.getElementById("ie-redo-btn")?.addEventListener("click", () => this._redo());
|
||||
document.getElementById("ie-save-btn")?.addEventListener("click", () => this._fileExport.savePng());
|
||||
document.getElementById("ie-save-gallery-btn")?.addEventListener("click", () => this._fileExport.saveToGallery());
|
||||
document.getElementById("ie-upload-comfy-btn")?.addEventListener("click", () => this._fileExport.uploadToComfyUI());
|
||||
document.getElementById("ie-save-psd-btn")?.addEventListener("click", () => this._fileExport.saveAsPsd());
|
||||
document.getElementById("ie-send-workflow-btn")?.addEventListener("click", () => this._fileExport.sendToWorkflow());
|
||||
document.getElementById("ie-zoom-fit")?.addEventListener("click", () => this._fitToView());
|
||||
document.getElementById("ie-zoom-100")?.addEventListener("click", () => {
|
||||
@@ -1141,7 +1430,7 @@ class ImageEditTab {
|
||||
this._setZoom(this._zoom * (e.deltaY > 0 ? 0.9 : 1.1));
|
||||
}, { passive: false });
|
||||
|
||||
wrap.addEventListener("mousedown", e => {
|
||||
wrap.addEventListener("pointerdown", e => {
|
||||
if (e.button === 1 || (e.button === 0 && this._spaceDown)) {
|
||||
e.preventDefault();
|
||||
this._panning = true;
|
||||
@@ -1160,7 +1449,7 @@ class ImageEditTab {
|
||||
if (!this._layerMgr || !drawCanvas) return;
|
||||
this._onToolMouseDown(e, drawCanvas);
|
||||
});
|
||||
window.addEventListener("mousemove", e => {
|
||||
window.addEventListener("pointermove", e => {
|
||||
if (this._panning) {
|
||||
this._panOffset.x = e.clientX - this._panStart.x;
|
||||
this._panOffset.y = e.clientY - this._panStart.y;
|
||||
@@ -1179,7 +1468,7 @@ class ImageEditTab {
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener("mouseup", e => {
|
||||
window.addEventListener("pointerup", e => {
|
||||
if (this._panning && (e.button === 1 || e.button === 0)) {
|
||||
this._panning = false;
|
||||
wrap.style.cursor = this._spaceDown ? "grab" : "";
|
||||
@@ -1192,22 +1481,24 @@ class ImageEditTab {
|
||||
}
|
||||
});
|
||||
|
||||
// draw / text 用 mousedown
|
||||
// draw / text 用 pointerdown(ペンタブレット筆圧対応のため Pointer Events を使用)
|
||||
const drawCanvas = document.getElementById("ie-canvas-draw");
|
||||
if (drawCanvas) {
|
||||
drawCanvas.addEventListener("mousedown", e => this._onToolMouseDown(e, drawCanvas));
|
||||
drawCanvas.addEventListener("mousemove", e => this._onToolMouseMove(e, drawCanvas));
|
||||
drawCanvas.addEventListener("mouseup", e => this._onToolMouseUp(e));
|
||||
drawCanvas.addEventListener("mouseleave", () => this._onToolMouseLeave());
|
||||
drawCanvas.style.touchAction = "none";
|
||||
drawCanvas.addEventListener("pointerdown", e => this._onToolMouseDown(e, drawCanvas));
|
||||
drawCanvas.addEventListener("pointermove", e => this._onToolMouseMove(e, drawCanvas));
|
||||
drawCanvas.addEventListener("pointerup", e => this._onToolMouseUp(e));
|
||||
drawCanvas.addEventListener("pointerleave", () => this._onToolMouseLeave());
|
||||
}
|
||||
|
||||
// select 用 mousedown(overlayCanvas)
|
||||
// select 用 pointerdown(overlayCanvas)
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) {
|
||||
overlay.addEventListener("mousedown", e => this._onToolMouseDown(e, overlay));
|
||||
overlay.addEventListener("mousemove", e => this._onToolMouseMove(e, overlay));
|
||||
overlay.addEventListener("mouseup", e => this._onToolMouseUp(e));
|
||||
overlay.addEventListener("mouseleave", () => this._onToolMouseLeave());
|
||||
overlay.style.touchAction = "none";
|
||||
overlay.addEventListener("pointerdown", e => this._onToolMouseDown(e, overlay));
|
||||
overlay.addEventListener("pointermove", e => this._onToolMouseMove(e, overlay));
|
||||
overlay.addEventListener("pointerup", e => this._onToolMouseUp(e));
|
||||
overlay.addEventListener("pointerleave", () => this._onToolMouseLeave());
|
||||
// テキストオブジェクトのダブルクリックで再編集
|
||||
overlay.addEventListener("dblclick", e => this._onOverlayDblClick(e, overlay));
|
||||
}
|
||||
@@ -1215,16 +1506,27 @@ class ImageEditTab {
|
||||
|
||||
_onToolMouseDown(e, refCanvas) {
|
||||
if (!this._layerMgr || e.button !== 0 || this._spaceDown) return;
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
// PointerEvent.pressure: pen -> actual pressure, mouse -> 0.5 while a button is held.
|
||||
const pressure = e.pressure || 0.5;
|
||||
|
||||
if (this._activeTool === "draw" && this._drawTool) {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer) return;
|
||||
this._saveUndo();
|
||||
this._drawTool.setCanvas(activeLayer.canvas);
|
||||
this._drawTool.onMouseDown(pos.x, pos.y);
|
||||
this._drawTool.onMouseDown(pos.x, pos.y, pressure);
|
||||
this._updateCompositeView();
|
||||
|
||||
} else if (this._activeTool === "fill" && this._fillTool) {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer) return;
|
||||
this._saveUndo();
|
||||
this._fillTool.setCanvas(activeLayer.canvas);
|
||||
this._fillTool.onMouseDown(pos.x, pos.y);
|
||||
this._updateCompositeView();
|
||||
this._refreshLayerList();
|
||||
|
||||
} else if (this._activeTool === "mask") {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer || activeLayer.type !== "mask") {
|
||||
@@ -1235,7 +1537,7 @@ class ImageEditTab {
|
||||
if (sub === "paint" && this._maskTool) {
|
||||
this._saveUndo();
|
||||
this._maskTool.setCanvas(activeLayer.canvas);
|
||||
this._maskTool.onMouseDown(pos.x, pos.y);
|
||||
this._maskTool.onMouseDown(pos.x, pos.y, pressure);
|
||||
this._updateCompositeView();
|
||||
} else if (sub === "color" && this._maskColorTool) {
|
||||
this._saveUndo();
|
||||
@@ -1280,15 +1582,16 @@ class ImageEditTab {
|
||||
|
||||
_onToolMouseMove(e, refCanvas) {
|
||||
if (!this._layerMgr) return;
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pressure = e.pressure || 0.5;
|
||||
if (this._activeTool === "draw") {
|
||||
this._drawTool?.onMouseMove(pos.x, pos.y);
|
||||
this._drawTool?.onMouseMove(pos.x, pos.y, pressure);
|
||||
if (this._drawTool?._drawing) this._updateCompositeView();
|
||||
}
|
||||
if (this._activeTool === "mask") {
|
||||
const sub = this._maskSubtool;
|
||||
if (sub === "paint") {
|
||||
this._maskTool?.onMouseMove(pos.x, pos.y);
|
||||
this._maskTool?.onMouseMove(pos.x, pos.y, pressure);
|
||||
if (this._maskTool?._drawing) this._updateCompositeView();
|
||||
} else if (sub === "vector") {
|
||||
this._maskVectorTool?.onMouseMove(pos.x, pos.y);
|
||||
@@ -1402,6 +1705,102 @@ class ImageEditTab {
|
||||
showToast(`Loaded: ${img.width}×${img.height}`, "success");
|
||||
}
|
||||
|
||||
// PSDファイルをアップロードし、レイヤーを分解して新規ドキュメントとして読み込む。
|
||||
async _loadPsdFile(file) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/import-psd-layers", { method: "POST", body: form });
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
data = await r.json();
|
||||
} catch (err) {
|
||||
showToast(`PSD import failed: ${err.message}`, "error");
|
||||
return;
|
||||
}
|
||||
await this.loadPsdLayersData(data, file.name.replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
|
||||
/** バックエンドの import-psd-layers / psd-layers が返すレイヤー配列(dataURL+メタデータ)を
|
||||
* 新規ドキュメントとして読み込む。グループ・調整レイヤー等はバックエンド側で既にスキップ
|
||||
* 済みのフラットな画像レイヤー配列が渡される想定(ギャラリーからの直接送信でも使う共通経路)。
|
||||
*/
|
||||
async loadPsdLayersData(data, baseName = "psd-image") {
|
||||
const { width, height, layers } = data || {};
|
||||
if (!width || !height || !layers?.length) {
|
||||
showToast("No importable layers found in this PSD", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
this._canvasW = width;
|
||||
this._canvasH = height;
|
||||
this._baseName = baseName;
|
||||
this._initCanvases();
|
||||
|
||||
// レイヤーはバックエンド側で先頭=最前面の順に並べ替え済み
|
||||
const loaded = await Promise.all(layers.map(ld => new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const layer = new Layer(ld.name || "Layer", "image", ld.width, ld.height);
|
||||
layer.x = ld.x;
|
||||
layer.y = ld.y;
|
||||
layer.displayW = ld.width;
|
||||
layer.displayH = ld.height;
|
||||
layer.opacity = ld.opacity;
|
||||
layer.blendMode = ld.blendMode;
|
||||
layer.visible = ld.visible;
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
resolve(layer);
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = ld.imageData;
|
||||
})));
|
||||
|
||||
this._layerMgr.layers = loaded.filter(Boolean);
|
||||
this._layerMgr.activeIndex = 0;
|
||||
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._setActiveTool("select");
|
||||
this._selectTool?.setLayer(this._layerMgr.activeLayer);
|
||||
this._refreshLayerList();
|
||||
this._updateCompositeView();
|
||||
this._fitToView();
|
||||
|
||||
document.getElementById("ie-placeholder").style.display = "none";
|
||||
showToast(`PSD imported: ${this._layerMgr.layers.length} layer(s)`, "success");
|
||||
}
|
||||
|
||||
// 現在のドキュメントを閉じ、未保存の変更を破棄してプレースホルダー表示に戻す
|
||||
_closeDocument() {
|
||||
if (!this._layerMgr) return;
|
||||
if (!confirm("Close the current document? Unsaved changes will be lost.")) return;
|
||||
|
||||
this._layerMgr = null;
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._canvasW = 512;
|
||||
this._canvasH = 512;
|
||||
this._baseName = "image";
|
||||
this._zoom = 1.0;
|
||||
this._panOffset = { x: 0, y: 0 };
|
||||
|
||||
const drawCanvas = document.getElementById("ie-canvas-draw");
|
||||
const overlayCanvas = document.getElementById("ie-canvas-overlay");
|
||||
drawCanvas?.getContext("2d").clearRect(0, 0, drawCanvas.width, drawCanvas.height);
|
||||
overlayCanvas?.getContext("2d").clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
||||
|
||||
const layerList = document.getElementById("ie-layer-list");
|
||||
if (layerList) layerList.innerHTML = "";
|
||||
|
||||
const placeholder = document.getElementById("ie-placeholder");
|
||||
if (placeholder) placeholder.style.display = "";
|
||||
}
|
||||
|
||||
_newCanvas() {
|
||||
const current = `${this._canvasW || 512}x${this._canvasH || 512}`;
|
||||
const input = prompt("Canvas size (WxH):", current);
|
||||
@@ -1540,6 +1939,8 @@ class ImageEditTab {
|
||||
this._refreshLayerList();
|
||||
});
|
||||
|
||||
this._fillTool = new FillTool();
|
||||
|
||||
this._compositeMode = false;
|
||||
}
|
||||
|
||||
@@ -1751,6 +2152,45 @@ class ImageEditTab {
|
||||
|
||||
// ── レイヤーパネル ────────────────────────────
|
||||
|
||||
// 指定レイヤーをアクティブ化し、種別・現在のツールに応じてキャンバス/ツールを同期する
|
||||
// (レイヤーリストのクリック選択・複製直後のアクティブ化などで共用)
|
||||
_activateLayerById(id) {
|
||||
if (!this._layerMgr) return;
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._layerMgr.setActive(id);
|
||||
const layer = this._layerMgr.activeLayer;
|
||||
if (layer?.type === "mask") {
|
||||
if (this._activeTool !== "mask") {
|
||||
this._setActiveTool("mask");
|
||||
} else {
|
||||
// すでにマスクツール選択中 → canvas だけ切り替え
|
||||
this._maskTool?.setCanvas(layer.canvas);
|
||||
this._maskTool?.activate();
|
||||
}
|
||||
// operation に合わせて MaskTool の mode を同期
|
||||
if (this._maskTool) {
|
||||
this._maskTool.mode = layer.operation === "subtract" ? "erase" : "paint";
|
||||
this._renderToolOptions("mask");
|
||||
}
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "draw" && layer) {
|
||||
this._drawTool?.setCanvas(layer.canvas);
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "select" && layer) {
|
||||
this._selectTool?.setLayer(layer);
|
||||
this._updateCompositeView();
|
||||
} else {
|
||||
this._updateCompositeView();
|
||||
}
|
||||
this._refreshLayerList();
|
||||
if (layer) {
|
||||
const sl = document.getElementById("ie-layer-opacity");
|
||||
const lb = document.getElementById("ie-layer-opacity-label");
|
||||
if (sl) sl.value = Math.round(layer.opacity * 100);
|
||||
if (lb) lb.textContent = Math.round(layer.opacity * 100) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
_setupLayerPanel() {
|
||||
document.getElementById("ie-add-layer-btn")?.addEventListener("click", () => {
|
||||
if (!this._layerMgr) return;
|
||||
@@ -1765,6 +2205,24 @@ class ImageEditTab {
|
||||
this._activateCurrentTool();
|
||||
});
|
||||
|
||||
document.getElementById("ie-dup-layer-btn")?.addEventListener("click", async () => {
|
||||
if (!this._layerMgr) return;
|
||||
const active = this._layerMgr.activeLayer;
|
||||
if (!active) return;
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._saveUndo();
|
||||
|
||||
const json = active.toJSON();
|
||||
json.name = `${active.name} copy`;
|
||||
const dup = await Layer.fromJSON(json);
|
||||
dup.id = crypto.randomUUID();
|
||||
|
||||
const idx = this._layerMgr.layers.indexOf(active);
|
||||
this._layerMgr.layers.splice(idx, 0, dup);
|
||||
|
||||
this._activateLayerById(dup.id);
|
||||
});
|
||||
|
||||
document.getElementById("ie-add-mask-btn")?.addEventListener("click", () => {
|
||||
if (!this._layerMgr) { showToast("Open an image first", "info"); return; }
|
||||
this._syncActiveLayerFromCanvas();
|
||||
@@ -1874,39 +2332,7 @@ class ImageEditTab {
|
||||
if (sel?.id === id) this._selectTool?.setLayer(sel);
|
||||
this._refreshLayerList();
|
||||
} else if (action === "select") {
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._layerMgr.setActive(id);
|
||||
const layer = this._layerMgr.activeLayer;
|
||||
if (layer?.type === "mask") {
|
||||
if (this._activeTool !== "mask") {
|
||||
this._setActiveTool("mask");
|
||||
} else {
|
||||
// すでにマスクツール選択中 → canvas だけ切り替え
|
||||
this._maskTool?.setCanvas(layer.canvas);
|
||||
this._maskTool?.activate();
|
||||
}
|
||||
// operation に合わせて MaskTool の mode を同期
|
||||
if (this._maskTool) {
|
||||
this._maskTool.mode = layer.operation === "subtract" ? "erase" : "paint";
|
||||
this._renderToolOptions("mask");
|
||||
}
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "draw" && layer) {
|
||||
this._drawTool?.setCanvas(layer.canvas);
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "select" && layer) {
|
||||
this._selectTool?.setLayer(layer);
|
||||
this._updateCompositeView();
|
||||
} else {
|
||||
this._updateCompositeView();
|
||||
}
|
||||
this._refreshLayerList();
|
||||
if (layer) {
|
||||
const sl = document.getElementById("ie-layer-opacity");
|
||||
const lb = document.getElementById("ie-layer-opacity-label");
|
||||
if (sl) sl.value = Math.round(layer.opacity * 100);
|
||||
if (lb) lb.textContent = Math.round(layer.opacity * 100) + "%";
|
||||
}
|
||||
this._activateLayerById(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1963,6 +2389,60 @@ class ImageEditTab {
|
||||
}
|
||||
}
|
||||
|
||||
/** ギャラリーなど外部から複数画像URLをまとめてレイヤーとして新規ドキュメントに読み込む
|
||||
* @param {Array<{url: string, name?: string}>} items 先頭=最背面, 末尾=最前面として重ねる
|
||||
*/
|
||||
async loadLayersFromUrls(items) {
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
let imgs;
|
||||
try {
|
||||
imgs = await Promise.all(items.map(({ url }) => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load: ${url}`));
|
||||
img.src = url;
|
||||
})));
|
||||
} catch (err) {
|
||||
showToast(err.message || "Failed to load images", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const w = Math.max(...imgs.map(i => i.width));
|
||||
const h = Math.max(...imgs.map(i => i.height));
|
||||
|
||||
this._canvasW = w;
|
||||
this._canvasH = h;
|
||||
this._baseName = "gallery-layers";
|
||||
this._initCanvases();
|
||||
|
||||
const layers = imgs.map((img, i) => {
|
||||
const fit = fitToCanvas(img.width, img.height, w, h);
|
||||
const layer = new Layer(items[i].name || `Layer ${i + 1}`, "image", img.width, img.height);
|
||||
layer.displayW = fit.w;
|
||||
layer.displayH = fit.h;
|
||||
layer.x = Math.round((w - fit.w) / 2);
|
||||
layer.y = Math.round((h - fit.h) / 2);
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
return layer;
|
||||
});
|
||||
|
||||
// items は先頭=最背面の順で渡される想定のため、Layer配列規約(先頭=最前面)に合わせて反転する
|
||||
this._layerMgr.layers = layers.reverse();
|
||||
this._layerMgr.activeIndex = 0;
|
||||
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._setActiveTool("select");
|
||||
this._selectTool?.setLayer(this._layerMgr.activeLayer);
|
||||
this._refreshLayerList();
|
||||
this._updateCompositeView();
|
||||
this._fitToView();
|
||||
|
||||
document.getElementById("ie-placeholder").style.display = "none";
|
||||
showToast(`Loaded ${layers.length} image(s) as layers`, "success");
|
||||
}
|
||||
|
||||
// ── キーボードショートカット ──────────────────
|
||||
|
||||
_setupKeyboard() {
|
||||
@@ -1984,6 +2464,7 @@ class ImageEditTab {
|
||||
if (e.key === "b") this._setActiveTool("draw");
|
||||
if (e.key === "t") this._setActiveTool("text");
|
||||
if (e.key === "s") this._setActiveTool("shape");
|
||||
if (e.key === "g") this._setActiveTool("fill");
|
||||
// Delete/Backspaceで選択オブジェクト削除
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && this._activeTool === "select") {
|
||||
const layer = this._selectTool?.getSelectedLayer();
|
||||
|
||||
@@ -20,6 +20,11 @@ export class DrawTool {
|
||||
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";
|
||||
@@ -80,29 +85,31 @@ export class DrawTool {
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
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);
|
||||
this._paintToStroke(x, y, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
this._paint(x, y, pressure);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
onMouseMove(x, y, pressure = 0.5) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
this._paintLine(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
@@ -117,6 +124,20 @@ export class DrawTool {
|
||||
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() {
|
||||
@@ -155,54 +176,48 @@ export class DrawTool {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
_paintToStroke(x, y, pressure = 0.5) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
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);
|
||||
this._paintImageBrush(x, y, size);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
_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);
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
_getStamp(size) {
|
||||
size = Math.max(1, Math.round(size));
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampSize === size &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampColor === this.color
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
@@ -225,11 +240,12 @@ export class DrawTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
_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.opacity;
|
||||
this.ctx.globalAlpha = this._effectiveOpacity(pressure);
|
||||
if (this.mode === "erase") {
|
||||
this.ctx.globalCompositeOperation = "destination-out";
|
||||
}
|
||||
@@ -237,13 +253,13 @@ export class DrawTool {
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
_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);
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,8 +335,8 @@ export class DrawTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
_paintImageBrush(x, y, size = this.brushSize) {
|
||||
const stamp = this._getImageStamp(Math.round(size));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* 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 ComfyUI's own /upload/image endpoint.
|
||||
* Gallery, and the selected node's image widget on the ComfyUI canvas
|
||||
* (Send to LI node).
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
@@ -62,6 +63,92 @@ export class FileExport {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用レイヤー配列を構築: 各レイヤーを個別の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();
|
||||
@@ -104,24 +191,7 @@ export class FileExport {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadToComfyUI() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "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();
|
||||
showToast(`Uploaded: ${data.name}`, "success");
|
||||
} catch {
|
||||
showToast("Upload failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the composite like uploadToComfyUI(), then (via window.opener,
|
||||
// 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
|
||||
@@ -144,9 +214,9 @@ export class FileExport {
|
||||
const data = await r.json();
|
||||
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
|
||||
window.opener.wfmSendImageToSelectedNode(filename);
|
||||
showToast(`Sent "${filename}" to workflow`, "success");
|
||||
showToast(`Sent "${filename}" to LI node`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Send to workflow failed: ${err.message}`, "error");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export class MaskTool {
|
||||
this.sizeJitterAmount = 0.5; // 0–1: fraction of brushSize that can be removed
|
||||
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;
|
||||
|
||||
this._drawing = false;
|
||||
this._lastX = 0;
|
||||
this._lastY = 0;
|
||||
@@ -80,29 +85,31 @@ export class MaskTool {
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
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);
|
||||
this._paintToStroke(x, y, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
this._paint(x, y, pressure);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
onMouseMove(x, y, pressure = 0.5) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
this._paintLine(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
@@ -117,6 +124,14 @@ export class MaskTool {
|
||||
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
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
@@ -149,54 +164,48 @@ export class MaskTool {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
_paintToStroke(x, y, pressure = 0.5) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
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);
|
||||
this._paintImageBrush(x, y, size);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
_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);
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
_getStamp(size) {
|
||||
size = Math.max(1, Math.round(size));
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampSize === size &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampMode === this.mode
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
@@ -222,8 +231,9 @@ export class MaskTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
_paint(x, y, pressure = 0.5) {
|
||||
const size = this._effectiveSize(pressure);
|
||||
const stamp = this._getStamp(size);
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
@@ -233,13 +243,13 @@ export class MaskTool {
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
_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);
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,8 +317,8 @@ export class MaskTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
_paintImageBrush(x, y, size = this.brushSize) {
|
||||
const stamp = this._getImageStamp(Math.round(size));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -97,6 +97,81 @@ export async function readAllPNGTextChunks(file) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ── MP4 container metadata (moov/udta/meta の mdta keys/ilst) ──
|
||||
// ComfyUIのSaveVideoはPNGのtEXtチャンクと同じキー名(workflow=UI形式, prompt=API形式)で
|
||||
// JSON文字列をisobmff(mp4)のudta/meta(mdta)にコンテナレベルタグとして書き込む(FFmpegの
|
||||
// movflags=+use_metadata_tags)。ここではPyAV等に頼らずブラウザ側でボックスを直接辿って読む。
|
||||
function findMP4Box(view, bytes, start, end, targetType) {
|
||||
let offset = start;
|
||||
while (offset + 8 <= end) {
|
||||
const size = view.getUint32(offset);
|
||||
const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
|
||||
let boxSize = size;
|
||||
let bodyStart = offset + 8;
|
||||
if (size === 1) {
|
||||
if (offset + 16 > end) break;
|
||||
const hi = view.getUint32(offset + 8);
|
||||
const lo = view.getUint32(offset + 12);
|
||||
boxSize = hi * 4294967296 + lo;
|
||||
bodyStart = offset + 16;
|
||||
} else if (size === 0) {
|
||||
boxSize = end - offset;
|
||||
}
|
||||
if (boxSize < 8) break;
|
||||
if (type === targetType) return { bodyStart, end: offset + boxSize };
|
||||
offset += boxSize;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readMP4MetaTags(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
if (bytes.length < 12) return null;
|
||||
const ftyp = String.fromCharCode(bytes[4], bytes[5], bytes[6], bytes[7]);
|
||||
if (ftyp !== "ftyp") return null;
|
||||
|
||||
const moov = findMP4Box(view, bytes, 0, bytes.length, "moov");
|
||||
if (!moov) return null;
|
||||
const udta = findMP4Box(view, bytes, moov.bodyStart, moov.end, "udta");
|
||||
let meta = udta ? findMP4Box(view, bytes, udta.bodyStart, udta.end, "meta") : null;
|
||||
if (!meta) meta = findMP4Box(view, bytes, moov.bodyStart, moov.end, "meta");
|
||||
if (!meta) return null;
|
||||
const metaBody = meta.bodyStart + 4; // meta は FullBox (version+flags 4バイト)
|
||||
const keysBox = findMP4Box(view, bytes, metaBody, meta.end, "keys");
|
||||
const ilstBox = findMP4Box(view, bytes, metaBody, meta.end, "ilst");
|
||||
if (!keysBox || !ilstBox) return null;
|
||||
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: false });
|
||||
let off = keysBox.bodyStart + 4; // version+flags をスキップ
|
||||
const count = view.getUint32(off); off += 4;
|
||||
const keyNames = [];
|
||||
for (let i = 0; i < count && off + 8 <= keysBox.end; i++) {
|
||||
const ksize = view.getUint32(off);
|
||||
if (ksize < 8) break;
|
||||
keyNames.push(utf8.decode(bytes.slice(off + 8, off + ksize)));
|
||||
off += ksize;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
let ioff = ilstBox.bodyStart;
|
||||
while (ioff + 8 <= ilstBox.end) {
|
||||
const itemSize = view.getUint32(ioff);
|
||||
const keyIndex = view.getUint32(ioff + 4);
|
||||
if (itemSize < 8) break;
|
||||
const itemEnd = Math.min(ioff + itemSize, ilstBox.end);
|
||||
const data = findMP4Box(view, bytes, ioff + 8, itemEnd, "data");
|
||||
if (data) {
|
||||
const payload = bytes.slice(data.bodyStart + 8, data.end); // type(4)+locale(4)をスキップ
|
||||
const name = keyNames[keyIndex - 1];
|
||||
if (name) { try { result[name] = utf8.decode(payload); } catch { /* skip */ } }
|
||||
}
|
||||
ioff = itemEnd;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Workflow extraction helpers ───────────────────────────────
|
||||
function collectUnique(arr) {
|
||||
const seen = new Set(), out = [];
|
||||
@@ -174,6 +249,7 @@ function extractLoRAs(wf) {
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "LoraLoader") add(n.inputs?.lora_name, n.inputs?.strength_model, n.inputs?.strength_clip);
|
||||
else if (ct === "LoraLoaderModelOnly") add(n.inputs?.lora_name, n.inputs?.strength, 1.0);
|
||||
else if (ct === "ImageMetadataLoRALoader") { for (let i = 1; i <= 3; i++) add(n.inputs?.[`lora_${i}`], n.inputs?.[`strength_model_${i}`], n.inputs?.[`strength_clip_${i}`]); }
|
||||
else if (ct === "Lora Loader (LoraManager)") {
|
||||
const lorasData = n.inputs?.loras;
|
||||
const list = lorasData?.__value__ ?? (Array.isArray(lorasData) ? lorasData : null);
|
||||
@@ -184,10 +260,61 @@ function extractLoRAs(wf) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// LiveChatStream(ComfyUI-LiveChatStream)のRETURN_NAMES順 = INPUT_TYPESの対応ウィジェット名
|
||||
// (positive→prompt_text, negative→negative_text, response→response_text, ...)。nodes.pyの
|
||||
// INPUT_TYPES/RETURN_TYPESコメント通り、ウィジェット・出力スロットとも末尾追加のみで位置互換。
|
||||
const LIVE_CHAT_STREAM_OUTPUT_KEYS = ["prompt_text", "negative_text", "response_text", "chat_p_text", "chat_n_text", "thinking_text"];
|
||||
|
||||
function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("TextEncode") || ct.includes("TextEncoderSD"); }
|
||||
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
|
||||
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
|
||||
|
||||
// サンプラーノードの設定値(seed/steps/cfg/sampler_name/scheduler/denoise)をAPI形式の
|
||||
// 名前付きinputsから読む。ノードタイプごとにwidgets_valuesの並びが異なる(KSampler/
|
||||
// KSamplerAdvanced等)ため、UI形式(LiteGraph)はconvertUiToApi()を経由した後に呼ぶ想定。
|
||||
const SAMPLER_SETTING_KEYS = ["seed", "noise_seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"];
|
||||
function extractSamplerSettings(apiWf) {
|
||||
if (!apiWf || typeof apiWf !== "object" || Array.isArray(apiWf.nodes)) return null;
|
||||
const pick = (inputs, keys) => {
|
||||
const out = {};
|
||||
for (const key of keys) {
|
||||
const v = inputs?.[key];
|
||||
if (v !== undefined && !Array.isArray(v)) out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
// Flux/SD3系はRandomNoise+BasicScheduler+KSamplerSelect+CFGGuider等に設定が分散するため、
|
||||
// 補助ノードから不足分を補う。
|
||||
const AUX = {
|
||||
RandomNoise: ["noise_seed"],
|
||||
BasicScheduler: ["steps", "scheduler", "denoise"],
|
||||
KSamplerSelect: ["sampler_name"],
|
||||
CFGGuider: ["cfg"],
|
||||
DualCFGGuider: ["cfg"],
|
||||
};
|
||||
// 設定キーを最も多く持つサンプラーを主とし、同数なら後ろのノード(hires-fix等の最終パス)を優先する
|
||||
let main = null, mainCount = 0;
|
||||
const aux = {};
|
||||
for (const [id, n] of Object.entries(apiWf)) {
|
||||
if (!n || typeof n !== "object") continue;
|
||||
const ct = n.class_type ?? "";
|
||||
const inputs = n.inputs ?? {};
|
||||
if (AUX[ct]) {
|
||||
const got = pick(inputs, AUX[ct]);
|
||||
for (const [k, v] of Object.entries(got)) if (!(k in aux)) aux[k] = v;
|
||||
continue;
|
||||
}
|
||||
if (!isSamplerNode(ct)) continue;
|
||||
const settings = pick(inputs, SAMPLER_SETTING_KEYS);
|
||||
const count = Object.keys(settings).length;
|
||||
if (count > 0 && count >= mainCount) {
|
||||
main = settings; mainCount = count;
|
||||
}
|
||||
}
|
||||
const merged = { ...aux, ...(main ?? {}) };
|
||||
return Object.keys(merged).length > 0 ? merged : null;
|
||||
}
|
||||
|
||||
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
|
||||
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
|
||||
function resolveEditPlusText(mode, textEdit, text1, text2) {
|
||||
@@ -203,7 +330,7 @@ function resolveEditPlusText(mode, textEdit, text1, text2) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractPrompts(wf) {
|
||||
export function extractPrompts(wf) {
|
||||
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
|
||||
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
|
||||
}
|
||||
@@ -284,18 +411,16 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(type)) continue;
|
||||
const text = n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") {
|
||||
textMap.set(n.id, text);
|
||||
} else if (Array.isArray(n.inputs)) {
|
||||
const textInput = n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt");
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
// A text widget converted to a wired input keeps its last typed value in widgets_values[0]
|
||||
// (stale — never sent to the backend), so the linked source must win over it.
|
||||
const textInput = Array.isArray(n.inputs)
|
||||
? n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt")
|
||||
: null;
|
||||
const linkedText = textInput?.link != null && linkOrigin.has(textInput.link)
|
||||
? resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, linkOrigin.get(textInput.link), linkSlot.get(textInput.link) ?? 0)
|
||||
: null;
|
||||
const text = linkedText || n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") textMap.set(n.id, text);
|
||||
}
|
||||
const pos = new Set(), neg = new Set();
|
||||
let foundSampler = false;
|
||||
@@ -306,11 +431,13 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role slots.
|
||||
let inputsToScan = n.inputs;
|
||||
let viaBasicGuider = false;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
viaBasicGuider = guiderNode?.type === "BasicGuider";
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
@@ -318,7 +445,9 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
// BasicGuider (Flux/Ming Image etc., no negative): its single "conditioning" input is positive.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive")
|
||||
|| (viaBasicGuider && name === "conditioning");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
@@ -405,6 +534,15 @@ function resolveLinkedText(wf, srcId, slot, depth = 0) {
|
||||
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// LiveChatStream (ComfyUI-LiveChatStream) — 出力(positive/negative/response/chat_p/
|
||||
// chat_n/thinking)は同名+"_text"の非表示ウィジェットの値をそのまま返すだけのノード。
|
||||
// 下の汎用キー探索には引っかからない(prompt_text等は"prompt"等と一致しない)ため個別対応する。
|
||||
// response(LLM応答全文)は数千〜数万文字になり得るが、文字列を返すだけなので問題なく解決できる。
|
||||
if (ct === "LiveChatStream") {
|
||||
const key = LIVE_CHAT_STREAM_OUTPUT_KEYS[slot];
|
||||
const v = key ? src.inputs?.[key] : undefined;
|
||||
return (typeof v === "string" && v) ? v : null;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
|
||||
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
|
||||
// リテラルへ解決できた方を採用する。
|
||||
@@ -459,6 +597,13 @@ function extractPromptsAPI(wf) {
|
||||
if (combined) textMap.set(id, combined);
|
||||
continue;
|
||||
}
|
||||
// MiniMaxH3ImageToVideo等の動画生成オールインワンノード。CLIPTextEncode等を経由せず
|
||||
// 自ノードの"prompt"入力に直接テキストを持つ(positive/negativeの区別も無い)ため、
|
||||
// isTextEncoderNode判定を通さずtextMapへ直接投入する(fallbackのtextsに載る)。
|
||||
if (typeof n.inputs?.prompt === "string" && /ImageToVideo|TextToVideo/i.test(ct)) {
|
||||
textMap.set(id, n.inputs.prompt);
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(ct)) continue;
|
||||
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
|
||||
// model text encoders use this key instead of "text"/"text_g".
|
||||
@@ -480,14 +625,18 @@ function extractPromptsAPI(wf) {
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
let viaBasicGuider = false;
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
viaBasicGuider = guiderNode?.class_type === "BasicGuider";
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
// BasicGuider (Flux/Ming Image etc., no negative): its single "conditioning" input is positive.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive")
|
||||
|| (viaBasicGuider && key === "conditioning");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
@@ -577,7 +726,7 @@ function extractPromptsLiteGraph(wf) {
|
||||
}
|
||||
|
||||
// ── SD/Fooocus prompt extraction ──────────────────────────────
|
||||
function parseSDAParameters(raw) {
|
||||
export function parseSDAParameters(raw) {
|
||||
const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const stepsMatch = text.match(/\nSteps:\s+\d/);
|
||||
if (!stepsMatch) return null;
|
||||
@@ -595,32 +744,51 @@ function parseSDAParameters(raw) {
|
||||
while ((m = re.exec(paramsLine)) !== null) params[m[1].trim()] = m[2].trim().replace(/^"|"$/g, "");
|
||||
return { positive, negative, params };
|
||||
}
|
||||
function parseFooocusMetadata(raw) {
|
||||
export function parseFooocusMetadata(raw) {
|
||||
let obj; try { obj = JSON.parse(raw); } catch { return null; }
|
||||
if (!obj?.base_model) return null;
|
||||
const toArray = v => !v ? [] : Array.isArray(v) ? v.filter(Boolean) : [String(v)];
|
||||
return { checkpoint: obj.base_model, vae: (obj.vae && obj.vae !== "Default") ? obj.vae : null, positives: toArray(obj.full_prompt ?? obj.prompt), negatives: toArray(obj.full_negative_prompt ?? obj.negative_prompt) };
|
||||
}
|
||||
|
||||
// ComfyUIワークフローを持たない画像(A1111/Forge/Fooocus)の埋め込みテキストからプロンプトを取り出す。
|
||||
// embedded: PNG tEXt/iTXtのキー→文字列マップ。取り出せなければnull。
|
||||
export function extractPromptsFromParameters(embedded) {
|
||||
const raw = embedded?.parameters;
|
||||
if (!raw || typeof raw !== "string") return null;
|
||||
if (embedded.fooocus_scheme === "fooocus") {
|
||||
const f = parseFooocusMetadata(raw);
|
||||
return f ? { positives: f.positives, negatives: f.negatives, texts: [] } : null;
|
||||
}
|
||||
const p = parseSDAParameters(raw);
|
||||
if (!p) return null;
|
||||
return { positives: p.positive ? [p.positive] : [], negatives: p.negative ? [p.negative] : [], texts: [] };
|
||||
}
|
||||
|
||||
// ── Master extractAllMetadata ─────────────────────────────────
|
||||
export async function extractAllMetadata(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
const isJSON = file.type === "application/json" || name.endsWith(".json");
|
||||
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
|
||||
const isMP4 = file.type === "video/mp4" || name.endsWith(".mp4");
|
||||
|
||||
async function fromWorkflow(originalWf, source) {
|
||||
let wf = originalWf;
|
||||
let apiWf = Array.isArray(wf?.nodes) ? null : wf;
|
||||
// サブグラフを持つワークフローは、外側のサブグラフノードに実際に設定された値
|
||||
// (ユーザーが見ている値)が内部ノードのwidgets_valuesへ反映されていないことがある
|
||||
// (テンプレートの古いデフォルト値のまま)。convertUiToApi() はこの注入も含めて
|
||||
// 正しく解決するため、そちらを経由してから抽出する。通常のワークフローには影響しない。
|
||||
if (Array.isArray(wf?.nodes) && wf.definitions?.subgraphs?.length > 0) {
|
||||
if (Array.isArray(wf?.nodes)) {
|
||||
try {
|
||||
const apiWf = await comfyWorkflow.convertUiToApi(wf);
|
||||
if (apiWf && Object.keys(apiWf).length > 0) wf = apiWf;
|
||||
const converted = await comfyWorkflow.convertUiToApi(wf, { readOnly: true });
|
||||
if (converted && Object.keys(converted).length > 0) {
|
||||
apiWf = converted;
|
||||
if (wf.definitions?.subgraphs?.length > 0) wf = converted;
|
||||
}
|
||||
} catch { /* 変換失敗時は元のUI形式のまま抽出(既存ロジックにフォールバック) */ }
|
||||
}
|
||||
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
|
||||
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), samplerSettings: extractSamplerSettings(apiWf), ...extractPrompts(wf) };
|
||||
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)。
|
||||
// API変換後は nodes 情報が失われるため、常に元のUI形式ワークフローから抽出する。
|
||||
const mdm = extractMarkdownNoteModels(originalWf);
|
||||
@@ -644,6 +812,17 @@ export async function extractAllMetadata(file) {
|
||||
const wf = extractWorkflowFromEXIF(exif);
|
||||
return wf ? await fromWorkflow(wf, "comfyui") : null;
|
||||
}
|
||||
if (isMP4) {
|
||||
let tags; try { tags = await readMP4MetaTags(file); } catch { return null; }
|
||||
if (!tags) return null;
|
||||
for (const key of ["prompt", "workflow"]) {
|
||||
const raw = tags[key];
|
||||
if (!raw) continue;
|
||||
let wf; try { wf = JSON.parse(sanitizeJSON(raw)); } catch { continue; }
|
||||
if (wf) return await fromWorkflow(wf, "comfyui");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// PNG
|
||||
const chunks = await readAllPNGTextChunks(file);
|
||||
if (!chunks) return null;
|
||||
@@ -693,7 +872,7 @@ function buildLoRAItem(lora) {
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
|
||||
export function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-meta-item wfm-meta-item-clickable";
|
||||
const snippet = label.length > 60 ? label.slice(0, 60) + "…" : label;
|
||||
@@ -722,10 +901,13 @@ function renderSection(sectionEl, listEl, items, buildFn) {
|
||||
// ── External API ──────────────────────────────────────────────
|
||||
let _externalHandleFile = null;
|
||||
|
||||
export async function loadFileIntoMetadataTab(file) {
|
||||
document.querySelector('.wfm-tab[data-tab="metadata"]')?.click();
|
||||
// path: Gallery上のサーバーファイルパス(任意)。渡された場合、/wfm/gallery/image/meta から
|
||||
// 幅・高さやnanobanana履歴(cc_nanobananaフォルダの画像のみ)を補って表示する。
|
||||
export async function loadFileIntoMetadataTab(file, path) {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="metadata"]')?.click();
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
if (_externalHandleFile) await _externalHandleFile(file);
|
||||
if (_externalHandleFile) await _externalHandleFile(file, path);
|
||||
}
|
||||
|
||||
// ── Tab initialization ────────────────────────────────────────
|
||||
@@ -752,6 +934,26 @@ export function initMetadataTab() {
|
||||
const promptFull = document.getElementById("wfm-meta-prompt-full");
|
||||
const promptFullLabel = document.getElementById("wfm-meta-prompt-full-label");
|
||||
|
||||
// Settings (画像サイズ・KSampler設定値) / Nanobanana履歴
|
||||
const settingsSection = document.getElementById("wfm-meta-settings-section");
|
||||
const setSize = document.getElementById("wfm-meta-set-size");
|
||||
const setSeed = document.getElementById("wfm-meta-set-seed");
|
||||
const setSteps = document.getElementById("wfm-meta-set-steps");
|
||||
const setCfg = document.getElementById("wfm-meta-set-cfg");
|
||||
const setSampler = document.getElementById("wfm-meta-set-sampler");
|
||||
const setScheduler = document.getElementById("wfm-meta-set-scheduler");
|
||||
const setDenoise = document.getElementById("wfm-meta-set-denoise");
|
||||
|
||||
const nbSection = document.getElementById("wfm-meta-nb-section");
|
||||
const nbEngine = document.getElementById("wfm-meta-nb-engine");
|
||||
const nbModel = document.getElementById("wfm-meta-nb-model");
|
||||
const nbSize = document.getElementById("wfm-meta-nb-size");
|
||||
const nbSeed = document.getElementById("wfm-meta-nb-seed");
|
||||
const nbTimestamp = document.getElementById("wfm-meta-nb-timestamp");
|
||||
const nbPrompt = document.getElementById("wfm-meta-nb-prompt");
|
||||
const nbNegSection = document.getElementById("wfm-meta-nb-negative-section");
|
||||
const nbNegative = document.getElementById("wfm-meta-nb-negative");
|
||||
|
||||
if (!dropZone) return;
|
||||
|
||||
// Apply i18n to section titles
|
||||
@@ -762,6 +964,8 @@ export function initMetadataTab() {
|
||||
"wfm-meta-te-section": "metaSectionTe",
|
||||
"wfm-meta-lora-section": "metaSectionLora",
|
||||
"wfm-meta-prompt-section": "metaSectionPrompt",
|
||||
"wfm-meta-settings-section": "metaSectionSettings",
|
||||
"wfm-meta-nb-section": "metaSectionNanobanana",
|
||||
};
|
||||
for (const [id, key] of Object.entries(titleMap)) {
|
||||
const title = document.querySelector(`#${id} .wfm-meta-section-title`);
|
||||
@@ -790,6 +994,8 @@ export function initMetadataTab() {
|
||||
"wfm-help-metadata-3": "helpMetadata3",
|
||||
"wfm-help-metadata-4": "helpMetadata4",
|
||||
"wfm-help-metadata-5": "helpMetadata5",
|
||||
"wfm-help-metadata-6": "helpMetadata6",
|
||||
"wfm-help-metadata-7": "helpMetadata7",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIds)) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -801,10 +1007,70 @@ export function initMetadataTab() {
|
||||
[ckptSection, vaeSection, diffSection, teSection, loraSection, promptSection].forEach(s => { if (s) s.classList.add("wfm-meta-section-empty"); });
|
||||
if (promptFull) promptFull.value = "";
|
||||
if (promptFullLabel) promptFullLabel.textContent = "";
|
||||
renderSettings(null, null);
|
||||
renderNanobanana(null);
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
// ── Settings (画像サイズ・KSampler設定) ─────────────────────
|
||||
function setRow(el, label, value) {
|
||||
if (!el) return;
|
||||
el.textContent = (value !== undefined && value !== null && value !== "") ? `${label}: ${value}` : "";
|
||||
}
|
||||
|
||||
function renderSettings(samplerSettings, dims) {
|
||||
setRow(setSize, t("metaSettingsSize") || "Size", dims?.width && dims?.height ? `${dims.width}×${dims.height}` : "");
|
||||
const s = samplerSettings ?? {};
|
||||
setRow(setSeed, t("metaSettingsSeed") || "Seed", s.seed ?? s.noise_seed);
|
||||
setRow(setSteps, t("metaSettingsSteps") || "Steps", s.steps);
|
||||
setRow(setCfg, t("metaSettingsCfg") || "CFG", s.cfg);
|
||||
setRow(setSampler, t("metaSettingsSampler") || "Sampler", s.sampler_name);
|
||||
setRow(setScheduler, t("metaSettingsScheduler") || "Scheduler", s.scheduler);
|
||||
setRow(setDenoise, t("metaSettingsDenoise") || "Denoise", s.denoise);
|
||||
const hasAny = (dims?.width && dims?.height) || Object.keys(s).length > 0;
|
||||
if (settingsSection) settingsSection.style.display = hasAny ? "" : "none";
|
||||
}
|
||||
|
||||
// ── Nanobanana履歴(cc_nanobananaフォルダの画像のみ、Galleryから開いた場合に表示) ──
|
||||
function renderNanobanana(entry) {
|
||||
if (!nbSection) return;
|
||||
if (!entry) {
|
||||
nbSection.style.display = "none";
|
||||
return;
|
||||
}
|
||||
nbSection.style.display = "";
|
||||
setRow(nbEngine, t("metaNbEngine") || "Engine", entry.engine);
|
||||
setRow(nbModel, t("metaNbModel") || "Model", entry.model);
|
||||
const size = entry.image_size || (entry.width && entry.height ? `${entry.width}×${entry.height}` : "");
|
||||
setRow(nbSize, t("metaNbSize") || "Size", size);
|
||||
setRow(nbSeed, t("metaNbSeed") || "Seed", entry.seed != null ? String(entry.seed) : "");
|
||||
setRow(nbTimestamp, t("metaNbTimestamp") || "Date", entry.timestamp);
|
||||
if (nbPrompt) nbPrompt.value = entry.prompt || "";
|
||||
if (nbNegSection && nbNegative) {
|
||||
if (entry.negative_prompt) { nbNegative.value = entry.negative_prompt; nbNegSection.style.display = ""; }
|
||||
else { nbNegative.value = ""; nbNegSection.style.display = "none"; }
|
||||
}
|
||||
}
|
||||
|
||||
// path指定時、サーバーの /wfm/gallery/image/meta から幅・高さとnanobanana履歴を取得する
|
||||
// (Galleryタブから「Metadata」ボタンで開いた場合のみ渡される。ドラッグ&ドロップされた
|
||||
// 任意ファイルはブラウザ上のFileオブジェクトのみでサーバーパスを持たないため対象外)。
|
||||
async function fetchServerMeta(path) {
|
||||
if (!path) return null;
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.error ? null : json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 連続で画像を開いたとき、先に始めた処理が後から描画して結果が混ざるのを防ぐ
|
||||
let _loadSeq = 0;
|
||||
async function handleFile(file, path) {
|
||||
if (!file) return;
|
||||
const seq = ++_loadSeq;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
fileInfo.textContent = t("metaFileTooLarge");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
@@ -815,32 +1081,60 @@ export function initMetadataTab() {
|
||||
fileInfo.style.color = "var(--wfm-text-secondary)";
|
||||
clearAll();
|
||||
|
||||
const serverMeta = await fetchServerMeta(path);
|
||||
if (seq !== _loadSeq) return;
|
||||
|
||||
// Show preview for images
|
||||
const isImage = file.type.startsWith("image/") || file.name.toLowerCase().match(/\.(png|webp|jpg|jpeg)$/);
|
||||
let naturalDims = null;
|
||||
if (isImage) {
|
||||
const url = URL.createObjectURL(file);
|
||||
previewImg.src = url;
|
||||
previewImg.style.display = "block";
|
||||
dropLabel.style.display = "none";
|
||||
previewImg.onload = () => URL.revokeObjectURL(url);
|
||||
await new Promise(resolve => {
|
||||
previewImg.onload = () => {
|
||||
naturalDims = { width: previewImg.naturalWidth, height: previewImg.naturalHeight };
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
};
|
||||
previewImg.onerror = resolve;
|
||||
});
|
||||
} else {
|
||||
previewImg.style.display = "none";
|
||||
dropLabel.style.display = "flex";
|
||||
}
|
||||
|
||||
if (seq !== _loadSeq) return;
|
||||
const dims = (serverMeta?.width && serverMeta?.height) ? serverMeta : naturalDims;
|
||||
renderNanobanana(serverMeta?.nanobanana ?? null);
|
||||
|
||||
let meta;
|
||||
try {
|
||||
meta = await extractAllMetadata(file);
|
||||
} catch (err) {
|
||||
if (seq !== _loadSeq) return;
|
||||
console.error("[MetadataTab]", err);
|
||||
fileInfo.textContent = t("metaParseError");
|
||||
fileInfo.style.color = "var(--wfm-danger)";
|
||||
renderSettings(null, dims);
|
||||
return;
|
||||
}
|
||||
|
||||
if (seq !== _loadSeq) return;
|
||||
if (!meta) {
|
||||
fileInfo.textContent = t("metaNoMetadata");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
// nanobanana(Gemini API経由)の画像はComfyUIワークフローを埋め込んでいないため
|
||||
// extractAllMetadataはnullを返す。その場合でもnanobanana履歴があれば
|
||||
// 「メタデータ無し」エラーにせず、画像サイズのみSettingsに表示する。
|
||||
if (serverMeta?.nanobanana) {
|
||||
const sizeKB = (file.size / 1024).toFixed(1);
|
||||
fileInfo.textContent = `${file.name} (${sizeKB} KB · Nanobanana)`;
|
||||
fileInfo.style.color = "var(--wfm-text-secondary)";
|
||||
} else {
|
||||
fileInfo.textContent = t("metaNoMetadata");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
}
|
||||
renderSettings(null, dims);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -854,6 +1148,7 @@ export function initMetadataTab() {
|
||||
renderSection(diffSection, diffList, meta.diffusionModels, n => buildModelItem(n));
|
||||
renderSection(teSection, teList, meta.textEncoders, n => buildModelItem(n));
|
||||
renderSection(loraSection, loraList, meta.loras, l => buildLoRAItem(l));
|
||||
renderSettings(meta.samplerSettings, dims);
|
||||
|
||||
// Prompts
|
||||
promptList.innerHTML = "";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { openBadgeEditModal, renderBadgeFilter, setGridChangeCallback } from "./
|
||||
import { isModelDisabled, renderTagFilter, renderGroupFilter, renderDirFilter } from "./models/filters.js";
|
||||
import { renderModelGrid, clearBatchGroup, clearStackGroup } from "./models/grid-view.js";
|
||||
import { toggleSelectMode, renderBulkActionBar, fetchSubdirs } from "./models/selection-bulk.js";
|
||||
import { fetchPreviewKeys } from "./models/helpers.js";
|
||||
import { closeSidePanel, renderSideCivitai, renderSideInfo, batchFetchCivitai, fetchCivitaiCache } from "./models/detail-panel.js";
|
||||
|
||||
export { openBadgeEditModal };
|
||||
@@ -302,7 +303,7 @@ async function loadModelsForCurrentType() {
|
||||
if (!fetchFn) throw new Error("Unknown model type: " + type);
|
||||
|
||||
const [models, disabledList, groups] = await Promise.all([
|
||||
fetchFn(), fetchDisabledModels(type), fetchModelGroups(),
|
||||
fetchFn(), fetchDisabledModels(type), fetchModelGroups(), fetchPreviewKeys(type),
|
||||
]);
|
||||
const disabledSet = new Set(Array.isArray(disabledList) ? disabledList : []);
|
||||
state.disabledModels[type] = disabledSet;
|
||||
@@ -550,6 +551,16 @@ export function initModelsTab() {
|
||||
document.getElementById("wfm-models-civitai-batch-btn")?.addEventListener("click", () => {
|
||||
batchFetchCivitai();
|
||||
});
|
||||
// Skip models already known to be missing on CivitAI (persisted per browser)
|
||||
const skipNfEl = document.getElementById("wfm-models-civitai-skip-nf");
|
||||
const skipNfLabel = document.getElementById("wfm-models-civitai-skip-nf-label");
|
||||
if (skipNfLabel) skipNfLabel.textContent = t("civitaiSkipNotFound");
|
||||
if (skipNfEl) {
|
||||
skipNfEl.checked = localStorage.getItem("wfm_models_civitai_skip_nf") === "1";
|
||||
skipNfEl.addEventListener("change", () => {
|
||||
localStorage.setItem("wfm_models_civitai_skip_nf", skipNfEl.checked ? "1" : "0");
|
||||
});
|
||||
}
|
||||
|
||||
// Side panel tab switching
|
||||
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((btn) => {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Models Tab - Auto badges from CivitAI base model info
|
||||
*
|
||||
* CivitAI's `baseModel` is already a categorical string, so labels are derived
|
||||
* with a deterministic rule table (variants such as "SDXL 1.0" / "SDXL Turbo"
|
||||
* collapse into one family badge). Unknown strings fall back to the raw value.
|
||||
* Apply semantics: model badges are appended (existing badges are kept);
|
||||
* a label already in the palette gets its color overwritten (optional).
|
||||
*/
|
||||
|
||||
import { openModal, closeModal, showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { getCurrentModels } from "./filters.js";
|
||||
import { getBadgePalette, saveBadgePalette, renderBadgeFilter } from "./badges.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import { saveModelMetadata } from "../models-tab.js";
|
||||
|
||||
// [pattern, label, color] — first match wins, so specific families come first.
|
||||
const FAMILY_RULES = [
|
||||
[/^pony/i, "Pony", "#d946ef"],
|
||||
[/^illustrious/i, "Illustrious", "#f59e0b"],
|
||||
[/^noobai/i, "NoobAI", "#14b8a6"],
|
||||
[/^sdxl|^sd ?xl/i, "SDXL", "#3b82f6"],
|
||||
[/^sd ?1\.[45]/i, "SD1.5", "#22c55e"],
|
||||
[/^sd ?2/i, "SD2", "#84cc16"],
|
||||
[/^sd ?3/i, "SD3", "#06b6d4"],
|
||||
[/^flux\.?1|^flux$/i, "Flux.1", "#ef4444"],
|
||||
[/^flux\.?2/i, "Flux.2", "#dc2626"],
|
||||
[/^qwen/i, "Qwen", "#8b5cf6"],
|
||||
[/^wan/i, "Wan", "#0ea5e9"],
|
||||
[/^z-?image/i, "Z-Image", "#ec4899"],
|
||||
[/^hidream/i, "HiDream", "#f97316"],
|
||||
[/^hunyuan/i, "Hunyuan", "#6366f1"],
|
||||
[/^chroma/i, "Chroma", "#a855f7"],
|
||||
[/^lumina/i, "Lumina", "#10b981"],
|
||||
[/^auraflow/i, "AuraFlow", "#64748b"],
|
||||
[/^kolors/i, "Kolors", "#e11d48"],
|
||||
];
|
||||
const SKIP_VALUES = new Set(["", "other", "unknown"]);
|
||||
const FALLBACK_COLORS = ["#0891b2", "#7c3aed", "#be185d", "#b45309", "#4d7c0f", "#475569"];
|
||||
|
||||
export function baseModelToBadge(baseModel) {
|
||||
const raw = (baseModel || "").trim();
|
||||
if (SKIP_VALUES.has(raw.toLowerCase())) return null;
|
||||
for (const [re, label, color] of FAMILY_RULES) {
|
||||
if (re.test(raw)) return { label, color };
|
||||
}
|
||||
let h = 0;
|
||||
for (const ch of raw) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return { label: raw, color: FALLBACK_COLORS[h % FALLBACK_COLORS.length] };
|
||||
}
|
||||
|
||||
/** Scan current-type models → { assignments: [{model, label}], labels: Map(label → {color, count}) } */
|
||||
function buildPlan() {
|
||||
const assignments = [];
|
||||
const labels = new Map();
|
||||
let noInfo = 0;
|
||||
for (const m of getCurrentModels()) {
|
||||
const sha = state.modelMetadata[m]?.sha256;
|
||||
const civ = sha && state.civitaiCache[sha];
|
||||
const badge = civ ? baseModelToBadge(civ.baseModel) : null;
|
||||
if (!badge) { noInfo++; continue; }
|
||||
assignments.push({ model: m, label: badge.label });
|
||||
const entry = labels.get(badge.label) || { color: badge.color, count: 0 };
|
||||
entry.count++;
|
||||
labels.set(badge.label, entry);
|
||||
}
|
||||
return { assignments, labels, noInfo };
|
||||
}
|
||||
|
||||
export function openAutoBadgeModal(onDone = null) {
|
||||
const { assignments, labels, noInfo } = buildPlan();
|
||||
if (assignments.length === 0) {
|
||||
showToast(t("autoBadgeNoData"), "warning");
|
||||
return;
|
||||
}
|
||||
const palette = getBadgePalette();
|
||||
const rows = [...labels.entries()].sort((a, b) => b[1].count - a[1].count).map(([label, info]) => {
|
||||
const exists = label in palette;
|
||||
return `<div class="wfm-badge-color-row">
|
||||
<span class="wfm-badge wfm-badge-model" style="background:${info.color};color:#fff;">${escapeHtml(label)}</span>
|
||||
<span style="font-size:12px;">${info.count}</span>
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">${exists ? t("autoBadgeExisting") : t("autoBadgeNew")}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
openModal(t("autoBadgeTitle"), `
|
||||
<div style="min-width:340px;">
|
||||
<p style="font-size:12px;color:var(--wfm-text-secondary);margin:0 0 8px;">${t("autoBadgeDesc")}</p>
|
||||
${rows}
|
||||
<p style="font-size:11px;color:var(--wfm-text-secondary);">${t("autoBadgeNoInfo")}: ${noInfo}</p>
|
||||
<label style="font-size:12px;display:block;margin:8px 0;">
|
||||
<input type="checkbox" id="wfm-auto-badge-overwrite" checked> ${t("autoBadgeOverwrite")}
|
||||
</label>
|
||||
<label style="font-size:12px;display:block;margin:8px 0;">
|
||||
<input type="checkbox" id="wfm-auto-badge-skip"> ${t("autoBadgeSkipExisting")}
|
||||
</label>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-auto-badge-apply">${t("autoBadgeApply")} (${assignments.length})</button>
|
||||
</div>`);
|
||||
|
||||
const hasBadges = (m) => (state.modelMetadata[m]?.badges || []).length > 0;
|
||||
const targets = () => document.getElementById("wfm-auto-badge-skip")?.checked
|
||||
? assignments.filter(({ model }) => !hasBadges(model)) : assignments;
|
||||
document.getElementById("wfm-auto-badge-skip")?.addEventListener("change", () => {
|
||||
const btn = document.getElementById("wfm-auto-badge-apply");
|
||||
if (btn) btn.textContent = `${t("autoBadgeApply")} (${targets().length})`;
|
||||
});
|
||||
|
||||
document.getElementById("wfm-auto-badge-apply")?.addEventListener("click", async (e) => {
|
||||
e.target.disabled = true;
|
||||
const overwrite = document.getElementById("wfm-auto-badge-overwrite")?.checked;
|
||||
const target = targets();
|
||||
const pal = getBadgePalette();
|
||||
const usedLabels = new Set(target.map((a) => a.label));
|
||||
for (const [label, info] of labels) {
|
||||
if (!usedLabels.has(label)) continue;
|
||||
if (!(label in pal) || overwrite) pal[label] = info.color;
|
||||
}
|
||||
saveBadgePalette(pal);
|
||||
|
||||
const todo = target.filter(({ model, label }) => !(state.modelMetadata[model]?.badges || []).includes(label));
|
||||
for (let i = 0; i < todo.length; i += 8) {
|
||||
await Promise.all(todo.slice(i, i + 8).map(({ model, label }) => {
|
||||
const badges = [...(state.modelMetadata[model]?.badges || []), label];
|
||||
return saveModelMetadata(model, { badges });
|
||||
}));
|
||||
}
|
||||
renderBadgeFilter();
|
||||
renderModelGrid();
|
||||
showToast(`${todo.length} ${t("autoBadgeDone")}`, "success");
|
||||
closeModal();
|
||||
if (onDone) onDone();
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { openModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml, readJsonStorage } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { openAutoBadgeModal } from "./auto-badge.js";
|
||||
|
||||
// Grid re-render is owned by models-tab.js (grid rendering hasn't been split
|
||||
// out yet); registered once from initModelsTab() to avoid a circular import.
|
||||
@@ -61,6 +62,9 @@ export function openBadgeEditModal(onPaletteChange = null) {
|
||||
<input type="color" id="wfm-badge-new-color" value="#6366f1" style="width:36px;height:28px;padding:1px;border-radius:4px;cursor:pointer;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-badge-add-btn">${t("badgeAdd")}</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-badge-auto-btn" title="${t("autoBadgeDesc")}">✨ ${t("autoBadgeTitle")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(t("badgeManage"), html);
|
||||
@@ -93,6 +97,10 @@ function bindBadgeModalEvents(onPaletteChange = null) {
|
||||
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
|
||||
document.getElementById("wfm-badge-auto-btn")?.addEventListener("click", () => {
|
||||
openAutoBadgeModal(onPaletteChange);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-badge-add-btn")?.addEventListener("click", () => {
|
||||
const labelInput = document.getElementById("wfm-badge-new-label");
|
||||
const colorInput = document.getElementById("wfm-badge-new-color");
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
import { showToast, openModal, closeModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { escapeHtml, sanitizeHtml, safeHttpUrl } from "../util.js";
|
||||
import { state, RESERVED_GROUPS, GENUI_TYPE_MAP } from "./state.js";
|
||||
import { getCurrentModels, renderTagFilter, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette, modelBadgesHtml, openBadgeEditModal } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl } from "./helpers.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl, markHasPreview, fetchPreviewKeys } from "./helpers.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import {
|
||||
applyToGenUI, applyEmbeddingToPrompt, saveModelMetadata, toggleGroupEnable,
|
||||
@@ -200,6 +200,7 @@ export function openDetailModal(modelName) {
|
||||
const res = await fetch("/api/wfm/models/change-preview", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
markHasPreview(modelName);
|
||||
// Reload preview in modal
|
||||
const newUrl = previewUrl(modelName) + "&t=" + Date.now();
|
||||
const mImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
@@ -308,12 +309,6 @@ export function renderSideInfo(modelName) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsFilePath")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<span id="wfm-models-side-filepath" class="wfm-model-filepath" title="${t("modelsCopyPath")}" style="cursor:pointer;word-break:break-all;font-size:0.85em;color:#aaa;">${t("modelsLoading")}...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsTags")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
@@ -348,33 +343,6 @@ export function renderSideInfo(modelName) {
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and display file path
|
||||
fetch(`/api/wfm/models/filepath?type=${encodeURIComponent(state.activeModelType)}&name=${encodeURIComponent(modelName)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl && data.path) {
|
||||
fpEl.textContent = data.path;
|
||||
fpEl.title = t("modelsCopyPath");
|
||||
} else if (fpEl) {
|
||||
fpEl.textContent = modelName;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) fpEl.textContent = modelName;
|
||||
});
|
||||
|
||||
// Copy file path on click
|
||||
el.querySelector("#wfm-models-side-filepath")?.addEventListener("click", () => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) {
|
||||
navigator.clipboard.writeText(fpEl.textContent).then(() => {
|
||||
showToast(t("modelsCopiedPath"), "success");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save button
|
||||
el.querySelector("#wfm-models-side-save-btn")?.addEventListener("click", () => {
|
||||
const tagsInput = document.getElementById("wfm-models-side-tags");
|
||||
@@ -564,6 +532,15 @@ export function renderSideGroup(modelName) {
|
||||
|
||||
// ── Side Panel: CivitAI Tab ───────────────────────────────
|
||||
|
||||
// ハッシュ未計算を示す赤字の行(CivitAIタブ)。更新/取得ボタンでハッシュ計算して取得する
|
||||
function hashMissingRowHtml() {
|
||||
return `
|
||||
<div style="display:flex;align-items:center;justify-content:center;font-size:12px;margin-bottom:10px;">
|
||||
<span style="color:var(--wfm-text-secondary);margin-right:8px;">${t("civitaiHashLabel")}</span>
|
||||
<span id="wfm-civitai-hash-missing" style="color:#e53935;font-weight:600;">${t("civitaiHashNotCalculated")}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderSideCivitai(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-civitai");
|
||||
if (!el) return;
|
||||
@@ -593,6 +570,7 @@ export function renderSideCivitai(modelName) {
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
${hashMissingRowHtml()}
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiFetchDesc")}
|
||||
</p>
|
||||
@@ -629,6 +607,7 @@ export async function fetchCivitaiForModel(modelName, el) {
|
||||
if (!state.modelMetadata[modelName]) state.modelMetadata[modelName] = {};
|
||||
state.modelMetadata[modelName].sha256 = data.sha256;
|
||||
}
|
||||
if (data.preview_saved) markHasPreview(modelName);
|
||||
renderCivitaiInfo(el, data.civitai, modelName);
|
||||
showToast(t("civitaiFound"), "success");
|
||||
// Always refresh preview: local file if saved, otherwise civitai image as fallback
|
||||
@@ -640,7 +619,7 @@ export async function fetchCivitaiForModel(modelName, el) {
|
||||
if (data.preview_saved) {
|
||||
sideImg.src = previewUrl(modelName) + "&t=" + Date.now();
|
||||
} else {
|
||||
const civitaiImg = data.civitai.images && data.civitai.images[0];
|
||||
const civitaiImg = safeHttpUrl(data.civitai.images && data.civitai.images[0]);
|
||||
if (civitaiImg) sideImg.src = civitaiImg;
|
||||
}
|
||||
sideImg.style.display = "";
|
||||
@@ -669,7 +648,7 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
? `https://${civitaiHost}/models/${info.modelId}?modelVersionId=${info.versionId}`
|
||||
: info.versionId
|
||||
? `https://${civitaiHost}/model-versions/${info.versionId}`
|
||||
: (info.modelUrl || "#");
|
||||
: safeHttpUrl(info.modelUrl, "#");
|
||||
|
||||
// Hash: BLAKE3 優先、なければ SHA256
|
||||
const fileHashes = info.fileHashes || {};
|
||||
@@ -700,7 +679,11 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
<code class="wfm-hash-value" data-hash="${escapeHtml(hashFull)}"
|
||||
style="background:var(--wfm-bg-secondary);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 84px);"
|
||||
title="${t("civitaiCopyHash")}">${escapeHtml(hashType)}: ${escapeHtml(hashFull.substring(0, 16).toUpperCase())}…</code>
|
||||
</div>` : "";
|
||||
</div>` : `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiHashLabel")}</span>
|
||||
<span style="color:#e53935;font-weight:600;">${t("civitaiHashNotCalculated")}</span>
|
||||
</div>`;
|
||||
|
||||
const detailSection = (typeRow || baseModelRow || hashRow)
|
||||
? `<div style="margin-bottom:10px;">${typeRow}${baseModelRow}${hashRow}</div>` : "";
|
||||
@@ -714,7 +697,7 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
).join(" ");
|
||||
|
||||
// Sample images — all go to Sample pane
|
||||
const images = info.images || [];
|
||||
const images = (info.images || []).filter((u) => safeHttpUrl(u));
|
||||
const sampleImagesHtml = images.map((url) =>
|
||||
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${t("civitaiOpenImage")}"><img src="${escapeHtml(url)}" style="width:100%;border-radius:4px;margin-bottom:6px;cursor:pointer;display:block;" loading="lazy" /></a>`
|
||||
).join("");
|
||||
@@ -744,7 +727,7 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
${detailSection}
|
||||
${tagsHtml ? `<div style="margin-bottom:8px;">${tagsHtml}</div>` : ""}
|
||||
${trainedWordsHtml ? `<div style="margin-bottom:10px;"><div style="font-weight:600;font-size:12px;margin-bottom:4px;">${t("civitaiTriggerWords")}</div>${trainedWordsHtml}</div>` : ""}
|
||||
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${info.description}</div>` : ""}
|
||||
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${sanitizeHtml(info.description)}</div>` : ""}
|
||||
<div style="margin-top:10px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-refresh-btn">${t("civitaiRefresh")}</button>
|
||||
</div>
|
||||
@@ -815,10 +798,13 @@ export async function batchFetchCivitai() {
|
||||
}
|
||||
|
||||
// Filter out models that already have CivitAI data
|
||||
// Optionally also skip models previously recorded as "not found on CivitAI"
|
||||
const skipNotFound = document.getElementById("wfm-models-civitai-skip-nf")?.checked;
|
||||
const meta = state.modelMetadata;
|
||||
const uncached = models.filter((m) => {
|
||||
const sha = meta[m]?.sha256;
|
||||
return !sha || !state.civitaiCache[sha];
|
||||
if (sha && state.civitaiCache[sha]) return false;
|
||||
return !(skipNotFound && meta[m]?.civitaiNotFound);
|
||||
});
|
||||
|
||||
if (uncached.length === 0) {
|
||||
@@ -860,15 +846,16 @@ export async function batchFetchCivitai() {
|
||||
const pct = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
|
||||
const statusText = data.status === "hashing" ? t("civitaiHashing2")
|
||||
: data.status === "fetching" ? t("civitaiFetching")
|
||||
: data.status === "previews" ? t("civitaiSavingPreviews")
|
||||
: data.status === "cached" ? "✓"
|
||||
: data.status === "found" ? "✓"
|
||||
: data.status === "not_found" ? "—"
|
||||
: data.status === "not_found" || data.status === "needs_hash" ? "—"
|
||||
: "";
|
||||
if (progressEl) progressEl.textContent = `${pct}% (${data.current}/${data.total}) ${statusText}`;
|
||||
} else if (eventType === "done") {
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
const previewNote = data.preview_saved > 0 ? ` (+${data.preview_saved} preview)` : "";
|
||||
showToast(t("civitaiBatchDone", data.found, data.not_found) + previewNote, "success");
|
||||
showToast(t("civitaiBatchDone", data.found, data.not_found, data.needs_hash || 0) + previewNote, "success");
|
||||
// Reload caches
|
||||
const [newMeta, newCache] = await Promise.all([fetchModelMetadata(), fetchCivitaiCache()]);
|
||||
// Apply sha256 hashes from batch result directly in case fetchModelMetadata
|
||||
@@ -879,6 +866,7 @@ export async function batchFetchCivitai() {
|
||||
if (!newMeta[modelName].sha256) newMeta[modelName].sha256 = sha256;
|
||||
}
|
||||
}
|
||||
if (data.preview_saved > 0) await fetchPreviewKeys(state.activeModelType);
|
||||
state.modelMetadata = newMeta;
|
||||
state.civitaiCache = newCache;
|
||||
renderModelGrid();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { state } from "./state.js";
|
||||
import { safeHttpUrl } from "../util.js";
|
||||
|
||||
export function parseModelPath(fullName) {
|
||||
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
|
||||
@@ -30,8 +31,35 @@ export function previewUrl(modelName, modelType) {
|
||||
* (aiohttp add_get does not auto-handle HEAD method).
|
||||
* Falls back to CivitAI cached image if no local preview exists.
|
||||
*/
|
||||
/** モデル名 → previewKeys のキー(`サブフォルダ/拡張子なし名`、小文字・/区切り) */
|
||||
export function previewKey(modelName) {
|
||||
const n = modelName.replace(/\\/g, "/");
|
||||
const dot = n.lastIndexOf(".");
|
||||
const slash = n.lastIndexOf("/");
|
||||
return (dot > slash ? n.substring(0, dot) : n).toLowerCase();
|
||||
}
|
||||
|
||||
/** プレビュー画像を持つモデルのキー一覧をサーバから取得してstateに保存。失敗時は保存せず従来動作に戻る */
|
||||
export async function fetchPreviewKeys(modelType) {
|
||||
const type = modelType || state.activeModelType;
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/models/preview-keys?type=${encodeURIComponent(type)}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.keys)) state.previewKeys[type] = new Set(data.keys);
|
||||
} catch { /* 従来動作(都度リクエスト)にフォールバック */ }
|
||||
}
|
||||
|
||||
/** 新しくプレビューが作られた/変更されたモデルを登録する */
|
||||
export function markHasPreview(modelName, modelType) {
|
||||
const set = state.previewKeys[modelType || state.activeModelType];
|
||||
if (set) set.add(previewKey(modelName));
|
||||
}
|
||||
|
||||
export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
const url = previewUrl(modelName, modelType);
|
||||
const keys = state.previewKeys[modelType || state.activeModelType];
|
||||
const noLocalPreview = !!keys && !keys.has(previewKey(modelName));
|
||||
imgEl.onload = () => {
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
@@ -41,7 +69,7 @@ export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiImg = civitai && civitai.images && civitai.images[0];
|
||||
const civitaiImg = safeHttpUrl(civitai && civitai.images && civitai.images[0]);
|
||||
if (civitaiImg) {
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
@@ -55,5 +83,6 @@ export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
}
|
||||
};
|
||||
if (noLocalPreview) { imgEl.onerror(); return; }
|
||||
imgEl.src = url;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const state = {
|
||||
allModelGroups: {}, // { type: { groupName: [models] } } — all types
|
||||
civitaiCache: {},
|
||||
disabledModels: {}, // { type: Set<modelName> }
|
||||
previewKeys: {}, // { type: Set<"subdir/stem"(小文字)> } プレビュー画像を持つモデル。未取得の型は従来どおり都度リクエスト
|
||||
subdirs: [],
|
||||
selectMode: false,
|
||||
selectedModels: new Set(),
|
||||
@@ -48,14 +49,31 @@ export const state = {
|
||||
};
|
||||
|
||||
|
||||
// 設定「モデルフォルダ」で追加されたフォルダのモデル(ComfyUIのobject_infoには出ない)
|
||||
async function fetchExtraModels(type) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/models/files?type=${encodeURIComponent(type)}&extra_only=1`);
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
const withExtra = (type, fn) => async () => {
|
||||
const [base, extra] = await Promise.all([fn(), fetchExtraModels(type)]);
|
||||
return extra.length ? [...new Set([...base, ...extra])] : base;
|
||||
};
|
||||
|
||||
export const FETCH_MAP = {
|
||||
checkpoint: () => comfyUI.fetchCheckpoints(),
|
||||
lora: () => comfyUI.fetchLoras(),
|
||||
vae: () => comfyUI.fetchVaes(),
|
||||
controlnet: () => comfyUI.fetchControlNets(),
|
||||
unet: () => comfyUI.fetchDiffusionModels(),
|
||||
textencoder: () => comfyUI.fetchTextEncoders(),
|
||||
hypernetwork: () => comfyUI.fetchHypernetworks(),
|
||||
checkpoint: withExtra("checkpoint", () => comfyUI.fetchCheckpoints()),
|
||||
lora: withExtra("lora", () => comfyUI.fetchLoras()),
|
||||
vae: withExtra("vae", () => comfyUI.fetchVaes()),
|
||||
controlnet: withExtra("controlnet", () => comfyUI.fetchControlNets()),
|
||||
unet: withExtra("unet", () => comfyUI.fetchDiffusionModels()),
|
||||
textencoder: withExtra("textencoder", () => comfyUI.fetchTextEncoders()),
|
||||
hypernetwork: withExtra("hypernetwork", () => comfyUI.fetchHypernetworks()),
|
||||
embedding: () => comfyUI.fetchEmbeddings(),
|
||||
};
|
||||
|
||||
|
||||
@@ -28,8 +28,35 @@ const state = {
|
||||
currentPage: 0,
|
||||
selectedNodes: new Set(),
|
||||
lastSelectionIndex: -1,
|
||||
renderToken: 0,
|
||||
};
|
||||
|
||||
// 数千件のカード/行を一度にDOMへ作ると描画が固まるため、先頭だけ同期で作り
|
||||
// 残りはフレームごとに分割して追加する。新しい描画が始まったら古い分は打ち切る。
|
||||
const RENDER_FIRST_CHUNK = 120;
|
||||
const RENDER_CHUNK = 250;
|
||||
function appendChunked(items, parent, build) {
|
||||
const token = ++state.renderToken;
|
||||
let i = 0;
|
||||
const step = (size) => {
|
||||
if (token !== state.renderToken) return;
|
||||
const frag = document.createDocumentFragment();
|
||||
const end = Math.min(i + size, items.length);
|
||||
for (; i < end; i++) frag.appendChild(build(items[i]));
|
||||
parent.appendChild(frag);
|
||||
if (i < items.length) requestAnimationFrame(() => step(RENDER_CHUNK));
|
||||
};
|
||||
step(RENDER_FIRST_CHUNK);
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function extractPackageName(pythonModule) {
|
||||
@@ -56,6 +83,40 @@ function categoryBadgeHtml(cat) {
|
||||
|
||||
// ── API ───────────────────────────────────────────────────
|
||||
|
||||
// 軽量一覧(入力定義なし)。/object_info より桁違いに速い。失敗時は null。
|
||||
async function fetchLightNodes() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/light");
|
||||
if (!res.ok) return null;
|
||||
const list = await res.json();
|
||||
if (!Array.isArray(list)) return null;
|
||||
return list.map(info => ({
|
||||
name: info.name,
|
||||
display_name: info.display_name || info.name,
|
||||
description: info.description || "",
|
||||
category: info.category || "uncategorized",
|
||||
python_module: info.python_module || "",
|
||||
package: extractPackageName(info.python_module),
|
||||
input: null, // /object_info で後から補完
|
||||
input_order: {},
|
||||
output: info.output || [],
|
||||
output_name: info.output_name || [],
|
||||
output_node: info.output_node || false,
|
||||
search_aliases: info.search_aliases || [],
|
||||
deprecated: info.deprecated || false,
|
||||
experimental: info.experimental || false,
|
||||
}));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// 入力定義などを full node (object_info 由来) から既存ノードへ反映する
|
||||
function mergeFullNode(target, full) {
|
||||
target.input = full.input;
|
||||
target.input_order = full.input_order;
|
||||
target.output = full.output;
|
||||
target.output_name = full.output_name;
|
||||
}
|
||||
|
||||
async function fetchAllNodes() {
|
||||
const data = await comfyUI.fetchAllObjectInfo();
|
||||
return Object.entries(data).map(([name, info]) => ({
|
||||
@@ -76,6 +137,15 @@ async function fetchAllNodes() {
|
||||
}));
|
||||
}
|
||||
|
||||
// 検索用の小文字化済み文字列(タグ以外)を1回だけ作る。キー入力ごとの再計算を避ける。
|
||||
function searchBase(node) {
|
||||
if (node._searchBase === undefined) {
|
||||
node._searchBase = [node.name, node.display_name, node.description, ...(node.search_aliases || [])]
|
||||
.join(" ").toLowerCase();
|
||||
}
|
||||
return node._searchBase;
|
||||
}
|
||||
|
||||
async function fetchNodeMetadata() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata");
|
||||
@@ -189,13 +259,10 @@ function filterNodes() {
|
||||
|
||||
if (state.searchText) {
|
||||
const q = state.searchText.toLowerCase();
|
||||
const meta = state.nodeMetadata[node.name];
|
||||
const searchable = [
|
||||
node.name, node.display_name, node.description,
|
||||
...(node.search_aliases || []),
|
||||
...(meta?.tags || []),
|
||||
].join(" ").toLowerCase();
|
||||
if (!searchable.includes(q)) return false;
|
||||
if (!searchBase(node).includes(q)) {
|
||||
const tags = state.nodeMetadata[node.name]?.tags;
|
||||
if (!tags || !tags.join(" ").toLowerCase().includes(q)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -255,6 +322,7 @@ function renderNodeGrid() {
|
||||
if (countEl) countEl.textContent = `${filtered.length} / ${state.allNodes.length}`;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
state.renderToken++; // 実行中の分割描画を止める(止めないと古いカードが追加され続ける)
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
|
||||
return;
|
||||
}
|
||||
@@ -265,7 +333,7 @@ function renderNodeGrid() {
|
||||
}
|
||||
|
||||
grid.innerHTML = "";
|
||||
filtered.forEach(node => grid.appendChild(createNodeCard(node)));
|
||||
appendChunked(filtered, grid, createNodeCard);
|
||||
}
|
||||
|
||||
function createNodeCard(node) {
|
||||
@@ -341,7 +409,7 @@ function renderNodeTableView(grid, filtered) {
|
||||
<th>In</th><th>Out</th>
|
||||
</tr></thead>`;
|
||||
const tbody = document.createElement("tbody");
|
||||
filtered.forEach(node => {
|
||||
const buildRow = (node) => {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "wfm-nodes-table-row";
|
||||
@@ -352,15 +420,16 @@ function renderNodeTableView(grid, filtered) {
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
tr.classList.add("multi-selected");
|
||||
}
|
||||
const inputCount = Object.keys(node.input.required || {}).length +
|
||||
Object.keys(node.input.optional || {}).length;
|
||||
const inputCount = node.input
|
||||
? Object.keys(node.input.required || {}).length + Object.keys(node.input.optional || {}).length
|
||||
: "\u2026";
|
||||
const outputCount = (node.output || []).length;
|
||||
tr.innerHTML = `
|
||||
<td><button class="${meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn"}">${meta.favorite ? "\u2605" : "\u2606"}</button></td>
|
||||
<td title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</td>
|
||||
<td>${packageBadgeHtml(node.package)}</td>
|
||||
<td>${escapeHtml(node.category)}</td>
|
||||
<td>${inputCount}</td><td>${outputCount}</td>`;
|
||||
<td class="wfm-nodes-in-count">${inputCount}</td><td>${outputCount}</td>`;
|
||||
tr.querySelector(".wfm-fav-btn").addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
@@ -390,10 +459,11 @@ function renderNodeTableView(grid, filtered) {
|
||||
showNodeSidePanel(node);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
return tr;
|
||||
};
|
||||
table.appendChild(tbody);
|
||||
grid.appendChild(table);
|
||||
appendChunked(filtered, tbody, buildRow);
|
||||
}
|
||||
|
||||
// ── Favorite ──────────────────────────────────────────────
|
||||
@@ -526,6 +596,19 @@ function showNodeSidePanel(node) {
|
||||
renderSideDetails(node);
|
||||
renderSideIO(node);
|
||||
renderSideGroups(node);
|
||||
|
||||
// 入力定義が未取得なら、そのノード分だけ先に取得して差し替える
|
||||
if (!node.input) {
|
||||
comfyUI.fetchObjectInfo(node.name).then(res => {
|
||||
const full = res?.[node.name];
|
||||
if (!full) return;
|
||||
node.input = full.input || {};
|
||||
node.input_order = full.input_order || {};
|
||||
node.output = full.output || node.output;
|
||||
node.output_name = full.output_name || node.output_name;
|
||||
if (state.selectedNode === node) renderSideIO(node);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderSideDetails(node) {
|
||||
@@ -591,8 +674,8 @@ function renderSideIO(node) {
|
||||
const el = document.getElementById("wfm-nodes-side-io");
|
||||
if (!el) return;
|
||||
|
||||
const required = node.input.required || {};
|
||||
const optional = node.input.optional || {};
|
||||
const required = node.input?.required || {};
|
||||
const optional = node.input?.optional || {};
|
||||
|
||||
let html = `<h4 style="margin:0 0 8px;">${t("nodesInputs")}</h4>`;
|
||||
html += `<table class="wfm-node-io-table"><thead><tr><th>Name</th><th>Type</th><th>Details</th></tr></thead><tbody>`;
|
||||
@@ -1128,6 +1211,30 @@ function showEditSetModal(set) {
|
||||
|
||||
// ── Data Loading ──────────────────────────────────────────
|
||||
|
||||
async function loadFullNodeDefinitions(nodes) {
|
||||
try {
|
||||
const full = await fetchAllNodes();
|
||||
// 取得中に再読み込みされていたら破棄
|
||||
if (state.allNodes !== nodes) return;
|
||||
const byName = new Map(full.map(n => [n.name, n]));
|
||||
for (const node of nodes) {
|
||||
const f = byName.get(node.name);
|
||||
if (f) mergeFullNode(node, f);
|
||||
}
|
||||
// テーブル表示のIn列を埋める
|
||||
document.querySelectorAll("#wfm-nodes-grid tr.wfm-nodes-table-row").forEach(tr => {
|
||||
const node = byName.get(tr.dataset.nodeName);
|
||||
const cell = tr.querySelector(".wfm-nodes-in-count");
|
||||
if (node && cell) {
|
||||
cell.textContent = Object.keys(node.input.required || {}).length + Object.keys(node.input.optional || {}).length;
|
||||
}
|
||||
});
|
||||
if (state.selectedNode) renderSideIO(state.selectedNode);
|
||||
} catch (err) {
|
||||
console.warn("Failed to load full node definitions:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodesData() {
|
||||
const placeholder = document.getElementById("wfm-nodes-placeholder");
|
||||
if (placeholder) placeholder.textContent = t("loading");
|
||||
@@ -1138,11 +1245,13 @@ async function loadNodesData() {
|
||||
return;
|
||||
}
|
||||
|
||||
const [nodes, metadata, sets] = await Promise.all([
|
||||
fetchAllNodes(),
|
||||
// 一覧は軽量APIで先に表示し、入力定義を含む /object_info は裏で取得する
|
||||
const [lightNodes, metadata, sets] = await Promise.all([
|
||||
fetchLightNodes(),
|
||||
fetchNodeMetadata(),
|
||||
fetchNodeSets(),
|
||||
]);
|
||||
const nodes = lightNodes ?? await fetchAllNodes();
|
||||
|
||||
state.allNodes = nodes;
|
||||
state.nodeMetadata = metadata;
|
||||
@@ -1152,6 +1261,8 @@ async function loadNodesData() {
|
||||
|
||||
renderFilters();
|
||||
renderNodeGrid();
|
||||
|
||||
if (lightNodes) loadFullNodeDefinitions(nodes);
|
||||
} catch (err) {
|
||||
console.error("Failed to load nodes:", err);
|
||||
if (placeholder) placeholder.textContent = t("nodesConnectToComfyUI");
|
||||
@@ -1189,10 +1300,11 @@ export function initNodesTab() {
|
||||
// Search
|
||||
const searchInput = document.getElementById("wfm-nodes-search");
|
||||
if (searchInput) {
|
||||
const rerender = debounce(() => renderNodeGrid(), 150);
|
||||
searchInput.addEventListener("input", () => {
|
||||
state.searchText = searchInput.value;
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
setupSearchClearBtn("wfm-nodes-search", "wfm-nodes-search-clear-btn", () => {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Prompt Tab - AI Assistant (Ollama / LM Studio / Lemonade / Unsloth chat, translate, apply to GenerateUI)
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { unslothProxy } from "./util.js";
|
||||
import { openAiBackendSettingsModal, getAiBackendConfig } from "./ai-settings-modal.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
const ollamaState = {
|
||||
chatHistory: [],
|
||||
attachedImage: null,
|
||||
};
|
||||
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
|
||||
export function getPromptAiConfig() {
|
||||
return getAiBackendConfig(PROMPT_AI_KEY);
|
||||
}
|
||||
|
||||
// Ollama-style messages (with .images) → OpenAI互換 content 配列に変換
|
||||
function _toOpenAiMessages(messages) {
|
||||
return messages.map(msg => {
|
||||
if (msg.images?.length) {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: [
|
||||
{ type: "text", text: msg.content },
|
||||
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { role: msg.role, content: msg.content };
|
||||
});
|
||||
}
|
||||
|
||||
function _stripThinkingTags(text) {
|
||||
return (text || "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Ollamaは `think` (bool) と `options.num_predict`、OpenAI互換は `max_tokens` で制御
|
||||
function _applyGenOptions(body, backend, cfg) {
|
||||
if (backend === "ollama") {
|
||||
body.think = !!cfg.thinkingMode;
|
||||
if (cfg.maxTokens > 0) body.options = { ...(body.options || {}), num_predict: cfg.maxTokens };
|
||||
} else if (cfg.maxTokens > 0) {
|
||||
body.max_tokens = cfg.maxTokens;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function chatWithAi(model, messages) {
|
||||
const cfg = getPromptAiConfig();
|
||||
const { backend, url } = cfg;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(_applyGenOptions({ model, messages, stream: false }, backend, cfg)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = (await res.json()).message?.content || "";
|
||||
return cfg.thinkingMode ? text : _stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: _toOpenAiMessages(messages), stream: false }, backend, cfg);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const msg = data.choices?.[0]?.message;
|
||||
// Unslothはreasoningを別フィールドで返すため<think>タグに畳んで表示に混ぜる
|
||||
const text = (msg?.reasoning_content ? `<think>${msg.reasoning_content}</think>` : "") + (msg?.content || "");
|
||||
return cfg.thinkingMode ? text : _stripThinkingTags(text);
|
||||
} else {
|
||||
// LM Studio / Lemonade (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(_applyGenOptions({ model, messages: _toOpenAiMessages(messages), stream: false }, backend, cfg)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = (await res.json()).choices?.[0]?.message?.content || "";
|
||||
return cfg.thinkingMode ? text : _stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chat UI
|
||||
// ============================================
|
||||
|
||||
function addChatMessage(role, content) {
|
||||
const chat = document.getElementById("wfm-ollama-chat");
|
||||
if (!chat) return;
|
||||
|
||||
const welcome = chat.querySelector(".wfm-ollama-welcome");
|
||||
if (welcome) welcome.remove();
|
||||
|
||||
const msg = document.createElement("div");
|
||||
msg.className = `wfm-ollama-msg ${role}`;
|
||||
|
||||
const roleLabel = document.createElement("div");
|
||||
roleLabel.className = "wfm-ollama-msg-role";
|
||||
roleLabel.textContent = role === "user" ? t("you") : "AI";
|
||||
msg.appendChild(roleLabel);
|
||||
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "wfm-ollama-msg-content";
|
||||
contentDiv.textContent = content;
|
||||
msg.appendChild(contentDiv);
|
||||
|
||||
// Apply button for assistant messages
|
||||
if (role === "assistant") {
|
||||
const applyRow = document.createElement("div");
|
||||
applyRow.className = "wfm-ollama-apply-row";
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
|
||||
applyBtn.textContent = t("applyToGenerateUI");
|
||||
applyBtn.addEventListener("click", () => applyToGenerateUI(content));
|
||||
applyRow.appendChild(applyBtn);
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "wfm-btn wfm-btn-sm";
|
||||
copyBtn.textContent = t("copy");
|
||||
copyBtn.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
copyBtn.textContent = t("copied");
|
||||
setTimeout(() => { copyBtn.textContent = t("copy"); }, 1500);
|
||||
});
|
||||
});
|
||||
applyRow.appendChild(copyBtn);
|
||||
|
||||
msg.appendChild(applyRow);
|
||||
}
|
||||
|
||||
chat.appendChild(msg);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
ollamaState.chatHistory = [];
|
||||
ollamaState.attachedImage = null;
|
||||
updateAttachmentDisplay();
|
||||
|
||||
const chat = document.getElementById("wfm-ollama-chat");
|
||||
if (chat) {
|
||||
chat.innerHTML = `
|
||||
<div class="wfm-ollama-welcome">
|
||||
<p>AI Assistant</p>
|
||||
<p>${t("assistantWelcome")}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Image Attachment
|
||||
// ============================================
|
||||
|
||||
function attachFile(file) {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
ollamaState.attachedImage = { name: file.name, dataUrl: e.target.result };
|
||||
updateAttachmentDisplay();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function updateAttachmentDisplay() {
|
||||
const container = document.getElementById("wfm-ollama-attachments");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!ollamaState.attachedImage) return;
|
||||
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-ollama-attachment";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = ollamaState.attachedImage.dataUrl;
|
||||
item.appendChild(img);
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "wfm-ollama-attachment-name";
|
||||
name.textContent = ollamaState.attachedImage.name;
|
||||
item.appendChild(name);
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "wfm-ollama-attachment-remove";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
ollamaState.attachedImage = null;
|
||||
updateAttachmentDisplay();
|
||||
});
|
||||
item.appendChild(removeBtn);
|
||||
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Status label (設定モーダルで選択中のbackend/modelを表示)
|
||||
// ============================================
|
||||
|
||||
function _updateStatusLabel() {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (!status) return;
|
||||
const { backend, model } = getPromptAiConfig();
|
||||
status.textContent = model ? `${backend}: ${model}` : `${backend}: ${t("selectModelFirst")}`;
|
||||
status.className = model ? "wfm-ollama-status connected" : "wfm-ollama-status error";
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Send Message
|
||||
// ============================================
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById("wfm-ollama-input");
|
||||
if (!input) return;
|
||||
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
const { model } = getPromptAiConfig();
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
addChatMessage("user", message);
|
||||
input.value = "";
|
||||
|
||||
const userMsg = { role: "user", content: message };
|
||||
if (ollamaState.attachedImage) {
|
||||
userMsg.images = [ollamaState.attachedImage.dataUrl.split(",")[1]];
|
||||
}
|
||||
ollamaState.chatHistory.push(userMsg);
|
||||
|
||||
const sendBtn = document.getElementById("wfm-ollama-send-btn");
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const reply = await chatWithAi(model, ollamaState.chatHistory);
|
||||
addChatMessage("assistant", reply);
|
||||
ollamaState.chatHistory.push({ role: "assistant", content: reply });
|
||||
ollamaState.attachedImage = null;
|
||||
updateAttachmentDisplay();
|
||||
} catch (err) {
|
||||
showToast(t("error") + ": " + err.message, "error");
|
||||
} finally {
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Translation
|
||||
// ============================================
|
||||
|
||||
async function sendTranslate(direction) {
|
||||
const input = document.getElementById("wfm-ollama-input");
|
||||
if (!input) return;
|
||||
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const { model } = getPromptAiConfig();
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = { ja2en: "JA→EN", en2ja: "EN→JA", zh2en: "ZH→EN", en2zh: "EN→ZH" };
|
||||
addChatMessage("user", `[${labels[direction] || direction}]\n${text}`);
|
||||
input.value = "";
|
||||
|
||||
const btns = ["wfm-ollama-send-btn", "wfm-ollama-ja2en-btn", "wfm-ollama-en2ja-btn", "wfm-ollama-zh2en-btn", "wfm-ollama-en2zh-btn"]
|
||||
.map((id) => document.getElementById(id));
|
||||
btns.forEach((b) => { if (b) b.disabled = true; });
|
||||
|
||||
try {
|
||||
const prompts = {
|
||||
ja2en: `Translate the following Japanese text into English. Output only the translated text, nothing else.\n\n${text}`,
|
||||
en2ja: `以下の英語のテキストを日本語に翻訳してください。翻訳結果のみを出力し、他の説明は不要です。\n\n${text}`,
|
||||
zh2en: `Translate the following Chinese text into English. Output only the translated text, nothing else.\n\n${text}`,
|
||||
en2zh: `请将以下英文翻译成中文。只输出翻译结果,不需要其他说明。\n\n${text}`,
|
||||
};
|
||||
const prompt = prompts[direction];
|
||||
|
||||
const reply = await chatWithAi(model, [{ role: "user", content: prompt }]);
|
||||
addChatMessage("assistant", reply);
|
||||
} catch (err) {
|
||||
showToast(t("error") + ": " + err.message, "error");
|
||||
} finally {
|
||||
btns.forEach((b) => { if (b) b.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Apply to GenerateUI
|
||||
// ============================================
|
||||
|
||||
function applyToGenerateUI(text) {
|
||||
const textareas = document.querySelectorAll("#wfm-gen-prompt-fields textarea");
|
||||
if (textareas.length === 0) {
|
||||
showToast(t("noPromptFields"), "error");
|
||||
return;
|
||||
}
|
||||
textareas[0].value = text;
|
||||
textareas[0].dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textareas[0].dispatchEvent(new Event("change", { bubbles: true }));
|
||||
showToast(t("appliedToGenerateUI"), "success");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI wiring (called from prompt-tab.js's initPromptTab)
|
||||
// ============================================
|
||||
|
||||
export function initAiChatUI() {
|
||||
_updateStatusLabel();
|
||||
|
||||
// Settings modal
|
||||
document.getElementById("wfm-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(PROMPT_AI_KEY, t("settings"), _updateStatusLabel);
|
||||
});
|
||||
|
||||
// Send message
|
||||
document.getElementById("wfm-ollama-send-btn")?.addEventListener("click", sendMessage);
|
||||
|
||||
// Enter to send
|
||||
document.getElementById("wfm-ollama-input")?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear chat
|
||||
document.getElementById("wfm-ollama-clear-btn")?.addEventListener("click", clearChat);
|
||||
|
||||
// File attachment
|
||||
const fileInput = document.getElementById("wfm-ollama-file-input");
|
||||
document.getElementById("wfm-ollama-attach-btn")?.addEventListener("click", () => fileInput?.click());
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
attachFile(fileInput.files[0]);
|
||||
fileInput.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Translation
|
||||
document.getElementById("wfm-ollama-ja2en-btn")?.addEventListener("click", () => sendTranslate("ja2en"));
|
||||
document.getElementById("wfm-ollama-en2ja-btn")?.addEventListener("click", () => sendTranslate("en2ja"));
|
||||
document.getElementById("wfm-ollama-zh2en-btn")?.addEventListener("click", () => sendTranslate("zh2en"));
|
||||
document.getElementById("wfm-ollama-en2zh-btn")?.addEventListener("click", () => sendTranslate("en2zh"));
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Prompt Tab - Presets (API CRUD, groups, Preset Manager panel)
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
// Preset data (loaded from API, with localStorage migration)
|
||||
let promptPresets = [];
|
||||
let pmActiveTab = "all"; // "all" | "favorites" | "groups"
|
||||
let pmSearchText = "";
|
||||
let pmSelectedId = null; // currently selected preset id
|
||||
export let pmGroups = {}; // { groupName: [presetId, ...] }
|
||||
const PM_GROUPS_KEY = "wfm_prompt_preset_groups";
|
||||
export const PROMPT_RESERVED_GROUPS = ["Batch"];
|
||||
|
||||
// ============================================
|
||||
// Preset API helpers
|
||||
// ============================================
|
||||
|
||||
export async function fetchPresets() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/prompts");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function apiCreatePreset(data) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/prompts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await res.json();
|
||||
return result.status === "ok" ? result.prompt : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function apiUpdatePreset(id, updates) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/prompts/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
});
|
||||
const result = await res.json();
|
||||
return result.status === "ok" ? result.prompt : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function apiDeletePreset(id) {
|
||||
try {
|
||||
await fetch("/api/wfm/prompts/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Migrate localStorage presets to API
|
||||
// ============================================
|
||||
|
||||
const PRESETS_KEY = "wfm_prompt_presets";
|
||||
|
||||
async function migrateLocalStoragePresets() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PRESETS_KEY);
|
||||
if (!raw) return;
|
||||
const local = JSON.parse(raw);
|
||||
if (!Array.isArray(local) || local.length === 0) return;
|
||||
|
||||
// Only migrate if API has no presets yet
|
||||
const existing = await fetchPresets();
|
||||
if (existing.length > 0) {
|
||||
localStorage.removeItem(PRESETS_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of local) {
|
||||
await apiCreatePreset({
|
||||
name: p.name || "Untitled",
|
||||
text: p.posText || p.text || "",
|
||||
negText: p.negText || "",
|
||||
category: "",
|
||||
tags: [],
|
||||
favorite: false,
|
||||
});
|
||||
}
|
||||
localStorage.removeItem(PRESETS_KEY);
|
||||
} catch { /* ignore migration errors */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Preset data management
|
||||
// ============================================
|
||||
|
||||
export async function loadAllPresets() {
|
||||
await migrateLocalStoragePresets();
|
||||
promptPresets = await fetchPresets();
|
||||
|
||||
// Load groups from localStorage
|
||||
pmGroups = readJsonStorage(PM_GROUPS_KEY);
|
||||
|
||||
// Clean stale entries from groups (preserve reserved groups even if empty)
|
||||
const validIds = new Set(promptPresets.map(p => p.id));
|
||||
for (const g of Object.keys(pmGroups)) {
|
||||
pmGroups[g] = (pmGroups[g] || []).filter(id => validIds.has(id));
|
||||
if (pmGroups[g].length === 0 && !PROMPT_RESERVED_GROUPS.includes(g)) delete pmGroups[g];
|
||||
}
|
||||
// Ensure reserved groups always exist
|
||||
for (const g of PROMPT_RESERVED_GROUPS) {
|
||||
if (!pmGroups[g]) pmGroups[g] = [];
|
||||
}
|
||||
saveGroups();
|
||||
|
||||
renderPresetSelect();
|
||||
renderGroupSelect();
|
||||
renderPresetManager();
|
||||
}
|
||||
|
||||
export function saveGroups() {
|
||||
localStorage.setItem(PM_GROUPS_KEY, JSON.stringify(pmGroups));
|
||||
renderGroupSelect();
|
||||
}
|
||||
|
||||
function renderGroupSelect() {
|
||||
const select = document.getElementById("wfm-preset-group-select");
|
||||
if (!select) return;
|
||||
const prevVal = select.value;
|
||||
select.innerHTML = `<option value="">Select group...</option>`;
|
||||
for (const g of Object.keys(pmGroups).sort()) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if (prevVal && pmGroups[prevVal]) select.value = prevVal;
|
||||
}
|
||||
|
||||
function renderPresetSelect() {
|
||||
const select = document.getElementById("wfm-preset-select");
|
||||
if (!select) return;
|
||||
const prevVal = select.value;
|
||||
select.innerHTML = `<option value="">${t("newPreset")}</option>`;
|
||||
promptPresets.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
// Restore selection if still valid
|
||||
if (prevVal && promptPresets.find(p => p.id === prevVal)) {
|
||||
select.value = prevVal;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPresetInEditor(preset) {
|
||||
const presetSelect = document.getElementById("wfm-preset-select");
|
||||
const presetName = document.getElementById("wfm-preset-name");
|
||||
const presetCategory = document.getElementById("wfm-preset-category");
|
||||
const presetPos = document.getElementById("wfm-preset-pos");
|
||||
const presetNeg = document.getElementById("wfm-preset-neg");
|
||||
|
||||
if (presetSelect) presetSelect.value = preset ? preset.id : "";
|
||||
if (presetName) presetName.value = preset ? preset.name : "";
|
||||
if (presetCategory) presetCategory.value = preset ? (preset.category || "") : "";
|
||||
if (presetPos) presetPos.value = preset ? (preset.text || preset.posText || "") : "";
|
||||
if (presetNeg) presetNeg.value = preset ? (preset.negText || "") : "";
|
||||
|
||||
pmSelectedId = preset ? preset.id : null;
|
||||
renderPresetManager();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Preset Manager rendering
|
||||
// ============================================
|
||||
|
||||
export function esc(s) {
|
||||
return s ? String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") : "";
|
||||
}
|
||||
|
||||
export function renderPresetManager() {
|
||||
const container = document.getElementById("wfm-pm-list");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
switch (pmActiveTab) {
|
||||
case "all": renderPmAll(container); break;
|
||||
case "favorites": renderPmFavorites(container); break;
|
||||
case "groups": renderPmGroups(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesSearch(p) {
|
||||
if (!pmSearchText) return true;
|
||||
const s = pmSearchText.toLowerCase();
|
||||
return (p.name || "").toLowerCase().includes(s) ||
|
||||
(p.text || "").toLowerCase().includes(s) ||
|
||||
(p.category || "").toLowerCase().includes(s) ||
|
||||
(p.tags || []).some(t => t.toLowerCase().includes(s));
|
||||
}
|
||||
|
||||
// Ordered list of actual preset objects for a group (stale ids already filtered out by
|
||||
// loadAllPresets, but this is defensive for callers that read pmGroups directly too).
|
||||
export function getPresetsInGroup(groupName) {
|
||||
const ids = pmGroups[groupName] || [];
|
||||
return ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function isInBatchPreset(id) {
|
||||
return (pmGroups["Batch"] || []).includes(id);
|
||||
}
|
||||
|
||||
export function toggleBatchPreset(id) {
|
||||
const batch = pmGroups["Batch"] || [];
|
||||
const idx = batch.indexOf(id);
|
||||
if (idx >= 0) { batch.splice(idx, 1); } else { batch.push(id); }
|
||||
pmGroups["Batch"] = batch;
|
||||
saveGroups();
|
||||
}
|
||||
|
||||
export function clearBatchPresets() {
|
||||
pmGroups["Batch"] = [];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(t("promptBatchClear"), "success");
|
||||
}
|
||||
|
||||
function createPmItem(preset) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-pm-item" + (pmSelectedId === preset.id ? " active" : "");
|
||||
|
||||
const previewText = (preset.text || "").length > 50
|
||||
? preset.text.substring(0, 50) + "..."
|
||||
: (preset.text || "");
|
||||
|
||||
const catBadge = preset.category
|
||||
? `<span style="font-size:9px;color:var(--wfm-primary);margin-left:4px;">[${esc(preset.category)}]</span>`
|
||||
: "";
|
||||
|
||||
const inBatch = isInBatchPreset(preset.id);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-pm-item-body">
|
||||
<div class="wfm-pm-item-name">${preset.favorite ? '<span style="color:#ffd700;">★</span> ' : ""}${esc(preset.name)}${catBadge}</div>
|
||||
<div class="wfm-pm-item-sub">${esc(previewText)}</div>
|
||||
</div>
|
||||
<div class="wfm-pm-item-actions">
|
||||
<button class="wfm-pm-action-btn pm-batch-btn${inBatch ? " batch-active" : ""}" title="Batch">B</button>
|
||||
<button class="wfm-pm-action-btn pm-fav-btn${preset.favorite ? " fav-active" : ""}" title="Favorite">★</button>
|
||||
<button class="wfm-pm-action-btn pm-del-btn" title="Delete" style="color:var(--wfm-danger);">✕</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click to select in editor
|
||||
el.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-pm-item-actions")) return;
|
||||
selectPresetInEditor(preset);
|
||||
});
|
||||
|
||||
// Batch toggle
|
||||
el.querySelector(".pm-batch-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleBatchPreset(preset.id);
|
||||
renderPresetManager();
|
||||
});
|
||||
|
||||
// Favorite toggle
|
||||
el.querySelector(".pm-fav-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const updated = await apiUpdatePreset(preset.id, { favorite: !preset.favorite });
|
||||
if (updated) {
|
||||
const idx = promptPresets.findIndex(p => p.id === preset.id);
|
||||
if (idx >= 0) promptPresets[idx] = updated;
|
||||
renderPresetManager();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete
|
||||
el.querySelector(".pm-del-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete "${preset.name}"?`)) return;
|
||||
await apiDeletePreset(preset.id);
|
||||
promptPresets = promptPresets.filter(p => p.id !== preset.id);
|
||||
if (pmSelectedId === preset.id) {
|
||||
pmSelectedId = null;
|
||||
selectPresetInEditor(null);
|
||||
}
|
||||
renderPresetSelect();
|
||||
renderPresetManager();
|
||||
showToast(t("deleted"), "success");
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderPmAll(container) {
|
||||
const items = promptPresets.filter(matchesSearch);
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-pm-empty">${promptPresets.length === 0 ? "No presets yet.<br><small>Create one in the Presets panel</small>" : "No matches"}</div>`;
|
||||
return;
|
||||
}
|
||||
for (const p of items) {
|
||||
container.appendChild(createPmItem(p));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPmFavorites(container) {
|
||||
const favs = promptPresets.filter(p => p.favorite);
|
||||
const items = favs.filter(matchesSearch);
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-pm-empty">${favs.length === 0 ? "No favorites yet.<br><small>Star presets to add them here</small>" : "No matches"}</div>`;
|
||||
return;
|
||||
}
|
||||
for (const p of items) {
|
||||
container.appendChild(createPmItem(p));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPmGroups(container) {
|
||||
const groupNames = Object.keys(pmGroups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-pm-empty">No groups yet.<br><small>Create groups from the Presets panel below</small></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const groupName of groupNames) {
|
||||
const ids = pmGroups[groupName] || [];
|
||||
const presets = ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
|
||||
|
||||
const section = document.createElement("div");
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-pm-group-header collapsed";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-pm-badge">${presets.length}</span>`;
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.style.display = "none";
|
||||
|
||||
header.addEventListener("click", () => {
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
|
||||
for (const p of presets) {
|
||||
const item = createPmItem(p);
|
||||
// Add remove-from-group button
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "wfm-pm-action-btn";
|
||||
removeBtn.title = "Remove from group";
|
||||
removeBtn.textContent = "➖";
|
||||
removeBtn.style.fontSize = "10px";
|
||||
removeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
|
||||
// 予約グループ (Batch) は空になってもキーを保持する
|
||||
if (pmGroups[groupName].length === 0 && !PROMPT_RESERVED_GROUPS.includes(groupName)) delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
});
|
||||
item.querySelector(".wfm-pm-item-actions").prepend(removeBtn);
|
||||
list.appendChild(item);
|
||||
}
|
||||
section.appendChild(header);
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI wiring (called from prompt-tab.js's initPromptTab)
|
||||
// ============================================
|
||||
|
||||
export function initPresetsUI() {
|
||||
loadAllPresets();
|
||||
|
||||
const presetSelect = document.getElementById("wfm-preset-select");
|
||||
const presetName = document.getElementById("wfm-preset-name");
|
||||
const presetCategory = document.getElementById("wfm-preset-category");
|
||||
const presetPos = document.getElementById("wfm-preset-pos");
|
||||
const presetNeg = document.getElementById("wfm-preset-neg");
|
||||
|
||||
// Load preset on selection
|
||||
presetSelect?.addEventListener("change", () => {
|
||||
const id = presetSelect.value;
|
||||
const p = promptPresets.find(pp => pp.id === id);
|
||||
selectPresetInEditor(p || null);
|
||||
});
|
||||
|
||||
// Copy positive prompt to clipboard
|
||||
document.getElementById("wfm-preset-copy-pos-btn")?.addEventListener("click", () => {
|
||||
const text = presetPos?.value || "";
|
||||
if (!text.trim()) {
|
||||
showToast(t("noTextToCopy"), "error");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(t("copiedToClipboard"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// Copy negative prompt to clipboard
|
||||
document.getElementById("wfm-preset-copy-neg-btn")?.addEventListener("click", () => {
|
||||
const text = presetNeg?.value || "";
|
||||
if (!text.trim()) {
|
||||
showToast(t("noTextToCopy"), "error");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(t("copiedToClipboard"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// Save preset (create or update via API)
|
||||
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", async () => {
|
||||
const name = presetName?.value.trim();
|
||||
const category = presetCategory?.value.trim() || "";
|
||||
const pos = presetPos?.value || "";
|
||||
const neg = presetNeg?.value || "";
|
||||
|
||||
if (!name) {
|
||||
showToast(t("enterPresetName"), "error");
|
||||
return;
|
||||
}
|
||||
if (!pos.trim() && !neg.trim()) {
|
||||
showToast(t("noPromptToSave"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedId = presetSelect?.value;
|
||||
const existing = selectedId ? promptPresets.find(p => p.id === selectedId) : null;
|
||||
|
||||
if (existing) {
|
||||
// Update
|
||||
const updated = await apiUpdatePreset(existing.id, {
|
||||
name, text: pos, negText: neg, category,
|
||||
});
|
||||
if (updated) {
|
||||
const idx = promptPresets.findIndex(p => p.id === existing.id);
|
||||
if (idx >= 0) promptPresets[idx] = updated;
|
||||
showToast(t("presetSaved"), "success");
|
||||
}
|
||||
} else {
|
||||
// Create
|
||||
const created = await apiCreatePreset({
|
||||
name, text: pos, negText: neg, category,
|
||||
tags: [], favorite: false,
|
||||
});
|
||||
if (created) {
|
||||
promptPresets.push(created);
|
||||
showToast(t("presetSaved"), "success");
|
||||
}
|
||||
}
|
||||
|
||||
pmSelectedId = null;
|
||||
renderPresetSelect();
|
||||
renderPresetManager();
|
||||
if (presetSelect) presetSelect.value = "";
|
||||
});
|
||||
|
||||
// Apply preset to GenerateUI
|
||||
document.getElementById("wfm-preset-apply-btn")?.addEventListener("click", () => {
|
||||
const pos = presetPos?.value || "";
|
||||
const neg = presetNeg?.value || "";
|
||||
const textareas = document.querySelectorAll("#wfm-gen-prompt-fields textarea");
|
||||
if (textareas.length === 0) {
|
||||
showToast(t("noPromptFields"), "error");
|
||||
return;
|
||||
}
|
||||
let applied = false;
|
||||
if (textareas.length > 0 && pos) {
|
||||
textareas[0].value = pos;
|
||||
textareas[0].dispatchEvent(new Event("input", { bubbles: true }));
|
||||
applied = true;
|
||||
}
|
||||
if (textareas.length > 1 && neg) {
|
||||
textareas[1].value = neg;
|
||||
textareas[1].dispatchEvent(new Event("input", { bubbles: true }));
|
||||
applied = true;
|
||||
}
|
||||
if (applied) showToast(t("appliedToGenerateUI"), "success");
|
||||
});
|
||||
|
||||
// --- Group management (Presets side) ---
|
||||
document.getElementById("wfm-preset-new-group-btn")?.addEventListener("click", () => {
|
||||
const name = prompt("Group name:");
|
||||
if (!name || !name.trim()) return;
|
||||
const key = name.trim();
|
||||
if (pmGroups[key]) {
|
||||
showToast(t("groupExists"), "error");
|
||||
return;
|
||||
}
|
||||
pmGroups[key] = [];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(t("groupCreated", key), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-preset-add-to-group-btn")?.addEventListener("click", () => {
|
||||
const groupSelect = document.getElementById("wfm-preset-group-select");
|
||||
const groupName = groupSelect?.value;
|
||||
if (!groupName) {
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
return;
|
||||
}
|
||||
const id = presetSelect?.value;
|
||||
if (!id) {
|
||||
showToast(t("selectPresetFirst"), "error");
|
||||
return;
|
||||
}
|
||||
if (!pmGroups[groupName]) pmGroups[groupName] = [];
|
||||
if (pmGroups[groupName].includes(id)) {
|
||||
showToast(t("alreadyInGroup"), "info");
|
||||
return;
|
||||
}
|
||||
pmGroups[groupName].push(id);
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(t("addedToGroup"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-preset-del-group-btn")?.addEventListener("click", () => {
|
||||
const groupSelect = document.getElementById("wfm-preset-group-select");
|
||||
const groupName = groupSelect?.value;
|
||||
if (!groupName) {
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
return;
|
||||
}
|
||||
if (PROMPT_RESERVED_GROUPS.includes(groupName)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete group "${groupName}"?`)) return;
|
||||
delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(t("groupDeleted", groupName), "success");
|
||||
});
|
||||
|
||||
// --- Preset Manager ---
|
||||
document.querySelectorAll(".wfm-pm-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
pmActiveTab = btn.dataset.pmtab;
|
||||
document.querySelectorAll(".wfm-pm-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderPresetManager();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("wfm-pm-search-input")?.addEventListener("input", (e) => {
|
||||
pmSearchText = e.target.value.trim();
|
||||
renderPresetManager();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-pm-batch-clear-btn")?.addEventListener("click", () => {
|
||||
clearBatchPresets();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Prompt Tab - Style manager (Col3 "Style" タブ)
|
||||
*/
|
||||
// Wildcardファイルマネージャーと同じCRUDパターン(一覧→エディタ→保存/削除/キャンセル)。
|
||||
// 保存・削除後は generate-tab.js の refreshStylesList() を呼び、GenerateUIのStyleドロップダウンや
|
||||
// Batchタブのチェックリストを再読み込み無しで最新状態へ同期する。
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { setupSearchClearBtn } from "./util.js";
|
||||
import { refreshStylesList } from "./generate-tab.js";
|
||||
import { esc } from "./prompt-presets.js";
|
||||
|
||||
let styleListData = [];
|
||||
let styleSearchText = "";
|
||||
let styleEditingOriginalName = null; // null = 新規作成
|
||||
let styleEditingFile = null; // 編集中/追加先スタイルの定義元ファイル名(style/配下、null = デフォルトのcustom.json)
|
||||
|
||||
export async function styleFetchList() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/styles");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function styleApiCreate(name, prompt, negativePrompt, targetFile) {
|
||||
try {
|
||||
const body = { name, prompt, negative_prompt: negativePrompt };
|
||||
if (targetFile) body.file = targetFile;
|
||||
const res = await fetch("/api/wfm/styles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function styleApiUpdate(originalName, name, prompt, negativePrompt) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(originalName)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, prompt, negative_prompt: negativePrompt }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function styleApiDelete(name) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function styleRenderList() {
|
||||
const list = document.getElementById("wfm-style-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
const q = styleSearchText.trim().toLowerCase();
|
||||
const filtered = q ? styleListData.filter(s => s.name.toLowerCase().includes(q)) : styleListData;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = styleListData.length === 0
|
||||
? `<div class="wfm-pm-empty" style="font-size:12px;">No styles yet.<br><small>Click "+ New" to create one.</small></div>`
|
||||
: `<div class="wfm-pm-empty" style="font-size:12px;">No matching styles.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of filtered) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item";
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.name)}</span>
|
||||
${s.file ? `<span class="wfm-wc-file-ext" title="${esc(s.file)}">${esc(s.file)}</span>` : ""}
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-style-edit-btn" title="Edit style">✎</button>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".wfm-style-edit-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
styleOpenEditor(s);
|
||||
});
|
||||
item.addEventListener("click", () => styleOpenEditor(s));
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// forcedFile: 「+ Add to this file」から呼ばれた場合に、既存スタイルではなく
|
||||
// 特定ファイルを追加先として指定するために使う(styleは渡さずnullにする)
|
||||
function styleOpenEditor(style, forcedFile) {
|
||||
styleEditingOriginalName = style ? style.name : null;
|
||||
styleEditingFile = style ? (style.file || null) : (forcedFile || null);
|
||||
|
||||
const editor = document.getElementById("wfm-style-editor");
|
||||
const nameInput = document.getElementById("wfm-style-editor-name");
|
||||
const promptTA = document.getElementById("wfm-style-editor-prompt");
|
||||
const negativeTA = document.getElementById("wfm-style-editor-negative");
|
||||
const deleteBtn = document.getElementById("wfm-style-editor-delete-btn");
|
||||
const addFileBtn = document.getElementById("wfm-style-editor-addfile-btn");
|
||||
const fileLabel = document.getElementById("wfm-style-editor-file-label");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = style?.name || "";
|
||||
promptTA.value = style?.prompt || "";
|
||||
negativeTA.value = style?.negative_prompt || "";
|
||||
deleteBtn.style.display = style ? "" : "none";
|
||||
// 「このファイルへ追加」は既存スタイルを編集中(=対象ファイルが分かっている)時だけ表示
|
||||
addFileBtn.style.display = style ? "" : "none";
|
||||
if (styleEditingFile) {
|
||||
fileLabel.textContent = `File: ${styleEditingFile}`;
|
||||
fileLabel.style.display = "";
|
||||
} else {
|
||||
fileLabel.style.display = "none";
|
||||
}
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function styleCloseEditor() {
|
||||
styleEditingOriginalName = null;
|
||||
styleEditingFile = null;
|
||||
const editor = document.getElementById("wfm-style-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
export async function styleRefreshList() {
|
||||
styleListData = await styleFetchList();
|
||||
styleRenderList();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI wiring (called from prompt-tab.js's initPromptTab)
|
||||
// ============================================
|
||||
|
||||
export function initStylesUI() {
|
||||
document.getElementById("wfm-style-new-btn")?.addEventListener("click", () => {
|
||||
styleOpenEditor(null);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-style-refresh-btn")?.addEventListener("click", () => {
|
||||
styleRefreshList();
|
||||
});
|
||||
|
||||
// 編集中の既存スタイルと同じファイルへ、新しい空のスタイルを追加する
|
||||
document.getElementById("wfm-style-editor-addfile-btn")?.addEventListener("click", () => {
|
||||
if (!styleEditingFile) return;
|
||||
styleOpenEditor(null, styleEditingFile);
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-style-search", "wfm-style-search-clear-btn", () => {
|
||||
styleSearchText = "";
|
||||
styleRenderList();
|
||||
});
|
||||
document.getElementById("wfm-style-search")?.addEventListener("input", (e) => {
|
||||
styleSearchText = e.target.value;
|
||||
styleRenderList();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-style-editor-save-btn")?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-style-editor-name");
|
||||
const promptTA = document.getElementById("wfm-style-editor-prompt");
|
||||
const negativeTA = document.getElementById("wfm-style-editor-negative");
|
||||
|
||||
const name = (nameInput?.value || "").trim();
|
||||
const prompt = promptTA?.value || "";
|
||||
const negativePrompt = negativeTA?.value || "";
|
||||
|
||||
if (!name) { showToast(t("pleaseEnterStyleName"), "error"); return; }
|
||||
|
||||
const result = styleEditingOriginalName
|
||||
? await styleApiUpdate(styleEditingOriginalName, name, prompt, negativePrompt)
|
||||
: await styleApiCreate(name, prompt, negativePrompt, styleEditingFile);
|
||||
|
||||
if (result.ok) {
|
||||
showToast(t("savedAs", name), "success");
|
||||
styleCloseEditor();
|
||||
await styleRefreshList();
|
||||
await refreshStylesList();
|
||||
} else {
|
||||
showToast(t("errorWithMsg", result.error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: delete
|
||||
document.getElementById("wfm-style-editor-delete-btn")?.addEventListener("click", async () => {
|
||||
if (!styleEditingOriginalName) return;
|
||||
if (!confirm(`Delete "${styleEditingOriginalName}"?`)) return;
|
||||
const result = await styleApiDelete(styleEditingOriginalName);
|
||||
if (result.ok) {
|
||||
showToast(t("deletedName", styleEditingOriginalName), "success");
|
||||
styleCloseEditor();
|
||||
await styleRefreshList();
|
||||
await refreshStylesList();
|
||||
} else {
|
||||
showToast(t("errorWithMsg", result.error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: cancel
|
||||
document.getElementById("wfm-style-editor-cancel-btn")?.addEventListener("click", () => {
|
||||
styleCloseEditor();
|
||||
});
|
||||
|
||||
// Initial style list load
|
||||
styleRefreshList();
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Prompt Tab - Wildcard file manager (list, editor, insert-at-cursor helpers)
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { esc } from "./prompt-presets.js";
|
||||
|
||||
// ============================================
|
||||
// Wildcard API helpers
|
||||
// ============================================
|
||||
|
||||
export async function wcFetchFiles() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function wcFetchContent(filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(filename)}`);
|
||||
const data = await res.json();
|
||||
return data.content ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function wcSaveFile(filename, content) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, content }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.status === "ok" ? data.file : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function wcDeleteFile(filename) {
|
||||
try {
|
||||
await fetch("/api/wfm/wildcards/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard state & helpers
|
||||
// ============================================
|
||||
|
||||
let wcFiles = [];
|
||||
let wcEditingFilename = null; // null = new file
|
||||
|
||||
// Insert text at cursor; if open+close provided wraps selection
|
||||
function wcInsertAtCursor(textarea, open, close = "") {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = textarea.value.substring(start, end);
|
||||
let inserted, cursorStart, cursorEnd;
|
||||
|
||||
if (selected && close) {
|
||||
inserted = open + selected + close;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart + selected.length;
|
||||
} else if (close) {
|
||||
inserted = open + close;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart;
|
||||
} else {
|
||||
inserted = open;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart;
|
||||
}
|
||||
|
||||
textarea.value =
|
||||
textarea.value.substring(0, start) +
|
||||
inserted +
|
||||
textarea.value.substring(end);
|
||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file list rendering
|
||||
// ============================================
|
||||
|
||||
function wcRenderFileList() {
|
||||
const list = document.getElementById("wfm-wc-file-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
if (wcFiles.length === 0) {
|
||||
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">No wildcard files yet.<br><small>Click "+ New" to create one.</small></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by directory; root ("") first, then alphabetical
|
||||
const grouped = {};
|
||||
for (const f of wcFiles) {
|
||||
const dir = f.dir || "";
|
||||
if (!grouped[dir]) grouped[dir] = [];
|
||||
grouped[dir].push(f);
|
||||
}
|
||||
const dirs = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === "") return -1;
|
||||
if (b === "") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
function addFileItem(f, indented) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item" + (indented ? " wfm-wc-file-item--sub" : "");
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span class="wfm-wc-file-name">${esc(f.name)}</span>
|
||||
<span class="wfm-wc-file-ext">.${esc(f.ext)}</span>
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-wc-use-btn" title="Insert __${esc(f.wc_name)}__ at cursor">Use</button>
|
||||
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">✎</button>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
|
||||
});
|
||||
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const content = await wcFetchContent(f.filename);
|
||||
wcOpenEditor(f.wc_name, f.ext, content ?? "", f.filename);
|
||||
});
|
||||
item.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (dir) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-wc-dir-header";
|
||||
header.textContent = dir + "/";
|
||||
list.appendChild(header);
|
||||
}
|
||||
for (const f of grouped[dir]) {
|
||||
addFileItem(f, dir !== "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file editor
|
||||
// ============================================
|
||||
|
||||
function wcOpenEditor(name, ext, content, existingFilename) {
|
||||
wcEditingFilename = existingFilename || null;
|
||||
|
||||
const editor = document.getElementById("wfm-wc-editor");
|
||||
const nameInput = document.getElementById("wfm-wc-editor-name");
|
||||
const extSelect = document.getElementById("wfm-wc-editor-ext");
|
||||
const contentTA = document.getElementById("wfm-wc-editor-content");
|
||||
const deleteBtn = document.getElementById("wfm-wc-editor-delete-btn");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = name || "";
|
||||
extSelect.value = ext || "txt";
|
||||
contentTA.value = content || "";
|
||||
deleteBtn.style.display = existingFilename ? "" : "none";
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function wcCloseEditor() {
|
||||
wcEditingFilename = null;
|
||||
const editor = document.getElementById("wfm-wc-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
export async function wcRefreshFiles() {
|
||||
wcFiles = await wcFetchFiles();
|
||||
wcRenderFileList();
|
||||
wcUpdateFilePicker();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file picker popup
|
||||
// ============================================
|
||||
|
||||
// Renders the wildcard-file-reference list into `picker`, inserting into `targetTa` on click.
|
||||
// Shared by the Form tab's fixed file-picker popup and any dynamically built toolbar
|
||||
// (see createWildcardToolbar) so both stay in sync with a single implementation.
|
||||
function renderFilePicker(picker, targetTa) {
|
||||
picker.innerHTML = "";
|
||||
if (wcFiles.length === 0) {
|
||||
picker.innerHTML = `<div style="padding:8px;font-size:11px;color:var(--wfm-text-secondary);">No wildcard files</div>`;
|
||||
return;
|
||||
}
|
||||
// Group by directory same as file list
|
||||
const grouped = {};
|
||||
for (const f of wcFiles) {
|
||||
const dir = f.dir || "";
|
||||
if (!grouped[dir]) grouped[dir] = [];
|
||||
grouped[dir].push(f);
|
||||
}
|
||||
const dirs = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === "") return -1;
|
||||
if (b === "") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
for (const dir of dirs) {
|
||||
if (dir) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "wfm-wc-picker-dir";
|
||||
sep.textContent = dir + "/";
|
||||
picker.appendChild(sep);
|
||||
}
|
||||
for (const f of grouped[dir]) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-wc-picker-item" + (dir ? " wfm-wc-picker-item--sub" : "");
|
||||
btn.textContent = `__${f.wc_name}__`;
|
||||
btn.addEventListener("click", () => {
|
||||
wcInsertAtCursor(targetTa, `__${f.wc_name}__`);
|
||||
picker.style.display = "none";
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wcUpdateFilePicker() {
|
||||
const picker = document.getElementById("wfm-wc-file-picker");
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!picker || !ta) return;
|
||||
renderFilePicker(picker, ta);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Reusable wildcard syntax toolbar (buttons + file-reference picker)
|
||||
// ============================================
|
||||
|
||||
const WC_SYNTAX_BUTTONS = [
|
||||
{ open: "{", close: "}", label: "{ }", title: "Random pick set e.g. {cat|dog}" },
|
||||
{ open: "|", close: "", label: "|", title: "Separator" },
|
||||
{ open: "__", close: "__", label: "__", title: "Wildcard file reference e.g. __animals__" },
|
||||
{ open: ":", close: "", label: ":", title: "Colon" },
|
||||
{ open: ";", close: "", label: ";", title: "Semicolon" },
|
||||
{ open: "$$", close: "", label: "$$", title: "Multi-pick e.g. {2$$cat|dog|bird}" },
|
||||
{ open: "[", close: "]", label: "[ ]", title: "Bracket set e.g. [cat|dog]" },
|
||||
];
|
||||
|
||||
// Builds a standalone wildcard-syntax toolbar (syntax buttons, LoRA/n$${} helpers, and the
|
||||
// file-reference picker) wired to insert into the given textarea. The Form tab's Col3 panel
|
||||
// uses the static toolbar markup in index.html instead (wired in initWildcardsUI below); this
|
||||
// is for callers that need the same toolbar attached to a textarea built at runtime, such as
|
||||
// the Table tab's wildcard edit modal (see prompt-table.js).
|
||||
export function createWildcardToolbar(textareaEl) {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "wfm-wc-toolbar";
|
||||
|
||||
for (const b of WC_SYNTAX_BUTTONS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-wc-btn";
|
||||
btn.title = b.title;
|
||||
btn.textContent = b.label;
|
||||
btn.addEventListener("click", () => wcInsertAtCursor(textareaEl, b.open, b.close));
|
||||
toolbar.appendChild(btn);
|
||||
}
|
||||
|
||||
const loraBtn = document.createElement("button");
|
||||
loraBtn.className = "wfm-wc-btn";
|
||||
loraBtn.title = "LoRA with LBW e.g. <lora:name:1:LBW=...;...>";
|
||||
loraBtn.textContent = "<lora>";
|
||||
loraBtn.addEventListener("click", () => {
|
||||
wcInsertAtCursor(textareaEl, "<lora::1:LBW=;>");
|
||||
// Move cursor to after "lora:" for the name
|
||||
const pos = textareaEl.selectionStart - "<lora::1:LBW=;>".length + 6;
|
||||
textareaEl.setSelectionRange(pos, pos);
|
||||
textareaEl.focus();
|
||||
});
|
||||
toolbar.appendChild(loraBtn);
|
||||
|
||||
const nsetBtn = document.createElement("button");
|
||||
nsetBtn.className = "wfm-wc-btn";
|
||||
nsetBtn.title = "Multi-pick set e.g. {2$$cat|dog|bird}";
|
||||
nsetBtn.textContent = "n$${ }";
|
||||
nsetBtn.addEventListener("click", () => {
|
||||
const n = prompt("Number of items to pick (n):", "2");
|
||||
if (n === null) return;
|
||||
const num = parseInt(n, 10);
|
||||
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
|
||||
const template = "{" + num + "$$|}";
|
||||
const start = textareaEl.selectionStart;
|
||||
wcInsertAtCursor(textareaEl, template);
|
||||
// Move cursor between $$ and |
|
||||
const inner = start + ("{" + num + "$$").length;
|
||||
textareaEl.setSelectionRange(inner, inner);
|
||||
textareaEl.focus();
|
||||
});
|
||||
toolbar.appendChild(nsetBtn);
|
||||
|
||||
const pickerWrap = document.createElement("div");
|
||||
pickerWrap.className = "wfm-wc-file-picker-wrap";
|
||||
const pickBtn = document.createElement("button");
|
||||
pickBtn.className = "wfm-wc-btn";
|
||||
pickBtn.title = "Insert wildcard file reference";
|
||||
pickBtn.innerHTML = "📂 File";
|
||||
const picker = document.createElement("div");
|
||||
picker.className = "wfm-wc-file-picker";
|
||||
picker.style.display = "none";
|
||||
pickBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = picker.style.display !== "none";
|
||||
picker.style.display = isOpen ? "none" : "";
|
||||
if (!isOpen) renderFilePicker(picker, textareaEl);
|
||||
});
|
||||
document.addEventListener("click", () => { picker.style.display = "none"; });
|
||||
pickerWrap.appendChild(pickBtn);
|
||||
pickerWrap.appendChild(picker);
|
||||
toolbar.appendChild(pickerWrap);
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI wiring (called from prompt-tab.js's initPromptTab)
|
||||
// ============================================
|
||||
|
||||
export function initWildcardsUI() {
|
||||
// ── Wildcard syntax buttons ──────────────────────────────
|
||||
document.querySelectorAll(".wfm-wc-btn[data-wc-open]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
const open = btn.dataset.wcOpen || "";
|
||||
const close = btn.dataset.wcClose || "";
|
||||
wcInsertAtCursor(ta, open, close);
|
||||
});
|
||||
});
|
||||
|
||||
// LoRA LBW template button
|
||||
document.getElementById("wfm-wc-lora-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
wcInsertAtCursor(ta, "<lora::1:LBW=;>");
|
||||
// Move cursor to after "lora:" for the name
|
||||
const pos = ta.selectionStart - "<lora::1:LBW=;>".length + 6;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
ta.focus();
|
||||
});
|
||||
|
||||
// n$${ } button — asks for n then inserts multi-pick template
|
||||
document.getElementById("wfm-wc-nset-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
const n = prompt("Number of items to pick (n):", "2");
|
||||
if (n === null) return;
|
||||
const num = parseInt(n, 10);
|
||||
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
|
||||
const template = "{" + num + "$$|}";
|
||||
const start = ta.selectionStart;
|
||||
wcInsertAtCursor(ta, template);
|
||||
// Move cursor between $$ and |
|
||||
const inner = start + ("{" + num + "$$").length;
|
||||
ta.setSelectionRange(inner, inner);
|
||||
ta.focus();
|
||||
});
|
||||
|
||||
// File picker toggle
|
||||
const filePickBtn = document.getElementById("wfm-wc-filepick-btn");
|
||||
const filePicker = document.getElementById("wfm-wc-file-picker");
|
||||
filePickBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!filePicker) return;
|
||||
const isOpen = filePicker.style.display !== "none";
|
||||
filePicker.style.display = isOpen ? "none" : "";
|
||||
if (!isOpen) wcUpdateFilePicker();
|
||||
});
|
||||
document.addEventListener("click", () => {
|
||||
if (filePicker) filePicker.style.display = "none";
|
||||
});
|
||||
|
||||
// ── Wildcard prompt actions ──────────────────────────────
|
||||
document.getElementById("wfm-wc-copy-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
const text = ta?.value || "";
|
||||
if (!text.trim()) { showToast(t("noTextToCopy"), "error"); return; }
|
||||
navigator.clipboard.writeText(text).then(() => showToast(t("copiedToClipboard"), "success"));
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-pos-btn")?.addEventListener("click", () => {
|
||||
const src = document.getElementById("wfm-wc-prompt");
|
||||
const dst = document.getElementById("wfm-preset-pos");
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast(t("sentToPositive"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
|
||||
const src = document.getElementById("wfm-wc-prompt");
|
||||
const dst = document.getElementById("wfm-preset-neg");
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast(t("sentToNegative"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) { ta.value = ""; ta.focus(); }
|
||||
});
|
||||
|
||||
// ── Wildcard file manager ────────────────────────────────
|
||||
document.getElementById("wfm-wc-new-file-btn")?.addEventListener("click", () => {
|
||||
wcOpenEditor("", "txt", "", null);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-files-refresh-btn")?.addEventListener("click", () => {
|
||||
wcRefreshFiles();
|
||||
});
|
||||
|
||||
// Editor: save
|
||||
document.getElementById("wfm-wc-editor-save-btn")?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-wc-editor-name");
|
||||
const extSelect = document.getElementById("wfm-wc-editor-ext");
|
||||
const contentTA = document.getElementById("wfm-wc-editor-content");
|
||||
|
||||
// Normalize separators and strip leading/trailing slashes
|
||||
const rawName = (nameInput?.value || "").trim().replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
|
||||
const ext = extSelect?.value || "txt";
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!rawName) { showToast(t("pleaseEnterFilename"), "error"); return; }
|
||||
// Validate each path component (allow letters, numbers, -, _, ., space)
|
||||
const nameParts = rawName.split("/");
|
||||
if (nameParts.some(p => !p || !/^[\w\-. ]+$/.test(p))) {
|
||||
showToast(t("invalidPathFormat"), "error");
|
||||
return;
|
||||
}
|
||||
const name = rawName;
|
||||
const filename = `${name}.${ext}`;
|
||||
const saved = await wcSaveFile(filename, content);
|
||||
if (saved) {
|
||||
showToast(t("savedAs", filename), "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
} else {
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: delete
|
||||
document.getElementById("wfm-wc-editor-delete-btn")?.addEventListener("click", async () => {
|
||||
if (!wcEditingFilename) return;
|
||||
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
|
||||
await wcDeleteFile(wcEditingFilename);
|
||||
showToast(t("deletedName", wcEditingFilename), "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
});
|
||||
|
||||
// Editor: cancel
|
||||
document.getElementById("wfm-wc-editor-cancel-btn")?.addEventListener("click", () => {
|
||||
wcCloseEditor();
|
||||
});
|
||||
|
||||
// Initial wildcard file load
|
||||
wcRefreshFiles();
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
|
||||
|
||||
import { getSettings, readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
|
||||
import { DECISION_BACKENDS, getDecisionSettings, saveDecisionSettings, testDecisionConnection, listDecisionModels } from "./decision-client.js";
|
||||
import { initVramSettings } from "./vram-prepare.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
@@ -336,6 +338,34 @@ export function applyTextareaFontSize(size) {
|
||||
el.textContent = `${TEXTAREA_FONT_SIZE_TARGETS} { font-size: ${px}px !important; }`;
|
||||
}
|
||||
|
||||
// Video playback volume — shared by every <video> player in the app (Video tab preview,
|
||||
// Gallery detail/lightbox/compare). The browser default for a freshly created <video> element
|
||||
// is always 100%, and Gallery's players are recreated from scratch each time a different video
|
||||
// is opened, so without this the volume silently jumps back to max on every video switch.
|
||||
const VIDEO_VOLUME_DEFAULT = 1;
|
||||
|
||||
export function getVideoVolume() {
|
||||
const v = loadLocalSettings().videoVolume;
|
||||
return typeof v === "number" && v >= 0 && v <= 1 ? v : VIDEO_VOLUME_DEFAULT;
|
||||
}
|
||||
|
||||
function setVideoVolume(volume) {
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
saveLocalSettings({ videoVolume: clamped });
|
||||
return clamped;
|
||||
}
|
||||
|
||||
// Applies the saved volume to a <video> element, and keeps it in sync with any further change
|
||||
// the user makes via that element's own native controls — so adjusting the volume on any video
|
||||
// player anywhere in the app is remembered for the next video opened anywhere else.
|
||||
export function applyStoredVideoVolume(videoEl) {
|
||||
if (!videoEl) return;
|
||||
videoEl.volume = getVideoVolume();
|
||||
if (videoEl.dataset.wfmVolumeWired) return;
|
||||
videoEl.dataset.wfmVolumeWired = "1";
|
||||
videoEl.addEventListener("volumechange", () => setVideoVolume(videoEl.volume));
|
||||
}
|
||||
|
||||
// JSON highlight color settings
|
||||
const JSON_COLOR_STYLE_ID = "wfm-json-color-style";
|
||||
|
||||
@@ -378,7 +408,7 @@ export function applyModelTabActiveColor(color) {
|
||||
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active { color: ${val}; }`;
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active,\n.wfm-video-plan-content label.wfm-model-label-active { color: ${val}; }`;
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
@@ -427,6 +457,7 @@ export async function initSettingsTab() {
|
||||
|
||||
const uiLang = getLang();
|
||||
const summaryLang = getSummaryLang();
|
||||
const decisionSettings = getDecisionSettings();
|
||||
|
||||
container.innerHTML = `
|
||||
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
|
||||
@@ -575,6 +606,24 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Video Volume -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("videoVolumeLabel") || "Video Volume"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>${t("videoVolumeLabel") || "Video Volume"}</span>
|
||||
<span id="wfm-video-volume-val" style="font-size:12px;color:var(--wfm-text-secondary);">${Math.round(getVideoVolume() * 100)}%</span>
|
||||
</label>
|
||||
<input type="range" class="wfm-range" id="wfm-video-volume"
|
||||
min="0" max="100" step="1"
|
||||
value="${Math.round(getVideoVolume() * 100)}"
|
||||
style="width:100%;margin-top:4px;">
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("videoVolumeHint") || "Applies to: Video tab preview, Gallery detail/lightbox/compare video players"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- RAW JSON Colors -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("jsonColorsSection") || "RAW JSON Colors"}</summary>
|
||||
@@ -628,6 +677,38 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Models Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("modelsDir")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("modelsDirLabel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-models-dir"
|
||||
value="${serverSettings.models_dir || ""}"
|
||||
placeholder="C:\\Users\\...\\StabilityMatrix\\Models">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-models-dir-apply">${t("workflowsDirApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-models-dir-reset">${t("workflowsDirDefault")}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("modelsDirHint")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Windows Event Loop (stability) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("selectorLoop")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-settings-selector-loop" ${serverSettings.windows_selector_event_loop ? "checked" : ""}>
|
||||
<span>${t("selectorLoopLabel")}</span>
|
||||
</label>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("selectorLoopHint")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Gallery Output Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
|
||||
@@ -691,44 +772,6 @@ export async function initSettingsTab() {
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-default-ckpt-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- AI Assistant Settings (Prompt Tab) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("aiSettingsBackend")}</label>
|
||||
<div class="wfm-ai-backend-row">
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="ollama"> Ollama
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaUrl")}</label>
|
||||
<input type="text" class="wfm-input" id="wfm-settings-prompt-ai-url"
|
||||
placeholder="http://localhost:11434">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaDefaultModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-prompt-ai-model" style="flex:1;">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-refresh">${t("refresh")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-prompt-ai-save">${t("saveOllama")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-test">${t("testConnection")}</button>
|
||||
<span id="wfm-settings-prompt-ai-status" style="font-size:12px;line-height:28px;"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- CivitAI Settings -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("civitaiApiKeySetting")}</summary>
|
||||
@@ -795,6 +838,73 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Decision Model (Laya) — independent of the AI TOOL / Tagger LLM/VLM backends -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("decisionSection")}</summary>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-bottom:8px;">
|
||||
${t("decisionHint")}
|
||||
</small>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionBackend")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-decision-backend">
|
||||
${Object.entries(DECISION_BACKENDS).map(([b, def]) => `<option value="${b}" ${decisionSettings.backend === b ? "selected" : ""}>${def.label}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionUrl")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-decision-url"
|
||||
value="${decisionSettings.baseUrl}"
|
||||
placeholder="${DECISION_BACKENDS[decisionSettings.backend].defaultUrl}">
|
||||
<button class="wfm-btn" id="wfm-settings-decision-test">${t("test")}</button>
|
||||
</div>
|
||||
<div id="wfm-settings-decision-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-decision-model" style="flex:1;">
|
||||
<option value="${escapeHtml(decisionSettings.model)}" selected>${escapeHtml(decisionSettings.model)}</option>
|
||||
</select>
|
||||
<button class="wfm-btn" id="wfm-settings-decision-refresh" title="${t("decisionRefreshModels")}">↻</button>
|
||||
</div>
|
||||
<div id="wfm-settings-decision-model-hint" style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;"></div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionThreshold")}</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-decision-threshold"
|
||||
value="${decisionSettings.threshold}" min="0.5" max="0.99" step="0.05" style="width:100px;">
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("decisionThresholdHint")}
|
||||
</small>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-decision-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- VRAM Management (Ollama) — unload Ollama models before every generation (vram-prepare.js) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("vramSection")}</summary>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-bottom:8px;">
|
||||
${t("vramHint")}
|
||||
</small>
|
||||
<div class="wfm-form-group">
|
||||
<label for="wfm-settings-vram-mode">${t("vramModeLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-vram-mode">
|
||||
<option value="off">${t("vramModeOff")}</option>
|
||||
<option value="auto">${t("vramModeAuto")}</option>
|
||||
<option value="all">${t("vramModeAll")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label for="wfm-settings-vram-target">${t("vramTargetLabel")}</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-vram-target" min="0" max="64" step="0.5" value="8" style="width:100px;">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-vram-unload">${t("vramUnloadNow")}</button>
|
||||
<span id="wfm-settings-vram-status" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- G'MIC Integration -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">G'MIC-Qt Integration</summary>
|
||||
@@ -824,6 +934,29 @@ export async function initSettingsTab() {
|
||||
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--wfm-text-secondary);margin:12px 0 8px 0;">${t("fullBackupHint")}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:8px;">
|
||||
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
|
||||
<input type="checkbox" id="wfm-settings-backup-include-workflows">
|
||||
${t("fullBackupIncludeWorkflows")}
|
||||
</label>
|
||||
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
|
||||
<input type="checkbox" id="wfm-settings-backup-include-wildcard">
|
||||
${t("fullBackupIncludeWildcard")}
|
||||
</label>
|
||||
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
|
||||
<input type="checkbox" id="wfm-settings-backup-include-video-media">
|
||||
${t("fullBackupIncludeVideoMedia")}
|
||||
</label>
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">${t("fullBackupExternalHint")}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export-full">${t("exportFullBackup")}</button>
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
|
||||
${t("importFullBackup")}
|
||||
<input type="file" id="wfm-settings-import-full-file" accept=".zip" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
|
||||
</details>
|
||||
|
||||
@@ -1095,6 +1228,48 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Windows selector event loop (needs ComfyUI restart) ---
|
||||
document.getElementById("wfm-settings-selector-loop")?.addEventListener("change", async (e) => {
|
||||
const enabled = !!e.target.checked;
|
||||
try {
|
||||
await saveServerSettings({ windows_selector_event_loop: enabled });
|
||||
serverSettings.windows_selector_event_loop = enabled;
|
||||
showToast(t("selectorLoopSaved"), "success");
|
||||
} catch (err) {
|
||||
e.target.checked = !enabled;
|
||||
showToast(`${t("saveError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Models dir handlers ---
|
||||
const saveModelsDir = async (value) => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/models-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ models_dir: value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById("wfm-settings-models-dir");
|
||||
if (input) input.value = data.saved || "";
|
||||
showToast(t("modelsDirChanged"), "success");
|
||||
window.dispatchEvent(new CustomEvent("wfm-models-dir-changed"));
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
document.getElementById("wfm-settings-models-dir-apply")?.addEventListener("click", () => {
|
||||
saveModelsDir(document.getElementById("wfm-settings-models-dir").value.trim());
|
||||
});
|
||||
document.getElementById("wfm-settings-models-dir-reset")?.addEventListener("click", () => {
|
||||
document.getElementById("wfm-settings-models-dir").value = "";
|
||||
saveModelsDir("");
|
||||
});
|
||||
|
||||
// --- Gallery output dir handlers ---
|
||||
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
@@ -1139,92 +1314,6 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- AI Assistant (Prompt Tab) settings ---
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
const loadPromptAiCfg = () => {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
};
|
||||
const savePromptAiCfg = (patch) => {
|
||||
const data = { ...loadPromptAiCfg(), ...patch };
|
||||
localStorage.setItem(PROMPT_AI_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Restore saved values into UI
|
||||
const promptAiSaved = loadPromptAiCfg();
|
||||
const promptAiBackendSaved = promptAiSaved.backend || "ollama";
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.checked = r.value === promptAiBackendSaved;
|
||||
});
|
||||
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
|
||||
if (promptAiUrlEl) {
|
||||
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
|
||||
promptAiUrlEl.placeholder = getAiBackendDefaultUrl(promptAiBackendSaved);
|
||||
}
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
|
||||
});
|
||||
});
|
||||
|
||||
// Load models directly from the selected backend
|
||||
async function loadPromptAiModels() {
|
||||
const select = document.getElementById("wfm-settings-prompt-ai-model");
|
||||
if (!select) return;
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
try {
|
||||
let models = [];
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
models = (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
models = ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
const saved = loadPromptAiCfg().model || "";
|
||||
select.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === saved ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
} catch {
|
||||
select.innerHTML = `<option value="">${t("failedLoadModels")}</option>`;
|
||||
}
|
||||
}
|
||||
await loadPromptAiModels();
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-refresh")?.addEventListener("click", loadPromptAiModels);
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
|
||||
try {
|
||||
let count = 0;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
count = ((await res.json()).models || []).length;
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
count = ((await res.json()).data || []).length;
|
||||
}
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaConnected")} (${count} models)`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
await loadPromptAiModels();
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaFailed")}: ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-save")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = promptAiUrlEl?.value.trim() || "";
|
||||
const model = document.getElementById("wfm-settings-prompt-ai-model")?.value || "";
|
||||
savePromptAiCfg({ backend, backendUrl: url, model });
|
||||
showToast(t("ollamaSaved"), "success");
|
||||
});
|
||||
|
||||
// CivitAI host select
|
||||
document.getElementById("wfm-settings-civitai-host")?.addEventListener("change", async (e) => {
|
||||
const host = e.target.value;
|
||||
@@ -1296,6 +1385,83 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Decision Model (Laya) ---
|
||||
const readDecisionForm = () => {
|
||||
const baseUrl = document.getElementById("wfm-settings-decision-url")?.value.trim() || "";
|
||||
const threshold = parseFloat(document.getElementById("wfm-settings-decision-threshold")?.value);
|
||||
let urlOk = false;
|
||||
try { urlOk = ["http:", "https:"].includes(new URL(baseUrl).protocol); } catch {}
|
||||
if (!urlOk) throw new Error(t("decisionInvalidUrl"));
|
||||
if (!(threshold >= 0.5 && threshold <= 0.99)) throw new Error(t("decisionInvalidThreshold"));
|
||||
return {
|
||||
backend: document.getElementById("wfm-settings-decision-backend")?.value || "unsloth",
|
||||
baseUrl,
|
||||
model: document.getElementById("wfm-settings-decision-model")?.value || "",
|
||||
threshold,
|
||||
};
|
||||
};
|
||||
// Unsloth: fixed Laya names. Ollama: installed models whose capabilities include "decision";
|
||||
// none installed (or Ollama unreachable) → keep the preferred name selectable and show why.
|
||||
const refreshDecisionModels = async (preferred) => {
|
||||
const backend = document.getElementById("wfm-settings-decision-backend")?.value || "unsloth";
|
||||
const baseUrl = document.getElementById("wfm-settings-decision-url")?.value.trim() || "";
|
||||
const select = document.getElementById("wfm-settings-decision-model");
|
||||
const hint = document.getElementById("wfm-settings-decision-model-hint");
|
||||
if (!select) return;
|
||||
const def = DECISION_BACKENDS[backend];
|
||||
const current = preferred ?? select.value;
|
||||
let models = [];
|
||||
let hintText = "";
|
||||
try {
|
||||
({ models } = await listDecisionModels(backend, baseUrl));
|
||||
if (backend === "ollama" && models.length === 0) hintText = t("decisionNoOllamaModels");
|
||||
} catch (err) {
|
||||
hintText = `${t("failedConnect")}: ${err.message}`;
|
||||
}
|
||||
const list = models.length ? [...models] : [...def.models];
|
||||
if (current && !list.includes(current) && (backend === "ollama") === !/^laya/.test(current)) list.unshift(current);
|
||||
const selected = list.includes(current) ? current : (list.includes(def.defaultModel) ? def.defaultModel : list[0]);
|
||||
select.innerHTML = list.map((m) => `<option value="${escapeHtml(m)}" ${m === selected ? "selected" : ""}>${m === "laya" ? t("decisionModelLaya") : escapeHtml(m)}</option>`).join("");
|
||||
if (hint) hint.textContent = hintText;
|
||||
};
|
||||
refreshDecisionModels(decisionSettings.model);
|
||||
initVramSettings();
|
||||
document.getElementById("wfm-settings-decision-refresh")?.addEventListener("click", () => refreshDecisionModels());
|
||||
document.getElementById("wfm-settings-decision-backend")?.addEventListener("change", (e) => {
|
||||
const urlInput = document.getElementById("wfm-settings-decision-url");
|
||||
const def = DECISION_BACKENDS[e.target.value]?.defaultUrl;
|
||||
if (urlInput && def) { urlInput.value = def; urlInput.placeholder = def; }
|
||||
refreshDecisionModels(DECISION_BACKENDS[e.target.value]?.defaultModel);
|
||||
});
|
||||
document.getElementById("wfm-settings-decision-save")?.addEventListener("click", () => {
|
||||
try {
|
||||
saveDecisionSettings(readDecisionForm());
|
||||
showToast(t("settingsSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
});
|
||||
// Tests the values currently in the form (saved or not), same as the Eagle test button.
|
||||
document.getElementById("wfm-settings-decision-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-decision-status");
|
||||
let form;
|
||||
try { form = readDecisionForm(); } catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = t("decisionTesting");
|
||||
statusEl.style.color = "var(--wfm-text-secondary)";
|
||||
try {
|
||||
const { ms, yes } = await testDecisionConnection({ backend: form.backend, baseUrl: form.baseUrl, model: form.model });
|
||||
statusEl.textContent = `${t("connectedCheck")} ${t("decisionTestOk").replace("{ms}", ms).replace("{yes}", typeof yes === "number" ? yes.toFixed(3) : "?")}`;
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("failedConnect")}: ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
}
|
||||
});
|
||||
|
||||
// Test Eagle URL (via server proxy to avoid CORS)
|
||||
document.getElementById("wfm-settings-test-eagle")?.addEventListener("click", async () => {
|
||||
const urlInput = document.getElementById("wfm-settings-eagle-url");
|
||||
@@ -1342,6 +1508,16 @@ export async function initSettingsTab() {
|
||||
applyTextareaFontSize(px);
|
||||
});
|
||||
|
||||
// --- Video volume slider (auto-saves immediately, no "Save" click needed — a dragged
|
||||
// slider that silently didn't persist would defeat the whole point of this setting) ---
|
||||
document.getElementById("wfm-video-volume")?.addEventListener("input", (e) => {
|
||||
const pct = parseInt(e.target.value) || 0;
|
||||
const valEl = document.getElementById("wfm-video-volume-val");
|
||||
if (valEl) valEl.textContent = `${pct}%`;
|
||||
const volume = setVideoVolume(pct / 100);
|
||||
document.querySelectorAll("video").forEach((v) => { v.volume = volume; });
|
||||
});
|
||||
|
||||
// --- JSON highlight color pickers ---
|
||||
document.getElementById("wfm-json-color-grid")?.addEventListener("input", (e) => {
|
||||
if (e.target.type !== "color") return;
|
||||
@@ -1532,8 +1708,60 @@ export async function initSettingsTab() {
|
||||
statusEl.textContent = t("importError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
// Reset file input so same file can be re-selected
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
// --- Full Backup Export (ZIP) ---
|
||||
document.getElementById("wfm-settings-export-full")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
try {
|
||||
const includeWorkflows = document.getElementById("wfm-settings-backup-include-workflows")?.checked ? "1" : "0";
|
||||
const includeWildcard = document.getElementById("wfm-settings-backup-include-wildcard")?.checked ? "1" : "0";
|
||||
const includeVideoMedia = document.getElementById("wfm-settings-backup-include-video-media")?.checked ? "1" : "0";
|
||||
const res = await fetch(`/api/wfm/settings/export-full?include_workflows=${includeWorkflows}&include_wildcard=${includeWildcard}&include_video_media=${includeVideoMedia}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "wfm-full-backup.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
statusEl.textContent = t("exportSuccess");
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("exportError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Full Backup Import (ZIP) ---
|
||||
document.getElementById("wfm-settings-import-full-file")?.addEventListener("change", async (e) => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
const res = await fetch("/api/wfm/settings/import-full", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || res.statusText);
|
||||
let msg = `${t("importSuccess")}: ${result.extracted?.length ?? 0} ${t("fullBackupFilesUnit")}`;
|
||||
const restoredMedia = result.media_restored?.length ?? 0;
|
||||
const existingMedia = result.media_existing?.length ?? 0;
|
||||
if (restoredMedia || existingMedia) {
|
||||
msg += ` / ${t("fullBackupMediaResult").replace("{restored}", restoredMedia).replace("{existing}", existingMedia)}`;
|
||||
}
|
||||
statusEl.textContent = msg;
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("importError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Save G'MIC path setting
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Tagger Tab
|
||||
* Handles WD Tagger / DeepDanbooru + Ollama VLM single/batch tagging.
|
||||
* Handles WD Tagger / DeepDanbooru + VLM (Ollama/LM Studio/Lemonade/Unsloth) single/batch tagging.
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { openAiBackendSettingsModal, getAiBackendConfig } from "./ai-settings-modal.js";
|
||||
|
||||
const TAGGER_AI_KEY = "wfm_tagger_ai_settings";
|
||||
function getTaggerAiConfig() {
|
||||
return getAiBackendConfig(TAGGER_AI_KEY);
|
||||
}
|
||||
|
||||
// ── 状態 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,7 +38,7 @@ export function initTaggerTab() {
|
||||
_setupBatchActions();
|
||||
_setupDbActions();
|
||||
_loadModels();
|
||||
_loadSettings();
|
||||
_updateVlmBackendIndicators();
|
||||
}
|
||||
|
||||
/** GalleryタブからTaggerタブへ画像を渡す */
|
||||
@@ -74,8 +80,6 @@ function _applyI18n() {
|
||||
"wfm-tagger-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-generate-btn": "taggerGenerateTags",
|
||||
"wfm-tagger-ollama-header-label": "taggerOllamaEnable",
|
||||
"wfm-tagger-ollama-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-ollama-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-ollama-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-ollama-max-label": "taggerOllamaMaxTags",
|
||||
"wfm-tagger-result-header": "taggerResult",
|
||||
@@ -87,8 +91,7 @@ function _applyI18n() {
|
||||
"wfm-tagger-batch-folder-header": "taggerBatchFolder",
|
||||
"wfm-tagger-batch-threshold-label": "taggerThreshold",
|
||||
"wfm-tagger-batch-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-batch-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-batch-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-batch-ollama-header-label": "taggerOllamaEnable",
|
||||
"wfm-tagger-batch-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-batch-output-header": "taggerBatchOutput",
|
||||
"wfm-tagger-batch-save-db-label": "taggerSaveToDB",
|
||||
@@ -107,12 +110,18 @@ function _applyI18n() {
|
||||
"wfm-tagger-db-edit-vlm-label": "taggerDbVlm",
|
||||
"wfm-tagger-db-save-btn": "taggerDbSave",
|
||||
"wfm-tagger-db-delete-btn": "taggerDbDelete",
|
||||
"wfm-tagger-db-delete-selected-btn": "taggerDbDelete"
|
||||
};
|
||||
for (const [id, key] of Object.entries(map)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
["wfm-tagger-ollama-settings-btn", "wfm-tagger-batch-ollama-settings-btn"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = t("settings");
|
||||
});
|
||||
|
||||
// サブタブボタン
|
||||
const subtabKeys = ["taggerSingleTab", "taggerBatchTab", "taggerDbTab"];
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach((btn, i) => {
|
||||
@@ -168,6 +177,24 @@ function _setupOllamaToggle() {
|
||||
});
|
||||
wrap.style.display = chk.checked ? "" : "none";
|
||||
}
|
||||
|
||||
document.getElementById("wfm-tagger-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(TAGGER_AI_KEY, t("settings"), _updateVlmBackendIndicators);
|
||||
});
|
||||
document.getElementById("wfm-tagger-batch-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(TAGGER_AI_KEY, t("settings"), _updateVlmBackendIndicators);
|
||||
});
|
||||
}
|
||||
|
||||
// ── VLMバックエンド表示 (Taggerタブ独自のAI設定を参照) ────────────
|
||||
|
||||
function _updateVlmBackendIndicators() {
|
||||
const { backend, model } = getTaggerAiConfig();
|
||||
const label = `${backend}: ${model || t("taggerNoModel")}`;
|
||||
const singleEl = document.getElementById("wfm-tagger-ollama-backend-value");
|
||||
if (singleEl) singleEl.textContent = label;
|
||||
const batchEl = document.getElementById("wfm-tagger-batch-backend-value");
|
||||
if (batchEl) batchEl.textContent = label;
|
||||
}
|
||||
|
||||
// ── モデル一覧ロード ─────────────────────────────────────────────
|
||||
@@ -188,61 +215,6 @@ async function _loadModels() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ollamaモデル一覧ロード ───────────────────────────────────────
|
||||
|
||||
async function _loadOllamaModels(urlId, selId) {
|
||||
const urlEl = document.getElementById(urlId);
|
||||
const selEl = document.getElementById(selId);
|
||||
if (!urlEl || !selEl) return;
|
||||
const api = urlEl.value || "http://127.0.0.1:11434";
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/ollama/models?api_url=${encodeURIComponent(api)}`);
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
if (models.length === 0) {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
} else {
|
||||
selEl.innerHTML = models.map(m => `<option value="${m}">${m}</option>`).join("");
|
||||
}
|
||||
} catch {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 設定の読み込み/保存 ─────────────────────────────────────────
|
||||
|
||||
async function _loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/settings");
|
||||
const data = await res.json();
|
||||
if (data.ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-ollama-url");
|
||||
if (el) el.value = data.ollama_url;
|
||||
}
|
||||
if (data.batch_ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-batch-ollama-url");
|
||||
if (el) el.value = data.batch_ollama_url;
|
||||
}
|
||||
} catch {
|
||||
// 初回は無視
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveSettings() {
|
||||
try {
|
||||
await fetch("/wfm/tagger/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ollama_url: document.getElementById("wfm-tagger-ollama-url")?.value || "",
|
||||
batch_ollama_url: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "",
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// サイレントに失敗
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single サブタブ ─────────────────────────────────────────────
|
||||
|
||||
function _setupSingleActions() {
|
||||
@@ -279,11 +251,6 @@ function _setupSingleActions() {
|
||||
// タグ生成
|
||||
document.getElementById("wfm-tagger-generate-btn")?.addEventListener("click", _generateTags);
|
||||
|
||||
// Ollamaリフレッシュ
|
||||
document.getElementById("wfm-tagger-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-ollama-url", "wfm-tagger-ollama-model");
|
||||
});
|
||||
|
||||
// 結果アクション
|
||||
document.getElementById("wfm-tagger-send-genui-btn")?.addEventListener("click", _sendToGenUI);
|
||||
document.getElementById("wfm-tagger-send-prompt-btn")?.addEventListener("click", _sendToPrompt);
|
||||
@@ -321,8 +288,7 @@ async function _generateTags() {
|
||||
const threshold = parseFloat(document.getElementById("wfm-tagger-threshold")?.value || "0.35");
|
||||
const charThreshold = parseFloat(document.getElementById("wfm-tagger-char-threshold")?.value || "0.85");
|
||||
const useOllama = document.getElementById("wfm-tagger-ollama-enable")?.checked || false;
|
||||
const ollamaUrl = document.getElementById("wfm-tagger-ollama-url")?.value || "http://127.0.0.1:11434";
|
||||
const ollamaModel = document.getElementById("wfm-tagger-ollama-model")?.value || "";
|
||||
const { backend: vlmBackend, url: vlmUrl, model: ollamaModel, thinkingMode, maxTokens } = getTaggerAiConfig();
|
||||
const ollamaPrompt = document.getElementById("wfm-tagger-ollama-prompt")?.value || "";
|
||||
const maxTags = parseInt(document.getElementById("wfm-tagger-ollama-max-tags")?.value || "40");
|
||||
|
||||
@@ -344,12 +310,13 @@ async function _generateTags() {
|
||||
}
|
||||
|
||||
if (useOllama && ollamaModel) {
|
||||
const res2 = await fetch("/wfm/tagger/ollama/predict", {
|
||||
const res2 = await fetch("/wfm/tagger/vlm/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
image_b64: state.imageB64, api_url: ollamaUrl,
|
||||
image_b64: state.imageB64, backend: vlmBackend, api_url: vlmUrl,
|
||||
model: ollamaModel, prompt: ollamaPrompt, max_tags: maxTags,
|
||||
thinking_mode: thinkingMode, max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
@@ -361,7 +328,6 @@ async function _generateTags() {
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
document.getElementById("wfm-tagger-result").value = tags;
|
||||
showToast(t("taggerDone"), "success");
|
||||
_saveSettings();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
} finally {
|
||||
@@ -453,10 +419,6 @@ async function _saveToDb() {
|
||||
// ── Batch サブタブ ───────────────────────────────────────────────
|
||||
|
||||
function _setupBatchActions() {
|
||||
document.getElementById("wfm-tagger-batch-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-batch-ollama-url", "wfm-tagger-batch-ollama-model");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-tagger-batch-start-btn")?.addEventListener("click", _batchStart);
|
||||
document.getElementById("wfm-tagger-batch-stop-btn")?.addEventListener("click", _batchStop);
|
||||
}
|
||||
@@ -465,15 +427,19 @@ async function _batchStart() {
|
||||
const folder = document.getElementById("wfm-tagger-batch-folder")?.value || "";
|
||||
if (!folder) { showToast(t("taggerBatchFolder"), "warning"); return; }
|
||||
|
||||
const { backend: vlmBackend, url: vlmUrl, model: vlmModel, thinkingMode, maxTokens } = getTaggerAiConfig();
|
||||
const body = {
|
||||
folder,
|
||||
model: document.getElementById("wfm-tagger-batch-model")?.value || "",
|
||||
threshold: parseFloat(document.getElementById("wfm-tagger-batch-threshold")?.value || "0.35"),
|
||||
char_threshold: parseFloat(document.getElementById("wfm-tagger-batch-char-threshold")?.value || "0.85"),
|
||||
use_ollama: document.getElementById("wfm-tagger-batch-ollama-enable")?.checked || false,
|
||||
ollama_api: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "http://127.0.0.1:11434",
|
||||
ollama_model: document.getElementById("wfm-tagger-batch-ollama-model")?.value || "",
|
||||
vlm_backend: vlmBackend,
|
||||
vlm_api_url: vlmUrl,
|
||||
ollama_model: vlmModel,
|
||||
ollama_prompt: document.getElementById("wfm-tagger-batch-ollama-prompt")?.value || "",
|
||||
thinking_mode: thinkingMode,
|
||||
max_tokens: maxTokens,
|
||||
save_db: document.getElementById("wfm-tagger-batch-save-db")?.checked ?? true,
|
||||
write_file: document.getElementById("wfm-tagger-batch-write-file")?.checked || false,
|
||||
write_txt: document.getElementById("wfm-tagger-batch-write-txt")?.checked || false,
|
||||
@@ -549,6 +515,8 @@ function _setupDbActions() {
|
||||
});
|
||||
document.getElementById("wfm-tagger-db-save-btn")?.addEventListener("click", _dbSave);
|
||||
document.getElementById("wfm-tagger-db-delete-btn")?.addEventListener("click", _dbDelete);
|
||||
document.getElementById("wfm-tagger-db-select").addEventListener("click", _dbToggleSelection);
|
||||
document.getElementById("wfm-tagger-db-delete-selected-btn").addEventListener("click", _dbDeleteSelected);
|
||||
}
|
||||
|
||||
async function _dbLoad() {
|
||||
@@ -576,12 +544,20 @@ async function _dbSearch() {
|
||||
function _renderDbTable(rows) {
|
||||
const tbody = document.getElementById("wfm-tagger-db-tbody");
|
||||
if (!tbody) return;
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(row => `
|
||||
<tr data-id="${row.id}" title="${_esc(row.all_tags)}">
|
||||
<td class="wfm-tagger-db-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-id="${row.id}"
|
||||
>
|
||||
</td>
|
||||
<td>${row.id}</td>
|
||||
<td>${_esc(row.filename)}</td>
|
||||
<td>${_esc(_truncate(row.interrogator_tags, 60))}</td>
|
||||
@@ -591,9 +567,13 @@ function _renderDbTable(rows) {
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(tr => {
|
||||
tr.addEventListener("click", () => {
|
||||
tr.addEventListener("click", e => {
|
||||
// チェックボックスをクリックした場合は行選択を発生させない
|
||||
if (e.target.closest(".wfm-tagger-db-select")) return;
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(r => r.classList.remove("selected"));
|
||||
tr.classList.add("selected");
|
||||
|
||||
const row = rows.find(r => r.id === parseInt(tr.dataset.id));
|
||||
if (row) _openDbEdit(row);
|
||||
});
|
||||
@@ -655,6 +635,55 @@ function _dbExport() {
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function _dbDeleteSelected() {
|
||||
const ids = _getSelectedDbIds();
|
||||
|
||||
if (!ids.length) {
|
||||
showToast(t("taggerDbNoSelection"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(t("taggerDbDeleteConfirm", ids.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/db", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
showToast(t("taggerDbDeletedCount", data.count), "success");
|
||||
_dbLoad();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _getSelectedDbIds() {
|
||||
return [...document.querySelectorAll(".wfm-tagger-db-select input:checked")]
|
||||
.map(el => parseInt(el.dataset.id))
|
||||
.filter(Number.isInteger);
|
||||
}
|
||||
|
||||
function _dbToggleSelection(e) {
|
||||
const checked = e.target.checked;
|
||||
const checkboxes = document.querySelectorAll(
|
||||
'#wfm-tagger-db-tbody input[type="checkbox"]'
|
||||
);
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
// ── ユーティリティ ───────────────────────────────────────────────
|
||||
|
||||
function _esc(str) {
|
||||
|
||||
@@ -82,6 +82,57 @@ export function escapeHtml(s) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// http(s) 以外(javascript: / data: など)を拒否して安全なURLだけ返す。不正なら fallback
|
||||
export function safeHttpUrl(url, fallback = "") {
|
||||
// 絶対URLのhttp(s)のみ許可(相対URLだとComfyUI自身のURLへリクエストを送らせられるため不可)
|
||||
const s = String(url ?? "").trim();
|
||||
if (!/^https?:\/\/[^\s]+$/i.test(s)) return fallback;
|
||||
try {
|
||||
new URL(s);
|
||||
return s;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// 外部由来のHTML(CivitAIの説明文など)を許可リスト方式で無害化して返す。
|
||||
// スクリプト・イベント属性・style・javascript:/data: URL・iframe等は除去する。
|
||||
const _SAN_DROP = new Set(["script", "style", "iframe", "frame", "frameset", "object", "embed", "applet",
|
||||
"link", "meta", "base", "form", "input", "button", "textarea", "select", "svg", "math", "template",
|
||||
"noscript", "audio", "video", "source", "track", "canvas", "title", "head"]);
|
||||
const _SAN_KEEP = new Set(["p", "br", "hr", "strong", "b", "em", "i", "u", "s", "del", "ins", "mark", "small",
|
||||
"sub", "sup", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "code", "pre", "kbd",
|
||||
"span", "div", "a", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "details", "summary"]);
|
||||
|
||||
export function sanitizeHtml(html) {
|
||||
if (html == null || html === "") return "";
|
||||
const doc = new DOMParser().parseFromString(String(html), "text/html"); // 不活性ドキュメント(スクリプト非実行)
|
||||
const walk = (node) => {
|
||||
let out = "";
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === 3) { // text
|
||||
out += escapeHtml(child.nodeValue);
|
||||
} else if (child.nodeType === 1) { // element
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (_SAN_DROP.has(tag)) continue;
|
||||
if (!_SAN_KEEP.has(tag)) { out += walk(child); continue; } // 未知タグは中身だけ残す
|
||||
if (tag === "br" || tag === "hr") { out += `<${tag}>`; continue; }
|
||||
if (tag === "a") {
|
||||
const href = safeHttpUrl(child.getAttribute("href") || "", "");
|
||||
out += `<a${href ? ` href="${escapeHtml(href)}"` : ""} target="_blank" rel="noopener noreferrer">${walk(child)}</a>`;
|
||||
} else if (tag === "img") {
|
||||
const src = safeHttpUrl(child.getAttribute("src") || "", "");
|
||||
if (src) out += `<img src="${escapeHtml(src)}" alt="${escapeHtml(child.getAttribute("alt") || "")}" loading="lazy" style="max-width:100%;">`;
|
||||
} else {
|
||||
out += `<${tag}>${walk(child)}</${tag}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return walk(doc.body);
|
||||
}
|
||||
|
||||
// localStorage からJSONを安全に読む(不正JSON・未設定時はfallback)
|
||||
export function readJsonStorage(key, fallback = {}) {
|
||||
try {
|
||||
@@ -109,6 +160,52 @@ export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
// Unslothはローカルでも常にAPIキーが必要なため、フロントから直接叩かず
|
||||
// サーバー側プロキシ(/api/wfm/unsloth/proxy)経由でキーを付与して中継する
|
||||
// (see py/routes/unsloth_routes.py)
|
||||
export async function unslothProxy(baseUrl, path, method, payload) {
|
||||
const res = await fetch("/api/wfm/unsloth/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, path, method, payload }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ローカルVLM/LLMバックエンドからモデルをVRAM/メモリからアンロードする。
|
||||
// 公式APIが存在しない場合(Unsloth)は "UNSUPPORTED" エラーを投げる。
|
||||
export async function unloadAiModel(url, backend, model) {
|
||||
if (!model) throw new Error("No model selected");
|
||||
if (backend === "ollama") {
|
||||
// keep_alive:0 + 空プロンプトが公式に文書化されたアンロード方法
|
||||
// (専用エンドポイントは存在しない) https://docs.ollama.com/api
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt: "", keep_alive: 0 }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else if (backend === "lmstudio") {
|
||||
const res = await fetch(`${url}/api/v1/models/unload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ instance_id: model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else if (backend === "lemonade") {
|
||||
const res = await fetch(`${url}/api/v1/unload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else {
|
||||
throw new Error("UNSUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Video Tab - Asset subtab: browses the videos a Plan run has generated.
|
||||
*
|
||||
* Deliberately thin — it reuses Gallery's existing backend (groups, tags/memo
|
||||
* sidecar, thumbnailing) exactly like feeder-tab.js's own Gallery mode does,
|
||||
* rather than re-implementing folder browsing, bulk selection, etc. Anything
|
||||
* beyond basic browsing/tagging (move, export, delete, multi-select) is left
|
||||
* to the real Gallery tab via the "Open in Gallery" button.
|
||||
*/
|
||||
|
||||
import { VIDEO_GROUP, VTEMP_GROUP, ensureVideoGroup, isVideoFile, isAudioFile } from "./gallery-tab.js";
|
||||
import { setSourcePreview } from "./video-preview.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { addClipFromFile, setBgmFromFile, addPipFromFile, addSoundFromFile } from "./video-edit-tab.js";
|
||||
import { setBlockImageFromFile } from "./video-plan-tab.js";
|
||||
|
||||
// Sentinel for the "All Video Assets" option — not a real backend group, since
|
||||
// Gallery's group filter only ever matches one group at a time. Selecting it
|
||||
// fetches VIDEO_GROUP and VTEMP_GROUP separately and merges them (see _loadImages).
|
||||
const ALL_GROUPS_VALUE = "__video_asset_all__";
|
||||
|
||||
// Display names are now identical to the underlying group names (minus the
|
||||
// "__...__" wrapping) — no more per-plan "VideoPlan:<name>" sub-groups, so the
|
||||
// dropdown is always exactly these three fixed entries.
|
||||
const _GROUPS = [ALL_GROUPS_VALUE, VIDEO_GROUP, VTEMP_GROUP];
|
||||
|
||||
const _s = {
|
||||
outputDir: "",
|
||||
group: ALL_GROUPS_VALUE,
|
||||
images: [],
|
||||
selectedPath: null,
|
||||
loaded: false, // becomes true once the Asset subtab has been shown at least once
|
||||
searchQuery: "",
|
||||
viewMode: "grid", // "grid" | "table"
|
||||
kindFilter: "all", // "all" | "video" | "image" | "audio"
|
||||
};
|
||||
|
||||
function _formatDate(mtime) {
|
||||
return new Date(mtime * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function _filteredImages() {
|
||||
const q = _s.searchQuery.trim().toLowerCase();
|
||||
let list = _s.images;
|
||||
if (_s.kindFilter === "video") list = list.filter((img) => isVideoFile(img));
|
||||
else if (_s.kindFilter === "audio") list = list.filter((img) => isAudioFile(img));
|
||||
else if (_s.kindFilter === "image") list = list.filter((img) => !isVideoFile(img) && !isAudioFile(img));
|
||||
if (q) list = list.filter((img) => img.filename.toLowerCase().includes(q));
|
||||
return list;
|
||||
}
|
||||
|
||||
async function _fetchOutputDir() {
|
||||
if (_s.outputDir) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_s.outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
function _groupLabel(g) {
|
||||
if (g === ALL_GROUPS_VALUE) return "All Video Assets";
|
||||
if (g === VIDEO_GROUP) return "Video Assets";
|
||||
if (g === VTEMP_GROUP) return "Video Temp";
|
||||
return g;
|
||||
}
|
||||
|
||||
function _renderGroupSelect() {
|
||||
const sel = document.getElementById("wfm-video-asset-group");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = _GROUPS
|
||||
.map((g) => `<option value="${g}"${g === _s.group ? " selected" : ""}>${_groupLabel(g)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function _fetchGroupImages(group) {
|
||||
const params = new URLSearchParams({ folder: _s.outputDir, group, recursive: "true", sort: "date_desc" });
|
||||
const res = await fetch(`/wfm/gallery/images?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
return data.images || [];
|
||||
}
|
||||
|
||||
async function _loadImages() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const statusEl = document.getElementById("wfm-video-asset-status");
|
||||
if (!grid) return;
|
||||
await _fetchOutputDir();
|
||||
if (!_s.outputDir) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No output folder configured</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = `<div class="wfm-placeholder">Loading...</div>`;
|
||||
try {
|
||||
let images;
|
||||
if (_s.group === ALL_GROUPS_VALUE) {
|
||||
// "All Video Assets" = the union of the curated __Video Assets__
|
||||
// group and __Video Temp__ (auto-populated by every plan run,
|
||||
// organized into __Video Assets__ by the user on their own
|
||||
// schedule) — not a single backend group filter, so fetch both
|
||||
// and merge.
|
||||
const [curated, temp] = await Promise.all([
|
||||
_fetchGroupImages(VIDEO_GROUP),
|
||||
_fetchGroupImages(VTEMP_GROUP),
|
||||
]);
|
||||
const byPath = new Map();
|
||||
for (const img of [...curated, ...temp]) byPath.set(img.path, img);
|
||||
images = Array.from(byPath.values()).sort((a, b) => b.mtime - a.mtime);
|
||||
} else {
|
||||
images = await _fetchGroupImages(_s.group);
|
||||
}
|
||||
_s.images = images;
|
||||
_renderList();
|
||||
if (statusEl) statusEl.textContent = `${_s.images.length} video(s)`;
|
||||
} catch (err) {
|
||||
_s.images = [];
|
||||
grid.innerHTML = `<div class="wfm-placeholder" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Renders whichever of grid/table is active for the current viewMode — called
|
||||
// on load, on search input, and after switching view modes.
|
||||
function _renderList() {
|
||||
if (_s.viewMode === "table") _renderTable();
|
||||
else _renderGrid();
|
||||
}
|
||||
|
||||
function _renderGrid() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
if (!grid) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No videos yet</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
for (const img of images) grid.appendChild(_makeCard(img));
|
||||
}
|
||||
|
||||
function _renderTable() {
|
||||
const body = document.getElementById("wfm-video-asset-table-body");
|
||||
if (!body) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
body.innerHTML = `<tr><td colspan="3" class="wfm-placeholder">No videos yet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
for (const img of images) body.appendChild(_makeRow(img));
|
||||
}
|
||||
|
||||
function _makeRow(img) {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "wfm-video-asset-row" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
row.dataset.path = img.path;
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.textContent = img.filename;
|
||||
nameCell.title = img.filename;
|
||||
|
||||
const dateCell = document.createElement("td");
|
||||
dateCell.textContent = _formatDate(img.mtime);
|
||||
|
||||
const tagsCell = document.createElement("td");
|
||||
tagsCell.textContent = (img.tags || []).join(", ");
|
||||
|
||||
row.append(nameCell, dateCell, tagsCell);
|
||||
row.addEventListener("click", () => _selectImage(img));
|
||||
return row;
|
||||
}
|
||||
|
||||
function _makeCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-video-asset-card" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
card.dataset.path = img.path;
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "wfm-video-asset-card-img";
|
||||
thumb.loading = "lazy";
|
||||
thumb.src = `/wfm/gallery/image/thumb?path=${encodeURIComponent(img.path)}&w=200`;
|
||||
thumb.onerror = () => { thumb.style.display = "none"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-video-asset-card-name";
|
||||
name.textContent = img.filename;
|
||||
name.title = img.filename;
|
||||
|
||||
card.append(thumb, name);
|
||||
card.addEventListener("click", () => _selectImage(img));
|
||||
return card;
|
||||
}
|
||||
|
||||
function _selectImage(img) {
|
||||
_s.selectedPath = img.path;
|
||||
document.querySelectorAll(".wfm-video-asset-card, .wfm-video-asset-row").forEach((c) => {
|
||||
c.classList.toggle("selected", c.dataset.path === img.path);
|
||||
});
|
||||
_renderDetail(img);
|
||||
_loadIntoSourcePreview(img);
|
||||
}
|
||||
|
||||
// Feeds the selected asset's media into the center panel's Asset/Source preview
|
||||
// pane (see video-preview.js) so it's visible without switching tabs, and so the
|
||||
// Frame/GIF property tools can operate on it just like a Video Source drop.
|
||||
// Fetched as a Blob and wrapped as a "local" source rather than trying to map
|
||||
// Gallery's arbitrary absolute path onto ComfyUI's filename/subfolder/type
|
||||
// triple — that keeps the GIF tool's existing upload-on-first-use path working
|
||||
// unchanged for both cases. Still/animated images (this group isn't exclusively
|
||||
// videos — anything tagged into it shows up here) preview via the pane's <img>
|
||||
// companion element instead of trying to play them as a <video>.
|
||||
async function _loadIntoSourcePreview(img) {
|
||||
if (isAudioFile(img)) return; // 音声は映像プレビュー対象外(詳細パネルの<audio>で試聴)
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const isVideo = isVideoFile(img);
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideo ? "video/mp4" : "image/png") });
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file }, isVideo ? "video" : "image");
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] failed to load preview:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDetail(img) {
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (!panel) return;
|
||||
// Filename/memo are untrusted (filesystem names, user-entered text) — the
|
||||
// markup skeleton is static, and those two values are assigned afterward
|
||||
// via textContent/.value, never interpolated into the HTML string itself.
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-video-asset-name" id="wfm-video-asset-name"></div>
|
||||
${isAudioFile(img) ? `<audio id="wfm-video-asset-audio" class="wfm-gallery-audio-player" controls src="/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}" style="width:100%;margin-bottom:8px;"></audio>` : ""}
|
||||
<label>Tags</label>
|
||||
<div class="wfm-video-asset-tags" id="wfm-video-asset-tags"></div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<input type="text" id="wfm-video-asset-tag-input" class="wfm-input" placeholder="Add tag" style="flex:1;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-tag-add">Add</button>
|
||||
</div>
|
||||
<label style="margin-top:10px;">Memo</label>
|
||||
<textarea id="wfm-video-asset-memo" class="wfm-textarea" rows="3"></textarea>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-memo-save" style="margin-top:6px;">Save Memo</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-send-to-edit" style="width:100%;margin-top:12px;display:${isAudioFile(img) ? "none" : "block"};">${t("videoEditSendToEdit")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-add-overlay" style="width:100%;margin-top:6px;display:${isAudioFile(img) ? "none" : "block"};">${t("videoAssetAddAsOverlay")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-bgm" style="width:100%;margin-top:${isAudioFile(img) ? "12" : "6"}px;display:${isVideoFile(img) || isAudioFile(img) ? "block" : "none"};">${t("videoAssetSetAsBgm")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-add-sound" style="width:100%;margin-top:6px;display:${isAudioFile(img) ? "block" : "none"};">${t("videoAssetAddAsSound")}</button>
|
||||
<div id="wfm-video-asset-set-plan-image-row" style="display:${isVideoFile(img) || isAudioFile(img) ? "none" : "flex"};gap:6px;margin-top:6px;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-first" style="flex:1;">${t("videoAssetSetAsFirst")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-last" style="flex:1;">${t("videoAssetSetAsLast")}</button>
|
||||
</div>
|
||||
<button type="button" class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-video-asset-open-gallery" style="width:100%;margin-top:6px;">Open in Gallery</button>
|
||||
`;
|
||||
|
||||
const nameEl = document.getElementById("wfm-video-asset-name");
|
||||
if (nameEl) { nameEl.textContent = img.filename; nameEl.title = img.filename; }
|
||||
const memoEl = document.getElementById("wfm-video-asset-memo");
|
||||
if (memoEl) memoEl.value = img.memo || "";
|
||||
|
||||
_renderTags(img);
|
||||
|
||||
panel.querySelector("#wfm-video-asset-tag-add")?.addEventListener("click", async () => {
|
||||
const input = panel.querySelector("#wfm-video-asset-tag-input");
|
||||
const tag = input?.value.trim();
|
||||
if (!tag) return;
|
||||
const tags = Array.from(new Set([...(img.tags || []), tag]));
|
||||
await _saveMeta(img.path, { tags });
|
||||
img.tags = tags;
|
||||
input.value = "";
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-memo-save")?.addEventListener("click", async () => {
|
||||
const memo = panel.querySelector("#wfm-video-asset-memo")?.value || "";
|
||||
await _saveMeta(img.path, { memo });
|
||||
img.memo = memo;
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-send-to-edit")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideoFile(img) ? "video/mp4" : "image/png") });
|
||||
addClipFromFile(file, img.filename);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoEditClipSent", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Overlay clip (picture-in-picture): works for both videos and images, and
|
||||
// jumps to the Edit subtab's Overlay track so the new clip is visible.
|
||||
panel.querySelector("#wfm-video-asset-add-overlay")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideoFile(img) ? "video/mp4" : "image/png") });
|
||||
if (await addPipFromFile(file, img.filename)) {
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
document.getElementById("wfm-video-edit-track-pip")?.click();
|
||||
showToast(t("videoAssetAddAsOverlayDone", img.filename), "success");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// BGM source: an audio asset directly, or a video asset's own audio track
|
||||
// (LoadAudio decodes audio from video files).
|
||||
panel.querySelector("#wfm-video-asset-set-bgm")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isAudioFile(img) ? "audio/mpeg" : "video/mp4") });
|
||||
if (await setBgmFromFile(file, img.filename)) {
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoAssetSetAsBgmDone", img.filename), "success");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Timeline sound (sound effect / voice): placed at the selected clip's start on the Audio track.
|
||||
panel.querySelector("#wfm-video-asset-add-sound")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "audio/mpeg" });
|
||||
if (await addSoundFromFile(file, img.filename)) {
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
document.getElementById("wfm-video-edit-track-audio")?.click();
|
||||
showToast(t("videoAssetAddAsSoundDone", img.filename), "success");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const setAsPlanImage = async (which) => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
await setBlockImageFromFile(which, file);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="plan"]')?.click();
|
||||
showToast(t(which === "first" ? "videoAssetSetAsFirstDone" : "videoAssetSetAsLastDone", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
};
|
||||
panel.querySelector("#wfm-video-asset-set-first")?.addEventListener("click", () => setAsPlanImage("first"));
|
||||
panel.querySelector("#wfm-video-asset-set-last")?.addEventListener("click", () => setAsPlanImage("last"));
|
||||
|
||||
panel.querySelector("#wfm-video-asset-open-gallery")?.addEventListener("click", () => {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
const filterSel = document.getElementById("wfm-gallery-group-filter");
|
||||
if (filterSel) {
|
||||
// ALL_GROUPS_VALUE is a virtual union with no Gallery-side equivalent
|
||||
// (Gallery's own filter only ever matches one real group) — fall back
|
||||
// to the curated group, the closer of the two to "everything worth keeping".
|
||||
filterSel.value = _s.group === ALL_GROUPS_VALUE ? VIDEO_GROUP : _s.group;
|
||||
filterSel.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _renderTags(img) {
|
||||
const container = document.getElementById("wfm-video-asset-tags");
|
||||
if (!container) return;
|
||||
const tags = img.tags || [];
|
||||
container.innerHTML = "";
|
||||
if (tags.length === 0) {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "wfm-placeholder";
|
||||
placeholder.textContent = "No tags";
|
||||
container.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
// Built via DOM APIs (not innerHTML) since tag text is user-entered —
|
||||
// textContent keeps it inert regardless of what characters it contains.
|
||||
for (const tag of tags) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "wfm-video-asset-tag";
|
||||
pill.append(document.createTextNode(`${tag} `));
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", async () => {
|
||||
const newTags = tags.filter((t2) => t2 !== tag);
|
||||
await _saveMeta(img.path, { tags: newTags });
|
||||
img.tags = newTags;
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
pill.appendChild(removeBtn);
|
||||
container.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveMeta(path, data) {
|
||||
try {
|
||||
await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, ...data }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] saveMeta failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Called whenever the Asset subtab becomes active (see video-tab.js's subtab
|
||||
// toggle) — always refetches rather than caching, so a Plan run's newly
|
||||
// generated videos show up as soon as the user switches over to look.
|
||||
export async function refreshVideoAssetTab() {
|
||||
if (!_s.loaded) {
|
||||
_renderGroupSelect();
|
||||
_s.loaded = true;
|
||||
}
|
||||
await _loadImages();
|
||||
}
|
||||
|
||||
function _setViewMode(mode) {
|
||||
_s.viewMode = mode;
|
||||
document.getElementById("wfm-video-asset-view-grid")?.classList.toggle("active", mode === "grid");
|
||||
document.getElementById("wfm-video-asset-view-table")?.classList.toggle("active", mode === "table");
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const tableWrap = document.getElementById("wfm-video-asset-table-wrap");
|
||||
if (grid) grid.style.display = mode === "grid" ? "" : "none";
|
||||
if (tableWrap) tableWrap.style.display = mode === "table" ? "" : "none";
|
||||
_renderList();
|
||||
}
|
||||
|
||||
export function initVideoAssetTab() {
|
||||
document.getElementById("wfm-video-asset-group")?.addEventListener("change", (e) => {
|
||||
_s.group = e.target.value;
|
||||
_s.selectedPath = null;
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (panel) panel.innerHTML = `<span class="wfm-placeholder">Select a video</span>`;
|
||||
_loadImages();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-refresh")?.addEventListener("click", () => _loadImages());
|
||||
document.getElementById("wfm-video-asset-kind")?.addEventListener("change", (e) => {
|
||||
_s.kindFilter = e.target.value;
|
||||
_renderList();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-search")?.addEventListener("input", (e) => {
|
||||
_s.searchQuery = e.target.value;
|
||||
_renderList();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-view-grid")?.addEventListener("click", () => _setViewMode("grid"));
|
||||
document.getElementById("wfm-video-asset-view-table")?.addEventListener("click", () => _setViewMode("table"));
|
||||
|
||||
// Neither reserved group is ever auto-created by a batch run itself (see
|
||||
// video-plan-tab.js's _ensureVideoAssetGroups, which only ensures
|
||||
// __Video Temp__ right before a run starts), so a fresh install or a group
|
||||
// deleted via Gallery's Manage Groups would otherwise never come back on
|
||||
// its own — add_to_group() happily records membership on individual images
|
||||
// without ever re-registering the group itself. ensureVideoGroup() ensures
|
||||
// both __Video Assets__ and __Video Temp__ every time the Asset subtab inits.
|
||||
ensureVideoGroup();
|
||||
}
|
||||