Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9dbf3f399 | ||
|
|
a99bfc19a1 | ||
|
|
d75a9c812f | ||
|
|
925a249cbe | ||
|
|
e067f34cd4 | ||
|
|
995e98e1aa | ||
|
|
398be43060 | ||
|
|
1155cf1233 | ||
|
|
5aef634115 | ||
|
|
b09cd2ef90 | ||
|
|
426f27c05a | ||
|
|
336e37724f | ||
|
|
7274017347 | ||
|
|
b94ab48ca4 | ||
|
|
ed3512d269 | ||
|
|
9ed91e64f8 | ||
|
|
c0db7374b7 | ||
|
|
cd4b721d51 | ||
|
|
46c789c492 | ||
|
|
ede39ec715 | ||
|
|
ad3b380929 | ||
|
|
c589e562e4 | ||
|
|
cb61dc61a7 | ||
|
|
ad9d06d3e1 | ||
|
|
ea408690ff | ||
|
|
cbb096ec85 | ||
|
|
99f342cc89 | ||
|
|
9681e76848 |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,178 @@
|
||||
# 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.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 統一** — 概要
|
||||
@@ -1,9 +1,499 @@
|
||||
異性
|
||||
|
||||
# DEVLOG - ComfyUI-Workflow-Studio
|
||||
|
||||
---
|
||||
|
||||
## v0.6.2
|
||||
|
||||
### Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
|
||||
|
||||
ユーザーから「Comic CreatorのImageタブ同様にこちらのImage Editタブをペンタブレット対応させたい」という依頼を起点に、Draw/Maskツールの筆圧対応、Fillツール(バケツ塗りつぶし)の移植、そして実機フィードバックを受けた追加のペンタブレット不具合修正まで一連の作業を行った。
|
||||
|
||||
**Draw/Maskツールの筆圧対応**: `image-edit-tab.js`のキャンバスイベントを`mousedown/mousemove/mouseup/mouseleave`から`pointerdown/pointermove/pointerup/pointerleave`に変更し、両canvasに`touch-action: none`を設定。`PointerEvent.pressure`(ペン=実際の筆圧、マウス=ボタン押下中は常に0.5)を`DrawTool`/`MaskTool`の`onMouseDown`/`onMouseMove`に伝播させ、両ツールに`pressureEnabled`(既定ON)を追加——筆圧に応じてブラシサイズ(0.3〜1.7倍)とDraw側は不透明度(0.4〜1.6倍)を可変化する。pressure=0.5(マウス)では従来と同じ1.0倍になるため、マウス操作の挙動は変わらない。実装はComic Creator(`eagle_comic_creator_spa`)のImageタブで先行実装済みだった筆圧対応パターンをそのまま移植した。
|
||||
|
||||
**Fillツール(バケツ塗りつぶし)を新規追加**: Comic CreatorのImageタブにあった`FillTool.js`(スキャンライン方式のフラッドフィル、Solid/Gradient(線形・放射状)両対応、particle_engine.js由来のグラデーション評価ロジック)をそのまま移植し、`image-edit-tab.js`にツール切り替え・アクティブ化・クリック処理・プロパティパネル(Mode/Color/Ramp/Direction dial/Tolerance/Opacity)を追加、ショートカット`G`を割り当てた。ツールバーのボタンは`templates/index.html`に静的HTMLとして定義されていたため個別追加が必要だった(`TOOL_DEFS`配列だけでは表示されない)。
|
||||
|
||||
**追加のペンタブレット不具合修正(ユーザー実機フィードバック)**: Fillツールのグラデーション設定UI——色ストップを並べた「Ramp」(ドラッグでストップ位置を変更)と方向・強度を指定する「Direction」ミニダイヤル——がペンタブレットでスライド操作できないという報告を受けた。原因はこの2つのミニウィジェットのドラッグ処理だけが`mousedown/mousemove/mouseup`のまま残っていたこと(`touch-action`未指定でブラウザのパン/スクロールジェスチャに奪われやすく、`document`に張った`mousemove`はポインタキャプチャが無いため速いペンの動きを取りこぼしやすい)。`_setupFillGradientRamp()`/`_setupFillGradientDir()`を`pointerdown/pointermove/pointerup/pointercancel`に変更し、`canvas.style.touchAction = "none"`と`canvas.setPointerCapture(e.pointerId)`を追加、ドラッグ中のリスナーも`document`ではなく`canvas`自身に張るよう変更した。
|
||||
|
||||
**Comic Creator側も同じ不具合を横展開で修正**: Fillツールの移植元であるComic Creator(`image-tab.js`)にも同一パターンの`_setupFillGradientRamp()`/`_setupFillGradientDir()`に加え、Shapeツールの塗りグラデーションランプ`_setupShapeGradRamp()`(同一コードパターンの複製)が存在し、同じ理由でペンタブレット操作ができない状態だった。3箇所とも同じPointer Events化を適用して修正した。
|
||||
|
||||
**検証**: Kaptureで実ブラウザ(ComfyUI_5上のWorkflow Studio `/wfm`、Comic Creator `/ccc`)を操作し、Draw/Mask/Fillそれぞれで新規キャンバス作成→クリック描画/塗りつぶし→コンソールエラーなし・レイヤーサムネイル即時更新を確認。Ramp/Directionダイヤルもクリックでのストップ選択・ハイライト表示が正しく動作することを確認した(ペン特有の筆圧・ドラッグ挙動そのものは自動化ツールでは検証できないため、筆圧の効き具合とドラッグ操作は実機ユーザー確認による)。Comic Creator側は開発フォルダがComfyUI custom_nodes配下にシンボリックリンクされているため、コピー不要で編集がそのまま反映される構成だった(Workflow Studio側は開発リポジトリと実行時custom_nodesが別実体のため、変更のたびに手動コピーが必要)。
|
||||
|
||||
**How to apply**: キャンバスベースの描画・ドラッグ操作を実装する際は、最初から`mousedown/mousemove/mouseup`ではなく`pointerdown/pointermove/pointerup/pointercancel`(Pointer Events)を使い、`touch-action: none`を設定する——マウス専用に見えるミニウィジェット(カラーランプ、方向ダイヤル等)であっても、後からペンタブレットで使われた際に同じ不具合が起きる。ドラッグ中の`move`/`up`リスナーは`document`ではなく対象の`canvas`自身に張り、`setPointerCapture()`を使うと要素境界を越えた高速な動きでも取りこぼさない。同一パターンのコードが複数箇所(ファイル間・同一ファイル内)に複製されている場合、1箇所の不具合報告を機に全箇所を横展開で確認する。
|
||||
|
||||
関連: [[project_v062_fill_tool_pentablet]], [[feedback_realtime_debug_logs_and_kapture]], [[project_comic_creator_deploy_symlink]]
|
||||
|
||||
## v0.6.1
|
||||
|
||||
### Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
|
||||
|
||||
ユーザーから「ギャラリータブで検索を行うと読み込み中のままでクリアを行っても中断できない。50枚程度のフォルダで検索をかけても時間がかかっている」という報告を受け、原因調査から修正まで対応した。
|
||||
|
||||
**フロントエンドの2つの不具合**(`gallery-tab.js`): 検索欄はキー入力のたびに即座に(debounceなしで)`loadImages()`を呼んでおり、しかも直前のfetchをキャンセルする仕組み(AbortController)が無かった。そのため「クリア」を押した直後でも、その前に打っていた検索文字列の(まだ処理中の)リクエストが後から返ってきて、クリア後の空検索の結果を上書きしてしまい、「読み込み中のまま/検索結果のまま」に見えていた。検索入力に300msのdebounceを追加し、`loadImages()`にAbortController管理を追加(新しいリクエスト発行時に前回の未完了リクエストを中断)、「クリア」「Clear filters」ボタンはdebounceタイマーもクリアして即座に反映するようにした。
|
||||
|
||||
**バックエンドのロック競合**(`gallery_metadata.py`, `gallery_service.py`): フォルダを開くたびに起動するバックグラウンドインデックス処理が、`gallery_metadata.json`(画像数が多い環境では1MBを超える)全体を`threading.RLock`保持したままJSONシリアライズ+ディスク書き込みしており、その間ずっと検索リクエストの`get()`呼び出しがロック待ちでブロックされていた。さらに10枚処理するごとに1件ずつ`save()`を呼んでいたため、実質10回分の全体書き込みが発生していた。`_save_to_disk()`をロック内ではデータのdeepcopyのみに留め、実際のファイルI/Oは別の書き込み専用ロック(`_write_lock`)でロック外に出すよう変更し、バックグラウンドインデックス側もまとめて1回で保存する`save_batch()`を新設して使うようにした。また検索時は(該当時に全メタデータのJSON全体書き込みを伴う)`cleanup_stale_images()`をスキップするようにした(通常の検索なしロード時に実行されれば十分なため)。
|
||||
|
||||
**根本原因: ponyxlWildcardsVaultフォールバック探索でのVaultフォルダ誤検出**(`gallery_service.py`): 上記2件を直しても実機で53枚のフォルダへの検索が約3秒かかっており、一時的な計測ログを実機に仕込んで原因を特定した。出力ルート直下にComfyUI/StabilityMatrixが作る空の`thumbnails`フォルダがたまたま存在しており、これが検索対象画像の`.txt`サイドカーが無い場合のフォールバックである`_find_vault_category_root()`(ponyxlWildcardsVault形式——「直下にthumbnails(_option2)フォルダを持つ祖先ディレクトリ」というゆるい条件で判定)によって誤ってVaultのcategory_rootと認識されていた。誤検出されたcategory_root(1万枚超のファイルを含む出力ルート)に対し、`_load_vault_leaves()`がキャッシュの妥当性確認のため画像1枚ごとに`glob("*.yaml")`+`glob("*.yml")`を実行しており、1回あたり約53msかかるglobを53回(検索対象の画像数分)実行して合計2.8秒——実測ベンチマークで再現・確認した数値と実機ログの数値がほぼ一致した。`_load_vault_leaves()`をTTL(60秒)ベースのキャッシュに変更し、TTL内はglobを省略するようにして解消した。
|
||||
|
||||
**検証**: Kaptureで実ブラウザ(ComfyUI_5上のWorkflow Studio)を操作し、ネットワークリクエストのタイミングを実測しながら段階的に検証した。Python変更後の再起動が確実に反映されているかはプロセス起動時刻とファイル更新時刻を比較して都度確認した。最終的に53枚のフォルダへの検索は3327ms→74msまで短縮し、検索・クリアとも実機で即座に反映されることを確認した。デバッグ用の計測ログは検証完了後に削除済み。
|
||||
|
||||
**How to apply**: サブフォルダを持つ画像出力ルートで「Vault風フォルダ名(thumbnails等)の存在だけで判定するフォールバック検出」を書くと、無関係な巨大フォルダを誤検出してglob/scandir系の操作が本来の対象より何桁も遅くなることがある——検出ロジック自体だけでなく、誤検出時に何度も高コストな操作(特に大量ファイルを含むディレクトリへのglob)が繰り返し呼ばれないか、呼び出し頻度とキャッシュの妥当性も含めて確認する。原因不明のパフォーマンス低下は仮説で止めず、実機に一時的な計測ログ(`perf_counter`で区間ごとの時間を`logger.warning`に出力)を仕込んで実測すると、コードレビューだけでは気づけない誤検出のような盲点が見つかることがある。
|
||||
|
||||
関連: [[feedback_asyncio_blocking_in_aiohttp_routes]], [[feedback_realtime_debug_logs_and_kapture]], [[feedback_verify_restart_via_process_creation_time]]
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
|
||||
|
||||
ユーザーから「ギャラリーで複数画像を選択してPSDファイルを作成したい」という依頼を起点に、段階的に機能を積み上げてPSD対応を一式実装した。
|
||||
|
||||
**ライブラリ選定の紆余曲折**: 当初はPythonでPSDを書き込めるライブラリとして`pytoshop`を候補に選定したが、実際にインストールを試すとWindows用のプリビルドwheelが存在せず、`packbits`というC拡張のビルドに`cl.exe`(Visual Studio Build Tools)を要求することが発覚した。StabilityMatrix/ComfyUI Portable環境のユーザーは通常Cコンパイラを持たないため、この依存はほぼ確実にインストール失敗を招く。代わりに`psd-tools>=1.10`を採用した——1.10以降は読み取り専用ではなく`PSDImage.frompil()`/`PixelLayer.frompil()`による書き込みにも対応しており、依存(`attrs`, `numpy`, `Pillow`, `typing-extensions`)は全てプリビルドwheelを持ちCコンパイラ不要。実機と同等の条件(venv構築→pip install→実際にPSD作成→再読み込みで往復検証)でレイヤー構成・重なり順・opacity・blend_mode・visibleが正しく機能することを確認してから実装に着手した。
|
||||
|
||||
**Gallery: 複数選択→PSD一括書き出し** — Galleryの一括操作バーに「PSD作成」ボタンを追加。選択した複数画像を、既存のZIPエクスポート機能と同じ選択・送信パターンを踏襲して1つのPSDファイル(各画像=1レイヤー、レイヤー名=ファイル名)にまとめてダウンロードする。画像サイズが異なる場合は最大サイズのキャンバスに左上基準で配置する仕様とした(ユーザー選定)。バックエンドは`GalleryService.build_psd_from_images()`、ルートは`POST /wfm/gallery/images/export-psd`。
|
||||
|
||||
**Image Edit: Save PSD / Open PSD** — Image Editタブ自体にも「Save PSD」(アクションバー)を追加。`LayerManager.layers`を背面→前面順に走査し、各レイヤーをキャンバス全体サイズのoffscreen canvasに`Layer.applyTransform()`(回転・反転・拡縮)のみベイクして書き出す——PSDのピクセルレイヤーは矩形しか持てず回転角度を保持できないため、位置・変形は必ずピクセルに焼き込む一方、opacity・blend_mode・visibleは実際のPSDレイヤー属性としてそのまま渡し、Photoshop側で編集可能な状態を保つ。`maskApply=true`のクリッピングマスクは対象レイヤーと合成して1枚にベイクする簡略化とし、PSD本来のレイヤーマスク機構は使わない(「ライブラリで素直に対応できない部分は無視してよい」というユーザーの了承のもとでの意図的な妥協)。続けて逆方向の「Open PSD」も実装——`psd_tools.PSDImage.open()`→`psd.descendants()`で全レイヤーを再帰的に走査し、`layer.topil()`でレイヤーのbbox範囲にクロップ済みの実サイズ・実位置の画像を復元、`layer.bbox`/`opacity`/`blend_mode`/`visible`を`Layer`オブジェクトに変換する。グループ自体(フォルダ)はスキップしつつ中身は再帰的に取り込み、PSD側のブレンドモード(4バイトタグ)とCanvas2Dの`globalCompositeOperation`文字列の相互変換テーブルを新設した(Canvas2Dに対応物が無いモードはNormalにフォールバック)。
|
||||
|
||||
**Gallery → Image Edit: 複数画像を一括レイヤー転送** — Galleryの一括操作バーに「レイヤーとして編集」ボタンを追加。選択した複数画像(mp4は自動除外)でImage Editタブに切り替え、各画像を独立レイヤーとして展開する。既存の単一画像版「Image Edit」送信ボタンと同じ`window._wfmImageEditTab`グローバル参照の仕組みを再利用し、PSDファイルを経由しない直接URL読み込み方式(`loadLayersFromUrls()`新設)とした——各URLを`new Image()`で並列ロードし、最大幅・高さをキャンバスサイズとして採用、`fitToCanvas()`(既存ヘルパー)で各画像をアスペクト比を保ったまま中央配置する。
|
||||
|
||||
**Gallery: PSDファイルの一覧・サムネイル・プレビュー表示** — `.psd`を`IMAGE_EXTENSIONS`に追加し、Galleryのフォルダ一覧・検索・グループ機能に自動的に乗るようにした。ブラウザは`<img>`でPSDを直接レンダリングできないため、`serve_image`/`serve_thumb`ルートで`.psd`の場合は`psd_tools`で全レイヤーを合成(非表示レイヤーは除外)した上でPNG/JPEGに変換し、mtimeベースのキャッシュキーでディスクキャッシュして配信する方式にした。情報パネルの幅・高さもPSDヘッダーから取得するようにした。
|
||||
|
||||
**実機テストで発覚した2件のバグとその修正**:
|
||||
1. **サムネイル表示が遅く、他の無関係なPNGサムネイルまで真っ黒になる**——原因は`serve_image`/`serve_thumb`ルートがPSD合成という重いCPU処理を`async def`ハンドラ内で直接同期実行しており、aiohttpの単一スレッドイベントループ全体をブロックしていたこと。PSD1枚の合成中は他の並行リクエスト(別画像のサムネイル配信など)も完全に止まり、結果的に無関係な画像まで表示が固まって見えた。`serve_image`・`serve_thumb`・`import_psd_layers`・新設`get_psd_layers`の4ルートを`loop.run_in_executor(None, ...)`でデフォルトのThreadPoolExecutorに逃がすことで解消した。この教訓は今後の重い処理全般に適用すべき一般原則として別途メモリに記録した。
|
||||
2. **単一画像「Image Edit」送信ボタンでPSDを開くと1レイヤーしかない**(「Open PSD」ボタンでは正しく全レイヤー開ける)——原因はこのボタンが常に`loadFromUrl()`(1枚のフラット画像として読み込む経路)を使っており、PSD専用のレイヤー分解経路を通っていなかったこと。サーバー上のファイルを再アップロードせず直接レイヤー分解できる新規`GET /wfm/gallery/image/psd-layers`(`GalleryService.get_psd_layers_from_path()`)を追加し、既存の`import_psd_layers()`とロジックを`_extract_psd_layers()`に共通化した上で、PSDファイルの場合はこの新エンドポイントを使うよう分岐させた。
|
||||
|
||||
**Image Editタブのアクションバー・レイヤーパネル改善**: 「Upload」を「File Open」に改名し「Open PSD」をその右隣に移動;新規「Close」ボタン(確認ダイアログ後、現在のドキュメントを破棄してプレースホルダー表示に戻す)を追加;レイヤーパネルヘッダーを2段構成(1段目=+/⧉(複製)/−/↑/↓、2段目=「Mask」ボタン単独、旧「M」から改名・移動)に変更;新規「複製レイヤー」ボタン(⧉)を追加し、`Layer.toJSON()`→`Layer.fromJSON()`のシリアライズ往復でアクティブレイヤーを複製(id再採番)して元レイヤーの直前に挿入する方式とした。実装にあたり、レイヤーリストクリック時の「選択」ロジック(種別・現在ツールに応じたキャンバス同期)を`_activateLayerById()`という共通メソッドに抽出し、複製直後の自動選択でも再利用することで、レイヤー種別(画像・描画・マスク・テキスト)を問わず正しく動作するようにした。
|
||||
|
||||
**ヘルプドキュメント更新**: 上記全機能についてヘルプタブを更新(`templates/index.html` + `i18n.js`英日中3言語 + `app.js`のhelpIdMapの3点セット)。更新作業の検証中に、今回の変更とは無関係の既存バグを2件発見し合わせて修正した——Image Editの「タイプアイコン」説明が英語版で重複定義されておりマスクレイヤーの表記が欠けていた点(片方は死んだ定義だったため削除)、日本語版でStyle Catalogの「Load in GenerateUI」項目が丸ごと未翻訳で英語表示にフォールバックしていた点(翻訳を追加)。
|
||||
|
||||
**検証**: PSD書き込み/読み込みロジックは全て、実際の`GalleryService`コード(相対importを差し替えたコピーをテスト用venvで直接実行)に対してテストスクリプトで検証した——複数レイヤー・サイズ不一致・非表示レイヤー・opacity/blend_mode・グループ内レイヤー・クリッピングマスクを含むPSDを実際に構築し、書き出し→再読み込みで往復一致することを確認。Gallery一括PSD書き出し・Image Edit Save/Open PSD・Gallery表示・Gallery→Image Editレイヤー転送・アクションバー/レイヤーパネル改善は全てユーザーが実機(実ComfyUI環境)で動作確認済み。
|
||||
|
||||
**How to apply**: Cコンパイラを要求するPythonライブラリはWindows向けComfyUIカスタムノードでは避け、`pip install --only-binary=:all: <lib>`でプリビルドwheelの有無を先に確認してから採用する。aiohttpの`async def`ルート内でCPU負荷の高い同期処理(画像デコード・合成・リサイズ等)を直接呼ばず、`loop.run_in_executor()`でスレッドプールに逃がす——特にサムネイル/プレビューのように頻繁に並行して呼ばれるエンドポイントは、1回の遅延が他の全リクエストに波及するため重要。ヘルプ更新は`index.html`だけでなく`i18n.js`(3言語)と`app.js`の`helpIdMap`の3点セットで行う。
|
||||
|
||||
関連: [[project_gallery_psd_export]], [[project_gallery_psd_display]], [[project_gallery_to_image_edit_layers]], [[project_v04x_image_edit_ui_polish]], [[feedback_psd_export_library_choice]], [[feedback_asyncio_blocking_in_aiohttp_routes]]
|
||||
|
||||
## v0.5.8
|
||||
|
||||
### Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
|
||||
|
||||
GitHub上の外部コントリビューター(`censor-ed`氏、fork経由)からPR #2「Tagger DBの複数選択削除を追加」が届いたため、内容確認・実機検証・マージまで一貫して対応した。PRの変更内容はバックエンド(`tagger_routes.py`に`DELETE /wfm/tagger/db`エンドポイントを新設、`tagger_db_service.py`に`delete_bulk()`をプレースホルダ経由のSQLite `DELETE ... WHERE id IN (...)`で実装)とフロントエンド(DBテーブルの各行にチェックボックス、ヘッダーに全選択チェックボックス、既存の単一削除`_dbDelete`と同じ`confirm()`パターンを踏襲した確認ダイアログ、英日中3言語のi18n)。
|
||||
|
||||
コードレビューでは危険な処理(外部送信・eval・shell実行等)は無く、SQLインジェクション対策も適切と判断。ただし2点の懸念を指摘した:(1) 一括削除の`count`がAPIレスポンスで返る削除件数の妥当性、(2) 全選択チェックボックス`_dbToggleSelection`が単純トグル(クリックのたびに全チェックボックスの状態を反転)方式になっており、一部だけ選択済みの状態でクリックすると直感に反する(選択済み行が外れ未選択行が入る)挙動になる懸念。
|
||||
|
||||
実機検証はKapture経由でComfyUI_5(ポート8189)に実際に接続して実施。データベースには実データ(既存のタグ付け済み画像)しか存在しないため、削除操作自体は実行せず安全に検証する方針を取った:一括削除APIは`curl`で存在しないID(999999)を渡して直接叩き、`ids`未指定時の400エラー・不正な値混入時の500エラーハンドリングを確認。UI側はKaptureで実際にチェックボックスを操作し、行クリックとチェックボックスクリックが正しく分離されていること、未選択で削除ボタンを押すと警告トーストが出ること、削除ボタンで`confirm()`ダイアログ(日本語i18n表示)が開くことを確認した上で`mcp__kapture__dialog`の`accept:false`でキャンセルし、実データには一切触れずに検証を完了した。この過程で指摘(2)の懸念が実機で再現することを確認——ID66のみ選択した状態で全選択チェックボックスをクリックすると、ID66のチェックが外れ他の全行がチェックされるという実際の誤動作を確認した。またAPIレベルの検証で指摘(1)も実バグと確定:存在しないID 1件を渡しても`{"ok": true, "count": 1}`が返っていた。原因は`handle_db_delete_bulk`が`delete_bulk()`の戻り値(実際の削除件数=`cur.rowcount`)を使わず、渡された`ids`の件数(`len(ids)`)をそのまま返していたため。
|
||||
|
||||
この検証プロセス中に、ComfyUIの「再起動」がユーザー申告だけでは信頼できないケースに遭遇した。Pythonファイル(`tagger_routes.py`)をコピーしてComfyUI再起動を依頼したところ、依頼直後は新エンドポイントへの`DELETE`が405 Method Not Allowed(`Allow: GET,HEAD`のみ)を返し続けた。原因調査でComfyUI起動ログ(`user/comfyui_8189.log`)や`app.router`のルート登録順序(ComfyUIコア自身のSPAフォールバック用static routeとの競合の可能性)を疑ったが、最終的にはPowerShellの`Get-CimInstance Win32_Process`でプロセスの実際の`CreationDate`を確認し、動いているComfyUIプロセスの起動時刻がコードをコピーした時刻より前であることを突き止めた——つまりユーザーが「再起動しました」と報告した操作は、ブラウザページのリロード等では反映されず、プロセス自体は起動されたままだった。ユーザーに再度プロセスの完全停止・起動を依頼し、新しいプロセス起動時刻(コピー時刻より後)を確認してから再テストしたところ405エラーは解消した。
|
||||
|
||||
2点のバグ修正(`handle_db_delete_bulk`が`delete_bulk()`の戻り値を`count`として返すよう変更、`_dbToggleSelection`をクリックされたヘッダーチェックボックス自身の新しい状態(`e.target.checked`)に全行を揃える一般的な「全選択」挙動に変更)をコミットし、`maintainerCanModify`権限を使ってPR元のフォークブランチ(`censor-ed/ComfyUI-Workflow-Studio`の`feature/tagger-bulk-delete`)に直接pushしてPRへ反映、`gh pr merge`でGitHub上のPR #2を正式にマージした(マージコミット`e067f34`)。ローカル`main`をfast-forward更新し、実行時`custom_nodes`フォルダへ最終版を再同期して`diff --strip-trailing-cr`で完全一致を確認した上で、最後にもう一度ComfyUI再起動→APIレスポンス確認まで実施した。
|
||||
|
||||
**検証**: バックエンドAPI(`curl`直叩き、正常系・異常系)とフロントエンドUI(Kaptureブラウザ操作、チェックボックス・確認ダイアログ・トースト)の両方を実機で確認。修正コミット後も同様に再検証し、`count`が正しい値(0)を返すこと・全選択チェックボックスが選択済み行を含めて正しく全ON/全OFFされることを確認した。実データは一度も削除していない。
|
||||
|
||||
**How to apply**: 外部コントリビューターのPRを取り込む際は、まず実行時`custom_nodes`フォルダと開発リポジトリの現在のブランチが完全一致していること(改行コード差分のみ)を確認してから安全にファイルを上書きする([[project_dev_deploy_sync]])。DBやファイルシステムなど実データを持つ機能のUIテストでは、可能な限り「削除・変更操作自体は実行せず、確認ダイアログの表示とキャンセル」「存在しないIDでのAPI直接テスト」など非破壊的な手段で検証を組み立てる。Pythonファイル変更後のComfyUI再起動は、ユーザーの「再起動しました」という申告だけでなく、`Get-CimInstance Win32_Process`等でプロセスの実際の起動時刻を確認し、コード変更時刻より後であることを検証してから動作確認に進む——ブラウザのページリロードやタブの再接続だけでは、バックエンドのPythonプロセス自体は再起動されていない場合がある。APIレスポンスの件数系フィールド(`count`等)は、渡された引数の長さではなく実際の処理結果(DBの`rowcount`等)を返すべきというありがちなバグパターンに注意する。「全選択」チェックボックスは常時トグルではなく、クリックされた自身の新しい状態を全体に適用する実装が直感に合う。
|
||||
|
||||
関連: [[project_dev_deploy_sync]], [[feedback_comfyui_running_check_before_launch]], [[feedback_realtime_debug_logs_and_kapture]]
|
||||
|
||||
## v0.5.7
|
||||
|
||||
### 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
|
||||
|
||||
「Videoプランの追加で設定のエクスポート、バックアップ機能の更新が必要では」との確認依頼を受けて調査。v0.5.5で追加された`video_plan/`ディレクトリ(Plan JSON+インデックスPNG、`lab_plan/`と同構造)が、フルバックアップZIP機能の説明文言から漏れていないか確認した。実装(`_build_full_backup_zip`)自体は`DATA_DIR`配下のトップレベルエントリを汎用的に走査する設計(除外リスト`_FULL_BACKUP_EXCLUDE_NAMES`は`gmic_temp`/`thumb_cache`/`wildcard`の3つのみ)のため、`video_plan/`は既存コードの変更なしに自動的にバックアップ対象へ含まれていた——つまり機能的な欠陥は無かった。一方、ユーザー向け説明文(`fullBackupHint`/`helpSettings15`のi18n.js3言語、README.mdのFull Backup説明、`settings_routes.py`のコード内コメント)は「AI Skills、Lab Plans、Style presets」という列挙のままVideo Plan機能追加時に更新されておらず、実際にバックアップされるデータの説明として不正確になっていた。該当6箇所+READMEを「Video Plans」を含む形に修正。
|
||||
|
||||
### Video Plan (Planサブタブ) 中央パネルの設定項目レイアウト刷新 + MiniMax H3 Turbo/LTX-2.5 Prompt Enhance/Wan2.2 Turbo対応
|
||||
|
||||
「Duration・Noise SeedをMultipleの右隣に移動し、その下に新しい行でWidth/Height/MiniMaxのturbo_mode/strength/steps・LTX-2.5のprompt_enhance・Wan2.2のenable_turbo_mode(MiniMaxのturbo_modeと兼用)を追加したい」との要望。着手前にComfyUI_5環境の直近保存ワークフロー(`minimax_turbo.json`、LTX-2.5の`ltx_25_test`/`test2`/`test3`、Wan2.2の`wan22_test`/`test2`/`test3_i`)を実際にPythonでパースしてサブグラフの公開入力を調査し、対応可否を事前検証してから実装した。
|
||||
|
||||
調査で判明した構造: MiniMax H3は`turbo_mode`(BOOLEAN)/`turbo_model_strength`(FLOAT、LoRA強度)/`turbo_steps`(INT)をサブグラフの直接ウィジェットとして公開しており、これらがユーザーの言う「strength」「steps」に対応する。LTX-2.5は3バリアントとも`prompt_enhance`(BOOLEAN)を公開。Wan2.2は`enable_turbo_mode`(BOOLEAN)を公開しており、MiniMaxの`turbo_mode`と意味的に同一のためUI上は1つの「Turbo Mode」欄として兼用する設計にした。width/heightはMiniMax・LTX-2.5のText/Image版ではResolutionSelector経由(サブグラフ入力がリンク接続されている)だが、LTX-2.5のFirst & Last Frame版とWan2.2は全バリアントでResolutionSelectorを持たずサブグラフの直接ウィジェット値のまま——既存コードのコメント通りの構造を実データで確認できた。noise_seedは全ファミリーで`name="noise_seed"`に統一されている(LTX-2.5のText to Videoのみ`label="seed"`を持つため、ラベルではなくname直接一致で検索するヘルパーを新設)が、**Wan2.2のText to Videoだけサブグラフの公開入力に`noise_seed`が存在しない**(Image to Videoにはある)という非対称性を発見した。
|
||||
|
||||
実装は`video-workflow.js`の`locateVideoModelNodes()`を拡張し、上記7項目(width/height直接指定用インデックス、noise_seed、turbo_mode、turbo_strength、turbo_steps、prompt_enhance)のインデックスを検出して返すようにした(対応する入力が無ければ-1を返し、呼び出し側で無効化・非ハイライトの判断に使う)。既存のAspect Ratio/Megapixels/Multipleと全く同じ「ワークフローが対応する場合のみ入力可能+ラベルハイライト」の仕組みで新規7項目も統一し、新しい選択UIを作らず既存の`wfm-model-label-active`パターンを再利用した。noise_seedについては値の書き込みロジックをあえて追加しなかった——`comfyUI.generate()`がAPI変換後のワークフロー全体から`noise_seed`/`seed`キーを持つ全ノードを一括で上書きする既存の`applySeedToWorkflow()`が必ず後から実行されるため、`applyBlockToWorkflow()`側で書いても意味がなく、Plan全体のシード管理は引き続きそちらに一任する設計とした。
|
||||
|
||||
UI面ではDuration(元々ブロックごとの独立値)とNoise Seed(Plan全体で共有)を、機能的な性質は変えずにDOM位置だけtopbarのbasesettings行へ移動した。Durationはブロックエディタの`innerHTML`再生成に巻き込まれない固定要素にする必要があったため、change時のリスナー登録をブロック切替のたびに再アタッチする方式から、初期化時に一度だけ登録し値の同期のみブロック切替時に行う方式(`_initDurationControl()`新設)に変更した。新規7項目は`_plan.base_settings`に追加しPlan保存/読込(`_buildPlanData`/`_applyPlanData`)にも対応させた——Aspect Ratio等と同様Plan全体で共有する値とし、ブロックごとには持たせない設計とした。
|
||||
|
||||
実機検証はComfyUI_5(ユーザーが起動中のポート8189、8188に誤って重複起動してしまった分は作業後に停止)上でKaptureブラウザ操作により実施。MiniMax H3ワークフローで`Turbo Mode`/`Strength`/`Steps`が正しくハイライト・値反映され`Width`/`Height`は無効化されること、LTX-2.5 First & Last Frame版で`Width`/`Height`が有効化・ハイライトされること、Wan2.2 Text to Videoで`Noise Seed`のみ非ハイライト(disabled)になり`Turbo Mode`はハイライトされること、ブロック追加後にDurationがブロックごとに独立して保持される(1つ目=5秒のまま、2つ目=7秒に変更後も1つ目に戻ると5秒を維持)ことを、DOM要素のクラス・disabled状態を直接読み取って確認した。
|
||||
|
||||
ヘルプ文言は`helpVideo1`/`helpVideo2`/`helpVideo10`をindex.html・i18n.js(英日中3言語)・app.jsの`helpIdMap`の3点セットで更新——`helpIdMap`自体は既存のキーマッピングをそのまま流用でき変更不要だったが、`applyI18nToHtml`が起動時にi18n.jsの内容でDOM要素を上書きする一方でindex.html側の静的テキストが更新漏れになっていたため、ソース整合性のため合わせて修正した。README.mdのVideo Tabセクション(Plan/Edit subtabsの基本設定説明、Field highlight colorの対象フィールド一覧)も新規7項目を反映して更新した。
|
||||
|
||||
**検証**: `node --check`で変更した全JSファイルの構文確認。実機(ComfyUI_5、ポート8189)でMiniMax H3/LTX-2.5(First & Last Frame版)/Wan2.2(Text to Video版)の3ワークフローをKapture経由で実際にロードし、ハイライト・disabled状態・値反映・ブロックごとのDuration独立性をDOM評価で確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイル(`video-workflow.js`/`video-plan-tab.js`/`i18n.js`/`index.html`/`video-tab.css`)を都度両フォルダへ同期し`diff`で完全一致を確認。
|
||||
|
||||
**How to apply**: UIに新しい項目を追加する際、「ワークフローによって対応する場合のみ有効化・ハイライトする」という既存パターンが確立している画面では、新規に選択UIやトグルを作らず既存の仕組み(本プロジェクトでは`wfm-model-label-active`クラス+ラベルID登録)にそのまま乗せるのが一貫性を保ちやすい([[feedback_reuse_existing_ui_before_building_new]])。同系統に見えるモデルファミリー間でも、実際のワークフローJSONをパースして公開入力の有無を確認しないと「あるはず」の項目が無い非対称性(今回のWan2.2 Text to VideoのNoise Seed欠如)を見落とす——README/ヘルプ文言に「ワークフローXにはYが無い」という例外を書く前に、必ず実データで裏取りする。UI上は1つのフィールドとして統一表示しつつ、内部的には複数のワークフローファミリーそれぞれの異なる入力名(`turbo_mode`と`enable_turbo_mode`)にマッピングする「兼用フィールド」パターンは、意味が同じであることをコメントで明記した上でフォールバック演算子(`||`)で検出すればよく、UI側の分岐は不要になる。生成時に上流のAPI変換・送信処理(今回の`applySeedToWorkflow`)が特定のキーを一括で上書きする既存の仕組みがある場合、新規追加するフィールドの値が同じキーに書き込まれるなら、二重に値を設定するコードを書かず上流の処理に委ねる方が正確(後から上書きされる無駄なコードパスを残さない)。
|
||||
|
||||
関連: [[project_video_plan_asset_tab]], [[project_dev_deploy_sync]], [[feedback_reuse_existing_ui_before_building_new]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.6
|
||||
|
||||
### バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
|
||||
|
||||
ユーザー(censor-ed氏)からのIssue報告: `wd-swinv2-tagger-v3`でタグ付けすると画像内容と一致しない不自然なタグが出力される。調査依頼者自身が原因を特定しており、`tagger_service.py`のONNX推論前処理がWD Tagger系モデルの学習時前処理と異なっていた(単純リサイズ+RGBのまま0.0〜1.0正規化)のに対し、WD Tagger系標準の前処理は「透過は白背景で合成→アスペクト比を維持したまま白背景で正方形にパディング→BICUBICリサイズ→RGB→BGR→0〜255のfloat32のまま(正規化なし)」であるとの具体的な指摘だった。`_predict_onnx()`をこの標準前処理に修正(本プラグインがサポートするONNXタガーはREADME記載の通りWD Tagger系のみのためパス全体を統一。DeepDanbooru用`_predict_dd()`は元々正しい前処理のため変更なし)。実機で`wd-eva02-large-tagger-v3`を使い、くまの着ぐるみパペットを持つ緑髪キャラクターのアニメ調画像でタグ付けを検証したところ、修正前は前処理が公式実装と異なる状態だったのに対し、修正後は`green_hair, bear_ears, bear_costume, paw_gloves, hoodie, animal_hood, animal_costume`など画像内容と高精度に一致する自然なタグが生成されることを確認した。
|
||||
|
||||
---
|
||||
|
||||
## v0.5.5
|
||||
|
||||
### Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
|
||||
|
||||
「Videoタブにバッチ動画生成機能を追加したい。既存の生成UIタブのLab機能を動画生成用に整えたイメージ」との要望を起点に着手。従来のVideoタブ(単発生成フォーム)をPlan/Asset/Editの3タブ構成に再設計し、複数セッションにわたるユーザー実機確認・レイアウト調整・追加要望への対応、および調査の過程で発見した重大バグの根本修正までを1リリースにまとめた。
|
||||
|
||||
**1. Planタブ: タイムラインブロックによるバッチ動画生成**: 「基本設定→タイムラインをブロック分割→各ブロックにFirst/Last画像とプロンプトを設定→実行で順次バッチ生成」という要望を実装。LabタブがCheckpoint/LoRA/VAE/Prompt/Ksamplerそれぞれ独立したキーフレーム配列を持つ設計なのに対し、Video Planは「各ブロックが完結した1本の生成ジョブ」という単位でデータを持つ点が本質的に異なるため、Lab方式(`_buildWorkflowForIteration`によるキーフレーム解決)ではなく新規のデータモデル(`{id, color, duration, prompt, first_image_mode, first_image_filename, last_image_filename}`の配列)を設計した。First ImageはNone/画像を指定/前ブロックの最終フレームを使用の3択(`first_image_mode`)を持ち、chain_previous選択時は前ブロックに明示的なLast Imageがあればそれを直接使い(フレーム抽出不要)、無ければ前ブロックの生成動画から実際の最終フレームを`extractLastFrameBlob()`(Labのchain image機能で実績のある関数を再利用)でクライアント側抽出してアップロードする、という優先順位をユーザーと合意の上で実装した。Run/Pause/Cancelと進捗バーはLabタブの`_runLabBatch`と同じPromiseベースの一時停止機構を踏襲。ワークフロー操作(モデルファミリー自動判定・ノードへの値注入)はVideoタブの既存ロジックから`video-workflow.js`として抽出し、Plan/旧単発フォームの双方から呼べる純粋関数群にした。
|
||||
|
||||
**2. Plan永続化**: Labの`ws_labplan_`プレフィックス+PNGコンタクトシートへのiTXt埋め込み方式をそのまま踏襲し、`ws_videoplan_`プレフィックスでPlan JSON+インデックス画像を保存する仕組みを新規実装(`video_plan_service.py`はLabのサービスを複製、共通化はしない方針)。ブロックが1個のPlan=旧来の単発生成と等価になるよう設計し、「Load in Video」ボタンは常にブロック1個の新規Planを生成するようにした。
|
||||
|
||||
**3. Assetタブ: Galleryのグループ機能を再利用した素材管理**: 独自のフォルダブラウザを新規実装する代わりに、`feeder-tab.js`の「Galleryモード」(Gallery既存バックエンドをグループスコープで叩く軽量グリッド)と同じパターンを踏襲。当初は生成された動画を全て予約グループ`__VideoAssets__`へ自動追加する設計だったが、ユーザーからのフィードバックで用途を再定義:`__VideoAssets__`はユーザーがGalleryで手動キュレーションする「素材」グループとし、バッチ実行の自動追加先は新設の予約グループ`__vtemp__`(Planを保存していない生成物の一時置き場)に変更。Plan保存済みの場合は`VideoPlan:<name>`サブグループへ、未保存の場合は`__vtemp__`へ、という条件分岐にした。Assetタブのグループ選択には「All Video Assets」(`__VideoAssets__`+`__vtemp__`の合算、バックエンドに単一グループでの合算フィルタが無いため2回フェッチしてマージ)に加え、両グループの個別選択・Plan別サブグループ選択も追加。Galleryタブ側には`__vtemp__`用の🔒予約表示(`__Feeder__`と同様リネーム・削除不可)と、詳細パネルに`__vtemp__`をワンクリックでクリアする「VtC」ボタン(既存の`__Feeder__`用「FC」ボタンと同じ`groups/{name}/clear`APIを利用)を追加した。
|
||||
|
||||
**4. UIレイアウトの複数回反復**: 初回実装は左サイドバー(380px固定幅)にPlan/Asset/Editの3サブタブを縦積みする構成にしたが、実機確認で「Timeline+First/Last画像+Promptを横並びにしたい」という要望とレイアウト崩れの報告を受け、Planタブの中身を中央の広いプレビュー欄の下部に移動(ブロックエディタをFirst Image/Last Image/Prompt/Duration+colorスウォッチの横並び4カラムに再設計、実行系(Run/Pause/Cancel/進捗/Seed)とプラン管理系(Note/Save/Load)を「Run」「Plan」の2タブに整理)。続けて「これをPlanタブ化してEditタブを追加、左ペインのPlan/Editは削除」との指示で、中央パネルにPlan/Edit専用のタブナビを新設し、左サイドバーにはAssetのみを残した。この際、両タブナビが同じ`.wfm-video-subtab-btn`/`.wfm-video-subtab-panel`クラスを共有する実装だったため「Assetを選ぶと中央のPlan/Editも連動して消える」という不具合が発生 — 中央タブの切替処理を`.wfm-video-center-panel`スコープに限定したquerySelectorAllに変更し、サイドバーのAsset単独ボタンは独立したshow/hideトグルとして分離することで解消した。中央・右ペイン(プレビュー、Frame抽出/GIF変換/Video Source)はサブタブ切替と独立した常時表示パネルとして、レイアウト変更を通じて無改修のまま維持している。
|
||||
|
||||
**5. バグ修正: Galleryのグループ追加がジャンクションフォルダ環境で一覧に反映されない**: 「生成完了後に`__vtemp__`グループには入らない。Galleryで手動追加したコンテンツは表示される」との報告を受けた調査で発見。ユーザー環境の`ComfyUI_5\output`はStabilityMatrixの共有出力フォルダ機能によるWindowsジャンクション(実体は`StabilityMatrix\Images\Text2Img`)だった。`save_image_meta`(タグ/メモ保存)には既に「`get_image_metadata()`側はresolve()済みパスをキーにするため保存側も統一する」という同種バグの修正コメントがあったが、`add_to_group`/`remove_from_group`/`toggle_favorite`/`bulk_set_favorite`の4箇所には同じ修正が未適用のまま残っていた。そのためグループ追加自体はサーバー上で成功する(ジャンクション形式の未解決パスをキーに保存)が、一覧取得(`list_images`)はフォルダを`.resolve()`した実パスでスキャンするためキーが一致せず、「追加はされるが一覧に出てこない」というサイレント不具合になっていた。4箇所とも`save_image_meta`と同じresolve()統一パターンに修正。Video機能に限らずGallery全体(お気に入り・グループ機能)に影響していた既存バグで、今回の調査で偶然発見・修正できた。
|
||||
|
||||
**6. バグ修正: Galleryのグループフィルタがサブフォルダ内のファイルを見つけられない**: 5の修正後も「rootで`__VideoAssets__`フィルタ時は画像のみ、動画は表示されない」との報告。動画は`video/`サブフォルダに保存されるが、Galleryタブの`loadImages()`はグループフィルタの有無に関わらず常に非再帰スキャン(現在のフォルダ直下のみ)だったため、グループメンバーがサブフォルダにあると机上から漏れていた。グループフィルタが有効な時のみ`recursive=true`を付与するよう修正。
|
||||
|
||||
**7. バグ修正: チェイン画像が無関係な既存ファイルにすり替わる**: 「2ブロックのPlanで2つ目のFirst Imageを前ブロックの最終フレームに設定したが、生成された2つ目の動画は無関係な画像が使われている」との報告。調査の結果、`convertUiToApi()`(UI形式→API形式変換の共通処理)が持つ「COMBO項目の値がキャッシュ済み`/object_info`の選択肢に無い場合、無警告で選択肢の先頭に差し替える」というフォールバック機構(チェックポイント名の互換性対応が主目的で元々存在)が原因と判明。LoadImageノードの「image」もCOMBO型のため対象になり、バッチ実行中にアップロードした抽出フレーム画像のファイル名はその時点でキャッシュ済みの`/object_info`にまだ存在せず、無関係な既存ファイル(選択肢の先頭)に差し替えられていた。LabタブのchainImage機能がこの問題に遭遇しないのは、Labが既にAPI形式のワークフローを直接操作し`convertUiToApi()`を経由しないため。`comfyui-workflow.js`に`invalidateObjectInfoCache()`を新設し、チェイン用フレーム・ユーザーの手動画像アップロードの直後に呼んで次回変換時に最新の`/object_info`を再取得させるよう修正した。
|
||||
|
||||
**8. バグ修正: `__VideoAssets__`グループが再起動後に一覧・詳細パネルから消える**: 「再起動してギャラリーを確認したがグループに`__VideoAssets__`、`__vtemp__`が表示されなくなった」との報告。`gallery_metadata.json`を実データで直接確認したところ、`__vtemp__`はレジストリ・10件のメンバーシップとも正常だった一方、`__VideoAssets__`はグループレジストリ配列からも全画像のメンバーシップからも完全に消えていた(`delete_group()`はレジストリと全画像の`groups`配列を同時に消す実装で、症状と完全に一致)。原因は`__vtemp__`/`VideoPlan:<name>`と異なり、`__VideoAssets__`はバッチ実行側から`ensure_group`を呼ぶコードが一切無い設計だったこと — 3番で予約グループ保護(🔒表示・削除ボタン無効化)を追加する**前**の時点でManage Groupsから削除操作が可能だったため、その時点で削除され、以後`add_to_group`は個別画像への書き込みは成功してもレジストリを再登録しないため二度と一覧に出てこなくなっていた。`gallery-tab.js`に`ensureFeederGroup()`と同型の`ensureVideoGroup()`を新設し、VideoタブのAssetタブ初期化のたびに`__VideoAssets__`を確実に再登録するよう修正。削除時に失われた過去のメンバーシップ自体(開発中のテスト動画)はバックアップが無く復元不可能である旨をユーザーに報告した(このensure漏れが後に13番の伏線になる — `__VideoAssets__`側だけ直し`__vtemp__`側のensureが漏れていた)。
|
||||
|
||||
**9. Assetタブに保存済みPlan一覧を表示する「Project」サブタブを追加**: 「AssetタブにVideo Planを表示したい」との要望を起点に着手。何を指すか確認したところ「右ペインにプロジェクトタブを追加し、保存済み一覧、各動画のplan名表示としたい」との回答。調査の結果、バックエンドには`GET /api/wfm/video/plans`一覧APIと`/wfm_data/video_plan/{name}.png`サムネイル配信が2番のPlan永続化実装時から既に存在していたが、フロントエンドからは一度も呼ばれておらず丸ごと未使用のまま埋まっていたことが判明した。新規`video-project-tab.js`で保存済みPlan一覧(サムネイル・名前・ブロック数・更新日時)表示、クリックでのPlanエディタ読み込み(`video-plan-tab.js`に`openSavedVideoPlan()`をexport)、削除機能を実装し、Assetカードには生成元のPlan名をバッジ表示する機能(`img.groups`から`VideoPlan:<name>`グループを逆引き)も合わせて追加した。当初の指示通り右ペイン(Frame/GIFタブの隣)に実装したが、直後のフィードバックで「左ペインに変更したい」との指示を受け、左サイドバー(Assetタブの隣)へ丸ごと再配置した。この移動に伴い、左サイドバーのトグルロジックを「Assetボタン単体のshow/hide」から「クリックした方を開き他方を閉じる、同じボタンの再クリックで両方とも折りたたむ」という排他的トグルに拡張した。
|
||||
|
||||
**10. 中央下部のワークフロー名表示行を1行に統合**: 「Open Workflowボタン行を上段の現在読み込んでいるワークフロー名行と1行にしたい」との要望。`wfm-video-wf-name`(現在ロード中のワークフロー名)と`wfm-video-plan-workflow-row`(Planに記録されたワークフロー名+Open Workflowボタン)が縦に2行表示されていたのを、`.wfm-video-wf-row`という横並びflexコンテナでまとめ1行に統合した。
|
||||
|
||||
**11. ワークフロー設定可能項目ハイライト表示の復活**: 「以前の単発生成フォーム時の設定項目のハイライト表示を行いたい」との要望を受けて調査したところ、v0.5.4で一度実装されていた機能(`wfm-model-label-active`クラス、GenerateUI Modelタブと共通の設定色)が、4番のPlan/Asset/Edit再設計で単発フォームのコードごと失われ、ヘルプ文言だけが取り残されていたことが判明した。Planタブのブロックエディタ向けに`_updateFieldHighlights()`を再実装:Prompt/Duration/First Image/Last Image/Aspect Ratio/Megapixels/Multipleの各ラベルにIDを付与し、読み込んだワークフローが実際にそのフィールドをサポートしている場合のみハイライトする(Wan2.2のようにResolutionSelectorが無いワークフローではAspect Ratio/Megapixels/Multipleは非ハイライトのまま)。ワークフローロード時・Open Workflowボタン使用時・ブロック切り替え時(`_renderBlockEditor()`はブロック選択のたびにDOMを作り直すため都度再適用が必要)のいずれでも正しく再適用されるようにし、ハイライト色を注入するCSSセレクタ(`settings-tab.js`の`applyModelTabActiveColor()`)も旧レイアウトの`.wfm-video-form-panel`から新レイアウトの`.wfm-video-plan-content`に更新した(4番のレイアウト変更で参照先の無いセレクタになっていた)。この過程で、ヘルプ本文(`helpVideo8`)がHTML側とi18n.js側で別内容になっている既存の不整合(HTML側は旧ハイライト説明、i18n.js側はチェイン機能の説明で後者が実際に表示される)を発見し、ハイライト説明を新規`helpVideo10`として追加し直した。
|
||||
|
||||
**12. Video Assets/Video Tempグループの命名統一・自動登録の運用整理**: 「__vtempグループが消えてしまっている」という報告と、ユーザー自身による修正方針の提示(表示名をグループ名で統一、VideoPlan:自動登録を廃止し常にVideo Tempへ、VtCボタン名を「Clear __Video Temp__」に変更)を受けて実施。3番/8番で導入した`__VideoAssets__`/`__vtemp__`を`__Video Assets__`/`__Video Temp__`に改名し、Assetタブの表示名もグループ名からそのまま導出する形に統一(「Temporary (unsaved runs)」という独自表示名は廃止)。3番の「Plan保存済みなら`VideoPlan:<name>`サブグループへ」という自動振り分けは廃止し、Plan保存の有無に関わらず常に`__Video Temp__`へ登録するよう変更 — `__Video Assets__`への整理は完全にユーザー任意のタイミングに委ねる設計に転換した。対応してAssetタブの「VideoPlan:」動的取得・フィルタ表示ロジックと、9番で追加したAssetカードへのPlan名バッジ表示も、対応するグループ自体が無くなったため削除した。バックエンド予約グループリスト(`py/routes/gallery_routes.py`の`_RESERVED_GROUPS`)も新グループ名に追従させている。既存の`VideoPlan:`グループ自体はユーザー側で削除する前提で進めた。
|
||||
|
||||
**13. 重大バグ修正: `gallery_metadata.json`の非アトミック書き込みによるデータ消失(根本原因)**: 「再起動するとグループに登録した内容が消えます」との報告を受けた調査。まず8番の`ensureVideoGroup()`が実は`__VideoAssets__`(現`__Video Assets__`)しかensureしておらず`__vtemp__`(現`__Video Temp__`)はensureしていなかったという見落としを修正したが、実データ(`user/default/Workflow-Studio/gallery_metadata.json`、画像9,470件)を直接調べたところ、より深刻な問題が見つかった。`GalleryMetadataStore._save_to_disk()`が`open(path, "w")`による直接上書きの非アトミック書き込みで、バックグラウンドインデックス処理(`_bg_index_folder`、未キャッシュ画像10枚ごとに保存)が大量画像フォルダに対して極めて高頻度にこのファイルへ書き込み続けていた(実測でファイルサイズが0.5秒間隔で数百KB単位で変動し、外部プロセスから読むとタイミングによってJSONパースが失敗する状態だった)。この書き込みの最中にComfyUIプロセスが終了すると、ファイルが不完全なJSONのまま残り、次回起動時の`_load()`がパースに失敗して警告ログを出すだけで中身を空の`{"images": {}, "groups": []}`にリセットしてしまう — 以降1回でも保存が走ると、それまでの全グループ登録・タグ・メモが跡形もなく上書きされて消える、という構造だった。実際に確認したところ、グループの登録リスト自体(`__Feeder__`/`__Video Assets__`/`__Video Temp__`等)は残っていたが、動画がどのグループに属しているかという個々のメンバーシップ情報は失われていた。`_save_to_disk()`を一時ファイル書き込み+`Path.replace()`によるアトミックな置き換えに変更し、`_load()`がJSON破損を検出した場合は単に空データにするのではなく`.corrupt`ファイルとしてバックアップしてから起動するよう変更した(今後同様の事態が起きても手動復旧の余地を残す)。単体テスト(アトミック書き込み・破損時バックアップ・復旧後の再保存)で動作確認。今回失われた登録内容自体はバックアップが無く復元不可能である旨をユーザーに報告した。
|
||||
|
||||
**14. 中央プレビューの2ペイン化**: 手描きの構成図での要望「動画ソース・ファイルドロップエリアを左端に移動、プレビューを2つにして左はアセットの選択動画/ドロップされた動画のプレビュー兼用、右は生成された動画のプレビュー用」を実装。従来は単一のプレビュー(Video Sourceドロップの表示専用で、Assetタブの選択動画やPlan実行結果を自動表示する仕組み自体がそもそも存在しなかった)だったのを、3カラム構成(左: Video Sourceフォーム、中央: Asset選択/Sourceドロップ兼用プレビュー、右: 生成動画専用プレビュー、いずれも新規実装)に再構成した。`video-tab.js`・`video-asset-tab.js`(Asset選択)・`video-plan-tab.js`(バッチ実行結果)の3ファイルがプレビュー状態を共有する必要があったため、新規`video-preview.js`という共有モジュールに切り出した(video-tab.jsが既に他の2ファイルをimportしているため、逆方向のimportを追加すると循環参照になることを回避するため)。Frame/GIFツールは「直近にアクティブになった方のプレビュー」を対象に動作する設計にした。Asset選択時はGalleryの絶対パスをComfyUIのfilename/subfolder/type形式に変換する手間を避けるため、動画をBlobとしてfetchし「ローカルファイル」として扱うことで、GIF変換の既存アップロードロジックをそのまま流用できるようにした。Assetサイドバーの詳細パネルにあった動画プレビュー(`<video>`要素)は、中央プレビューに統合されたため削除した。
|
||||
|
||||
**15. ヘルプの整合性チェック**: リリース前の最終確認としてVideo Tabヘルプ全11項目(Title/Desc/1〜10)をHTML・i18n.js(3言語)・app.jsのhelpIdMapで突き合わせ、11番で発見した`helpVideo8`の不整合以外にも、`helpVideoDesc`(旧「Plan / Asset / Edit」の一括り記述が9番以降のサイドバー分離構造と食い違っていた)と`helpVideo1`/`helpVideo2`/`helpVideo3`(HTML側が旧単発フォーム時代の文言のまま放置され、実際に表示されるi18n.js側の内容と長年食い違っていた)を発見・修正した。README.mdのVideo Tabセクションも同様に、4番のPlan/Asset/Edit再設計自体が一切反映されないまま単発フォーム時代の説明で放置されていたことが判明し、9〜14番までの内容を反映して全面的に書き直した。
|
||||
|
||||
**16. `pyproject.toml`のlicense記述をComfy Registry仕様準拠の形式に修正**: リリース準備の一環で、`license = "MIT"`という単純文字列形式が、公式仕様(`license = {file = "LICENSE"}`または`{text = "..."}`のいずれか)に該当しない非推奨形式であり、`comfy env`実行時にcomfy-cliが警告を出していたことを確認した([[project_registry_flagged_versions]]で追跡しているv0.5.0〜v0.5.3の原因不明のFlagged/Pending状態の調査依頼を受けて着手)。合わせて、参照先の`LICENSE`ファイル自体がリポジトリに存在しなかった(README.mdの「MIT License」という一文のみ)ため新規作成し、`license = { file = "LICENSE" }`に修正した。調査の結果、Comfy Registryのセキュリティスキャンは主にAI+静的解析(Ruff)によるPythonコード内の危険なパターン検出(`eval`/`exec`/`subprocess`/`pickle.loads`/カスタムpip wheel等)が対象で、`pyproject.toml`のメタデータ自体(license記述含む)がスキャン対象になるという情報は見つからなかった — つまり今回の修正はComfy Registryの公式仕様に沿った改善ではあるが、Flagged/Banned問題の直接的な解決策である確証はない。一方で、他の開発者(GitHub issue経由)でも同様の原因不明なFlaggedステータスが複数報告されており、pyproject.tomlの記述とは無関係にRegistry側の自動スキャンが誤検知(false positive)を起こしている可能性が高いことが分かった。具体例として、外部バイナリをsubprocessで呼び出す正当なコード(GraphicsMagick連携)が誤検知された報告があり、本プロジェクトにも同種のパターン(`gmic_routes.py`の`subprocess.Popen`+Windows固有の`CREATE_NEW_CONSOLE`フラグ、`wildcard_service.py`の`["cmd", "/c", "mklink", ...]`)が存在するため、これらが誤検知の一因になっている可能性はあるが確証はない(いずれも正当な機能で、変更は行っていない)。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`python -m py_compile`で構文確認、`templates/index.html`はPythonの`html.parser`でタグ対応チェックとJinja2実レンダリングで検証。ジャンクションバグの調査・検証は、ユーザーが起動中のComfyUI_5(ポート8189で稼働)に対し`curl`でAPIを直接叩いて実施 — `os.path.realpath()`で実際にジャンクションであることを確認し、`groups/{name}/images`(生成成功)と`list_images`(グループフィルタで空)の食い違いを再現、修正版デプロイ後の再検証はユーザー自身の実機確認で完了。8番の`__VideoAssets__`消失バグは、実行中サーバーの`gallery_metadata.json`を直接読み込んで原因を特定した(読み取り中にサーバー側の`_save_to_disk()`と競合しJSONDecodeErrorになることがあったため、パース成功するまで数回リトライして実データを確認)。13番の根本原因調査では、同じファイルへの読み取りを連続実行してもファイルサイズと成否が毎回変わることを確認し、実データが継続的に高頻度書き込みされている実態そのものを証拠として特定に用いた。13番の修正自体は`tempfile`を使った独立テストスクリプトで正常保存・再ロード後のデータ保持・破損ファイル検出時のバックアップ・復旧後の再保存を単体検証(ComfyUI起動不要)。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更・新規ファイルを都度両フォルダへ同期しCRLF/LF差異を除いた`diff`で完全一致を確認、Python変更(13番・12番の`gallery_routes.py`予約グループリスト)を含む回では実行時側の`__pycache__`もクリアした。各段階(レイアウト・Projectサブタブ追加・ハイライト復活・グループ運用整理・データ消失バグ・プレビュー2ペイン化・ヘルプ整合性)ともユーザー自身の実機確認・スクリーンショットや手描き構成図の提供を受けて次の修正に進む反復的な進め方で実施した。16番のRegistry仕様調査はWeb検索でComfy公式ドキュメント(`docs.comfy.org/registry/specifications`)とGitHub issue(Comfy-Org/ComfyUI-Manager #2927, #2997)を確認し、`pyproject.toml`の`license = { file = "LICENSE" }`変更後は`toml`パッケージでパース可能なことを確認した。
|
||||
|
||||
**How to apply**: 「◯◯機能では動くのに△△機能では動かない」という報告があった場合、両者の実行経路が本当に同じか(今回はLabがAPI形式を直接操作するのに対しVideoはUI形式から`convertUiToApi()`を経由する、という違い)を疑うと真因に早く到達できる。ComfyUIフロントエンドの`convertUiToApi()`のようなCOMBO値検証は「無警告でのフォールバック」を行う設計になっている場合があるため、実行時に動的アップロードした値をCOMBO項目に書き込む機能(画像チェイン等)を追加する際は、そのCOMBOの選択肢キャッシュが最新かどうかを必ず確認する必要がある。StabilityMatrixのような複数ComfyUIインスタンスでモデル/出力フォルダを共有するツールはジャンクション/シンボリックリンクを使うため、`Path.resolve()`をパスキーの一部に使うコードとファイルシステムスキャンで得たパスとの間に「解決する/しない」の不一致が生まれやすい — 同じ画像を指すパス文字列の保存/検索が全経路で一貫した規約(このプロジェクトでは「常にresolve()後の文字列で統一」)に従っているか、新しい書き込み経路を追加するたびに確認する価値がある。複数のタブ切替グループが同じCSSクラス/data属性を共有する実装は、後から片方だけ物理的に別の場所へ移動すると意図せず連動してしまうため、独立させたいグループは早い段階でクラス名やquerySelectorAllのスコープを分けておくとよい。予約グループ(`__Feeder__`のような「削除されると誰も自動で作り直さない」特別なグループ)を新設する際は、保護(削除禁止)の実装と同時に「常に存在することを保証する`ensure`呼び出し」もセットで用意しないと、保護を追加する前の空白期間や手違いで一度消えた場合に二度と復活しない — この2つは別々の対策であり、片方だけでは不十分。実行中サーバーが継続的に書き込んでいるJSONファイルを外部から直接読んで調査する際は、書き込みと衝突して不完全な内容を読んでしまうことがあるため、パースが安定して成功するまでリトライするのが安全。JSON等の設定/メタデータファイルへの永続化処理を新規実装・レビューする際は、単純な`open(path, "w")`による直接上書きになっていないか必ず確認する — 書き込み頻度が低いうちは問題が表面化しなくても、対象データ量が増えて書き込み回数が増えるほど「書き込み中のプロセス終了でファイルが壊れる」リスクが顕在化しやすく、かつ壊れたファイルのロード失敗を「空データにフォールバック」で処理していると、その後の1回の保存で過去の全データが消えるという最悪の組み合わせになる(一時ファイル+`Path.replace()`のアトミック置き換えと、破損検出時のバックアップ退避はセットで対策する)。UI再設計でコードごと削除した機能(今回のハイライト表示)は、対応するヘルプ文言だけが取り残されて「存在しない機能の説明」として残り続けることがあるため、大きめのUI再設計を行った際はヘルプ文言側の棚卸しも忘れずに行う。ユーザー向けドキュメント(README等)は実装が大きく変わった直後に更新を忘れると、次に触るまで長期間ズレたまま放置されやすい — 大規模なUI再設計コミットを作る際は、その場でREADMEの該当セクションも一緒に更新するのが安全(今回はPlan/Asset/Edit再設計そのものが数バージョン分READMEに未反映のまま残っていた)。
|
||||
|
||||
関連: [[project_v050_video_tab]], [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.4
|
||||
|
||||
### 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
|
||||
|
||||
複数セッションにまたがり実施された作業をまとめてリリース。Videoタブが従来MiniMax H3専用だった検出/生成ロジックをLTX-2.5・Wan2.2にも対応させ、続けて生成UIタブ側の解析ロジックにも同じ2ファミリーへの対応を追加した。並行してAI Assistant設定(Prompt/Tagger)をUnslothバックエンド対応・タブ別モーダル化・モデルアンロード機能へ刷新した。
|
||||
|
||||
**1. Videoタブ: LTX-2.5 / Wan2.2対応(ノード検出の汎用化)**: 従来MiniMax H3専用だったサブグラフ検出/フォーム反映ロジックを、サブグラフが公開する入力のlabel(無ければname)ベースで意味的にスロットを特定する方式に一般化した。MiniMax H3/LTX-2.5系は内部ノード型のプレフィックス(`LTXV*`等)で、Wan2.2は該当する固有ノード型を持たないためサブグラフ名自体("...(Wan2.2)"表記)で家系を判定する。これによりVideoタブの「Load in Video」からLTX-2.5のText/Image/First&Last-Frame to VideoワークフローとWan2.2のText to Videoワークフローも読み込み・生成できるようになった。同梱していない`minimax_test.json`の自動読み込みも廃止。
|
||||
|
||||
**2. バグ修正: convertUiToApi()のMultiTypeウィジェット脱落**: object_infoが複合型(例: `LTXVEmptyLatentAudio.frame_rate: "FLOAT,INT"`)を宣言している入力が、単純な完全一致判定(`upper === "INT" || ...`)に引っかからずウィジェット名/型リストから丸ごと脱落し、後続ウィジェットの値が1つずつずれる問題を発見・修正した。実際に`LTXVEmptyLatentAudio.batch_size`にframe_rateの値が誤って書き込まれ、動画生成が"Expected size 1 but got size 25"で失敗する原因になっていた。`_resolveWidgetType()`を新設し、カンマ区切りの型宣言から実際にウィジェットスロットを占める単一のスカラー型を解決するよう修正。
|
||||
|
||||
**3. 動画再生ボリュームの保存設定**: 動画を切り替えるたびに音量が最大へ戻ってしまう問題を解消するため、設定タブに音量設定を追加しVideo/Galleryタブの全プレーヤー間で保存・共有されるようにした。
|
||||
|
||||
**4. GenerateUIタブ: LTX-2.5 / Wan2.2対応(プロンプト検出・Latent Image・タイムアウト)**: Videoタブとは独立した解析経路(`analyzeWorkflow()`、フラット化済みAPI形式ワークフローを解析)側でも同じ2ファミリーの通常ワークフロー読み込みに対応させる作業。実機確認したところ、Model タブ(Checkpoint/VAE/Diffusion Model/Text Encoder/LoRA)は既存の`UNETLoader`/`CLIPLoader`/`VAELoader`ベースの汎用検出でWan2.2/LTX-2.5とも既に問題なく動作していたが、次の2点に不備を発見した。
|
||||
|
||||
- **Positive/Negative Prompt検出(LTX-2.5)**: LTX-2.5は`LTXVDualCFGGuider`(positive/negativeが同一の`LTXVConditioning`ノードの別出力スロットを指す)+ `LTXVConditioning`(自身のpositive/negative入力を介して実際のプロンプトソースへ分岐)という専用ノード構成を使うため、既存の`CFGGuider`/`InstructPixToPixConditioning`向けロール伝播ロジックでは拾えず、Positive/Negative Promptが空欄のままSaveVideoノードへのフォールバック表示になっていた。`LTXVDualCFGGuider`をロール伝播の起点に、`LTXVConditioning`を`InstructPixToPixConditioning`と同型の分岐伝播ノードとして追加し解決した。
|
||||
- **Latent Image検出(Wan2.2・LTX-2.5共通)**: SettingsタブのLatent Imageセクションが`EmptyLatentImage`/`EmptySD3LatentImage`/`EmptyFlux2LatentImage`のみ対応しており、Wan2.2の`EmptyHunyuanLatentVideo`とLTX-2.5の`EmptyLTXVLatentVideo`が未対応で「No EmptyLatentImage node found」表示になっていた。両ノード型を追加し、動画特有の`length`(フレーム数)入力を編集するフィールドも新設した(Duration×FPSの数式ノードにリンクされ非リテラルなことが多いため、Width/Height同様「linked」表示にフォールバックする)。
|
||||
- あわせて`isVideoWorkflow()`(生成タイムアウトを30分に延長する判定)を、`SaveVideo`/`CreateVideo`ノードや動画用latentノードの存在でも判定できるよう拡張した。従来はMiniMax H3のようなオールインワン`*ToVideo`ノードの有無でしか判定できず、LTX-2.5/Wan2.2は通常の静止画と同じ短いタイムアウトで打ち切られる恐れがあった。
|
||||
|
||||
**5. Videoタブ左パネルのハイライト表示**: GenerateUIのModelタブが持つ「ワークフローに実際に該当する項目のラベルをハイライト」する仕組み(`wfm-model-label-active`クラス、設定タブでカスタマイズ可能な共通色)をVideoタブの左パネルにも適用した。Prompt/Durationは常時、First Frame/Last Frameはサブグラフがそのスロットを公開している場合のみ、Aspect Ratio/Megapixels/MultipleはResolutionSelectorノードが存在する場合のみハイライトする — Wan2.2のように該当ノードがなく操作不可(disabled)な項目は、既存の無効化表示と矛盾しないよう非ハイライトのままにした。
|
||||
|
||||
**6. バグ修正: 生成UIタブRAW JSON検索がヒット箇所にカーソルを当てて不意に編集させてしまう**: 検索でヒットした際に`editor.focus()` + `setSelectionRange()`でテキストエリアへフォーカス・選択範囲を作っていたため、検索直後にキー入力すると選択範囲が上書きされ意図せずJSONが書き換わってしまう問題があった。ハイライト表示(`<mark>`によるオーバーレイ)とスクロール追従は維持しつつ、フォーカス移動と選択状態の作成のみを取りやめ、編集はユーザーが明示的にエディタをクリックした場合のみに限定した。
|
||||
|
||||
**7. AI Assistant設定: Unsloth対応・Prompt/Tagger独立モーダル化・モデルアンロード機能**: Prompt AI(Prompt タブ)とTagger VLM(Taggerタブ)にUnslothバックエンドを追加した(サーバー側プロキシでAPIキーを付与)。Taggerタブは従来Ollama直呼び出しだった実装を、Ollama/LM Studio/Lemonade/Unslothの4バックエンド共通の`vlm_models`/`vlm_predict`エンドポイントに統一し、API URL直接入力欄を廃止した。中央Settingsタブにあった単一の「AI Assistant Settings」パネルを廃止し、Prompt/Tagger各タブに⚙設定ボタン+専用モーダル(`ai-settings-modal.js`、Backend/Connection/Model/Generation/Unload/Saveの共通UIコンポーネント)を新設、それぞれ独立した`localStorage`キーで設定を持つように変更した(PromptとTaggerで異なるバックエンド/モデルを使い分けられる)。Thinking mode / Max tokensの設定をPrompt/Tagger双方の生成呼び出しに反映(Tagger側はPython側の`vlm_predict`にも反映)。Ollama(`keep_alive:0`)/LM Studio(`/api/v1/models/unload`)/Lemonade(`/api/v1/unload`)向けのモデルアンロード機能を追加し、AI TOOLタブの設定にもUnloadボタンを追加した(Unslothは対応する公式APIが無いため非対応として明示)。
|
||||
|
||||
**8. CI: publish-node-actionのバージョンをSHA固定**: `Comfy-Org/publish-node-action@main`という可変ブランチ参照は、アクション側が更新された際に挙動が変わりうるサプライチェーンリスクのため、リリースタグ1.0.1のコミットSHAに固定するよう`.github/workflows/publish.yml`を修正した([[project_registry_flagged_versions]]で追跡している過去のRegistry Banned/Flagged原因調査の一環)。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUIタブ(LTX-2.5/Wan2.2対応・RAW JSON検索の挙動)とVideoタブ(ハイライト色機能)のヘルプ本文を英語・日本語・中国語の3言語で追加・更新した(Prompt/Settings/AI TOOL/Taggerタブのヘルプは7番の対応セッション内で既に更新済み)。README.mdはWorkflowタブ(Load in Video対象の拡大)、GenerateUIタブ(MultiTypeバグ修正・LTX-2.5/Wan2.2対応)、Videoタブ(対応ファミリー拡大・ハイライト色・音量設定・見出しバージョン範囲更新)、Settingsタブ(AI Assistant Settings廃止の案内・音量設定・ハイライト色の共有先追記)、Prompt/Taggerタブ(AI Assistant設定のモーダル化・マルチバックエンド化)、AI TOOLタブ(モデルアンロード機能)を更新した。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`ast.parse()`で構文確認。GenerateUIタブのLTX-2.5/Wan2.2対応は、実機ブラウザ(Kapture)でwan22_test.json / ltx_25_test.jsonの2ワークフローを実際に生成UIタブへ読み込み、Positive/Negative Promptの実文表示・Latent Image欄(Width/Height/Batch Size/Length)の表示・ハイライト色の点灯状態をスクリーンショットで確認した。RAW JSON検索のフォーカス修正は、検索実行後にキー入力してもtextareaの値が変化しないことを実機で確認した。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルを都度両フォルダへ同期しCRLF/LF差異を除いた`diff`で完全一致を確認した。
|
||||
|
||||
**How to apply**: `analyzeWorkflow()`のような中央解析関数はワークフロー「ファミリー」が増えるたびに、既存パターン(KSampler系のロール伝播、EmptyLatentImage系のlatentノード検出)と同型の分岐を追加していく設計で拡張できる — 逆に言えば、新しいノード構成(今回のLTXVDualCFGGuider/LTXVConditioningのような「複数出力を持つ1ノードがpositive/negativeを兼ねる」パターン)は`InstructPixToPixConditioning`のような既存の同型ケースを探して倣うと実装が速い。同じワークフローファミリーへの対応が複数タブ(Video/GenerateUI)にまたがる場合、検出ロジック自体は各タブの解析経路(Video: サブグラフを直接歩く、GenerateUI: フラット化済みAPI形式を解析)が別実装なので、片方を直しても他方は直らない — 「Videoタブでは動くのにGenerateUIでは動かない」という状況は、まさにこの二重実装のズレが原因だった。テキストエリアへの自動フォーカス/selectionRangeは、検索・ハイライトのような「見せるだけ」の操作と「編集を許可する」操作を暗黙に混同させるため、ハイライト表示に徹する機能ではフォーカス移動自体を避けるのが安全。
|
||||
|
||||
関連: [[project_v050_video_tab]], [[project_dev_deploy_sync]], [[project_v0336_conventions]], [[project_registry_flagged_versions]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.3
|
||||
|
||||
### バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
|
||||
|
||||
ユーザーからの複数のバグ報告・改善要望を1セッションで順に対応した作業。
|
||||
|
||||
**1. バグ修正: Labタブで起動時デフォルトワークフロー読み込み後に別ワークフローを読み込むと前の内容が残る**: 「別のワークフローを読み込んだ。一部以前のワークフローの内容が残ったまま表示される(クリックするか他のタブから戻ると消える)」との報告。Labタブの「Setting 1」列(Checkpoint/LoRA/VAE/Prompt/KSampler)はGenerateUIの現在の読み込み内容をライブ反映する仕組みだが、その再描画(`refreshLabLiveDefaults()`)は「サブタブをLabに切り替えたとき」の一箇所(`generate-tab.js`のサブタブクリックハンドラ)でしかトリガーされていなかった。Labタブを既に開いた状態で別のワークフローを読み込んだ場合、`loadWorkflowIntoEditor()`側では再描画が一切呼ばれず、前のワークフローの表示がそのまま残っていた。「セルをクリックしたり他のタブから戻ると直る」という報告内容は、それらの操作がたまたま再描画をトリガーしていたことの裏返しだった。`loadWorkflowIntoEditor()`の末尾で、現在アクティブなサブタブがLabの場合は`refreshLabLiveDefaults()`を追加で呼ぶよう修正。
|
||||
|
||||
**2. 改善: Galleryタブの生成後の更新が遅い**: 「生成後の画像表示に更新して読み直すのに時間がかかる」との報告。実データ(Output先の`Text2Img`フォルダ、画像9,777枚)で`_scan_folder()`を実測したところ**7.5秒**かかっており、原因を切り分けたところ`entry.stat(follow_symlinks=True)`と、各ファイルごとに呼んでいた`Path(entry.path).resolve()`の2箇所がボトルネックと判明。特に`resolve()`の再解決が支配的で、これだけで1ファイルあたり約0.7ms×9,777件のファイルシステム往復が発生していた。`_scan_folder()`の呼び出し元(`list_images()`/`_bg_index_folder()`)は既にフォルダ自体を`Path(folder_path).resolve()`済みで渡しているため、`entry.path`は既に絶対パスであり、エントリ単位での再`resolve()`は不要だった。`entry.is_file()`/`entry.stat()`を`follow_symlinks=False`に変更し、パス生成も`entry.path.replace("\\", "/")`に簡略化(出力フォルダにシンボリックリンクの画像が置かれる想定はないため安全と判断)。同じ理由で`os.walk()`ベースだった`_scan_folder_recursive()`(ImagePrompt/Style Catalog用の再帰スキャン)も、`os.scandir()`を自前で再帰しDirEntryベースでstatする実装に書き換えた。実測で9,777枚のフォルダに対する`list_images()`全体が0.14秒(コールド)/0.08秒(フォルダキャッシュ温存時)まで短縮(約50倍高速化)。
|
||||
|
||||
**3. 改善: Galleryフォルダツリーのrootをデフォルト展開状態に**: 上記の高速化を受けて「rootフォルダはデフォルトで展開状態で表示したい」との要望。フォルダスキャンが軽くなったことでサブフォルダの内容もすぐ確認できるようになった一方、`renderTreeNode()`はrootも他のノードと同じく初期状態は折りたたみ(▶)だったため気付きにくかった。root(`isRoot`)の場合のみ、ツリー構築時に矢印を展開済み(▼)にし子ノードを即座にレンダリングするよう`gallery-tab.js`を修正。
|
||||
|
||||
**4. READMEスクリーンショット刷新**: 「以下のスクリーンショットは削除したい」との依頼でGenUI Feeder / GenUI LoRA Stack / Models Multi-select Menu / Tagger Tab / Customizeの5件をREADME.mdの表と`docs/`実ファイルの両方から削除(残った項目は2列レイアウトに詰め直し)。続けてVideo Tab・Lab Tab(2枚)・AI TOOL Tabの新規スクリーンショット計4枚をユーザーが`docs/`に配置し、READMEの表に追加した。
|
||||
|
||||
**検証**: JS/Pythonとも`node --check`/`ast.parse()`で構文確認。Gallery高速化は開発元リポジトリの`GalleryService`を実データ(実際の`Text2Img`フォルダ、9,777枚)に対してvenv Pythonから直接呼び出し、修正前後の`_scan_folder()`/`list_images()`の実行時間をベンチマークして改善を数値で確認(7.5秒→0.063秒、`list_images()`全体は0.14秒)。返却されるパス文字列が修正前と同じ絶対パス形式であることも確認済み。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更・削除した全ファイル(`generate-tab.js`, `gallery_service.py`, `gallery-tab.js`, `README.md`, `docs/`配下の画像)を都度両フォルダへ同期し`diff`/存在確認で完全一致を確認、Pythonファイル変更のため`__pycache__`もクリア。全項目ともユーザー自身の実機確認で解消・反映を確認済み。
|
||||
|
||||
**How to apply**: あるタブ/サブタブの表示を「別の場所(GenerateUI本体など)の現在の状態をライブ反映する」設計にする場合、再描画のトリガーを「そのタブへの切替」1箇所だけに置くと、切替を経由しない別の更新経路(今回はワークフロー読み込み)で表示が古いまま取り残される。ライブ反映される値の更新元(今回は`loadWorkflowIntoEditor()`)側でも、現在表示中のタブを見て能動的に再描画を呼ぶ必要がある。パフォーマンス調査では「体感で重い処理」を分解して各行を実測するまで真犯人を決め打ちしない方がよい — 今回も当初`follow_symlinks=True`のstat呼び出しを主因と想定したが、実測すると`Path.resolve()`の方が支配的だった。Windows上で大量ファイルを扱う`os.scandir()`ループでは、`entry.path`が既に呼び出し元でresolve済みのフォルダ配下の絶対パスであることを踏まえ、エントリ単位での`resolve()`/`follow_symlinks=True`はファイル数に比例した無視できないコストになり得るため避ける。バックエンドの表示速度を改善した際は、それに応じてフロントエンドのデフォルトUI状態(今回は折りたたみ初期状態)も見直す価値がある — 「遅いから畳んでおく」という妥協がボトルネック解消後も残ったままになりがち。
|
||||
|
||||
関連: [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.2
|
||||
|
||||
### 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
|
||||
|
||||
v0.5.0/v0.5.1で専用Videoタブを整備したMiniMax H3動画生成ワークフローを、GenerateUI/Labタブ本体でも扱えるようにする要望を起点に着手。1セッションで複数の要望・バグ報告・監査依頼を順に実装した一連の作業。
|
||||
|
||||
**1. GenerateUI/Labタブの動画ワークフロー対応**: 「MiniMax H3の動画生成ワークフローを生成UIタブ、Labタブでも生成できるようにしたい」との要望。実際には`comfyWorkflow.convertUiToApi()`+`comfyUI.generate()`が既に汎用的で生成自体は動いていたが、`analyzeWorkflow()`が`MiniMaxH3ImageToVideo`のようなCLIPTextEncodeを介さないオールインワン動画ノードのpromptを検出できず、GenerateUIのInput>Promptタブが空になっていた。クラス名が`ImageToVideo`/`TextToVideo`を含み`inputs.prompt`が文字列のノードを汎用的に拾う分岐を追加(`metadata-tab.js`/`node_sets_menu.js`に既にあったプロンプト抽出と同じ判定パターン)。GenerateUIとLabタブは共に同じ`comfyUI.currentAnalysis`を参照する設計だったため、この一箇所の修正でPromptタブとLabのキーフレーム書き込みの両方が動画ワークフローに対応した。画像(first_frame)は既存のLoadImage検出がそのまま拾う。あわせて、動画生成は静止画より大幅に時間がかかるため`comfyWorkflow.isVideoWorkflow(analysis)`ヘルパーを新設し、動画ワークフロー検出時はGenerateUI/Labとも生成タイムアウトを30分に延長。GenerateUIの結果プレビューは`<img>`固定だったため動画結果が正しく表示できなかった問題も、`<video>`要素を追加し出力ファイルの拡張子で出し分けるよう修正した。
|
||||
|
||||
**2. バグ修正: Labタブのindex imageが真っ黒になる**: 実機確認で発覚。コンタクトシート生成(`_buildIndexImageDataUrl`)が各結果を`<img>`要素として読み込もうとしており、mp4は画像として読み込めず失敗、そのセルはcanvasの黒背景がそのまま残っていた。動画結果は既存のchain image機能で使っていた最終フレーム抽出ロジック(`extractLastFrameBlob`、`<video>`+`<canvas>`でクライアント側完結)を再利用してフレームを取り出してから描画するよう修正。
|
||||
|
||||
**3. バグ修正: LabタブKsampler列が動画ワークフローでも表示される**: 「Model同様に該当ノードがなければ非表示にしたい」との要望に対し当初`sampler_nodes.length > 0`で判定したが、実機確認で「まだ表示される」との報告。原因はMiniMax H3のサブグラフ内部に実際に`SamplerCustomAdvanced`が配線されており(内部プリセットの一部で、ユーザーが調整すべきものではない)、単純な有無判定では引っかからなかったこと。判定を`comfyWorkflow.isVideoWorkflow(analysis)`ベースに切り替え、動画ワークフロー全体ではKsampler列自体を常に非表示にするよう修正した。
|
||||
|
||||
**4. Videoタブ拡張: Frame抽出 / アニメーションGIF変換**: Eagleプラグイン(video-to-frame, video2gif)を参考に要望された2機能。Eagle版はNode.js+ffmpegバイナリ直接呼び出しの実装だったため、ComfyUI-Workflow-Studioの構成(Python/aiohttpバックエンド)に合わせて設計し直した。フレーム抽出は`<video>`+`<canvas>`でブラウザ完結(サーバー往復・追加依存なし)。GIF変換はユーザー確認の上サーバー側実装を選択:PyAV(デコード)+ Pillow(GIFエンコード)— どちらもComfyUI本体が既に要求している依存のため、ffmpegバイナリや新規ライブラリの追加インストールは不要。`py/services/video_service.py` / `py/routes/video_routes.py`を新規追加し、`folder_paths.get_save_image_path()`でComfyUI標準の自動採番保存パターンをそのまま踏襲。合成テスト動画(PyAVで生成)を使いvenv環境でデコード→GIFエンコードの実処理を検証済み。
|
||||
|
||||
- あわせてVideoタブの中央パネルを再設計:「結果」見出し/「まだ結果がありません」表示を廃止し、生成結果とドロップされた任意のローカル動画を同じプレビュー枠に統合、横に**Video Source**ドロップ/クリック選択エリア(Clearボタン付き)を新設。右パネルの「Properties」プレースホルダーを**Frame**/**GIF**の2タブに置き換え。生成結果に限らず任意の動画に対してFrame/GIF機能が使える設計にした。
|
||||
|
||||
**5. データ管理の監査とZIPフルバックアップ機能の新規実装**: 「設定のデータ管理を現在のバージョンで対応できているか確認してほしい」との依頼を受け、既存の`_DATA_FILES`(単一JSONバンドル方式)と実際のデータディレクトリを突き合わせ監査。**`civitai_cache.json`(3.6MB、CivitAI連携キャッシュ)がエクスポート対象から漏れていた**ことを発見し即修正。また、ヘルプが謳う「export all plugin data」に反し、フォルダ形式のデータ(`ai_skills/`、`lab_plan/`、`style/`)とSQLite DB(`tagger.db`)が単一JSONバンドル方式では原理的に扱えず完全に対象外になっていた点をユーザーに報告、ZIP形式のフルバックアップ機能を新規実装する方針で合意。`DATA_DIR`配下を丸ごとZIP化する`GET /api/wfm/settings/export-full`/`POST /api/wfm/settings/import-full`を追加(一時ファイル系`gmic_temp`/`thumb_cache`と、他custom_node(comfyui-impact-pack)へのシンボリックリンクである`wildcard/`のみ除外、`settings.json`はAPIキーを除外してから含める)。復元側はZip Slip対策(`../`等でDATA_DIR外に書き込もうとするエントリは`relative_to()`チェックでスキップ)を実装し、悪意あるZIPでの検証済み。
|
||||
|
||||
- 追加要望「デフォルトワークフローフォルダー(`user/default/workflows`)とwildcardフォルダー(`comfyui-impact-pack/wildcards`)もオプションでバックアップに含めたいが、インポートは除外し手動対応としたい」に対応:エクスポート画面にオプトインのチェックボックスを2つ追加、有効時はZIP内に`_external/`プレフィックスで格納し、復元処理は`_external/`配下のエントリを常にスキップする実装にした(他プラグイン/ComfyUI本体が管理する領域を自動上書きしないため)。実データ(ワークフロー535件・wildcard 34件)で検証済み。
|
||||
|
||||
**6. Send to LI node機能をGalleryタブにも追加 + ボタン名統一**: Image Editタブに既にあった「選択画像をComfyUIキャンバス上の選択中ノードのimageウィジェットへ送る」機能(`window.opener.wfmSendImageToSelectedNode`、`node_sets_menu.js`で定義されたクロスウィンドウ橋渡し)を、Galleryタブの選択画像にも使えるよう追加。Galleryの画像は任意のフォルダにあり得るため、既存の「Send GenUI Image」と異なりLoadImageノードの有無に依存せず、`/upload/image`でinputフォルダへアップロードしてからファイル名を書き込む方式にした。あわせてImage Edit側の既存ボタン名「Send to Workflow」を「Send to LI node」に統一(トースト文言・コメント・ヘルプも合わせて更新)。
|
||||
|
||||
**7. Galleryタブ ✕クリアボタンの位置移動**: 「表示形式切替ボタン左隣に移動したい」との要望どおり、`wfm-gallery-clear-filters-btn`をSort selectとView切替ボタンの間から、View切替ボタン(Thumbnail/Table)の左隣へ移動。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、Video Tab(Video Source/Frame/GIFの3項目)、Lab subtab(Ksampler非表示・chain image動画対応の項目文言修正)、Settings(Data Managementのフルバックアップ・オプション外部フォルダの2項目)、Gallery Tab(Send to LI nodeボタンの項目)のヘルプ本文を英語・日本語・中国語の3言語で追加・更新。README.mdはVideo Tab(Frame/GIFタブ、Video Sourceパネル)、GenerateUI Tab(動画ワークフロー対応)、Lab subtab(動画ワークフロー対応・chain image動画対応、バージョン見出し範囲更新)、Settings Tab(Data Management更新)、Gallery Tab(Send to LI node・✕クリアボタン位置)、Image Edit Tab(ボタン名統一)を更新。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`ast.parse()`で構文確認。GIF変換・フルバックアップZIP・Zip Slipガードはvenv Python環境で実データ・合成テストデータに対する実処理検証を実施(PyAVで生成した合成テスト動画のフレーム抽出→Pillow GIFエンコード、実際のデータディレクトリ(civitai_cache.json含む36ファイル・約2.6MB)のZIP化、悪意あるパストラバーサルZIPでのスキップ確認、外部フォルダオプション有効時のワークフロー535件・wildcard 34件の`_external/`格納とインポート時全skipの確認)。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更・新規ファイルを都度両フォルダへ同期し改行コード差異を除いた`diff`で完全一致を確認、Pythonファイル追加時は`__pycache__`を都度クリア。動画ワークフロー対応・Labバグ修正・Videoタブ新機能・Send to LI node機能はユーザー自身の実機確認で動作を確認済み。
|
||||
|
||||
**How to apply**: `analyzeWorkflow()`のような中央解析関数に新しいノードパターンの検出を1箇所追加するだけで、それを参照する複数タブ(GenerateUI/Lab)に同時に効果が及ぶ設計は、機能追加のコストを大きく下げる — 逆に言えば、新しいワークフロータイプに対応する際はまずこの中央解析関数を疑うべき。「該当ノードが無ければ列を非表示にする」系の判定は、`sampler_nodes.length`のような表面的なノード種別の有無だけでなく、そのノードが「ユーザーが調整すべき設定」を表しているか(動画生成モデル内部に固定配線されたサンプラーのように、単に存在するだけでは判定材料にならない場合がある)を意識する必要がある。「all plugin data」のような謳い文句を持つ機能は、実装が実際にその範囲をカバーしているか定期的に実データと突き合わせて監査する価値がある — 今回`civitai_cache.json`の漏れは3.6MBというサイズながら発見されていなかった。
|
||||
|
||||
関連: [[project_v050_video_tab]], [[project_v051_mp4_metadata]], [[project_dev_deploy_sync]], [[project_v0336_conventions]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.1
|
||||
|
||||
### 機能追加: mp4動画の埋め込みメタデータ対応(Gallery Prompt/JSON・Metadataサブタブ・サイドパネルIタブ)+ Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
|
||||
|
||||
v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ)に続き、「ギャラリータブでmp4のメタデータのプロンプトを表示できるようにしたい。メタデータタブでもプロンプト、表示可能ならモデル等も表示させたい。またInfoタブで画像サイズ、再生時間も表示したい」との要望を起点に着手。Eagleでmp4のプロンプト/Steps/Sampler/Seed/Modelが見えている実例(MiniMax H3で生成した`.mp4`)を手がかりに、ComfyUIの`SaveVideo`がmp4にどう埋め込んでいるかを`ffprobe`/実PyAVで直接確認した上で実装した。
|
||||
|
||||
**1. 埋め込み形式の実地調査**: `ffprobe -show_format`で対象mp4を調べたところ、PNGのtEXtチャンクと全く同じキー名(`workflow`=UI形式寄りの内容、`prompt`=API形式)でJSON文字列がコンテナレベルのformatタグとして書き込まれていた。バイト単位で追うと`moov > udta > meta > (hdlr, keys, ilst)`という、iTunes系メタデータ(`mdta`名前空間の`keys`/`ilst`)の構造になっており、FFmpegの`movflags=+use_metadata_tags`相当の書き込み方だと判明。ComfyUI側は`comfy_api/latest/_input_impl/video_types.py`の`write_output_metadata()`がPyAV経由で`container.metadata[key] = json文字列`する実装。
|
||||
|
||||
**2. バックエンド(`py/services/gallery_service.py`)**: 既存の`serve_thumbnail()`が動画フレーム抽出に使っていた`av`(PyAV)依存を再利用し、`_read_mp4_metadata()`(`container.metadata`からタグ辞書を取得)と`_read_media_dimensions()`(画像はPillowの`Image.size`、動画はPyAVの`stream.width/height`+`container.duration`)を追加。`get_image_metadata()`・`_extract_embedded_workflow()`にmp4分岐を追加し、既存のPNG向けワークフロー抽出ロジック(`workflow`/`prompt`優先順位の切り替え)をそのままmp4にも適用した。venv Pythonで`GalleryService`を直接呼び出すE2E検証、および実mp4に対する`ffprobe`/PyAVの値の突き合わせ(幅高さ608×352, 再生時間3.042秒, workflow/promptタグ長一致)で正しさを確認済み。
|
||||
|
||||
**3. Galleryタブ Infoサブタブ**: 幅×高さ(画像)または再生時間(動画)を表示する行を追加(`gallery-tab.js` `renderDimensionInfo()`)。既存のPrompt/JSONタブは`extract_workflow_from_metadata`系のAPIをそのまま叩いているため、バックエンド対応だけでmp4でも自動的に動くようになった。
|
||||
|
||||
**4. Metadataサブタブ(Iタブ、`metadata-tab.js`)とサイドパネルIタブ(`web/comfyui/node_sets_menu.js`)**: どちらもドラッグ&ドロップされたFile自体(サーバー未経由のバイト列)を直接パースする設計のため、MP4のボックス構造(`moov→udta→meta→keys/ilst`)をブラウザ上で直接読む`readMP4MetaTags()`/`_readMP4MetaTags()`をゼロから実装(`findMP4Box`でサイズ/タイプ解決→ `keys`ボックスからキー名一覧 → `ilst`ボックスの`data`アトムから値を取り出し名前に対応付け)。Node.jsで実mp4のバイト列に対して単体テストし、`workflow`/`prompt`タグの内容がPyAV/`ffprobe`の結果と完全一致することを確認した。サイドパネルIタブは`metadata-tab.js`と同一ロジックを保持する独立した複製実装(ComfyUIネイティブ拡張のため別ファイル)のため、同じ変更を両方に適用。
|
||||
|
||||
**5. バグ発見・修正: オールインワン動画ノードのプロンプトが抽出されない**: 上記の実装後、実際のMiniMax H3出力mp4でPromptタブを確認したところ「プロンプトなし」と表示される問題を発見。原因は`MiniMaxH3ImageToVideo`のような、`CLIPTextEncode`系ノードを介さずノード自身の`prompt`入力に直接テキストを持つ「動画生成オールインワンノード」を、既存の`isTextEncoderNode()`判定(クラス名に`TextEncode`を含むかで判定)が拾えていなかったこと。`extractPromptsAPI()`(`metadata-tab.js`・`node_sets_menu.js`の両方)に、クラス名が`ImageToVideo`/`TextToVideo`を含み`inputs.prompt`が文字列のノードをtextMapへ直接投入する分岐を追加して解決(positive/negativeの区別が無いノードのため、フォールバックの`texts`バケットに載る)。
|
||||
|
||||
**6. ヘルプ更新**: Gallery Tab(Infoタブの寸法/再生時間表示、Formats行のMP4追加)、Metadata Tab(Supported Formatsカード)、Workflow Studio Library / Iタブ(サイドパネル)のヘルプ本文を英語・日本語・中国語の3言語(`i18n.js`)+ `templates/index.html`で更新。
|
||||
|
||||
**検証**: Python側は`GalleryService`をvenv Pythonで直接呼び出すE2Eテストと実mp4への`ffprobe`/PyAVでの突き合わせ、JS側のMP4パーサーとプロンプト抽出ロジックはNode.jsで実バイト列/実JSONに対する単体テストで検証。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイル(`gallery_service.py`, `gallery-tab.js`, `metadata-tab.js`, `node_sets_menu.js`, `i18n.js`, `templates/index.html`, `README.md`, `pyproject.toml`)を両フォルダへ同期し改行コード差異を除いた`diff`で完全一致を確認。ComfyUI実機でのブラウザ操作確認はユーザー側で実施済み(Gallery Prompt/JSON表示、サイドパネルIタブとも問題なしとの回答)。
|
||||
|
||||
関連: [[project_v050_video_tab]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0
|
||||
|
||||
### 機能追加: mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
|
||||
|
||||
動画生成ワークフロー対応の第一弾。「1. 動画ファイルmp4対応 2. 動画生成に対応していきたい。まずはminimax_H3動画生成ワークフローに対応させたい」との要望を起点に、実機フィードバックを重ねながら仕上げた一連の作業。ブランチ`feature/mp4-video-gallery-and-video-tab`で実施。
|
||||
|
||||
**1. Galleryタブのmp4動画対応**: `.mp4`を`IMAGE_EXTENSIONS`に追加し、一覧・お気に入り・タグ・グループ・移動・削除・エクスポートなど既存の画像操作全てにmp4も対象として乗るようにした。サムネイルはPillowで開けないため、PyAV (`av`) で先頭フレームを抽出しJPEGとしてディスクキャッシュする専用ロジックを追加(ComfyUI本体が既に`av>=17.0.0`を要求しており追加インストール不要。未導入環境ではサムネイル生成のみ失敗しプレースホルダー表示にフォールバックする設計)。フロントエンドは詳細パネル・lightbox・Compareビューで拡張子に応じて`<img>`/`<video controls>`を出し分け、サムネイルグリッドには▶バッジを重ねて動画であることを示す。
|
||||
|
||||
**2. 新規Videoタブ(MiniMax H3専用、GenerateUIタブとは完全に独立)**: `C:\Users\...\ComfyUI_5\user\default\workflows\minimax_test.json`をリファレンスに、first_frame/last_frame/prompt/aspect_ratio/megapixels/multiple/duration/noise_seedのフォームを持つ新規タブ`video-tab.js`を作成。モデル選択(UNet/CLIP/VAE/Audio VAE)はワークフローの既定値に固定し編集不可とした(ユーザー確認済み)。`comfyWorkflow.convertUiToApi()`が既にサブグラフのflatten処理を内蔵していたため、Videoタブ独自のグラフ→プロンプト変換は不要で、生成そのものは既存の`comfyUI.generate()`をそのまま再利用できた(ComfyUI本体の`SaveVideo`/`PreviewVideo`が`{"images":[...], "animated":true}`という画像と同じレスポンス形式で動画を返すため)。
|
||||
|
||||
**3. 実機フィードバックへの追随(ボタン配置・色・レイアウト)**: 一連の実機確認を経て、(a) Galleryの詳細パネルのダウンロードオーバーレイが動画の再生コントロール操作をブロックするため廃止し、Taggerボタン右隣に常時表示の「Download」ボタン(識別しやすいようオレンジ色)を新設、「Load GenUI」ボタンは「GenUI」に短縮、(b) Workflowタブのモーダルにあった「生成UIに読み込み」「Videoに読み込み」「キャンバスへ送る」の3ボタンを廃止し、一覧選択時のサイドパネルツールバー(生成UIに読み込みの右隣に新規追加した「Videoに読み込み」)へ統合、(c) Videoタブの結果パネルの位置・サイズを繰り返し調整 — 最終的に**3ペイン構成**(左:フォーム/中央:プレビュー+将来の編集機能用プレースホルダー/右:将来のプロパティ機能用プレースホルダー)に落ち着いた。各ペインを独立したflexカラムにしたことで、中央の生成結果の高さがどれだけ変わっても左右のペインの位置が動かなくなる(`max-height`だけで高さを抑えていた前段の実装では、動画のコントロールバー分だけ実高さが伸び、下のフォームパネルを押し下げてしまっていた)。Kapture経由で実際のブラウザDOM(`getBoundingClientRect()`)とスクリーンショットを直接確認しながら調整した。
|
||||
|
||||
**4. First Frame/Last Frameを両方optionalに、LoadImageノードが無いワークフローにも対応**: 「First Frameなしでも動作させたい(ノードをバイパスで処理)。またノードのないワークフローにも対応させたい」との要望。`minimax_test1.json`(first_frame/last_frame双方とも未接続、対応するLoadImageノードがトップレベルに存在しない構成)を新たに確認し、`locateMiniMaxNodes()`のLoadImageノード必須チェックを撤廃。first_frame未指定時はリンクなしのまま実行(バイパス)し、画像が指定された場合のみその場でLoadImageノードを動的に注入する設計に変更(ノードID/リンクIDはclone内の実際の最大値+1で採番するため重複の心配がない)。first_frameスロットにもlast_frameと同様のClearボタンを追加した。
|
||||
|
||||
**5. バグ修正: mp4のタグ/メモがComfyUI再起動後に消える**: 「タグ、メモadd、保存操作はできるが記録されていない。画像ファイルもできていない。エラー表示はなし、ブラウザもなし」との報告を受け、`comfyui_8189.log`を直接確認したところ`Cleaned up 189 stale metadata entries from ...\Text2Img`という決定的なログを発見。**根本原因**: `cleanup_stale_images()`は「現在表示中のフォルダ配下」のメタデータキーを全階層(サブフォルダ含む)対象にstale判定するが、Galleryがルートフォルダを非再帰スキャンした場合、`existing_paths`(現在のファイル一覧)にはルート直下のファイルしか含まれない。Videoタブで生成した動画は`SaveVideo`の`filename_prefix`設定で`video/MiniMax_H3/`というサブフォルダに保存されるため、ルートフォルダを開くたびに「サブフォルダ内ファイルは非再帰スキャンには映らない→存在しないと誤判定→メタデータ削除」が発生し、189件ものタグ/メモ/お気に入り情報が消えていた。`cleanup_stale_images()`に`recursive`引数を追加し、非再帰スキャン時はfolder_path直下のみを対象にするよう修正(`gallery_service.py`の`list_images()`から`recursive`をそのまま伝播)。既に削除済みのデータはバックアップが無く復元不可能だった旨をユーザーに伝えた。
|
||||
|
||||
- 副次的に発見したフロントエンドのバグも合わせて修正: `saveMetaField()`がサーバーのレスポンス(`{"ok": bool}`)を一切チェックせず常に成功したかのようにローカル状態を楽観的更新していたため、サーバー側で保存が拒否されてもUI上はエラーなく成功したように見えてしまう作りになっていた。レスポンスの`ok`フィールドを検証し失敗時は例外を投げるよう変更、`addTag`/`removeTag`/メモ保存ハンドラは戻り値を見て成功時のみローカル状態を更新するよう修正した。保険的措置として`save_image_meta`(バックエンド)の保存キーも`get_image_metadata`と同じ`resolve()`後のパスに統一した。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、新規Video Tabヘルプページを追加。README.mdはバージョンバッジ更新、Workflow Tab(サイドパネルツールバーの3ボタン構成)、Gallery Tab(Downloadボタンの新配置・mp4対応・GenUIボタンへの改名)、新規Video Tabセクション(3ペイン構成・optional First/Last Frame・ノードなしワークフロー対応)を追加した。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`ast.parse()`で構文確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更した全11ファイルを都度同期しmd5ハッシュ比較で完全一致を確認、Pythonファイル変更時は`__pycache__`を全クリア。Kapture(ブラウザDevTools連携)で実際のブラウザに接続し、DOM要素の`getComputedStyle()`/`getBoundingClientRect()`とスクリーンショットで3ペインレイアウトの実測検証、生成後状態はダミー画像をDOMに直接注入して左右ペインが動かないことを確認。mp4のGallery表示・再生、タグ/メモ保存とComfyUI再起動後の永続性、Workflowタブの新規ボタン導線はユーザー自身の実機で確認済み。
|
||||
|
||||
**How to apply**: サーバーAPIが常にHTTP 200を返し成否をレスポンスボディの`ok`フィールドで表す設計(このプロジェクト全体で頻出するパターン)では、呼び出し側で必ずそのフィールドをチェックする — でないと「操作はできるがエラーも出ず、実は保存されていない」というサイレント障害が発生し得る。複数のパネルを横並びに配置するレイアウトで「あるパネルの内容量に応じて他のパネルの位置がずれる」不具合が出た場合、`max-height`のような上限指定では実コンテンツが小さいときに縮んでしまい根本解決にならない — 各パネルを独立したflexカラムに分離し、パネル間で高さを共有させない構造にするのが確実。「フォルダ配下」を対象にした一括処理(今回のstale cleanup)を書く際は、その処理が使う「現在のファイル一覧」がフォルダの全階層をカバーしているか(非再帰スキャンならサブフォルダは含まれない)を必ず確認し、範囲の不一致がないか意識する。ブラウザで実際に何が起きているか切り分けが必要な場面では、コードを読むだけでなくKapture等でDOMの実測値を直接取得するのが最も確実。
|
||||
|
||||
関連: [[project_dev_deploy_sync]], [[project_v0336_conventions]]
|
||||
|
||||
---
|
||||
|
||||
## v0.4.8
|
||||
|
||||
### 機能追加: LabタブにPromptグループ一括適用+キーフレーム並べ替え + バグ修正: デフォルトワークフローの上書き保存が反映されない + Styleテーブルの「このファイルへ追加」対応
|
||||
|
||||
ユーザーからの複数の要望・不具合報告を1セッションで順に実装した一連の作業。
|
||||
|
||||
**1. LabタブPrompt列に「PG」ボタン — Promptタブのグループをキーフレームへ一括変換**: 「生成UIタブのLabのプロンプトにプロンプトタブのグループを使用できるようにしたい。'PG'ボタンを配置、モーダルでプロンプトのグループを選択、決定するとプロンプトのキーフレームが#1から自動で作成されます(#1は上書き)」との要望。`prompt-presets.js`の`pmGroups`(グループ名→プリセットID配列)を参照する`getPresetsInGroup()`を新設してexportし、`lab-tab.js`側にPresetManagerの一覧UI(`.wfm-pm-item`)を再利用したグループ選択モーダルを実装。選択→適用で`_lab.columns.prompt`をグループ内プリセット順に#1から全置換(#1は上書き、以降は#2,#3...)する。グループのプリセット数がBatch数を超える場合はBatch数を自動引き上げ(Batch入力欄自体の上限50でキャップ)、それでも収まらない分は既存の「Batchを超えたキーフレーム」警告トースト(`_warnIfKeyframesExceedBatch`)をそのまま再利用して通知する形にした。
|
||||
|
||||
**2. Lab: Model/Prompt/KSampler キーフレームの並べ替え**: 続けて「モデル、プロンプト、Ksamplerそれぞれでキーフレームの順変更を可能にしたい。キーフレームのモーダル展開をWクリックに変更しシングルクリックでキーフレームを選択、PGボタン左隣に上下ボタンを設置し操作可能にしたい」との要望。キーフレームセルのクリック挙動を分離し、シングルクリックは選択状態(列ごとに`_selectedIdx[col]`で管理、青枠でハイライト)、ダブルクリックで従来の編集モーダルを開くように変更。各列ヘッダーに▲/▼ボタンを追加し(Promptは要望通りPGボタンの左隣)、選択中のキーフレームと隣接するキーフレームの`atIteration`を入れ替えることで並び順を変更する実装にした。キーフレーム#1(ベースライン — GenerateUIのライブ値を表示する特別な行)は移動不可、かつ他のキーフレームが#1の位置に入れ替わることもないよう制約した。Prompt Group適用・Plan読込・Plan Clearなど列を丸ごと置き換える操作では選択状態を自動リセットするようにし、配列外を指す不整合を防いだ。
|
||||
|
||||
**3. バグ修正: デフォルトワークフローに設定した状態で同名上書き保存すると変更が反映されない**: 「生成UIタブで読み込んだデフォルトワークフローのモデルを変更しワークフローを同名で上書き保存した。ワークフロータブでJSONでモデルが変更していることを確認。ブラウザハード再読み込みを行うと以前のモデルが表示される」との不具合報告(スクリーンショット添付、旧モデル名がインストール済みモデルの一覧に無く「見つかりません」警告が出ている状態)。調査の結果、「デフォルトワークフローに設定」機能(`workflow-tab.js`)は設定した時点のワークフローJSON全体を`localStorage`(`wfm_settings.defaultWorkflowData`)へスナップショットとして保存し、起動時の自動読込(`generate-tab.js`)はこのスナップショットを読む仕様であることが判明。GenerateUIタブでの同名上書き保存はファイル自体は更新するが、このスナップショットまでは更新していなかったため、リロードのたびに保存前の古い内容が復活していた。`saveCurrentWorkflow()`の保存成功後処理に、保存したファイル名が現在の`defaultWorkflow`と一致する場合は`defaultWorkflowData`も同時に更新する処理を追加して修正した。
|
||||
|
||||
**4. Prompt Tab Tableビュー Styleタブに「+ Add to this file」対応**: 「プロンプトタブ、テーブルのスタイルにForm同様に既存のjsonファイルに追加する方法が欲しい(custom.json以外の場合)。該当jsonのスタイルを選択状態で+ Add to this fileボタンで追加できるようにしたい」との要望。Formタブのスタイルマネージャー(`prompt-styles.js`)には既にこの機能があったが、Tableビュー(`prompt-table.js`)の新規スタイル作成は常に`custom.json`固定(`styleApiCreate(..., null)`)だった。行選択が1件かつそのスタイルの`file`が`custom.json`以外の場合のみ有効化される「+ Add to this file」ボタンをツールバーに追加し、クリックすると保存先ファイルを保持したまま空の新規行を追加(File列に保存前からバッジ表示)、保存時に`styleApiCreate(name, pos, neg, pendingNewStyleFile)`で対象ファイルへ追記する実装にした。バックエンド(`settings_routes.py`)は元々`file`パラメータでの追記に対応済みだったため、フロントエンドの導線を追加するだけで実現できた。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、Lab subtab(Setting/Columnsカードへ選択・並べ替えとPGボタンの説明2項目)とPrompt Tab(Table Viewカードへ「+ Add to this file」の説明1項目)を追加。README.mdはGenerateUI Tab(Saveボタン説明にデフォルトワークフロースナップショット修正の注記)、Lab subtab(見出しのバージョン範囲をv0.4.8へ更新、選択/並べ替えとPGボタンの新規項目)、Prompt Tab(Styleテーブルの項目に「+ Add to this file」のTableビュー対応を追記)を更新した。
|
||||
|
||||
**検証**: 全JSファイルを`node --check`で構文確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルごとに改行コード(CRLF/LF)の一致を確認してから同期し、都度`diff`で完全一致を検証した。デフォルトワークフローのバグ修正はユーザー自身の実機(GenerateUIでの上書き保存→ハード再読み込み)で確認済み。PGボタン・キーフレーム並べ替え・Styleの「+ Add to this file」もユーザー自身の実機確認で動作を確認済み。
|
||||
|
||||
**How to apply**: 開発元リポジトリと実行時`custom_nodes`フォルダへの同期前には必ず`file`コマンドで両ファイルの改行コードが一致しているか確認する(過去に一部ファイルだけLF化していた事例があるため — [[project_dev_deploy_sync]]参照)。一致していれば単純な`cp`で安全にデプロイでき、`diff`で完全一致を最終確認する。キーフレームや行の「並べ替え」機能を実装する際、内部的にソート順を保持する配列(今回は`atIteration`昇順)に対しては、配列そのものの並べ替えではなく「隣接要素同士でソートキーの値を交換する」実装にすると、他のロジック(`_resolveKeyframe`等)が前提とする「配列は常にソート済み」という不変条件を崩さずに済む。
|
||||
|
||||
関連: [[project_dev_deploy_sync]], [[project_v0336_conventions]]
|
||||
|
||||
---
|
||||
|
||||
## v0.4.7
|
||||
|
||||
### 機能追加: Prompt Tab内部リファクタ + Gallery Promptタブ新設 + Send to Canvas改善 + Presetsソート
|
||||
|
||||
ユーザーからの複数の要望を1セッションで順に実装した一連の作業。
|
||||
|
||||
**1. graphifyでの掘り下げを起点にprompt-tab.jsを分割**: graphifyの知識グラフでコードベースを掘り下げた結果、`prompt-tab.js`(1545行)がPreset管理・AI Chat・Wildcard管理・Style管理の4つの独立したクラスタで構成されていることが判明。`prompt-presets.js`/`prompt-ai-chat.js`/`prompt-wildcards.js`/`prompt-styles.js`の4ファイルに分割し、`prompt-tab.js`自体は各モジュールを結線するだけの薄いオーケストレーターにした。`prompt-table.js`/`app.js`は無変更(`prompt-tab.js`からのre-export経由で既存のimportをそのまま維持)。
|
||||
|
||||
**2. テーブルタブのWildcard入力支援ツールバー、実は配置ミス**: 「テーブルタブのワイルドカードのファイルがtxtの場合、モーダルにフォームの入力支援ボタンを表示したい」との要望を受け実装したが、直後にユーザーから「ワイルドカード構文はワイルドカードのモーダルに追加するのは誤りでプロンプトに追加するのが正しい気がします」との指摘。Form側の既存設計(構文ボタンはWildcard Composerにのみ存在し、ファイル中身のエディタには無い — ファイルの中身は単なる候補リストという前提)に照らして誤りだったと確認し、Presetsのプロンプト編集モーダルへ移設した。ワイルドカードツールバーは`prompt-wildcards.js`の`createWildcardToolbar()`として再利用可能な形に切り出し、Form側の静的ツールバーとテーブル側の動的モーダルの両方から使えるようにした。Styleモーダルには適用しない(Form側でもWildcard ComposerとStyleの連携導線は存在しないため)。
|
||||
|
||||
**3. Style×Wildcard展開順序のバグ修正**: 「スタイルにワイルドカード構文を使用しても使用時プロンプトに挿入されるので動作しますか?」との確認依頼から調査を開始。`generate-tab.js`の`_coreGenerate`内でワイルドカード展開(`_expandWildcardsInWorkflow`)がStyle適用(`_applyStyleToWorkflow`)より*先*に実行されていたため、Style文字列内に書いた`__file__`参照が展開されないまま生テキストのままComfyUIへ送信される不具合を発見。実行順序をStyle適用→ワイルドカード展開に入れ替えて修正。実際にComfyUIで生成を実行し、`comfyUI.generate`を一時的にモンキーパッチして送信直前のペイロードを確認、Style由来の`__flower__`が`"flower, gardenia scent"`のように正しく展開されることを実証した。
|
||||
|
||||
**4. yamlワイルドカードを使うサンプルワークフロー**: 「yamlファイルのワイルドカードを使用するワークフローを作成してもらうことはできますか」との依頼。調査の結果、このプラグイン自体のワイルドカード展開(SPA側の`_expandWildcardsInWorkflow`)は`.txt`拡張子固定でyaml非対応であり、yaml形式は`ImpactWildcardEncode`/`ImpactWildcardProcessor`ノード(Impact Pack、サーバー側処理)でのみ解決できることが判明。プラグインのwildcardディレクトリはjunction経由でImpact Packの`wildcards/`フォルダへ直結しているため、通常のWildcard File Manager経由で保存したyamlファイルがそのままImpactWildcardEncodeから参照可能。サンプルyamlワイルドカードファイル(`ws_sample_season_lighting.yaml`)とImpactWildcardEncodeノードを使うサンプルワークフロー(`ws_yaml_wildcard_sample.json`)を作成し、実際にComfyUIで生成→出力PNGのメタデータで`populated_text`が階層キー参照から正しく展開されていることを確認した。
|
||||
|
||||
**5. Galleryタブ詳細パネルに「Prompt」タブ新設**: 「メタデータでも確認できるが頻度を考えるとマウス操作が多いため、プロンプトだけはこちらでも表示としたい。ボタンは右端でデフォルトで選択状態としたい」との要望。当初は単純な全文連結表示で実装したが、「メタデータタブの表示の様に複数プロンプトがあるケースも考慮したい」とのフィードバックを受け、Metadataタブの`extractPrompts`/`buildPromptItem`をexportし、完全に同じPOS/NEGバッジ付きリスト→クリックで全文表示のUIに作り直した。
|
||||
|
||||
- さらに実機確認の中で、同一画像でもMetadataタブとGalleryタブでpositive数が食い違うケース(`Krea2_turbo_00133_.png`、Metadataでは2件のPositiveが見える)を発見。原因は埋め込みデータの優先順位の違い — Metadataタブは`prompt`(API形式、サブグラフ展開済みでフラット)を優先するのに対し、Gallery側サーバーAPIは`workflow`(UI形式、ノード位置等を保持)を優先していた。トップレベルとサブグラフに独立した2系統のサンプリングを持つワークフローでは、UI形式のトップレベルnodes配列に一方の系統しか含まれず、`extractPromptsLiteGraph`の早期returnロジックがその系統しか拾えないため。既存のLoad GenUI/JSON表示/Copy & Send Canvas等には一切影響しないよう、`gallery_service.py`に優先順位を選べる`_extract_embedded_workflow`ヘルパーを追加し、新規`extract_prompt_workflow_from_metadata`(API形式優先)を`/wfm/gallery/image/workflow`のレスポンスへ`prompt_workflow`として追加、Galleryの Promptタブの抽出だけこちらを使うようにした。Python側の変更を反映するため、ユーザー自身の手でComfyUIを再起動していただいた。
|
||||
|
||||
**6. Send to Canvasのフォールバック方式を刷新**: 「このワークフローを生成UIタブに読み込むと...またキャンバスへ送るでAPI形式のためタイトルをキャンバスへドラッグのメッセージが表示されますがこの動作を変更したい...UI形式のサムネイルがないパターンのワークフローも同様にしたい」との要望。`window.opener`経由の直接ロードに失敗した場合の従来のフォールバック(`localStorage`+パネルタイトルのドラッグ、API形式のワークフローはそもそも非対応でエラーのみ)を、Workflow Studio LibraryのW(Workflows)タブを該当ワークフローで絞り込み表示する方式に統一した。`node_sets_menu.js`に`showWorkflowInLibrary()`をexportし、`top_menu_extension.js`が新規localStorageキー`wfm_library_filter_request`への`storage`イベントを監視して呼び出す(同一オリジンの別タブでのみ発火する仕様を利用)。フォーマット(UI形式・API形式)を問わず同じ導線になり、サムネイル付きの実物のワークフローカードからユーザー自身がドラッグできるようになった。Galleryタブの「Copy & Send Canvas」(画像埋め込みのその場限りのワークフローが対象で、そもそもLibrary一覧=保存済みファイルには存在しない)は今回のスコープ外として変更していない。
|
||||
|
||||
**7. Presetsテーブルに★/Bソート機能**: 「プロンプトタブのテーブル(プリセット)で上部メニューに★とBボタンによるソート機能を追加したい」との要望。ツールバーに★(favorite)/B(Batch選択済み)のトグルボタンを追加し、該当する行を先頭にソートする機能を実装。両方同時にONにでき、★を主キー・Bを副キーとした複合ソートに対応、該当しない項目同士は安定ソートで元の相対順序を維持する。ソート有効時に行内の★/Bボタンで状態を変更すると即座に再ソートされる。
|
||||
|
||||
**8. graphifyの運用を一時停止**: ユーザー指示によりCLAUDE.md内の「graphify」セクション全体をコメントアウトし、明示的な指示があるまでコード変更後の`graphify update`は実行しないことにした(`graphify-out/`自体は削除せず保持、再開時にコメントを解除するだけで元に戻せる)。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、Prompt Tab(Table Viewカードに★/Bソートボタンの新規項目)を更新。README.mdはGenerateUI Tab(Style×Wildcard展開順序修正)、Prompt Tab(Presetsモーダルのワイルドカードツールバー、★/Bソート)、Gallery Tab(新規Promptタブ)、Workflow Studio Library(Send to CanvasのWタブフィルタ方式)を更新。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`ast.parse()`で構文確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルを都度両フォルダへ同期。Playwright MCP経由で実機ブラウザ操作を機能追加・修正ごとに実施: (1) prompt-tab.js分割後のPreset/AI Chat/Wildcard/Styleパネル全機能の回帰確認、(2) Wildcardツールバー移設前後でのモーダル表示・非表示の確認、(3) Style×Wildcard展開の実生成確認、(4) yamlサンプルワークフローの実生成とメタデータ確認、(5) Gallery Promptタブの複数プロンプト表示とprompt_workflow切替の確認、(6) Send to Canvasの別タブ間storage連携の実機確認、(7) Presetsテーブルの★/Bソート・複合ソート・安定ソートの確認。テスト用に作成したデータ(一時Style、yamlワイルドカードファイル等)はテストワークフロー用途のものを除き削除・復元済み。
|
||||
|
||||
**How to apply**: 開発時は `graphify query "<question>"` を使わず通常の Grep/Read/Explore で調査する(graphify運用停止中、明示的な再開指示があるまで)。ワイルドカード構文(`{|}`, `__|__`, `<lora>`等)は「プロンプトを書く場所」に対する入力支援であり、「ファイルの中身を編集する場所」には付けない、という区別を今後の入力支援UI追加でも踏襲する。ワークフローの実行順序に依存するバグ(Style適用とワイルドカード展開の順序等)は、実際にComfyUIへ送信される直前のペイロードをモンキーパッチや`/history`のPNGメタデータで確認するのが最も確実な検証方法。
|
||||
|
||||
---
|
||||
|
||||
## v0.4.6
|
||||
|
||||
### 機能追加: MetadataタブをGalleryタブへ統合 + Promptタブに一覧編集可能なTable表示を新設
|
||||
|
||||
ユーザーからの複数の要望を1セッションで順に実装した一連の作業。
|
||||
|
||||
**1. MetadataタブをGalleryタブのサブタブへ移動**: 「Metadataタブをギャラリータブ内に移動したい。Style_Catalogタブ右隣としたい」との要望。トップナビの独立タブを廃止し、Galleryタブのサブタブ列(Output/ImagePrompt/Style_Catalog)の右端に追加。外部から画像をMetadata表示させる`loadFileIntoMetadataTab()`(`metadata-tab.js`)や、Helpタブの「Open tab →」ジャンプボタンなど、旧トップレベルタブへの参照箇所(Gallery側の「Open in Metadata Tab」ボタン、Style_Catalog側の同名ボタン)はすべて「Galleryタブへ切替→Metadataサブタブへ切替」の2段階クリックへ置き換えた。
|
||||
|
||||
**2. GenerateUIタブ: Save ボタンをBatch/Labサブタブで非表示化**: 「ワークフローのSaveボタンをBatch、Labサブタブ選択時、非表示にしたい」との要望。両サブタブは現在ロード中のワークフロー本体ではなく、ワークフローの複製やLab独自のPlanファイルを操作するため、Saveボタンが誤解を招く(クリックしても実際に触っているワークフローとは別物が保存される)ことへの対処。サブタブ切替ハンドラ(`generate-tab.js`)にワンライナーの表示切替を追加。
|
||||
|
||||
**3. Promptタブに「Table」表示を新設**: 「現在のプロンプトタブをタブ化してテーブルタブを追加したい(既存はフォームタブとする)。テーブルタブではプロンプトのプリセット、ワイルドカード、スタイルがそれぞれ表形式で一覧表示、編集可能としたい」との要望を起点に、複数回のフィードバックを経て以下の形に発展した:
|
||||
|
||||
- 既存の3カラムレイアウト(AI Assistant / Presets・Preset Manager / Wildcard・Style)をまるごと「Form」パネルとしてラップし、新設の「Table」パネルと上部のトップレベルタブで切り替える構成にした。
|
||||
- Tableパネル内にPresets・Wildcards・Styleの3つの編集可能テーブルを新設(`prompt-table.js`新規ファイル)。既存の`prompt-tab.js`が保持するAPIヘルパー(`fetchPresets`/`apiUpdatePreset`/`wcSaveFile`/`styleApiUpdate`等)とリフレッシュ関数を`export`して再利用し、Table側での編集は即座にForm側にも反映されるようにした。
|
||||
- **「Presets右隣にPresets Groupを追加したい」**: グループ管理専用の4つ目のサブタブを新設。グループの作成・リネーム・削除・メンバー一覧(バッジ表示・×で削除)に対応。
|
||||
- **「共通で左端に番号(自動)列を追加したい。個別の削除ボタンは誤操作が予想されるため上部にボタンを設置し番号列で該当番号選択、削除という手順にしたい」**: 4テーブル共通で自動採番の`#`列を新設し、番号クリックで行選択(複数可・赤ハイライト)→ツールバーの「Delete」ボタンで一括削除、という手順に統一。個別の行削除ボタンは全廃した。新規行追加時は番号セルの代わりに小さな✓(Add)/✕(Cancel)アイコンを表示する設計にした。
|
||||
- **「バッチグループの登録方法を変更したい」**: PresetsテーブルにForm側と同じ仕組みの「B」列(クリックでグレー⇔黄色にトグルしBatchグループへ登録/解除)と、Batchグループを一括クリアする「BC」ボタンをツールバー上部に追加。Presets Groupテーブルからは予約グループ「Batch」を非表示にし、Batch管理をPresetsテーブル側のB列/BCボタンへ一本化した。
|
||||
- **「PresetsのコピーボタンとグループドロップダウンをテーブルのPresetsタブに配置したい。これによりPresets GroupタブのAdd Presetsをなくしたい(プリセットが多くなった際に選択が困難なため)」**: Presets Groupテーブルの「Add Preset」列(プリセットが多いと使いづらい長大なドロップダウン)を廃止。代わりにPresetsテーブルのツールバーに「PP Copy」「NP Copy」(選択中1件のPositive/Negativeをクリップボードへコピー)と、グループ選択+「Add to Group」(選択中の複数プリセットを一括で選んだグループへ追加)を新設した。プリセット側を検索・選択してから短いグループ一覧から選ぶ流れにしたことで、プリセット数が多くても操作しやすい設計になった。
|
||||
- **「選択クリアボタンも追加したい」**: 4テーブル共通で「Deselect」ボタンをツールバーへ追加し、選択状態を一括解除できるようにした。
|
||||
- **「プリセット、ワイルドカード、スタイルで番号をWクリックでモーダルでプロンプトの表示、編集したい」**: `#`列のダブルクリックで、既存の共有モーダル基盤(`app.js`の`openModal`/`closeModal`)を再利用したプロンプト編集モーダルを開くようにした。Presets/Styleは正負プロンプト切り替えボタン+共有の大型テキストエリア(切り替えても両方の編集内容を保持)、Wildcardsはファイル内容のみのテキストエリアで、Save/Closeボタンを備える。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUI Tab(Saveボタン非表示の説明)・Prompt Tab(新規カード「Form / Table」「Table View」10項目)・Gallery Tab(Metadataサブタブの説明)・Metadata Tab(Galleryタブ内へ移動した旨の注記、`metadata-tab.js`側の独自i18n適用ロジックへ追加)を更新。README.mdはGenerateUI TabのSaveボタン説明を拡張、Prompt Tabセクションに新規ブロックを追加、「Metadata Tab」セクションをGalleryタブの4番目のサブタブとして「Metadata Gallery subtab」に改称の上、Style_Catalog Gallery subtabの直後へ移設した。
|
||||
|
||||
**検証**: 全JSファイルを`node --check`で構文確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルを都度両フォルダへ同期。Playwright MCP経由で実機ブラウザ操作を機能追加ごとに実施: (1) Gallery→Metadataサブタブの表示とHelpジャンプボタンの遷移、(2) Batch/LabサブタブでのSaveボタン非表示、(3) Presets/Wildcards/Styleテーブルの実データ表示・インライン編集の永続化・行追加・削除、(4) 番号列での選択トグルとDelete/Deselectボタンの有効化、(5) Presets GroupタブでのグループCRUD・メンバー追加削除・予約グループBatchの削除保護、(6) B列/BCボタンでのBatchグループ登録・一括クリア(既存の"pony"データが誤って変更されないことも確認)、(7) PP/NP CopyとAdd to Groupの単一選択/複数選択それぞれの動作、(8) ダブルクリックモーダルの表示・Positive/Negative切り替え時のテキスト保持・保存→サーバー反映→モーダル自動クローズ、(9) ヘルプ全項目の日本語UIでの表示、を一通り確認。テスト用に作成したデータはすべて削除・復元済み。
|
||||
|
||||
**How to apply**:
|
||||
|
||||
1. 1つの機能要望(テーブル表示の追加)が実装後の実運用フィードバックを経て、削除方式・グループ登録方式・選択解除・詳細表示方式と段階的に発展していくケースでは、各段階で「既存の確立済みパターン(今回は#列クリック選択→ツールバー操作、共有モーダル基盤)を後続の要望にも一貫して適用できないか」を優先的に検討すると、機能ごとにUIパターンがバラつかず学習コストが低いまま拡張できる。
|
||||
2. 一覧が長大になりうるデータ(プリセット)をドロップダウンで選ばせるUIは、件数が増えると事実上使えなくなる。件数が少ないことが保証されている別の軸(今回はグループ)を主語にした選択UIへ倒すと、データ量に強い設計になる。
|
||||
3. 複数テーブルにまたがる共通パターン(番号列・選択トグル・ダブルクリックモーダル)は、テーブルごとに実装をコピーするのではなく共通ヘルパー関数(`buildNumCell`/`openPositiveNegativeModal`/`openSingleTextModal`)に切り出しておくと、後続の類似要望(今回で言えば選択クリアボタン・ダブルクリックモーダル)を全テーブルへ一度に反映しやすい。
|
||||
|
||||
関連: [[project_dev_deploy_sync]], [[project_v0336_conventions]]
|
||||
|
||||
---
|
||||
|
||||
## v0.4.5
|
||||
|
||||
### 調査: トップバー保存ワークフローのRMBG不具合報告(原因なし) + バグ修正: LabタブModel列の無意味な空キーフレーム表示を解消
|
||||
|
||||
ユーザーから「トップバーで保存した2つのワークフロー(I2I_rmbg.json/I2I_rmbg_2.json)を生成UIタブで読み込んで生成するとRemove Background(RMBG)ノードが機能せず透過画像が作成されない。ComfyUIのExport(API)で保存した3つ目のファイルは正しく動く」との報告を受けて調査した一連のセッション。
|
||||
|
||||
**1. ワークフロー解析の調査**: RMBGノードは`background_color`という「ウィジェットを持たない特殊な入力(COLORCODE型、`model`より前の位置)」を持つ特徴的な構造で、UI形式→API形式変換時に`widgets_values`の対応がずれる可能性を最初に疑った。`comfyui-workflow.js`の`convertUiToApi()`を段階的に検証:
|
||||
|
||||
- `/object_info/RMBG`を実機から取得し、`_getWidgetInputNames()`が`forceInput`相当(widgetキーを持たない入力)を正しく除外し、`background_color`がウィジェット名リストから漏れることを確認。
|
||||
- ユーザー提供の`I2I_rmbg_2.json`をブラウザ内(Playwright MCP)で実際に`convertUiToApi()`にかけ、変換結果を確認 — `model`/`sensitivity`/`background`など全フィールドが正しくマッピングされていた。
|
||||
- その変換結果を実際に`/prompt`へキューイングして実行、さらにGenerateUIタブの本物のGenerateボタンをクリックする経路でも実行 — いずれも出力PNGはRGBA・アルファチャンネル0〜255の**正しい透過画像**だった。
|
||||
- 結論: 現在デプロイされているv0.4.3のワークフロー解析ロジックに該当バグは再現せず、コード変更は行わなかった。報告時点でより古いバージョンを使っていた可能性が高いと判断し、ユーザーに調査結果と再検証手順を報告した。
|
||||
|
||||
**2. Labタブ: モデルの無いワークフローで無意味なキーフレームが作成される問題**: 調査の副産物として、RMBGのみ(Checkpoint/LoRA/VAEいずれのノードも無い)ワークフローをLabタブで開くと、Model列に「1: (ワークフローの設定のまま)」という空のキーフレーム行が常に表示されてしまうことを実機で発見。ユーザーから「不要なため作成しない形にしたい」との要望を受けて修正。`_buildWorkflowForIteration()`は対象ノードIDが存在しない場合、書き込みを黙ってスキップするだけ(`write()`の`wf[nodeId]`ガード)なので、この行はRun時に何の効果も持たない死んだコントロールだった。`_hasNodeForColumn(col)`を新設し、`_renderColumn()`と`_initColumnButtons()`の+ボタンで判定 — 対応ノードが無い列は「このワークフローに該当ノードがありません」というプレースホルダーを表示し、キーフレーム追加もブロックする。VAEだけは特殊扱いで、専用VAELoaderが無くてもCheckpointノードが存在すれば内蔵VAEの上書き注入が意味を持つため(既存の`_buildWorkflowForIteration`のVAELoader自動注入ロジック)、Checkpoint存在時はVAE列も通常表示のままにした。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習に従い、`i18n.js`に新規メッセージキー`labNoNodeForColumn`を日本語/英語/中国語すべてに追加(このケースは既存キーワードで賄えないUI状態のため新設。ヘルプページ本文の更新は対象外の細部挙動のため省略)。README.mdのLab subtabセクションに「Empty state when no matching node exists (v0.4.5)」の項目を追記し、見出しのバージョン範囲を`v0.3.87 – v0.4.5`に更新した。
|
||||
|
||||
**検証**: Playwright MCP経由で実機ブラウザ操作: (1) RMBGのみのモデル無しワークフローを読み込みLabタブへ切り替え、Model列(Checkpoint/LoRA/VAEいずれのビューでも)が空プレースホルダーになることを確認、(2) Checkpoint+VAELoaderを含む通常のワークフローでは従来通りライブ値付きキーフレームが表示され回帰が無いことを確認、(3) 同じworkflow内でLoRAノードだけが無いケースではLoRAビューのみ正しく空プレースホルダーになることを確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイル(`lab-tab.js`/`i18n.js`/`lab-tab.css`)を両フォルダへ同期しdiffで完全一致を確認した。
|
||||
|
||||
**How to apply**:
|
||||
|
||||
1. 「機能しない」という不具合報告を受けた際は、コードを読んで理屈上正しそうに見えても、実機(今回はPlaywright MCP経由の実ブラウザ+実ComfyUI)で変換結果と生成画像の中身(アルファチャンネルの実値など)まで直接検証してから結論を出す。理屈の検証だけでは「今のバージョンでは再現しない」という事実に辿り着けなかった。
|
||||
2. 「機能しない」の調査中に見つかった別の実害あるバグ(今回のLabタブの死んだキーフレーム行)は、本題の調査がクローズしても見逃さず、その場でユーザーに報告し必要なら合わせて修正する。
|
||||
3. UIに「設定できるが実行時には何の効果も持たない」コントロールを残すと、ユーザーは正常に設定できていると誤認する。対象データが存在しないなら、コントロール自体を出さない(無効化ではなく非表示)のが最も誤解を生まない。
|
||||
|
||||
関連: [[project_v042_lab_model_column]], [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.4.4
|
||||
|
||||
### 機能追加: Lab/GenerateUI ModelタブへLoRAバイパス機能 + AI TOOL Chatの画像生成パラメータ拡張
|
||||
|
||||
ユーザーから「生成UIタブのLab機能のLora、ModelタブのLoraのLoraノード自体をバイパスする機能を追加したい。Lab機能ではキーフレームごとに切り替え対応可能としたい」との要望を受けて着手。同じセッション内で続けて「Lab機能のLoraにLora syntax、Trigger wordsの適用を可能にしたい」「AI TOOLタブのCHATの画像生成でチャットによる設定項目を増やしたい」という要望にも順次対応した一連のセッション。
|
||||
|
||||
**1. LoRAノードのバイパス機能**: 実装方針の分岐点(ComfyUI本来のBypass(mode:4)相当の配線迂回か、strength=0にする簡易実装か)をAskUserQuestionでユーザーに確認し、簡易実装(strength=0)を選択。理由は、Lab/GenerateUIが扱う`workflow`は既に`comfyui-workflow.js`の`convertUiToApi()`でAPI形式に変換済みで`mode`の概念自体が失われており、`mode:4`を書き込んでも実行エンジンには一切効果がないため——本物のBypass相当の配線再接続を自前実装するのは過剰投資と判断した。
|
||||
|
||||
- **GenerateUI Model タブ**: LoRA列ヘッダーの「Refresh Stack group」ボタン左に⛔トグルボタンを追加(`comfyui-editor.js`の`toggleLoraNodeBypass()`/`_loraNodeBypass`/`_loraBypassSnapshot`)。対象ノードのstrength値をゼロ化し、退避したスナップショットから復元をトグルする。Lora Managerノードの場合はLoRAリスト自体(複数エントリ)は差し替えず維持したまま各エントリのstrength/clipStrengthだけを0にする。Single/Stack Applyボタンを押すとバイパス状態は自動クリアされ、あとから無言で再適用されることはない。
|
||||
- **Lab機能**: 既存の行単位「bypassed」(キーフレーム自体を無視するスキップ機能)とは独立した、LoRA列専用の`nodeBypass`フィールドをキーフレームの値に追加。編集モーダルに「LoRAノードをバイパス(強度を0にする)」チェックボックスを新設。`_applyLabLoraToWorkflow()`で`nodeBypass`時はstrength/active/textを0/false/空に強制する。
|
||||
|
||||
**2. Lab: LoRA syntax / Trigger words のポジティブプロンプト適用**: LoRA値に`applyToPrompt`フィールドを追加。ONにすると`<lora:stem:strengthModel:strengthClip>`とCivitAIキャッシュのtrainedWordsをポジティブプロンプトへカンマ区切りで追加する(`comfyui-editor.js`のSingle Applyと同じフォーマット)。「次のキーフレームでLora変更時はクリアされた上で新たなloraで追加」という要件は、`_buildWorkflowForIteration()`に`prevLoraInjection`引数を追加し、**前回のイテレーションが正確に注入した文字列だけ**を次回ビルド時に文字列一致で取り除いてから新規注入を追加する設計で実現した——正規表現による汎用パターン除去ではなく、自分が直前に足した分だけを覚えて消す方式なので、ユーザーが手で書いた他のプロンプト内容を誤って消す心配がない。metadata/civitaiCacheはRun開始時に一度だけフェッチしてキャッシュする。
|
||||
|
||||
**3. AI TOOL Chat: generate_imageツールのパラメータ拡張**: AskUserQuestionで追加したい項目をユーザーに確認し、KSampler系(steps/cfg/sampler_name/scheduler/denoise/seed)・画像サイズ(width/height)・生成枚数(batch_size)を選択。`comfyui-editor.js`に`setGenerationParams()`を新設し、Lab機能の`_buildWorkflowForIteration`と同じ`sampler_nodes`のstepsNodeId/cfgNodeId等のノードID間接参照パターンを再利用することで、Advanced Samplingワークフロー(Flux.1/2、SD3.5等でKSamplerが複数ノードに分割される構成)にも対応した。seedは`window._wfmGenerateTab.generate()`(実体は`handleGenerate`)へのオプション引数として渡すよう、`generate-tab.js`の`handleGenerate`/`_coreGenerate`にgenOptions引数を追加(既存の呼び出し元との後方互換は維持)。batch_size>1時は複数画像がチャットバブルに表示されるよう`appendChatBubble`に`imageUrls`(複数URL)対応を追加した。実機のOllama(ornith-1.5:9b)でツール呼び出しから画像生成までEnd-to-Endで検証し、指定した各パラメータが正しく反映されることを確認した。
|
||||
|
||||
**4. Resolution Selectorノード対応**: ユーザーから実際のワークフロー画像(aspect_ratio COMBO・megapixels・multipleウィジェットを持つ「Resolution Selector」ノード)を見せられ、「3:2などで指定可能か」と問われて調査。`/object_info/ResolutionSelector`で確認したところComfyUI本体組み込みノード(`comfy_extras.nodes_resolution`)で、aspect_ratioは`"3:2 (Photo)"`のような固定ラベル付きCOMBO値と判明した。当初のwidth/height直接指定実装ではこの手のノードに非対応だったため、追加で以下を実装:
|
||||
|
||||
- `comfyui-workflow.js`の`analyzeWorkflow()`に`resolution_selector_nodes`カテゴリを新設。
|
||||
- `comfyui-editor.js`に`resolveAspectRatioOption(nodeType, requested)`を新設。LLMが渡す短い`"3:2"`形式を、`/object_info`から取得した実際のCOMBO選択肢と前方一致でマッチさせ`"3:2 (Photo)"`のような完全な文字列に解決する(未知の比率はそのままフォールバックし、ComfyUI側の検証エラーに委ねる)。
|
||||
- `setGenerationParams()`にResolution Selector優先ロジックを追加:aspect_ratio指定時はEmptyLatentImageへのwidth/height直接書き込みをスキップし、ResolutionSelectorノードのaspect_ratioだけを書き換える——EmptyLatentImageのwidth/heightがResolutionSelectorの出力へのリンクになっている場合、直接数値を書き込むとそのリンクを上書きしてしまい、意図した比率指定が無視されてしまうため。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、`helpLab16`/`helpLab17`(Lab LoRAバイパス・プロンプト適用)、`helpGen29`(GenerateUI Model LoRAバイパスボタン)を新規追加し、`helpAi3`(AI TOOL Chatの説明)を拡張パラメータの説明を含めて更新した。README.mdのGenerateUI Tab/Lab subtab/AI TOOL Tabの各セクションも同内容で更新した。
|
||||
|
||||
**検証**: 全JSファイルを`node --check`で構文確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルのみ両フォルダへ同期しハッシュ一致を確認。Kapture MCP経由で実機ブラウザ操作: (1) GenerateUI ModelタブのLoRAバイパストグルでLora Managerノードのloras配列のstrength/clipStrengthが0⇔元の値へ正しく切り替わることを確認、(2) LabタブのLoRAキーフレームモーダルで両チェックボックスが表示・保存され、セル表示にラベルが反映されることを確認、(3) 実際にLabをRunし`/history`APIから最終ワークフローの`CLIPTextEncodeEditPlus.text_edit`を直接確認してLoRA構文+トリガーワードが正しく追加されていることを確認、(4) Ollama(ornith-1.5:9b)へChatで生成パラメータ付きの依頼を送りgenerate_imageのtool_callsが正しい引数を返し、実際に指定通りのwidth/height/seed/batch_sizeで2枚の画像が生成されることをネットワークログと生成結果の両方で確認、(5) `resolveAspectRatioOption`/`setGenerationParams`をモックworkflow/analysisで単体テストし、ResolutionSelectorノードのaspect_ratioが正しく書き換わりEmptyLatentImageのリンクは維持されることを確認。ヘルプページも実機で3箇所とも日本語表示が正しく反映されていることを確認した。
|
||||
|
||||
**How to apply**:
|
||||
|
||||
1. 「ノードをバイパスする」という要望を受けたら、まずそのワークフローが実行時にどのフォーマット(UI形式 mode:4対応/API形式 mode概念なし)で保持・実行されているかを確認する。今回のケースのように既にAPI形式へ変換済みの場合、本物のBypass(mode:4)相当の配線再接続を自前実装するのは過剰投資になりやすく、「効果が等価な簡易実装(strength=0)」で要件を満たせないか先にユーザーに確認するとよい。
|
||||
2. LLMのfunction calling引数にCOMBO値(enum文字列)を渡させる場合、ワークフロー解析結果には「ノードの現在値」しか含まれず「取りうる全選択肢」は含まれないことが多い。`/object_info`をその場でフェッチして選択肢を取得し、LLMの短い入力("3:2")を実際の完全な文字列("3:2 (Photo)")へ解決するワンクッションを挟むと、ノードごとに異なるラベル文言に振り回されずに済む。
|
||||
3. 「切り替えたらクリアしてから新しいものを追加する」という要求は、正規表現による汎用パターン除去よりも「前回自分が何を注入したかを正確に覚えておいて、その文字列だけを取り除く」設計の方が、ユーザーが手で書いた他のテキストを誤って消すリスクが低く安全。
|
||||
|
||||
関連: [[project_v042_lab_model_column]], [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.4.3
|
||||
|
||||
### バグ修正: 共有モーダルのお気に入り☆/リネームがLabモーダルへ残留 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
|
||||
|
||||
v0.4.2リリース直後、ユーザーからLabタブに関する2件のフィードバックを受けて修正した一連のセッション。
|
||||
|
||||
**1. 共有モーダルのお気に入り☆・リネームがLabモーダルに残留するバグ**: ユーザーから「Labタブモーダルについて確認したい。各モーダルでお気に入り☆、名前変更が可能なようですがこれは機能していますか?」との指摘を受けて調査。Workflow Studioの全「詳細モーダル」(Workflow/Models/Nodesタブの詳細表示、Labのキーフレーム編集)は`#wfm-modal-overlay`というDOMを1つだけ共有しており、共通の`openModal()`(`app.js`)はタイトルの**テキストだけ**を書き換え、呼び出し元が動的に注入したヘッダー要素(お気に入り☆ボタン、タイトルクリックでリネームするイベントリスナー)は一切クリアしていなかった。そのため「Workflowタブでワークフローの詳細モーダルを開いた直後にLabのキーフレームモーダルを開く」と、直前のワークフロー用の☆ボタンとリネームリスナーがそのまま残留表示され、Lab側で☆をクリックすると**Labのキーフレームとは無関係に、直前に見ていたワークフローのお気に入り状態が実際に切り替わってしまう**ことをPlaywright MCPで実機再現・立証した。`openModal()`にクリーンアップ処理(`.wfm-fav-btn`の除去、タイトル要素を`cloneNode`で複製・差し替えて前の呼び出し元のクリックリスナー/属性を一掃、リネーム用隠し入力欄のリセット)を追加して修正。副次効果として、Workflow詳細モーダルを連続で複数回開いた際にタイトルのクリックリスナーが際限なく累積していく別の潜在バグ(`workflow-tab.js`側にはリネーム用clickリスナーの重複防止ガードが無かった)も同時に解消した。
|
||||
|
||||
**2. Lab Model列の+/-が独立していなかった問題**: v0.4.2で実装したModel列(Checkpoint/LoRA/VAEを1つの`{checkpoint, lora, vae}`マージ行として1つのキーフレーム配列にまとめる設計)には設計上の欠陥があった。ユーザーから「checkpoint/lora/vaeいずれかで+で追加すると他のモデルも追加されてしまいます。逆に-で他の2つも削除される」との指摘を受け、根本的な設計見直しを実施。マージ行方式ではなく、**Checkpoint・LoRA・VAEを(Prompt・KSamplerと全く同じ)完全に独立したキーフレーム配列に戻し**、C/L/Vトグルは「今どの配列を表示・操作するか」を切り替えるルーティングとしてのみ機能する設計に変更した。この結果、v0.4.2で新設した`_resolveModelKeyframe()`(3フィールドを1行にバンドルしたまま個別に前方向解決するカスタムロジック)が丸ごと不要になり、既存の汎用`_resolveKeyframe()`(Checkpoint/VAE列が元々使っていたもの)にLoRAを1つ追加するだけで済むようになった——継承機能・ハイライト・inheritedヒント表示など、v0.4.2で追加した機能はすべてこのシンプルな独立配列方式のままで自然に成立する。
|
||||
|
||||
**設計上の教訓**: 複数の独立した設定を1つのUI列にまとめる際、「見た目を1列にする」ことと「データを1行にマージする」ことは別の問題であり、後者を選ぶと今回のような「+/-が意図せず道連れになる」バグを生みやすい。データモデルは独立させたまま、UI層(表示するフィールドの選択・共有される+/-ボタンのルーティング)だけを1列に見せかける設計の方が、機能追加・バグ修正のコストが低く、直感的な挙動にもなりやすい。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習に従い、Lab関連ヘルプ(`helpLab14`・`helpLab15`、英/日/中3言語+`templates/index.html`の英語フォールバック)を独立配列方式の挙動に合わせて更新(特に`helpLab15`の「Revert to #1's settingは3項目まとめて戻る」という記述はもう正しくないため修正)。README.mdのLabセクションも同内容に更新した。
|
||||
|
||||
**検証**: 全JSファイルを`node --check`で構文確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルのみ両フォルダへ同期しハッシュ一致を確認。Playwright MCP経由で実機ブラウザ操作: (1) Workflow詳細モーダル→Labモーダルの順で開き、☆・リネームツールチップ・cursorスタイルが一切残留しないことを確認、(2) 「C」表示での+/-がCheckpointのみに作用しLoRA/VAEは不変であること、「L」表示での+/-がLoRAのみに作用しCheckpoint/VAEは不変であることをPlan JSONタブのデータ構造で確認、(3) v0.4.2形式(マージ行)のPlan JSONを読み込ませ、新しい独立配列形式へ正しくマイグレーションされることを確認。実際の画像生成を伴うRun実行は割愛。
|
||||
|
||||
**How to apply**:
|
||||
|
||||
1. DOM/モーダルを複数の呼び出し元で共有する設計では、各呼び出し元が「自分固有のUI要素」を動的に注入する場合、その呼び出し元自身が毎回クリーンアップするのではなく、**共有側の入り口関数(今回は`openModal()`)で毎回リセットする**設計にしないと、呼び出し順序に依存した残留バグ・誤操作バグを生む。cloneNode+replaceWithは、addEventListenerの参照を保持していなくてもリスナーを確実に一掃できる簡便な手段。
|
||||
2. 複数の独立した設定を1つのUI要素(列・パネル等)にまとめる要望を実装する際、「データも1つにまとめる」のではなく「データは独立させたままUI層だけ共有する」設計を最初から検討する。今回はユーザーの2度のフィードバック(継承挙動の誤り→+/-の誤り)を経てようやくこの設計にたどり着いたが、根本原因は同じ「フィールドを1行にバンドルした」という初期設計判断にあった。
|
||||
|
||||
---
|
||||
|
||||
## v0.4.2
|
||||
|
||||
### 機能追加: Lab「Model」列統合(Checkpoint / LoRA Single / VAE)+ フィールド単位継承 + C/L/V表示切替
|
||||
@@ -5027,8 +5517,7 @@ SPA および Workflow Studio Library サイドパネルの両方に AI タブ
|
||||
**`applyTextareaFontSize(size)` 追加(export)**
|
||||
|
||||
- `<style id="wfm-ta-font-size-style">` を `<head>` に注入
|
||||
- 対象セレクタ(対象を 8 箇所に一括適用):
|
||||
| 対象 | ID |
|
||||
- 対象セレクタ(対象を 8 箇所に一括適用):| 対象 | ID |
|
||||
| ---------------------------------------- | -------------------------- |
|
||||
| Generate UI — Positive Prompt | `#wfm-prompt-pos-text` |
|
||||
| Generate UI — Negative Prompt | `#wfm-prompt-neg-text` |
|
||||
@@ -6312,6 +6801,7 @@ Feeder 専用スタイルを末尾に追加:
|
||||
|
||||
- **型バッジの削除:** ThumbView・CardView・SidePanelの `renderSideInfo` から `typeBadge = badgeHtml(typeLabel)` を削除。ユーザー定義バッジのみ表示
|
||||
- **`GENUI_TYPE_MAP` 追加:**
|
||||
|
||||
```javascript
|
||||
const GENUI_TYPE_MAP = {
|
||||
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
|
||||
@@ -6325,6 +6815,7 @@ Feeder 専用スタイルを末尾に追加:
|
||||
|
||||
hypernetwork / embedding は対象外
|
||||
- **`applyToGenUI(modelName, modelType)`** 追加:
|
||||
|
||||
- `comfyUI.currentWorkflow` からターゲット `inputKey` を持つノードを検索
|
||||
- `node.inputs[inputKey] = modelName` で直接セット
|
||||
- `<select id="wfm-model-${key}">` の表示値も同期
|
||||
|
||||
@@ -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,262 @@
|
||||
# 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
|
||||
|
||||
**⚡ 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)
|
||||
|
||||
### 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
|
||||
|
||||
### 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, 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, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI 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, 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.5.7)
|
||||
Dedicated video generation UI, independent of GenerateUI, Plan / Edit subtabs, Base settings row, Asset / Project subtabs, 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, 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); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[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
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -22,7 +22,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,29 +30,25 @@ 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 | |
|
||||
| :--------------------------------: | :-: |
|
||||
|  | |
|
||||
| Lab Tab | Lab Index Image |
|
||||
| :--------------------------------: | :------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,6 +65,7 @@ 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
|
||||
@@ -89,7 +86,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 +96,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,6 +113,9 @@ 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
|
||||
|
||||
</details>
|
||||
@@ -146,17 +149,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 +171,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 +182,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 +199,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 +224,17 @@ 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
|
||||
- **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, 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. 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 +244,37 @@ 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`)
|
||||
- **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 tabs — **Info** / **JSON** / **Groups** / **Prompt**
|
||||
- **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
|
||||
- **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 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 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
|
||||
- **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 +303,23 @@ 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 (center) | LoRA + Prompt (right)
|
||||
- **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`)
|
||||
- **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/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 +369,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 +382,47 @@ 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.5.7)</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 / Edit subtabs** (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); Edit is reserved for future video-editing tools
|
||||
- **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)
|
||||
- **Asset / Project subtabs** (sidebar, v0.5.5) — **Asset** browses generated videos 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); selecting a video shows it in the center panel's left preview and lets you tag/memo it or jump straight to the full Gallery tab. **Project** lists every saved plan file (thumbnail, block count, last updated) — click one to load it into the Plan editor, or delete it with the × button; either sidebar panel collapses back down when its own tab is clicked again
|
||||
- **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. 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)
|
||||
- **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 +434,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
|
||||
@@ -390,6 +446,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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
|
||||
- **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
|
||||
@@ -407,7 +464,8 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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`, and `Lora Loader (LoraManager)` 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`
|
||||
- **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
|
||||
@@ -415,7 +473,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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
|
||||
@@ -508,6 +566,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **[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`
|
||||
- **[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
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
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 |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -71,6 +71,7 @@ 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"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
|
||||
@@ -88,6 +88,10 @@ 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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -163,9 +167,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 +185,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 +210,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)
|
||||
|
||||
@@ -399,7 +415,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 +584,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)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Settings API routes."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -24,6 +27,7 @@ _DATA_FILES = [
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
"tagger_settings.json",
|
||||
"civitai_cache.json",
|
||||
]
|
||||
|
||||
|
||||
@@ -37,6 +41,8 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
app.router.add_post("/api/wfm/settings/import", handle_import)
|
||||
app.router.add_get("/api/wfm/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)
|
||||
@@ -248,6 +254,152 @@ 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)
|
||||
|
||||
|
||||
def _build_full_backup_zip(include_workflows: bool = False, include_wildcard: 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")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _apply_full_backup_zip(zip_bytes: bytes) -> 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."""
|
||||
summary = {"extracted": [], "skipped": []}
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
base = DATA_DIR.resolve()
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
if info.filename.startswith("_external/"):
|
||||
summary["skipped"].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."""
|
||||
try:
|
||||
include_workflows = request.rel_url.query.get("include_workflows") == "1"
|
||||
include_wildcard = request.rel_url.query.get("include_wildcard") == "1"
|
||||
zip_bytes = await asyncio.to_thread(_build_full_backup_zip, include_workflows, include_wildcard)
|
||||
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)
|
||||
zip_bytes = await field.read(decode=False)
|
||||
summary = await asyncio.to_thread(_apply_full_backup_zip, zip_bytes)
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Video tab API routes: last-frame capture, animated GIF conversion, and
|
||||
Plan (batch timeline) file management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import VIDEO_PLAN_DIR
|
||||
from ..services.video_service import VideoService
|
||||
from ..services.video_plan_service import VideoPlanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = VideoService()
|
||||
_plan_service = VideoPlanService(VIDEO_PLAN_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/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)
|
||||
|
||||
|
||||
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_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_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)
|
||||
@@ -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
|
||||
|
||||
@@ -16,8 +16,34 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
|
||||
# サポートする画像拡張子(.mp4は動画だがGalleryでは静止画と同じ一覧・配信経路を共有する。
|
||||
# .psdはブラウザが直接レンダリングできないため、配信時に合成済みPNGへ変換する)
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".mp4", ".psd"}
|
||||
|
||||
# Canvas2D の globalCompositeOperation 文字列 -> psd_tools.constants.BlendMode 名
|
||||
# (Image Edit タブの Layer.blendMode はCanvas2Dの合成モード文字列をそのまま保持している)
|
||||
_PSD_BLEND_MODE_MAP = {
|
||||
"source-over": "NORMAL",
|
||||
"multiply": "MULTIPLY",
|
||||
"screen": "SCREEN",
|
||||
"overlay": "OVERLAY",
|
||||
"darken": "DARKEN",
|
||||
"lighten": "LIGHTEN",
|
||||
"color-dodge": "COLOR_DODGE",
|
||||
"color-burn": "COLOR_BURN",
|
||||
"hard-light": "HARD_LIGHT",
|
||||
"soft-light": "SOFT_LIGHT",
|
||||
"difference": "DIFFERENCE",
|
||||
"exclusion": "EXCLUSION",
|
||||
"hue": "HUE",
|
||||
"saturation": "SATURATION",
|
||||
"color": "COLOR",
|
||||
"luminosity": "LUMINOSITY",
|
||||
}
|
||||
# 逆引き: psd_tools.constants.BlendMode 名 -> Canvas2D 文字列(PSDインポート用)。
|
||||
# CSSに対応物が無いPSD側のブレンドモード(Dissolve, Linear Burn/Dodge, Vivid/Linear/Pin Light,
|
||||
# Hard Mix, Subtract, Divide, Darker/Lighter Color, Pass Through)は "source-over" にフォールバックする。
|
||||
_CSS_BLEND_MODE_REVERSE = {v: k for k, v in _PSD_BLEND_MODE_MAP.items()}
|
||||
|
||||
# data URL の MIME タイプ -> 拡張子(save_image_to_gallery / save_image_to_folder共用)
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
@@ -160,8 +186,14 @@ class GalleryService:
|
||||
self._folder_cache = _FolderCache()
|
||||
self._bg_cancel = threading.Event()
|
||||
self._bg_cancel.set() # 初期状態: キャンセル済み
|
||||
# ponyxlWildcardsVault形式フォールバック用キャッシュ: category_root文字列 -> (yamlシグネチャ, {leaf: tags})
|
||||
self._vault_leaf_cache: dict[str, tuple[tuple, dict]] = {}
|
||||
# ponyxlWildcardsVault形式フォールバック用キャッシュ: category_root文字列 -> (キャッシュ時刻, {leaf: tags})
|
||||
self._vault_leaf_cache: dict[str, tuple[float, dict]] = {}
|
||||
# 画像の親フォルダ文字列 -> (キャッシュ時刻, category_root or None)。
|
||||
# _find_vault_category_root は祖先ディレクトリを最大8階層×2回stat()するため、
|
||||
# 検索フィルタで全画像に対して毎回呼ぶと(特にVaultでない通常フォルダでも
|
||||
# 無駄に)遅くなる。同一フォルダ内の画像は結果が同じなのでフォルダ単位で
|
||||
# キャッシュする。
|
||||
self._vault_root_cache: dict[str, tuple[float, Path | None]] = {}
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
@@ -225,13 +257,14 @@ class GalleryService:
|
||||
batch[abs_path] = {"prompt_cache": prompt_cache}
|
||||
processed += 1
|
||||
if len(batch) >= 10:
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
# 1件ずつsave()を呼ぶとバッチ内の枚数分だけJSONシリアライズ+
|
||||
# ファイルI/Oが発生してしまう(大量画像フォルダのメタデータでは
|
||||
# 1回あたり数百ms〜になりうる)ため、まとめて1回で保存する。
|
||||
self.metadata_store.save_batch(batch)
|
||||
batch = {}
|
||||
time.sleep(0.05) # 50ms 他の処理に譲る
|
||||
if batch and not cancel.is_set():
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
self.metadata_store.save_batch(batch)
|
||||
if processed:
|
||||
logger.debug("BG index done: %s (%d indexed)", folder_path, processed)
|
||||
except Exception as e:
|
||||
@@ -312,15 +345,22 @@ class GalleryService:
|
||||
try:
|
||||
with os.scandir(folder) as it:
|
||||
for entry in it:
|
||||
if not entry.is_file(follow_symlinks=True):
|
||||
# follow_symlinks=True forces stat() to make an extra syscall per file
|
||||
# to resolve the link target even for plain files. Output folders don't
|
||||
# contain symlinked images, so False is safe and much cheaper.
|
||||
if not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=True)
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
# entry.path is already absolute+resolved because callers always
|
||||
# pass in an already-resolved `folder`. Re-resolving it here via
|
||||
# Path(entry.path).resolve() costs an extra filesystem round-trip
|
||||
# per file — on a 9786-file folder that alone was 6.7s vs 0.07s.
|
||||
entries.append((
|
||||
entry.name,
|
||||
str(Path(entry.path).resolve()).replace("\\", "/"),
|
||||
entry.path.replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
@@ -333,30 +373,45 @@ class GalleryService:
|
||||
return entries
|
||||
|
||||
def _scan_folder_recursive(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
|
||||
"""フォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
|
||||
ImagePrompt/Style Catalogギャラリーのように深い階層に画像が置かれるケース用。
|
||||
キャッシュはしない(フォルダ数が少なく、都度スキャンしても軽い想定)。
|
||||
os.walk()はDirEntryを介さずPath.stat()(follow_symlinks=True相当)を呼ぶため、
|
||||
_scan_folder()と同じ理由でフォルダ内ファイル数に対して極端に遅くなる。
|
||||
os.scandir()を自前で再帰し、follow_symlinks=Falseでstatする。
|
||||
"""
|
||||
entries = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for name in files:
|
||||
if Path(name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
full = Path(root) / name
|
||||
try:
|
||||
st = full.stat()
|
||||
entries.append((
|
||||
name,
|
||||
str(full.resolve()).replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
def _walk(d):
|
||||
try:
|
||||
with os.scandir(d) as it:
|
||||
dir_entries = list(it)
|
||||
except (PermissionError, OSError):
|
||||
return
|
||||
for entry in dir_entries:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if not entry.name.startswith("."):
|
||||
_walk(entry.path)
|
||||
continue
|
||||
if not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
# entry.path is already absolute since the top-level `folder` passed
|
||||
# in by callers is always pre-resolved — see _scan_folder() for why
|
||||
# re-resolving per file here would be costly.
|
||||
entries.append((
|
||||
entry.name,
|
||||
entry.path.replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
_walk(folder)
|
||||
return entries
|
||||
|
||||
def list_images(
|
||||
@@ -385,9 +440,16 @@ class GalleryService:
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder_recursive(folder) if recursive else self._scan_folder(folder)
|
||||
|
||||
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
self.metadata_store.cleanup_stale_images(str(folder), existing_paths)
|
||||
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)。
|
||||
# recursive=False(非再帰スキャン)の場合、existing_pathsにはfolder直下の
|
||||
# ファイルしか含まれないため、cleanup側にもrecursive=Falseを伝えて対象範囲を
|
||||
# 直下のみに限定する(でないとサブフォルダ内ファイルのメタデータを誤削除する)。
|
||||
# 検索文字列入力中はキー入力のたびに呼ばれるため、ここではスキップする
|
||||
# (全メタデータ走査+該当時はJSON全体のディスク書き込みを伴い重く、検索の
|
||||
# 度に繰り返す必要はない。通常の検索なしロード時に実行されれば十分)。
|
||||
if not search:
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
self.metadata_store.cleanup_stale_images(str(folder), existing_paths, recursive=recursive)
|
||||
|
||||
results = []
|
||||
for name, abs_path, size, mtime in raw_entries:
|
||||
@@ -511,32 +573,45 @@ class GalleryService:
|
||||
|
||||
def _find_vault_category_root(self, image_path: Path) -> Path | None:
|
||||
"""image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ
|
||||
(=ponyxlWildcardsVaultのカテゴリルート) を探す"""
|
||||
(=ponyxlWildcardsVaultのカテゴリルート) を探す。
|
||||
同一フォルダ内の画像は結果が同じなので、フォルダ単位でTTLキャッシュする。"""
|
||||
parent_key = str(image_path.parent)
|
||||
cached = self._vault_root_cache.get(parent_key)
|
||||
if cached is not None and time.monotonic() - cached[0] <= _CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
cur = image_path.parent
|
||||
result: Path | None = None
|
||||
for _ in range(_VAULT_ROOT_SEARCH_DEPTH):
|
||||
if any((cur / name).is_dir() for name in _VAULT_THUMB_DIR_NAMES):
|
||||
return cur
|
||||
result = cur
|
||||
break
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
return None
|
||||
|
||||
self._vault_root_cache[parent_key] = (time.monotonic(), result)
|
||||
return result
|
||||
|
||||
def _load_vault_leaves(self, category_root: Path) -> dict[str, str]:
|
||||
yaml_files = sorted(category_root.glob("*.yaml")) + sorted(category_root.glob("*.yml"))
|
||||
if not yaml_files:
|
||||
return {}
|
||||
try:
|
||||
signature = tuple(sorted((str(f), f.stat().st_mtime) for f in yaml_files))
|
||||
except OSError:
|
||||
signature = ()
|
||||
"""category_root直下のyamlをパースしたleaf辞書をTTLキャッシュする。
|
||||
以前はキャッシュの妥当性確認のため毎回 category_root.glob("*.yaml") を実行して
|
||||
いたが、category_root は「thumbnails(_option2)という名前のフォルダを直下に持つ
|
||||
祖先」というゆるい条件で決まるため、Vaultとは無関係な通常の画像出力フォルダ
|
||||
(例: 直下にComfyUIが作る空のthumbnailsキャッシュフォルダがあるだけ)が誤検出
|
||||
されることがある。そのフォルダに数千〜数万枚のファイルがあると、glob自体が
|
||||
1回数十ms〜になり、検索フィルタで画像ごとに呼ばれると数秒単位まで積み上がって
|
||||
いた(実際に発生した不具合: 1万枚超のルート直下に誤検出されたフォルダがあり、
|
||||
53枚のサブフォルダを検索するだけで約3秒かかっていた)。TTL内はglobを省略する。"""
|
||||
cache_key = str(category_root)
|
||||
cached = self._vault_leaf_cache.get(cache_key)
|
||||
if cached is not None and cached[0] == signature:
|
||||
if cached is not None and time.monotonic() - cached[0] <= _CACHE_TTL:
|
||||
return cached[1]
|
||||
yaml_files = sorted(category_root.glob("*.yaml")) + sorted(category_root.glob("*.yml"))
|
||||
leaves: dict[str, str] = {}
|
||||
for yf in yaml_files:
|
||||
leaves.update(_parse_vault_yaml_leaves(yf))
|
||||
self._vault_leaf_cache[cache_key] = (signature, leaves)
|
||||
self._vault_leaf_cache[cache_key] = (time.monotonic(), leaves)
|
||||
return leaves
|
||||
|
||||
def _read_vault_prompt(self, image_path: Path) -> str | None:
|
||||
@@ -584,7 +659,10 @@ class GalleryService:
|
||||
embedded = self._read_png_metadata(path)
|
||||
elif ext in {".jpg", ".jpeg"}:
|
||||
embedded = self._read_jpeg_metadata(path)
|
||||
elif ext == ".mp4":
|
||||
embedded = self._read_mp4_metadata(path)
|
||||
|
||||
dims = self._read_media_dimensions(path, ext)
|
||||
saved = self.metadata_store.get(str(path))
|
||||
|
||||
# workflow キーを除く文字列フィールドをプロンプトキャッシュとして保存(検索用)
|
||||
@@ -603,6 +681,9 @@ class GalleryService:
|
||||
"mtime": stat.st_mtime,
|
||||
"ext": ext,
|
||||
"embedded": embedded,
|
||||
"width": dims["width"],
|
||||
"height": dims["height"],
|
||||
"duration": dims["duration"],
|
||||
"favorite": saved.get("favorite", False),
|
||||
"tags": saved.get("tags", []),
|
||||
"memo": saved.get("memo", ""),
|
||||
@@ -682,62 +763,129 @@ class GalleryService:
|
||||
logger.debug("JPEG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
|
||||
"""ワークフローを抽出する。
|
||||
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
|
||||
def _read_mp4_metadata(self, path: Path) -> dict:
|
||||
"""MP4コンテナのformat-levelメタデータタグ(workflow/prompt等)をPyAVで抽出する。
|
||||
ComfyUIのSaveVideoはPNGのtEXtチャンクと同じキー名(workflow=UI形式, prompt=API形式)で
|
||||
moov/udta/meta(mdta)にJSON文字列を書き込む。"""
|
||||
result = {}
|
||||
try:
|
||||
import av
|
||||
with av.open(str(path)) as container:
|
||||
for k, v in (container.metadata or {}).items():
|
||||
result[k] = v
|
||||
except Exception as e:
|
||||
logger.debug("MP4 metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_media_dimensions(self, path: Path, ext: str) -> dict:
|
||||
"""画像は幅高さ、動画は幅高さ+再生時間(秒)を返す。取得できない項目はNone。"""
|
||||
info: dict = {"width": None, "height": None, "duration": None}
|
||||
try:
|
||||
if ext == ".mp4":
|
||||
import av
|
||||
with av.open(str(path)) as container:
|
||||
if container.streams.video:
|
||||
stream = container.streams.video[0]
|
||||
info["width"] = stream.width
|
||||
info["height"] = stream.height
|
||||
if container.duration:
|
||||
info["duration"] = container.duration / 1_000_000
|
||||
elif ext == ".psd":
|
||||
from psd_tools import PSDImage
|
||||
psd = PSDImage.open(path)
|
||||
info["width"], info["height"] = psd.width, psd.height
|
||||
elif ext != ".svg":
|
||||
from PIL import Image
|
||||
with Image.open(path) as img:
|
||||
info["width"], info["height"] = img.size
|
||||
except Exception as e:
|
||||
logger.debug("dimension read error for %s: %s", path, e)
|
||||
return info
|
||||
|
||||
def _extract_embedded_workflow(self, image_path: str, prefer: str = "workflow") -> dict | None:
|
||||
"""埋め込みワークフローを抽出する(PNG/MP4の[workflow]/[prompt]の優先順位を選べる)。
|
||||
|
||||
prefer="workflow": UI形式(ノード位置・グループ等を保持)優先。GenerateUIへの
|
||||
ロードやJSON表示などエディタ復元が必要な用途向け。
|
||||
prefer="prompt": API形式(サブグラフ展開済みでフラット)優先。トップレベルと
|
||||
サブグラフに独立した複数系統を持つワークフロー(例: Krea2)ではUI形式の
|
||||
extractPromptsLiteGraphがトップレベル系統しか拾えないことがあるため、
|
||||
プロンプト抽出専用にAPI形式を優先する(Metadataタブのプロンプト抽出と同じ優先順位)。
|
||||
"""
|
||||
path = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(path):
|
||||
return None
|
||||
|
||||
if path.suffix.lower() == ".png":
|
||||
ext = path.suffix.lower()
|
||||
embedded = None
|
||||
if ext == ".png":
|
||||
embedded = self._read_png_metadata(path)
|
||||
# 1. workflow キー (ComfyUI UI形式)
|
||||
for key in ("workflow", "Workflow"):
|
||||
s = embedded.get(key)
|
||||
if s:
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 2. prompt キー (ComfyUI API形式 — 大多数の生成画像)
|
||||
for key in ("prompt", "Prompt"):
|
||||
s = embedded.get(key)
|
||||
if s:
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif ext == ".mp4":
|
||||
embedded = self._read_mp4_metadata(path)
|
||||
|
||||
# 3. gallery_metadata.json に保存されたworkflow
|
||||
if embedded:
|
||||
key_groups = (("workflow", "Workflow"), ("prompt", "Prompt"))
|
||||
if prefer == "prompt":
|
||||
key_groups = tuple(reversed(key_groups))
|
||||
for keys in key_groups:
|
||||
for key in keys:
|
||||
s = embedded.get(key)
|
||||
if s:
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# gallery_metadata.json に保存されたworkflow
|
||||
saved = self.metadata_store.get(str(path))
|
||||
return saved.get("workflow") or None
|
||||
|
||||
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
|
||||
"""ワークフローを抽出する。
|
||||
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
|
||||
"""
|
||||
return self._extract_embedded_workflow(image_path, prefer="workflow")
|
||||
|
||||
def extract_prompt_workflow_from_metadata(self, image_path: str) -> dict | None:
|
||||
"""プロンプト抽出専用: PNG[prompt] > PNG[workflow] > gallery_metadata.json[workflow]"""
|
||||
return self._extract_embedded_workflow(image_path, prefer="prompt")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# メタデータ保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def save_image_meta(self, image_path: str, data: dict) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
logger.warning("save_image_meta: path not allowed: %s", resolved)
|
||||
return False
|
||||
return self.metadata_store.save(image_path, data)
|
||||
# get_image_metadata() 側は resolve() 済みパスをキーにして取得するため、
|
||||
# ここでも resolve() 後の文字列で統一して保存する(キー不一致による
|
||||
# 「保存はできるが表示されない」というサイレントな食い違いを防ぐ)。
|
||||
return self.metadata_store.save(str(resolved), data)
|
||||
|
||||
def toggle_favorite(self, image_path: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
# get_image_metadata()/list_images() 側はresolve()済みパスをキーにして取得する
|
||||
# ため、ここでも統一する(save_image_metaと同じ理由。キー不一致による
|
||||
# 「保存はできるが表示されない」というサイレントな食い違いを防ぐ)。
|
||||
resolved_str = str(resolved)
|
||||
meta = self.metadata_store.get(resolved_str)
|
||||
new_val = not meta.get("favorite", False)
|
||||
self.metadata_store.save(image_path, {"favorite": new_val})
|
||||
self.metadata_store.save(resolved_str, {"favorite": new_val})
|
||||
return new_val
|
||||
|
||||
def bulk_set_favorite(self, paths: list[str], value: bool) -> dict:
|
||||
"""複数画像のお気に入りを一括設定する"""
|
||||
ok = fail = 0
|
||||
for p in paths:
|
||||
if not self._check_path_allowed(Path(p).resolve()):
|
||||
resolved = Path(p).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
fail += 1
|
||||
continue
|
||||
if self.metadata_store.save(p, {"favorite": value}):
|
||||
if self.metadata_store.save(str(resolved), {"favorite": value}):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
@@ -771,21 +919,28 @@ class GalleryService:
|
||||
return self.metadata_store.delete_group(name)
|
||||
|
||||
def add_to_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
# get_image_metadata()/list_images() 側はresolve()済みパスをキーにして取得する
|
||||
# ため、ここでも統一する(save_image_metaと同じ理由。キー不一致による
|
||||
# 「グループに追加はできるが一覧に反映されない」というサイレントな食い違いを防ぐ)。
|
||||
resolved_str = str(resolved)
|
||||
meta = self.metadata_store.get(resolved_str)
|
||||
groups = meta.get("groups", [])
|
||||
if group_name not in groups:
|
||||
groups.append(group_name)
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
return self.metadata_store.save(resolved_str, {"groups": groups})
|
||||
return True
|
||||
|
||||
def remove_from_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
resolved_str = str(resolved)
|
||||
meta = self.metadata_store.get(resolved_str)
|
||||
groups = [g for g in meta.get("groups", []) if g != group_name]
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
return self.metadata_store.save(resolved_str, {"groups": groups})
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
all_paths = self.metadata_store.list_images_in_group(group_name)
|
||||
@@ -906,8 +1061,215 @@ class GalleryService:
|
||||
errors.append(f"{p.name}: {e}")
|
||||
return {"moved": moved, "errors": errors}
|
||||
|
||||
def build_psd_from_images(self, paths: list) -> bytes:
|
||||
"""複数画像を各1レイヤーとして1つのPSDにまとめ、バイナリを返す。
|
||||
画像サイズが異なる場合はキャンバスを最大サイズに合わせ、各レイヤーは左上(0,0)基準で配置する。
|
||||
"""
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
from psd_tools.api.layers import PixelLayer
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
layers_data = []
|
||||
for img_path in paths:
|
||||
p = Path(img_path).resolve()
|
||||
if not p.is_file() or p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
if p.suffix.lower() == ".mp4":
|
||||
continue # 動画はPSDレイヤーにできない
|
||||
if not self._check_path_allowed(p):
|
||||
continue
|
||||
try:
|
||||
im = Image.open(p).convert("RGBA")
|
||||
except Exception as e:
|
||||
logger.warning("build_psd_from_images: failed to open %s: %s", p, e)
|
||||
continue
|
||||
layers_data.append((p.stem, im))
|
||||
|
||||
if not layers_data:
|
||||
raise ValueError("No valid images to export")
|
||||
|
||||
max_w = max(im.width for _, im in layers_data)
|
||||
max_h = max(im.height for _, im in layers_data)
|
||||
|
||||
base = Image.new("RGBA", (max_w, max_h), (0, 0, 0, 0))
|
||||
psd = PSDImage.frompil(base)
|
||||
for name, im in layers_data:
|
||||
PixelLayer.frompil(im, psd, name=name, top=0, left=0)
|
||||
|
||||
buf = io.BytesIO()
|
||||
psd.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def build_psd_from_layers(self, width: int, height: int, layers: list) -> bytes:
|
||||
"""Image Edit タブのレイヤー配列(dataURL+メタデータ)から1つのPSDを組み立てる。
|
||||
各レイヤー画像は既にフロントエンド側で回転・反転・拡縮をベイクしたキャンバス全体サイズの
|
||||
PNGとして渡される前提のため位置は常に(0,0)。opacity/blendModeはPSDレイヤー属性として渡す。
|
||||
"""
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
from psd_tools.api.layers import PixelLayer
|
||||
from psd_tools.constants import BlendMode
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
from PIL import Image
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
|
||||
if not layers:
|
||||
raise ValueError("No layers to export")
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Invalid canvas size")
|
||||
|
||||
base = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
psd = PSDImage.frompil(base)
|
||||
|
||||
added = 0
|
||||
for layer_data in layers:
|
||||
data_url = layer_data.get("imageData", "")
|
||||
m = re.match(r"^data:image/\w+;base64,(.+)$", data_url)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
im = Image.open(io.BytesIO(base64.b64decode(m.group(1)))).convert("RGBA")
|
||||
except Exception as e:
|
||||
logger.warning("build_psd_from_layers: failed to decode layer image: %s", e)
|
||||
continue
|
||||
|
||||
name = str(layer_data.get("name") or "Layer")[:255]
|
||||
opacity = int(max(0.0, min(1.0, float(layer_data.get("opacity", 1.0)))) * 255)
|
||||
visible = bool(layer_data.get("visible", True))
|
||||
blend_key = _PSD_BLEND_MODE_MAP.get(layer_data.get("blendMode", "source-over"), "NORMAL")
|
||||
blend_mode = getattr(BlendMode, blend_key)
|
||||
|
||||
pixel_layer = PixelLayer.frompil(im, psd, name=name, top=0, left=0)
|
||||
pixel_layer.opacity = opacity
|
||||
pixel_layer.blend_mode = blend_mode
|
||||
pixel_layer.visible = visible
|
||||
added += 1
|
||||
|
||||
if added == 0:
|
||||
raise ValueError("No valid layer images to export")
|
||||
|
||||
buf = io.BytesIO()
|
||||
psd.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def _extract_psd_layers(self, psd) -> dict:
|
||||
"""PSDImageインスタンスからレイヤー配列(dataURL+メタデータ)を抽出する共通ロジック。
|
||||
グループ自体・ラスタ化できないレイヤー(調整レイヤー等)はスキップする。
|
||||
戻り値のlayersはフロントエンドのLayer配列規約(先頭=最前面)に合わせて並べる。
|
||||
"""
|
||||
from psd_tools.constants import BlendMode
|
||||
import base64
|
||||
import io
|
||||
|
||||
layers_out = []
|
||||
for layer in psd.descendants():
|
||||
if layer.is_group():
|
||||
continue
|
||||
try:
|
||||
im = layer.topil()
|
||||
except Exception as e:
|
||||
logger.warning("_extract_psd_layers: failed to rasterize layer %r: %s", layer.name, e)
|
||||
continue
|
||||
if im is None:
|
||||
continue
|
||||
im = im.convert("RGBA")
|
||||
|
||||
left, top, right, bottom = layer.bbox
|
||||
w, h = right - left, bottom - top
|
||||
if w <= 0 or h <= 0:
|
||||
continue
|
||||
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, format="PNG")
|
||||
data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
blend_value = layer.blend_mode
|
||||
try:
|
||||
blend_name = BlendMode(blend_value.value if hasattr(blend_value, "value") else blend_value).name
|
||||
except ValueError:
|
||||
blend_name = "NORMAL"
|
||||
css_blend = _CSS_BLEND_MODE_REVERSE.get(blend_name, "source-over")
|
||||
|
||||
layers_out.append({
|
||||
"name": layer.name or "Layer",
|
||||
"x": left,
|
||||
"y": top,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"opacity": (layer.opacity if layer.opacity is not None else 255) / 255.0,
|
||||
"visible": bool(layer.visible),
|
||||
"blendMode": css_blend,
|
||||
"imageData": data_url,
|
||||
})
|
||||
|
||||
if not layers_out:
|
||||
raise ValueError("No importable layers found in this PSD")
|
||||
|
||||
# descendants()は背面→前面(下から上)の順で返るため、
|
||||
# フロントエンドのLayer配列規約(先頭=最前面)に合わせて反転する
|
||||
layers_out.reverse()
|
||||
|
||||
return {"width": psd.width, "height": psd.height, "layers": layers_out}
|
||||
|
||||
def import_psd_layers(self, psd_bytes: bytes) -> dict:
|
||||
"""アップロードされたPSDファイル(バイト列)をレイヤー配列(dataURL+メタデータ)に分解する。"""
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
import io
|
||||
|
||||
try:
|
||||
psd = PSDImage.open(io.BytesIO(psd_bytes))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to read PSD file: {e}") from e
|
||||
|
||||
return self._extract_psd_layers(psd)
|
||||
|
||||
def get_psd_layers_from_path(self, image_path: str) -> dict:
|
||||
"""サーバー上に既にあるPSDファイル(Gallery上のパス)をレイヤー配列に分解する。
|
||||
Galleryから直接Image Editへ送る際、アップロードの往復無しで済ませるために使う。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file() or p.suffix.lower() != ".psd":
|
||||
raise ValueError("Not a PSD file")
|
||||
if not self._check_path_allowed(p):
|
||||
raise ValueError("Access denied")
|
||||
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
|
||||
try:
|
||||
psd = PSDImage.open(p)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to read PSD file: {e}") from e
|
||||
|
||||
return self._extract_psd_layers(psd)
|
||||
|
||||
def serve_image(self, image_path: str):
|
||||
"""画像のPathオブジェクトを返す(ルートで使用)"""
|
||||
"""画像のPathオブジェクトを返す(ルートで使用)。
|
||||
PSDはブラウザが直接レンダリングできないため、合成済みPNGにキャッシュ変換して返す。"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return None
|
||||
@@ -916,13 +1278,42 @@ class GalleryService:
|
||||
if not self._check_path_allowed(p):
|
||||
logger.warning("serve_image: path not allowed: %s", p)
|
||||
return None
|
||||
if p.suffix.lower() == ".psd":
|
||||
return self._get_psd_composite_cache(p)
|
||||
return p
|
||||
|
||||
def _get_psd_composite_cache(self, p: Path) -> Path | None:
|
||||
"""PSDの全レイヤー合成結果をフル解像度PNGとしてディスクキャッシュし、そのPathを返す。"""
|
||||
try:
|
||||
mtime = int(p.stat().st_mtime * 1000)
|
||||
except OSError:
|
||||
return None
|
||||
cache_key = hashlib.md5(f"{p}:{mtime}:full".encode()).hexdigest()
|
||||
cache_dir = self.data_dir / "thumb_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
out_path = cache_dir / f"{cache_key}_psdfull.png"
|
||||
if out_path.exists():
|
||||
return out_path
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
psd = PSDImage.open(p)
|
||||
img = psd.composite()
|
||||
if img is None:
|
||||
return None
|
||||
img.convert("RGBA").save(out_path, "PNG")
|
||||
return out_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_image: psd composite failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
def serve_thumbnail(self, image_path: str, width: int = 256) -> Path | None:
|
||||
"""縮小サムネイルのPathを返す。
|
||||
ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。
|
||||
GIFはアニメーション保持のため元ファイルをそのまま返す。
|
||||
Pillowが使えない場合は元ファイルにフォールバック。
|
||||
MP4はPillowで開けないためPyAVで先頭フレームを抽出しJPEGとしてキャッシュする
|
||||
(元ファイルへのフォールバックはできないため、失敗時はNoneを返す)。
|
||||
PSDはpsd_toolsでレイヤーを合成してからJPEGとしてキャッシュする(同様にフォールバック不可)。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
@@ -955,6 +1346,42 @@ class GalleryService:
|
||||
if thumb_path.exists():
|
||||
return thumb_path
|
||||
|
||||
# MP4は動画のためPillowで開けない。PyAV(av)で先頭フレームを抽出しJPEGとしてキャッシュする。
|
||||
# avが未導入/デコード失敗の場合、元mp4ファイルは<img>的な経路に渡せないため
|
||||
# (GIF/SVGと違い元ファイルへフォールバックできない) Noneを返しプレースホルダー表示に委ねる。
|
||||
if p.suffix.lower() == ".mp4":
|
||||
try:
|
||||
import av
|
||||
from PIL import Image
|
||||
with av.open(str(p)) as container:
|
||||
stream = container.streams.video[0]
|
||||
frame = next(container.decode(stream))
|
||||
img = frame.to_image().convert("RGB")
|
||||
img.thumbnail((width, width), Image.LANCZOS)
|
||||
img.save(thumb_path, "JPEG", quality=85, optimize=True)
|
||||
return thumb_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_thumbnail: mp4 frame extraction failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
# PSDはPillowで直接開けない(内部のマージ画像がPhotoshop側の実装依存で信頼できない)ため
|
||||
# psd_toolsでレイヤーを正しく合成してからサムネイル化する。
|
||||
if p.suffix.lower() == ".psd":
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
from PIL import Image
|
||||
psd = PSDImage.open(p)
|
||||
img = psd.composite()
|
||||
if img is None:
|
||||
return None
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((width, width), Image.LANCZOS)
|
||||
img.save(thumb_path, "JPEG", quality=85, optimize=True)
|
||||
return thumb_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_thumbnail: psd composite failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(p) as img:
|
||||
|
||||
@@ -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,6 +5,8 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
@@ -185,19 +187,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 +239,107 @@ 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:
|
||||
@staticmethod
|
||||
def _unsloth_api_key() -> Optional[str]:
|
||||
"""UNSLOTH_API_KEY を環境変数(.env)から取得。Unslothはローカルでも常に必須。"""
|
||||
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")
|
||||
if backend == "ollama":
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
|
||||
headers = {}
|
||||
if backend == "unsloth":
|
||||
key = self._unsloth_api_key()
|
||||
if not key:
|
||||
logger.error("vlm_models: UNSLOTH_API_KEY is not set")
|
||||
return []
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/v1/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
return sorted(m["id"] for m in data.get("data", []))
|
||||
except Exception as e:
|
||||
logger.error("ollama_models: %s", e)
|
||||
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 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 urllib.request.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 not key:
|
||||
return {"error": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI."}
|
||||
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 urllib.request.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)
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── ファイルメタデータ書込 ────────────────────────────────
|
||||
@@ -334,8 +418,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 +455,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,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,127 @@
|
||||
"""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
|
||||
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
|
||||
|
||||
|
||||
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 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)}
|
||||
@@ -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
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
@@ -96,5 +115,6 @@ class WorkflowStudio:
|
||||
gmic_routes.setup_routes(app)
|
||||
skill_routes.setup_routes(app)
|
||||
lab_routes.setup_routes(app)
|
||||
video_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.6.2"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
|
||||
@@ -22,3 +22,14 @@ 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -5018,15 +5273,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,535 @@
|
||||
/* =============================================
|
||||
Video Tab (MiniMax H3 / LTX-2.5 / Wan2.2対応・GenerateUIタブとは独立)
|
||||
3ペイン構成: 左=Plan/Asset/Editサブタブ / 中央=プレビュー+Video Source / 右=プロパティ
|
||||
中央・右ペインは左のサブタブ切替と無関係に常時表示される。
|
||||
各ペインは独立したカラムとして高さ100%を占め、内部でスクロールする。
|
||||
中央ペインのコンテンツ量(生成結果の有無・動画の縦横比)が変わっても
|
||||
左右のペインの位置が動かないよう、意図的に高さを共有させない構造にしている。
|
||||
============================================= */
|
||||
|
||||
#wfm-tab-video {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-video-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 左ペイン: フォーム ── */
|
||||
.wfm-video-form-panel {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-form-panel label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-wf-name {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Plan/Asset/Edit サブタブナビ(Lab/Galleryと同型) ── */
|
||||
.wfm-video-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
margin: -16px -16px 12px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.wfm-video-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-video-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-video-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
/* 中央ペイン用のタブナビ: プレビュー行の"下"に来るため、サイドバー版と違って
|
||||
上マージンは潰さない(プレビュー行と重ならないようにする)。左右のみ潰して
|
||||
center-panelのpadding分フルブリードにする。 */
|
||||
.wfm-video-center-subtab-nav {
|
||||
margin: 0 -16px 12px;
|
||||
}
|
||||
|
||||
.wfm-video-subtab-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Plan: タイムライン(各ブロックのdurationに応じてflex-growで幅配分) ── */
|
||||
.wfm-video-timeline-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-timeline-track {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-timeline-block {
|
||||
flex-basis: 0;
|
||||
min-width: 24px;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.3);
|
||||
transition: outline 0.1s;
|
||||
}
|
||||
|
||||
.wfm-video-timeline-block:last-child { border-right: none; }
|
||||
.wfm-video-timeline-block.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-timeline-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Plan: 中央下部に配置されるコンテンツ全体(Planサブタブ選択時のみ表示) ── */
|
||||
.wfm-video-plan-topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 現在ロード中のワークフロー名 + Planに記録されたワークフローのOpen Workflow行を
|
||||
同じ1行にまとめる(以前は縦に2行だった)。 */
|
||||
.wfm-video-wf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-video-wf-row .wfm-lab-plan-workflow-row { margin-top: 0; }
|
||||
|
||||
.wfm-video-plan-basesettings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 150px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Plan: 2行目の追加設定(Width/Height/Turbo Mode/Strength/Steps/Prompt Enhance)。
|
||||
basesettingsと同じ見た目だが、ワークフローによって対応する項目が変わる可変集合の
|
||||
ため別クラスに分けている ── */
|
||||
.wfm-video-plan-extrasettings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 130px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wfm-video-plan-basesettings label,
|
||||
.wfm-video-plan-extrasettings label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Noise Seed欄: 数値入力とRandomizeチェックボックスを1セル内で横並びにする */
|
||||
.wfm-video-seed-inline {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-video-seed-inline .wfm-input { flex: 1; min-width: 0; }
|
||||
|
||||
.wfm-video-seed-randomize-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Turbo Mode / Prompt Enhance欄: ラベル行の下にチェックボックスを1つだけ置く
|
||||
(他のnumber/select欄と縦位置を揃えるため、input要素の高さを合わせる) */
|
||||
.wfm-video-checkbox-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* タイムラインの下: ブロックエディタ(左、横並びの3カラム) + Run/Planタブ(右) */
|
||||
.wfm-video-plan-main-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Plan: ブロックエディタ(選択中ブロックのFirst/Last Image・Prompt。Durationはtopbarの
|
||||
basesettings行に移動済み。中央の広いエリアに配置されるため横並びの3カラムで表示する) ── */
|
||||
.wfm-video-block-editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wfm-video-block-col label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-block-first-col,
|
||||
.wfm-video-block-last-col {
|
||||
width: 190px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-video-block-prompt-col {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-video-block-prompt-col textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.wfm-video-block-color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-color-swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
/* ── Plan: Run/Planタブ(実行系・プラン管理系を切替表示するコンパクトなウィジェット) ── */
|
||||
.wfm-video-runplan-panel {
|
||||
width: 260px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.wfm-video-runplan-tabnav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-runplan-tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 4px;
|
||||
cursor: pointer;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-video-runplan-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-video-runplan-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Asset: 素材グリッド・詳細パネル ── */
|
||||
.wfm-video-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-video-asset-card {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
transition: outline 0.1s;
|
||||
}
|
||||
|
||||
.wfm-video-asset-card.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-asset-card-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
}
|
||||
|
||||
.wfm-video-asset-card-name {
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-asset-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-detail label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-asset-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-asset-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--wfm-bg-secondary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-tag button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── 中央ペイン: プレビュー + 編集 ── */
|
||||
.wfm-video-center-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
/* 3カラム構成: 左=Video Source選択UI(固定幅) / 中央=Asset選択・Sourceドロップ兼用
|
||||
プレビュー / 右=生成結果専用プレビュー。.wfm-video-preview-frameは共通クラスなので
|
||||
両プレビュー枠とも自動的にflex:1で均等幅になる。 */
|
||||
.wfm-video-preview-row {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wfm-video-preview-frame {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wfm-video-preview-el {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-source-panel {
|
||||
width: 220px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-video-pane-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-prop-hint {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
/* 将来の動画編集機能(トリミング等)用のプレースホルダー領域 */
|
||||
.wfm-video-edit-panel {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 右ペイン: プロパティ (Frame抽出 / GIF変換タブ) ── */
|
||||
.wfm-video-properties-panel {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-video-prop-tabnav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-prop-tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 4px;
|
||||
cursor: pointer;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-video-prop-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-video-prop-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 左ペイン Project サブタブ: 保存済みPlan一覧 ── */
|
||||
.wfm-video-project-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-video-project-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-video-project-item:hover { border-color: var(--wfm-primary); }
|
||||
.wfm-video-project-item.active { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-project-item-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
}
|
||||
|
||||
.wfm-video-project-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-meta {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-delete {
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-delete:hover { color: var(--wfm-danger); }
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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;
|
||||
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,7 @@
|
||||
|
||||
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 { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
@@ -456,6 +456,20 @@ 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 || "";
|
||||
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 +562,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 +659,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 +684,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 +1002,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 +1099,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,10 @@ 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 galleryBulkDelete = document.getElementById("wfm-gallery-bulk-delete");
|
||||
if (galleryBulkDelete) galleryBulkDelete.textContent = t("galleryBulkDelete");
|
||||
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
|
||||
@@ -229,6 +261,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 +323,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");
|
||||
@@ -455,6 +487,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 +515,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 +536,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 +552,7 @@ 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-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 +572,14 @@ 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-card-styleprompt-title": "helpGalleryCardStyleprompt",
|
||||
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
|
||||
"wfm-help-tagger-title": "helpTaggerTitle",
|
||||
@@ -577,7 +627,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 +635,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 +738,13 @@ 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",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -768,6 +827,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 +835,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 +863,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 +881,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initModelsTab();
|
||||
initGenerateTab();
|
||||
initPromptTab();
|
||||
initPromptTableTab();
|
||||
initMetadataTab();
|
||||
initGalleryTab();
|
||||
initAiTab();
|
||||
initTaggerTab();
|
||||
imageEditTab.init();
|
||||
initVideoTab();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -1355,6 +1446,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;">
|
||||
@@ -1411,6 +1508,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 +2089,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");
|
||||
|
||||
@@ -75,6 +75,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 +118,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 +147,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;
|
||||
}
|
||||
@@ -614,6 +616,18 @@ export const comfyWorkflow = {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
// 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;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
@@ -886,6 +900,7 @@ export const comfyWorkflow = {
|
||||
prompt_nodes: [],
|
||||
sampler_nodes: [],
|
||||
latent_nodes: [],
|
||||
resolution_selector_nodes: [],
|
||||
lora_nodes: [],
|
||||
vae_nodes: [],
|
||||
save_nodes: [],
|
||||
@@ -961,6 +976,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 +1060,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
|
||||
@@ -1335,6 +1365,19 @@ 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 —
|
||||
@@ -1349,6 +1392,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 +1576,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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { loadFileIntoMetadataTab } from "./metadata-tab.js";
|
||||
import { loadFileIntoMetadataTab, extractPrompts, buildPromptItem } from "./metadata-tab.js";
|
||||
import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { escapeHtml, setupSearchClearBtn, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
@@ -14,6 +14,7 @@ import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { callLLM, loadAiSettings, isValidBackendUrl } from "./ai-tab.js";
|
||||
import { initImagePromptTab, activateImagePromptTab } from "./image-prompt-tab.js";
|
||||
import { initStyleCatalogTab, activateStyleCatalogTab } from "./style-catalog-tab.js";
|
||||
import { applyStoredVideoVolume } from "./settings-tab.js";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,9 +43,27 @@ const API = {
|
||||
imagesDelete: `/wfm/gallery/images/delete`,
|
||||
imagesMove: `/wfm/gallery/images/move`,
|
||||
imagesExportZip: `/wfm/gallery/images/export-zip`,
|
||||
imagesExportPsd: `/wfm/gallery/images/export-psd`,
|
||||
};
|
||||
|
||||
export const FEEDER_GROUP = "__Feeder__";
|
||||
export const VIDEO_GROUP = "__Video Assets__";
|
||||
// Video Planを実行するたびに生成された動画が自動的に追加される作業用グループ
|
||||
// (Plan保存の有無に関わらず常にここへ入る)。__Video Assets__(ユーザーが手動で
|
||||
// キュレーションする素材グループ)への整理はユーザー任意のタイミングで行う。
|
||||
export const VTEMP_GROUP = "__Video Temp__";
|
||||
|
||||
const _RESERVED_GROUPS = [FEEDER_GROUP, VIDEO_GROUP, VTEMP_GROUP];
|
||||
function _isReservedGroup(name) {
|
||||
return _RESERVED_GROUPS.includes(name);
|
||||
}
|
||||
|
||||
// 拡張子から動画ファイル(mp4)かどうかを判定する。
|
||||
// 一覧アイテムは path/filename、詳細メタ(get_image_metadata)は ext フィールドを持つため両対応。
|
||||
function isVideoFile(img) {
|
||||
const source = img?.ext || img?.filename || img?.path || "";
|
||||
return source.toLowerCase().endsWith(".mp4");
|
||||
}
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Generate UIのImage入力スロットへ直接セットする(I2I連携)。
|
||||
// Comic Creater側の「I2Iへ送る」ボタンから
|
||||
@@ -214,6 +233,9 @@ window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, work
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
let _scrollObserver = null;
|
||||
let _loadImagesAbortController = null;
|
||||
let _searchDebounceTimer = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// ── 状態 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -253,6 +275,13 @@ function formatDate(mtime) {
|
||||
return new Date(mtime * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const total = Math.round(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
async function openImageInMetadataTab(img) {
|
||||
try {
|
||||
const res = await fetch(API.serveImage(img.path));
|
||||
@@ -308,6 +337,30 @@ async function exportSelectedImagesToZip(paths) {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSelectedImagesToPsd(paths) {
|
||||
if (paths.length === 0) return;
|
||||
try {
|
||||
const res = await fetch(API.imagesExportPsd, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ paths }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `gallery_export_${Date.now()}.psd`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t("psdExportCompleted"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
function _getExpandedPaths() {
|
||||
@@ -453,6 +506,16 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
|
||||
// root フォルダはデフォルトで展開状態にする(フォルダスキャン高速化により
|
||||
// サブフォルダの内容もすぐ確認できるようになったため、折りたたみ初期状態だと
|
||||
// 気付きにくい)
|
||||
if (isRoot) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
@@ -463,6 +526,16 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
|
||||
// 前回のリクエストがまだ処理中なら中断する。中断せず並行させると、後から
|
||||
// 返ってきた古いレスポンス(例: クリア直前に打っていた検索文字列の結果)が
|
||||
// 最新の表示を上書きしてしまい、「クリアしても検索結果のまま/読み込み中の
|
||||
// まま」に見える不具合の原因になっていた。
|
||||
if (_loadImagesAbortController) {
|
||||
_loadImagesAbortController.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
_loadImagesAbortController = controller;
|
||||
|
||||
const grid = document.getElementById("wfm-gallery-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
@@ -474,18 +547,29 @@ async function loadImages() {
|
||||
if (state.favoriteOnly) params.favorite = "true";
|
||||
if (state.tagFilter) params.tag = state.tagFilter;
|
||||
|
||||
// グループフィルタはサーバーサイドで処理
|
||||
if (state.groupFilter) params.group = state.groupFilter;
|
||||
// グループフィルタはサーバーサイドで処理。グループのメンバーは現在のフォルダの
|
||||
// サブフォルダに散らばっている可能性がある(例: 動画は"video"サブフォルダ配下)ため、
|
||||
// 通常のフォルダ単位ブラウズ(非再帰)とは異なり、フィルタ時はフォルダ配下を再帰的に
|
||||
// 検索する。
|
||||
if (state.groupFilter) {
|
||||
params.group = state.groupFilter;
|
||||
params.recursive = "true";
|
||||
}
|
||||
|
||||
try {
|
||||
const images = (await apiFetch(API.images(params))).images || [];
|
||||
const images = (await apiFetch(API.images(params), { signal: controller.signal })).images || [];
|
||||
state.images = images;
|
||||
state.lastSelectionIndex = -1;
|
||||
document.getElementById("wfm-gallery-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
updateTagFilter(state.images);
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") return;
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
} finally {
|
||||
if (_loadImagesAbortController === controller) {
|
||||
_loadImagesAbortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +662,14 @@ function createThumbCard(img) {
|
||||
};
|
||||
card.appendChild(imgEl);
|
||||
|
||||
// 動画ファイルは再生アイコンバッジを重ねて表示(サムネイル自体は先頭フレームのJPEG)
|
||||
if (isVideoFile(img)) {
|
||||
const playBadge = document.createElement("div");
|
||||
playBadge.className = "wfm-gallery-thumb-video-badge";
|
||||
playBadge.textContent = "▶";
|
||||
card.appendChild(playBadge);
|
||||
}
|
||||
|
||||
// お気に入りトグルボタン(カード右上)
|
||||
const favBtn = document.createElement("button");
|
||||
favBtn.className = `wfm-gallery-thumb-fav-btn${img.favorite ? " active" : ""}`;
|
||||
@@ -877,18 +969,20 @@ async function loadImageDetail(img) {
|
||||
if (moveBtn) moveBtn.disabled = false;
|
||||
if (delBtn) delBtn.disabled = false;
|
||||
|
||||
// プレビュー
|
||||
// プレビュー(mp4は<video controls>、それ以外は<img>)
|
||||
const preview = document.getElementById("wfm-gallery-detail-preview");
|
||||
const isVideo = isVideoFile(img);
|
||||
const mediaHtml = isVideo
|
||||
? `<video src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" controls title="Double-click to enlarge"></video>`
|
||||
: `<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${escapeHtml(img.filename)}" title="Double-click to enlarge">`;
|
||||
preview.innerHTML = `
|
||||
<div class="wfm-gallery-preview-wrapper">
|
||||
<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${escapeHtml(img.filename)}" title="Double-click to enlarge">
|
||||
<div class="wfm-gallery-preview-overlay">
|
||||
<button class="wfm-gallery-download-btn" title="Download image">⬇</button>
|
||||
</div>
|
||||
${mediaHtml}
|
||||
</div>
|
||||
`;
|
||||
preview.querySelector("img").addEventListener("dblclick", () => openLightbox(img));
|
||||
preview.querySelector(".wfm-gallery-download-btn").addEventListener("click", () => downloadImage(img));
|
||||
const previewMediaEl = preview.querySelector(isVideo ? "video" : "img");
|
||||
previewMediaEl.addEventListener("dblclick", () => openLightbox(img));
|
||||
if (isVideo) applyStoredVideoVolume(previewMediaEl);
|
||||
|
||||
// ファイル名
|
||||
document.getElementById("wfm-gallery-detail-filename").textContent = img.filename;
|
||||
@@ -914,16 +1008,32 @@ async function loadImageDetail(img) {
|
||||
renderWorkflowJson(state.embeddedWorkflow);
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(metaRes.image_prompt);
|
||||
renderDimensionInfo(metaRes);
|
||||
// Promptタブは prompt_workflow (API形式優先) を使う。トップレベルとサブグラフに
|
||||
// 独立した複数系統を持つワークフローでは workflow (UI形式) からの抽出だと
|
||||
// トップレベル系統しか拾えないことがあるため(Metadataタブと同じ優先順位に揃える)。
|
||||
renderPromptTab(wfRes.prompt_workflow ?? state.embeddedWorkflow);
|
||||
} catch (e) {
|
||||
renderWorkflowJson(null);
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(null);
|
||||
renderDimensionInfo(null);
|
||||
renderPromptTab(null);
|
||||
}
|
||||
|
||||
// グループタブ更新
|
||||
renderDetailGroup(img);
|
||||
}
|
||||
|
||||
function renderDimensionInfo(metaRes) {
|
||||
const el = document.getElementById("wfm-gallery-info-dim");
|
||||
if (!el) return;
|
||||
const parts = [];
|
||||
if (metaRes?.width && metaRes?.height) parts.push(`${metaRes.width}×${metaRes.height}`);
|
||||
if (metaRes?.duration) parts.push(formatDuration(metaRes.duration));
|
||||
el.textContent = parts.length ? parts.join(" · ") : "";
|
||||
}
|
||||
|
||||
function renderImagePromptSection(imagePrompt) {
|
||||
const section = document.getElementById("wfm-gallery-image-prompt-section");
|
||||
const text = document.getElementById("wfm-gallery-image-prompt-text");
|
||||
@@ -964,6 +1074,44 @@ function renderWorkflowJson(workflow) {
|
||||
}
|
||||
}
|
||||
|
||||
// 埋め込みワークフローからpositive/negativeプロンプトを抽出してPromptタブに表示する。
|
||||
// 複数候補があるケース(PromptStyler等で正負が複数生成される場合)に対応するため、
|
||||
// Metadataタブと同じ POS/NEGリスト → クリックで全文表示のUI(buildPromptItem)をそのまま
|
||||
// 再利用する。選択画像を切り替えるたびにマウス操作無しで読めるよう、先頭項目を自動選択する。
|
||||
function renderPromptTab(workflow) {
|
||||
const listEl = document.getElementById("wfm-gallery-prompt-list");
|
||||
const fullArea = document.getElementById("wfm-gallery-prompt-full");
|
||||
const fullLabel = document.getElementById("wfm-gallery-prompt-full-label");
|
||||
if (!listEl || !fullArea || !fullLabel) return;
|
||||
|
||||
listEl.innerHTML = "";
|
||||
fullArea.value = "";
|
||||
fullLabel.textContent = "";
|
||||
|
||||
if (!workflow) {
|
||||
listEl.innerHTML = `<div class="wfm-meta-item" style="opacity:0.6;">No workflow embedded in this image.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const { positives, negatives, texts } = extractPrompts(workflow);
|
||||
const allPrompts = [
|
||||
...positives.map(p => ({ type: "positive", text: p })),
|
||||
...negatives.map(p => ({ type: "negative", text: p })),
|
||||
...(texts ?? []).map(p => ({ type: "text", text: p })),
|
||||
];
|
||||
|
||||
if (allPrompts.length === 0) {
|
||||
listEl.innerHTML = `<div class="wfm-meta-item" style="opacity:0.6;">No prompt found.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { type, text } of allPrompts) {
|
||||
listEl.appendChild(buildPromptItem(text, type, text, fullArea, fullLabel, listEl));
|
||||
}
|
||||
// Auto-select first item (positive prompts come first in allPrompts)
|
||||
listEl.querySelector(".wfm-meta-item-clickable")?.click();
|
||||
}
|
||||
|
||||
// ── タグ操作 ─────────────────────────────────────────────────
|
||||
|
||||
async function addTag(tag) {
|
||||
@@ -971,7 +1119,8 @@ async function addTag(tag) {
|
||||
const tags = [...(state.selectedImage.tags || [])];
|
||||
if (tags.includes(tag.trim())) return;
|
||||
tags.push(tag.trim());
|
||||
await saveMetaField({ tags });
|
||||
const ok = await saveMetaField({ tags });
|
||||
if (!ok) return;
|
||||
state.selectedImage.tags = tags;
|
||||
renderTagsDisplay(tags);
|
||||
updateTagFilter(state.images);
|
||||
@@ -980,24 +1129,38 @@ async function addTag(tag) {
|
||||
async function removeTag(tag) {
|
||||
if (!state.selectedImage) return;
|
||||
const tags = (state.selectedImage.tags || []).filter(t => t !== tag);
|
||||
await saveMetaField({ tags });
|
||||
const ok = await saveMetaField({ tags });
|
||||
if (!ok) return;
|
||||
state.selectedImage.tags = tags;
|
||||
renderTagsDisplay(tags);
|
||||
updateTagFilter(state.images);
|
||||
}
|
||||
|
||||
// 戻り値: 保存が実際にサーバーへ永続化されたか(true/false)。呼び出し元はこれを見て
|
||||
// ローカル状態(state.selectedImage等)の更新可否を判断すること — でないと、サーバーが
|
||||
// 保存を拒否した場合にもUI上は成功したように見えてしまう(リロードすると消える不具合の原因)。
|
||||
async function saveMetaField(fields) {
|
||||
if (!state.selectedImage) return;
|
||||
if (!state.selectedImage) return false;
|
||||
try {
|
||||
await fetch(API.saveImageMeta, {
|
||||
const res = await fetch(API.saveImageMeta, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.selectedImage.path, ...fields }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
// サーバー側は常にHTTP 200を返し、成否は body.ok (bool) で表す設計
|
||||
// (gallery_routes.py save_image_meta)。ここをチェックしないと、
|
||||
// 許可パス外などでサーバーが保存を拒否してもUI上は成功したように見えてしまう。
|
||||
if (json.error || json.ok === false) {
|
||||
throw new Error(json.error || "Save rejected by server");
|
||||
}
|
||||
const idx = state.images.findIndex(i => i.path === state.selectedImage.path);
|
||||
if (idx >= 0) Object.assign(state.images[idx], fields);
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast(t("saveFailed", e.message), "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,6 +1194,33 @@ export async function ensureFeederGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
/** __Video Assets__ と __Video Temp__ の両方が存在しない場合に作成する。add_to_groupは
|
||||
* 対象グループがレジストリに登録されているかを確認せず画像側にグループ名を書き込むだけの
|
||||
* ため、一度レジストリから消える(例: 予約保護が効く前の版で削除された)と、以後の手動追加は
|
||||
* 画像には記録されてもグループ一覧・フィルタには一切出てこなくなる。VideoタブのAsset
|
||||
* タブ初期化時に呼び、レジストリを確実に存在させる。
|
||||
* (__Video Temp__側もここでensureする — 以前はensureVideoGroupがVIDEO_GROUPしか
|
||||
* 保証しておらず、__Video Temp__のレジストリが何らかの理由で消えると誰も復活させられない
|
||||
* バグがあった。バッチRun開始時のensure(video-plan-tab.jsの_ensureVideoAssetGroups)は
|
||||
* Runするまで呼ばれないため、それだけでは不十分。) */
|
||||
export async function ensureVideoGroup() {
|
||||
try {
|
||||
await fetch(API.groupEnsure, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: VIDEO_GROUP }),
|
||||
});
|
||||
await fetch(API.groupEnsure, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: VTEMP_GROUP }),
|
||||
});
|
||||
await loadGroups();
|
||||
} catch (e) {
|
||||
console.warn("[Gallery] ensureVideoGroup error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Feederグループ内の全画像を除外する(FC ボタン) */
|
||||
async function clearFeederGroup() {
|
||||
try {
|
||||
@@ -1045,6 +1235,19 @@ async function clearFeederGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
/** __Video Temp__グループ内の全画像を除外する(詳細パネルの「Clear __Video Temp__」ボタン) */
|
||||
async function clearVtempGroup() {
|
||||
try {
|
||||
await fetch(API.groupClear(VTEMP_GROUP), { method: "POST" });
|
||||
showToast(t("vtempGroupCleared"), "success");
|
||||
if (state.groupFilter === VTEMP_GROUP) {
|
||||
await loadImages();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** ツールバーのグループフィルタと一括バーのセレクトを更新 */
|
||||
function _updateGroupSelects() {
|
||||
// ツールバー: グループフィルタ
|
||||
@@ -1124,15 +1327,16 @@ function renderDetailGroup(img) {
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map(g => {
|
||||
const label = g === FEEDER_GROUP ? `🔒 ${escapeHtml(g)}` : escapeHtml(g);
|
||||
const label = _isReservedGroup(g) ? `🔒 ${escapeHtml(g)}` : escapeHtml(g);
|
||||
return `<option value="${escapeHtml(g)}">${label}</option>`;
|
||||
}).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-gallery-grp-rename-btn"
|
||||
${allGroups.length === 0 || allGroups[0] === FEEDER_GROUP ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
${allGroups.length === 0 || _isReservedGroup(allGroups[0]) ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-gallery-grp-delete-btn"
|
||||
${allGroups.length === 0 || allGroups[0] === FEEDER_GROUP ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
${allGroups.length === 0 || _isReservedGroup(allGroups[0]) ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-gallery-vtemp-clear-btn" style="width:100%;margin-top:6px;" title="${t("vtempClearBtnTitle")}">${t("vtempClearBtnLabel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1211,15 +1415,18 @@ function renderDetailGroup(img) {
|
||||
}
|
||||
});
|
||||
|
||||
// 管理セレクト変更時: __Feeder__ は rename/delete を無効化
|
||||
// 管理セレクト変更時: 予約グループ(__Feeder__/__Video Assets__/__Video Temp__)は rename/delete を無効化
|
||||
el.querySelector("#wfm-gallery-grp-manage-sel")?.addEventListener("change", (e) => {
|
||||
const isReserved = e.target.value === FEEDER_GROUP;
|
||||
const isReserved = _isReservedGroup(e.target.value);
|
||||
const renameBtn = el.querySelector("#wfm-gallery-grp-rename-btn");
|
||||
const deleteBtn = el.querySelector("#wfm-gallery-grp-delete-btn");
|
||||
if (renameBtn) renameBtn.disabled = isReserved;
|
||||
if (deleteBtn) deleteBtn.disabled = isReserved;
|
||||
});
|
||||
|
||||
// Clear __Video Temp__ ボタン: __Video Temp__ グループをクリア(Video Plan実行時に自動登録される生成物置き場)
|
||||
el.querySelector("#wfm-gallery-vtemp-clear-btn")?.addEventListener("click", clearVtempGroup);
|
||||
|
||||
// グループ名変更
|
||||
el.querySelector("#wfm-gallery-grp-rename-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-gallery-grp-manage-sel");
|
||||
@@ -1540,9 +1747,12 @@ async function bulkSetFavorite(favoriteValue) {
|
||||
function openLightbox(img) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "wfm-gallery-lightbox";
|
||||
const mediaHtml = isVideoFile(img)
|
||||
? `<video src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" controls autoplay></video>`
|
||||
: `<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">`;
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-gallery-lightbox-inner">
|
||||
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
|
||||
${mediaHtml}
|
||||
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
|
||||
<button class="wfm-gallery-lightbox-close">×</button>
|
||||
</div>
|
||||
@@ -1553,6 +1763,7 @@ function openLightbox(img) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
if (isVideoFile(img)) applyStoredVideoVolume(overlay.querySelector("video"));
|
||||
}
|
||||
|
||||
// ── 画像比較ライトボックス ─────────────────────────────────────
|
||||
@@ -1564,12 +1775,17 @@ function openCompare(paths) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "wfm-gallery-lightbox wfm-lightbox-compare";
|
||||
|
||||
const itemsHtml = imgs.map(img => `
|
||||
const itemsHtml = imgs.map(img => {
|
||||
const mediaHtml = isVideoFile(img)
|
||||
? `<video src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" controls></video>`
|
||||
: `<img src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" alt="${escapeHtml(img.filename)}" loading="lazy">`;
|
||||
return `
|
||||
<div class="wfm-lightbox-compare-item">
|
||||
<img src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" alt="${escapeHtml(img.filename)}" loading="lazy">
|
||||
${mediaHtml}
|
||||
<div class="wfm-lightbox-compare-label">${escapeHtml(img.filename)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-lightbox-compare-inner">
|
||||
@@ -1583,6 +1799,7 @@ function openCompare(paths) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelectorAll("video").forEach(applyStoredVideoVolume);
|
||||
}
|
||||
|
||||
// ── outputパス取得 ────────────────────────────────────────────
|
||||
@@ -1636,7 +1853,7 @@ function onGalleryTabActivated() {
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog) ────────────────────────
|
||||
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog / Metadata) ────────────
|
||||
|
||||
function _initGallerySubtabToggle() {
|
||||
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((btn) => {
|
||||
@@ -1647,6 +1864,7 @@ function _initGallerySubtabToggle() {
|
||||
document.getElementById("wfm-gallery-panel-output")?.classList.toggle("active", target === "output");
|
||||
document.getElementById("wfm-gallery-panel-imageprompt")?.classList.toggle("active", target === "imageprompt");
|
||||
document.getElementById("wfm-gallery-panel-styleCatalog")?.classList.toggle("active", target === "styleCatalog");
|
||||
document.getElementById("wfm-gallery-panel-metadata")?.classList.toggle("active", target === "metadata");
|
||||
if (target === "imageprompt") activateImagePromptTab();
|
||||
if (target === "styleCatalog") activateStyleCatalogTab();
|
||||
});
|
||||
@@ -1661,17 +1879,32 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
// 選択画像を Image Edit タブへ送信
|
||||
document.getElementById("wfm-gallery-send-image-edit-btn")?.addEventListener("click", () => {
|
||||
document.getElementById("wfm-gallery-send-image-edit-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedImage) {
|
||||
showToast("Please select an image first", "info");
|
||||
return;
|
||||
}
|
||||
const url = API.serveImage(state.selectedImage.path);
|
||||
if (!window._wfmImageEditTab) return;
|
||||
const path = state.selectedImage.path;
|
||||
const name = (state.selectedImage.filename || "gallery-image").replace(/\.[^.]+$/, "");
|
||||
if (window._wfmImageEditTab) {
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
window._wfmImageEditTab.loadFromUrl(url, name);
|
||||
|
||||
// PSDはフラット画像1枚ではなくレイヤー分解して送る(Open PSDボタンと同じ経路)
|
||||
if (/\.psd$/i.test(path)) {
|
||||
try {
|
||||
const r = await fetch(`/wfm/gallery/image/psd-layers?path=${encodeURIComponent(path)}`);
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
|
||||
const data = await r.json();
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
await window._wfmImageEditTab.loadPsdLayersData(data, name);
|
||||
} catch (err) {
|
||||
showToast(`PSD import failed: ${err.message}`, "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const url = API.serveImage(path);
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
window._wfmImageEditTab.loadFromUrl(url, name);
|
||||
});
|
||||
|
||||
// 選択画像を GenerateUI Image タブへ送信
|
||||
@@ -1707,6 +1940,34 @@ function bindEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
// 選択画像をComfyUIキャンバス上で選択中のノード(LoadImage系)のimageウィジェットへ送信。
|
||||
// Image Editタブの「Send to LI node」と同じ橋渡し(window.opener.wfmSendImageToSelectedNode、
|
||||
// node_sets_menu.js側で定義)を使う。Galleryの画像はGalleryルート配下の任意フォルダにあり得るため、
|
||||
// GenUI送信と違いLoadImageノードの有無に依存しない — /upload/imageでinputフォルダへ
|
||||
// アップロードしてから、そのファイル名を書き込む。
|
||||
document.getElementById("wfm-gallery-send-li-node-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedImage) {
|
||||
showToast(t("gallerySelectImageFirst"), "info");
|
||||
return;
|
||||
}
|
||||
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
|
||||
showToast(t("sendToLiNodeNoOpener"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(API.serveImage(state.selectedImage.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], state.selectedImage.filename || "gallery-image.png", { type: blob.type || "image/png" });
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
const filename = result.subfolder ? `${result.subfolder}/${result.name}` : result.name;
|
||||
window.opener.wfmSendImageToSelectedNode(filename);
|
||||
showToast(t("sendToLiNodeSuccess", filename), "success");
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// 選択画像を ComfyUI Comic Creator の選択コマ/オーバーレイへ送信
|
||||
// (ComfyUI Comic CreaterからこのGalleryタブがiframe埋め込みされている場合のみ表示・動作する)
|
||||
const sendCcBtn = document.getElementById("wfm-gallery-send-cc-btn");
|
||||
@@ -1769,12 +2030,15 @@ function bindEvents() {
|
||||
});
|
||||
}
|
||||
|
||||
// 検索
|
||||
// 検索(連続キー入力のたびにリクエストを飛ばすと、50枚程度のフォルダでも
|
||||
// ファイルI/Oの重いサーバー処理が積み重なって読み込みが遅延するためdebounceする)
|
||||
document.getElementById("wfm-gallery-search")?.addEventListener("input", (e) => {
|
||||
state.search = e.target.value;
|
||||
loadImages();
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
_searchDebounceTimer = setTimeout(loadImages, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
setupSearchClearBtn("wfm-gallery-search", "wfm-gallery-search-clear-btn", () => {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
@@ -1840,7 +2104,7 @@ function bindEvents() {
|
||||
// メモ保存
|
||||
document.getElementById("wfm-gallery-memo-save-btn")?.addEventListener("click", () => {
|
||||
const memo = document.getElementById("wfm-gallery-memo").value;
|
||||
saveMetaField({ memo }).then(() => showToast(t("memoSaved"), "success"));
|
||||
saveMetaField({ memo }).then((ok) => { if (ok) showToast(t("memoSaved"), "success"); });
|
||||
});
|
||||
|
||||
// ワークフローコピー&キャンバスへ送る
|
||||
@@ -1928,6 +2192,36 @@ function bindEvents() {
|
||||
exportSelectedImagesToZip([...state.selectedImages]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-gallery-bulk-psd")?.addEventListener("click", () => {
|
||||
if (state.selectedImages.size === 0) return;
|
||||
exportSelectedImagesToPsd([...state.selectedImages]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-gallery-bulk-edit-layers")?.addEventListener("click", () => {
|
||||
if (state.selectedImages.size === 0) return;
|
||||
if (!window._wfmImageEditTab) return;
|
||||
|
||||
const allPaths = [...state.selectedImages];
|
||||
const paths = allPaths.filter(p => !/\.mp4$/i.test(p));
|
||||
if (paths.length === 0) {
|
||||
showToast(t("editLayersNoImages"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const items = paths.map(p => {
|
||||
const img = state.images.find(i => i.path === p);
|
||||
const filename = img?.filename || p.split(/[\\/]/).pop() || "layer";
|
||||
return { url: API.serveImage(p), name: filename.replace(/\.[^.]+$/, "") };
|
||||
});
|
||||
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
window._wfmImageEditTab.loadLayersFromUrls(items);
|
||||
|
||||
if (paths.length < allPaths.length) {
|
||||
showToast(t("editLayersSkippedVideo", allPaths.length - paths.length), "info");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-gallery-bulk-delete")?.addEventListener("click", () => {
|
||||
if (state.selectedImages.size === 0) return;
|
||||
const count = state.selectedImages.size;
|
||||
@@ -1973,6 +2267,16 @@ function bindEvents() {
|
||||
openImageInTaggerTab(state.selectedImage);
|
||||
});
|
||||
|
||||
// Downloadボタン: プレビューのオーバーレイだと動画の再生コントロール操作を妨げるため、
|
||||
// アクションボタン行に常時表示のボタンとして配置している
|
||||
document.getElementById("wfm-gallery-download-btn")?.addEventListener("click", () => {
|
||||
if (!state.selectedImage) {
|
||||
showToast(t("gallerySelectImageFirst"), "error");
|
||||
return;
|
||||
}
|
||||
downloadImage(state.selectedImage);
|
||||
});
|
||||
|
||||
// Load in GenerateUI ボタン: 埋め込みワークフローをGenerateUIタブに読み込む
|
||||
document.getElementById("wfm-gallery-load-genui-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedImage) {
|
||||
@@ -2012,6 +2316,7 @@ function bindEvents() {
|
||||
if (galleryClearBtn) {
|
||||
galleryClearBtn.textContent = t("clearFilters");
|
||||
galleryClearBtn.addEventListener("click", () => {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
state.favoriteOnly = false;
|
||||
state.tagFilter = "";
|
||||
|
||||
@@ -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 = [];
|
||||
@@ -203,7 +278,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);
|
||||
}
|
||||
@@ -459,6 +534,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".
|
||||
@@ -607,6 +689,7 @@ 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;
|
||||
@@ -644,6 +727,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 +787,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;
|
||||
@@ -723,7 +817,8 @@ function renderSection(sectionEl, listEl, items, buildFn) {
|
||||
let _externalHandleFile = null;
|
||||
|
||||
export async function loadFileIntoMetadataTab(file) {
|
||||
document.querySelector('.wfm-tab[data-tab="metadata"]')?.click();
|
||||
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);
|
||||
}
|
||||
@@ -790,6 +885,7 @@ export function initMetadataTab() {
|
||||
"wfm-help-metadata-3": "helpMetadata3",
|
||||
"wfm-help-metadata-4": "helpMetadata4",
|
||||
"wfm-help-metadata-5": "helpMetadata5",
|
||||
"wfm-help-metadata-6": "helpMetadata6",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIds)) {
|
||||
const el = document.getElementById(id);
|
||||
|
||||
@@ -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,7 @@ 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 } from "./util.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
@@ -336,6 +336,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 +406,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) {
|
||||
@@ -575,6 +603,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>
|
||||
@@ -691,44 +737,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>
|
||||
@@ -824,6 +832,25 @@ 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>
|
||||
<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>
|
||||
|
||||
@@ -1139,92 +1166,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;
|
||||
@@ -1342,6 +1283,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 +1483,53 @@ 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 res = await fetch(`/api/wfm/settings/export-full?include_workflows=${includeWorkflows}&include_wildcard=${includeWildcard}`);
|
||||
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);
|
||||
statusEl.textContent = `${t("importSuccess")}: ${result.extracted?.length ?? 0} ${t("fullBackupFilesUnit")}`;
|
||||
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) {
|
||||
|
||||
@@ -109,6 +109,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,296 @@
|
||||
/**
|
||||
* 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 } from "./gallery-tab.js";
|
||||
import { setSourcePreview } from "./video-preview.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
|
||||
};
|
||||
|
||||
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;
|
||||
_renderGrid();
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGrid() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
if (!grid) return;
|
||||
if (_s.images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No videos yet</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
for (const img of _s.images) grid.appendChild(_makeCard(img));
|
||||
}
|
||||
|
||||
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").forEach((c) => {
|
||||
c.classList.toggle("selected", c.dataset.path === img.path);
|
||||
});
|
||||
_renderDetail(img);
|
||||
_loadIntoSourcePreview(img);
|
||||
}
|
||||
|
||||
// Feeds the selected asset's video 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.
|
||||
async function _loadIntoSourcePreview(img) {
|
||||
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 || "video/mp4" });
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file });
|
||||
} 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>
|
||||
<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-primary wfm-btn-sm" id="wfm-video-asset-open-gallery" style="width:100%;margin-top:12px;">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-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();
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Video Tab - shared state for the two center-panel preview panes:
|
||||
* "source" (#wfm-video-source-preview-video — an Asset selection or a
|
||||
* locally-dropped file) and "result" (#wfm-video-preview-video — the latest
|
||||
* generated clip). video-asset-tab.js (Asset selection), video-plan-tab.js
|
||||
* (batch run results) and video-tab.js (the Video Source drop zone, Frame/GIF
|
||||
* tools) all need to read or write these. Pulling this into its own module
|
||||
* lets all three depend on it without needing to import each other —
|
||||
* video-tab.js already imports both of the others for init/refresh, so a
|
||||
* reverse import from either back into video-tab.js would create a cycle.
|
||||
*/
|
||||
|
||||
const _PANES = {
|
||||
source: { video: "wfm-video-source-preview-video", placeholder: "wfm-video-source-preview-placeholder" },
|
||||
result: { video: "wfm-video-preview-video", placeholder: "wfm-video-preview-placeholder" },
|
||||
};
|
||||
|
||||
const _objectUrls = { source: null, result: null };
|
||||
|
||||
// Whichever pane most recently received a video is what the Frame/GIF property
|
||||
// tabs act on (see getActivePreviewSource/getActivePreviewVideoElement below) —
|
||||
// an Asset selection or Video Source drop activates "source", a batch run
|
||||
// finishing a block activates "result".
|
||||
let _activePane = null; // "source" | "result" | null
|
||||
let _activeSource = null;
|
||||
|
||||
function _setPreview(pane, url, source) {
|
||||
const ids = _PANES[pane];
|
||||
const video = document.getElementById(ids.video);
|
||||
const placeholder = document.getElementById(ids.placeholder);
|
||||
if (!video || !placeholder) return;
|
||||
|
||||
if (_objectUrls[pane]) {
|
||||
URL.revokeObjectURL(_objectUrls[pane]);
|
||||
_objectUrls[pane] = null;
|
||||
}
|
||||
if (url && url.startsWith("blob:")) _objectUrls[pane] = url;
|
||||
|
||||
if (url) {
|
||||
video.src = url;
|
||||
video.style.display = "block";
|
||||
placeholder.style.display = "none";
|
||||
_activePane = pane;
|
||||
_activeSource = source;
|
||||
} else {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.style.display = "none";
|
||||
placeholder.style.display = "";
|
||||
if (_activePane === pane) { _activePane = null; _activeSource = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// Sets (or clears, with url=null) the Asset-selection/Video-Source-drop pane.
|
||||
export function setSourcePreview(url, source) { _setPreview("source", url, source); }
|
||||
|
||||
// Sets (or clears) the generated-result pane — called once per block as a
|
||||
// batch run's video output comes in.
|
||||
export function setResultPreview(url, source) { _setPreview("result", url, source); }
|
||||
|
||||
// What the Frame/GIF property tabs operate on: { kind: "output"|"input", filename,
|
||||
// subfolder, type } for anything already on the server, or { kind: "local", file }
|
||||
// for a picked/dropped file not yet uploaded. null if neither pane has a video.
|
||||
export function getActivePreviewSource() { return _activeSource; }
|
||||
|
||||
// The GIF tab uploads a still-local source on first use and needs to remember
|
||||
// the resulting server-side reference so a repeat conversion doesn't re-upload —
|
||||
// this rewrites the active source in place without touching either <video> element.
|
||||
export function updateActivePreviewSourceRef(source) { _activeSource = source; }
|
||||
|
||||
// The <video> element the Frame/GIF tools should read/capture from, or null if
|
||||
// neither pane currently has a video loaded.
|
||||
export function getActivePreviewVideoElement() {
|
||||
if (!_activePane) return null;
|
||||
return document.getElementById(_PANES[_activePane].video);
|
||||
}
|
||||
|
||||
// Both panes' <video> elements share the same persistent volume setting.
|
||||
export function getAllPreviewVideoElements() {
|
||||
return Object.values(_PANES).map((ids) => document.getElementById(ids.video)).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Video Tab - Sidebar "Project" subtab (next to Asset): browses saved Video
|
||||
* Plan files (ws_videoplan_*.json in video_plan/), independent of which plan
|
||||
* (if any) is currently loaded in the Plan editor.
|
||||
*
|
||||
* Deliberately thin, mirroring video-asset-tab.js's own scope — clicking an
|
||||
* item hands off to video-plan-tab.js's openSavedVideoPlan() rather than
|
||||
* re-implementing plan loading here.
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { openSavedVideoPlan } from "./video-plan-tab.js";
|
||||
|
||||
const _s = {
|
||||
plans: [],
|
||||
activeFilename: null,
|
||||
loaded: false, // becomes true once the Project subtab has been shown at least once
|
||||
};
|
||||
|
||||
function _formatUpdatedAt(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function _renderList() {
|
||||
const list = document.getElementById("wfm-video-project-list");
|
||||
if (!list) return;
|
||||
if (_s.plans.length === 0) {
|
||||
list.innerHTML = `<span class="wfm-placeholder">${t("videoProjectEmpty")}</span>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const plan of _s.plans) list.appendChild(_makeItem(plan));
|
||||
}
|
||||
|
||||
function _makeItem(plan) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-video-project-item" + (plan.filename === _s.activeFilename ? " active" : "");
|
||||
item.dataset.filename = plan.filename;
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "wfm-video-project-item-thumb";
|
||||
thumb.loading = "lazy";
|
||||
if (plan.thumbnail) thumb.src = plan.thumbnail;
|
||||
thumb.onerror = () => { thumb.style.visibility = "hidden"; };
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "wfm-video-project-item-info";
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-video-project-item-name";
|
||||
name.textContent = plan.name || plan.filename;
|
||||
name.title = plan.filename;
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "wfm-video-project-item-meta";
|
||||
const blockLabel = t("videoProjectBlockCount", plan.block_count ?? 0);
|
||||
const updated = _formatUpdatedAt(plan.updated_at);
|
||||
meta.textContent = updated ? `${blockLabel} · ${updated}` : blockLabel;
|
||||
meta.title = plan.note || "";
|
||||
|
||||
info.append(name, meta);
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.className = "wfm-video-project-item-delete";
|
||||
deleteBtn.textContent = "×";
|
||||
deleteBtn.title = t("delete");
|
||||
deleteBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_deletePlan(plan);
|
||||
});
|
||||
|
||||
item.append(thumb, info, deleteBtn);
|
||||
item.addEventListener("click", () => _openPlan(plan));
|
||||
return item;
|
||||
}
|
||||
|
||||
async function _openPlan(plan) {
|
||||
_s.activeFilename = plan.filename;
|
||||
_renderList();
|
||||
await openSavedVideoPlan(plan.filename);
|
||||
}
|
||||
|
||||
async function _deletePlan(plan) {
|
||||
if (!window.confirm(t("videoProjectDeleteConfirm", plan.name || plan.filename))) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/video/plans/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: plan.filename }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
if (_s.activeFilename === plan.filename) _s.activeFilename = null;
|
||||
showToast(t("videoProjectDeleted"), "success");
|
||||
await refreshVideoProjectTab();
|
||||
} catch (err) {
|
||||
showToast(`${t("videoProjectDeleteFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Called whenever the Project subtab becomes active (see video-tab.js's prop-tab
|
||||
// toggle) — always refetches rather than caching, so a plan saved elsewhere
|
||||
// (Plan pane's Save/Save As) shows up as soon as the user switches over to look.
|
||||
export async function refreshVideoProjectTab() {
|
||||
const list = document.getElementById("wfm-video-project-list");
|
||||
if (!list) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/video/plans");
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
_s.plans = await res.json();
|
||||
_renderList();
|
||||
} catch (err) {
|
||||
list.innerHTML = `<span class="wfm-placeholder" style="color:var(--wfm-danger)">${escapeHtml(t("videoProjectLoadListFailed"))}: ${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
_s.loaded = true;
|
||||
}
|
||||
|
||||
export function initVideoProjectTab() {
|
||||
document.getElementById("wfm-video-project-refresh")?.addEventListener("click", () => refreshVideoProjectTab());
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Video Tab - top-level orchestration for the Plan / Asset / Edit subtabs, plus
|
||||
* the Frame-capture / GIF-conversion / Video-Source tools (kept exactly as they
|
||||
* were — they don't depend on which subtab is active and stay visible across
|
||||
* all three; see video-tab.css's persistent-panel layout).
|
||||
*
|
||||
* Batch generation (the former single-shot form) now lives in
|
||||
* video-plan-tab.js, and per-block workflow manipulation in video-workflow.js.
|
||||
* This file only re-exports loadWorkflowIntoVideoEditor so workflow-tab.js's
|
||||
* "Load in Video" button needs no changes.
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { applyStoredVideoVolume } from "./settings-tab.js";
|
||||
import { initVideoPlanTab, loadWorkflowIntoVideoEditor } from "./video-plan-tab.js";
|
||||
import { initVideoAssetTab, refreshVideoAssetTab } from "./video-asset-tab.js";
|
||||
import { initVideoProjectTab, refreshVideoProjectTab } from "./video-project-tab.js";
|
||||
import {
|
||||
setSourcePreview, getActivePreviewSource, updateActivePreviewSourceRef,
|
||||
getActivePreviewVideoElement, getAllPreviewVideoElements,
|
||||
} from "./video-preview.js";
|
||||
|
||||
export { loadWorkflowIntoVideoEditor };
|
||||
|
||||
// ============================================
|
||||
// Subtab switching — two INDEPENDENT groups sharing the same .wfm-video-subtab-*
|
||||
// look, kept deliberately uncoupled: the sidebar's Asset/Project pair collapses
|
||||
// entirely when re-clicked (nothing needs to always be visible there — Video
|
||||
// Source covers the "load something to work with" case), while the center
|
||||
// Plan/Edit pair is a "always exactly one visible" 2-way tab. Querying each
|
||||
// scoped to its own container (rather than one global querySelectorAll) is
|
||||
// what keeps clicking one from affecting the other.
|
||||
// ============================================
|
||||
|
||||
function _initCenterSubtabToggle() {
|
||||
const scope = document.querySelector(".wfm-video-center-panel");
|
||||
if (!scope) return;
|
||||
scope.querySelectorAll(".wfm-video-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.videoSubtab;
|
||||
scope.querySelectorAll(".wfm-video-subtab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
scope.querySelectorAll(".wfm-video-subtab-panel").forEach((p) => {
|
||||
p.style.display = p.dataset.videoSubtabPanel === target ? "" : "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar Asset/Project pair: clicking a button shows its panel and hides the
|
||||
// other; clicking the already-active button collapses it (both panels hidden),
|
||||
// letting the video preview reclaim the sidebar's width when neither is needed.
|
||||
function _initSidebarSubtabToggle() {
|
||||
const sidebar = document.querySelector(".wfm-video-form-panel");
|
||||
if (!sidebar) return;
|
||||
const buttons = sidebar.querySelectorAll(".wfm-video-subtab-btn");
|
||||
buttons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.videoSubtab;
|
||||
const panel = document.getElementById(`wfm-video-subtab-${target}`);
|
||||
const willShow = !!panel && panel.style.display === "none";
|
||||
buttons.forEach((b) => b.classList.remove("active"));
|
||||
sidebar.querySelectorAll(".wfm-video-subtab-panel").forEach((p) => { p.style.display = "none"; });
|
||||
if (willShow) {
|
||||
btn.classList.add("active");
|
||||
panel.style.display = "";
|
||||
if (target === "asset") refreshVideoAssetTab();
|
||||
else if (target === "project") refreshVideoProjectTab();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// first_frame / last_frame アップロード(Video Sourceパネル・Frame/GIFタブと共有する
|
||||
// 汎用ドロップゾーン配線ヘルパー。video-plan-tab.jsのブロック画像アップロードとは別物)
|
||||
// ============================================
|
||||
|
||||
function _wireDropZone(dropZoneId, fileInputId, onFile) {
|
||||
const dropZone = document.getElementById(dropZoneId);
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
if (!dropZone || !fileInput) return;
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) onFile(fileInput.files[0]);
|
||||
});
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) onFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// i18nの適用(app.jsのapplyI18nToHtml()と同じ「個別要素にt()を適用」方式)
|
||||
// ============================================
|
||||
|
||||
function _applyVideoI18n() {
|
||||
const setText = (id, key) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
};
|
||||
setText("wfm-video-edit-placeholder-label", "videoEditPlaceholder");
|
||||
setText("wfm-video-source-label", "videoSourceLabel");
|
||||
setText("wfm-video-source-hint", "videoSourceHint");
|
||||
setText("wfm-video-source-drop-label", "videoSourceDropLabel");
|
||||
setText("wfm-video-source-clear", "clear");
|
||||
setText("wfm-video-source-preview-placeholder", "videoSourcePreviewPlaceholder");
|
||||
setText("wfm-video-preview-placeholder", "videoResultPreviewPlaceholder");
|
||||
setText("wfm-video-prop-tab-frame", "videoPropFrame");
|
||||
setText("wfm-video-prop-tab-gif", "videoPropGif");
|
||||
setText("wfm-video-subtab-btn-project", "videoPropProject");
|
||||
setText("wfm-video-project-hint", "videoProjectHint");
|
||||
setText("wfm-video-frame-hint", "videoFrameHint");
|
||||
setText("wfm-video-frame-capture-btn", "videoCaptureFrame");
|
||||
setText("wfm-video-frame-save-btn", "videoSaveToOutput");
|
||||
setText("wfm-video-gif-start-label", "videoGifStart");
|
||||
setText("wfm-video-gif-end-label", "videoGifEnd");
|
||||
setText("wfm-video-gif-fps-label", "videoGifFps");
|
||||
setText("wfm-video-gif-width-label", "videoGifWidth");
|
||||
setText("wfm-video-gif-convert-btn", "videoConvertToGif");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Video Source panel — loads any local video into the preview frame, independent of
|
||||
// generation, so Frame/GIF tools work without running the loaded video workflow first.
|
||||
// ============================================
|
||||
|
||||
function _wireVideoSourcePanel() {
|
||||
_wireDropZone("wfm-video-source-drop", "wfm-video-source-file", (file) => {
|
||||
if (!file.type.startsWith("video/")) return;
|
||||
const statusEl = document.getElementById("wfm-video-source-status");
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file });
|
||||
if (statusEl) { statusEl.textContent = file.name; statusEl.style.color = ""; }
|
||||
});
|
||||
|
||||
document.getElementById("wfm-video-source-clear")?.addEventListener("click", () => {
|
||||
setSourcePreview(null, null);
|
||||
const statusEl = document.getElementById("wfm-video-source-status");
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
const fileInput = document.getElementById("wfm-video-source-file");
|
||||
if (fileInput) fileInput.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Properties panel tabs (Frame / GIF)
|
||||
// ============================================
|
||||
|
||||
function _initPropTabs() {
|
||||
const tabs = document.querySelectorAll(".wfm-video-prop-tab");
|
||||
tabs.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
tabs.forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const target = btn.dataset.propTab;
|
||||
document.querySelectorAll(".wfm-video-prop-content").forEach((c) => {
|
||||
c.style.display = c.id === `wfm-video-prop-${target}` ? "" : "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Frame tab — captures the preview video's current playback position as a PNG,
|
||||
// client-side (canvas), and saves it into ComfyUI's own output folder.
|
||||
// ============================================
|
||||
|
||||
let _capturedFrameBlob = null;
|
||||
|
||||
function _blobToDataUrl(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error("Failed to read blob"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function _initFrameTab() {
|
||||
const captureBtn = document.getElementById("wfm-video-frame-capture-btn");
|
||||
const saveBtn = document.getElementById("wfm-video-frame-save-btn");
|
||||
const previewWrap = document.getElementById("wfm-video-frame-preview-wrap");
|
||||
const previewImg = document.getElementById("wfm-video-frame-preview-img");
|
||||
const statusEl = document.getElementById("wfm-video-frame-status");
|
||||
|
||||
captureBtn?.addEventListener("click", () => {
|
||||
const video = getActivePreviewVideoElement();
|
||||
if (!video || video.style.display === "none" || !video.videoWidth) {
|
||||
showToast(t("videoNoSourceLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d").drawImage(video, 0, 0);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
_capturedFrameBlob = blob;
|
||||
if (previewImg) previewImg.src = URL.createObjectURL(blob);
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}, "image/png");
|
||||
} catch (err) {
|
||||
showToast(t("videoFrameCaptureFailed", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
saveBtn?.addEventListener("click", async () => {
|
||||
if (!_capturedFrameBlob) return;
|
||||
saveBtn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = "Saving...";
|
||||
try {
|
||||
const dataUrl = await _blobToDataUrl(_capturedFrameBlob);
|
||||
const res = await fetch("/api/wfm/video/frame/save-to-output", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_base64: dataUrl }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
if (statusEl) { statusEl.textContent = `✓ ${json.filename}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
showToast(t("videoFrameSaved", json.filename), "success");
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GIF tab — sends the preview video (uploading it first if it's a local file not yet
|
||||
// on the server) to the server, which decodes it with PyAV and encodes an animated
|
||||
// GIF with Pillow (both already required by ComfyUI itself — no ffmpeg dependency).
|
||||
// ============================================
|
||||
|
||||
function _initGifTab() {
|
||||
const convertBtn = document.getElementById("wfm-video-gif-convert-btn");
|
||||
const progressBar = document.getElementById("wfm-video-gif-progress-bar");
|
||||
const progressText = document.getElementById("wfm-video-gif-progress-text");
|
||||
const previewWrap = document.getElementById("wfm-video-gif-preview-wrap");
|
||||
const previewImg = document.getElementById("wfm-video-gif-preview-img");
|
||||
const statusEl = document.getElementById("wfm-video-gif-status");
|
||||
|
||||
convertBtn?.addEventListener("click", async () => {
|
||||
if (!getActivePreviewSource()) {
|
||||
showToast(t("videoNoSourceLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
convertBtn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
if (progressBar) progressBar.style.width = "20%";
|
||||
if (progressText) progressText.textContent = "Uploading...";
|
||||
|
||||
try {
|
||||
let ref = getActivePreviewSource();
|
||||
if (ref.kind === "local") {
|
||||
const result = await comfyUI.uploadImage(ref.file, ref.file.name);
|
||||
ref = { kind: "input", filename: result.name, subfolder: result.subfolder || "", type: "input" };
|
||||
// Cache the uploaded reference so a repeat conversion doesn't re-upload.
|
||||
updateActivePreviewSourceRef(ref);
|
||||
}
|
||||
|
||||
const start = Number(document.getElementById("wfm-video-gif-start")?.value) || 0;
|
||||
const endRaw = document.getElementById("wfm-video-gif-end")?.value;
|
||||
const fps = Number(document.getElementById("wfm-video-gif-fps")?.value) || 10;
|
||||
const widthRaw = document.getElementById("wfm-video-gif-width")?.value;
|
||||
|
||||
if (progressBar) progressBar.style.width = "60%";
|
||||
if (progressText) progressText.textContent = t("videoGifConverting");
|
||||
|
||||
const res = await fetch("/api/wfm/video/to-gif", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: ref.filename,
|
||||
subfolder: ref.subfolder || "",
|
||||
type: ref.type,
|
||||
start_time: start,
|
||||
end_time: endRaw ? Number(endRaw) : null,
|
||||
fps,
|
||||
max_width: widthRaw ? Number(widthRaw) : null,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
if (progressText) progressText.textContent = t("videoGifDoneCount", json.frame_count ?? 0);
|
||||
|
||||
const params = new URLSearchParams({ filename: json.filename, subfolder: json.subfolder || "", type: "output" });
|
||||
if (previewImg) previewImg.src = `${comfyUI.baseUrl}/view?${params}`;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
if (statusEl) { statusEl.textContent = `✓ ${json.filename}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
showToast(t("videoGifSaved", json.filename), "success");
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
if (progressText) progressText.textContent = "Error";
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
} finally {
|
||||
convertBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 初期化
|
||||
// ============================================
|
||||
|
||||
export function initVideoTab() {
|
||||
_applyVideoI18n();
|
||||
_initCenterSubtabToggle();
|
||||
_initSidebarSubtabToggle();
|
||||
initVideoPlanTab();
|
||||
initVideoAssetTab();
|
||||
initVideoProjectTab();
|
||||
|
||||
_wireVideoSourcePanel();
|
||||
_initPropTabs();
|
||||
_initFrameTab();
|
||||
_initGifTab();
|
||||
|
||||
// 保存済みの音量を適用し、以降ユーザーがネイティブコントロールで変更した音量も
|
||||
// 自動保存する(要素自体は永続的なので初期化時に一度呼べば十分)。両方のプレビュー
|
||||
// ペイン(Asset/Source用・生成結果用)の<video>要素に適用する。
|
||||
getAllPreviewVideoElements().forEach((video) => applyStoredVideoVolume(video));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Video helper utilities shared across tabs that deal with generated mp4 output
|
||||
* (Lab tab's "use generated image for next" chaining, and the Video tab's future
|
||||
* batch features). Kept dependency-free (no comfyUI/comfyEditor imports) so it can
|
||||
* be reused from either tab without pulling in unrelated state.
|
||||
*/
|
||||
|
||||
export function isVideoFilename(filename) {
|
||||
return /\.(mp4|webm|mov)$/i.test(filename || "");
|
||||
}
|
||||
|
||||
// Extracts the last frame of a video at `videoUrl` as a PNG Blob, by seeking a
|
||||
// detached <video> element to just before its end and drawing that frame onto a
|
||||
// canvas. Runs entirely client-side (no server round-trip / PyAV dependency).
|
||||
//
|
||||
// Seeks to (duration - 0.05s) rather than duration itself: seeking exactly to the
|
||||
// end is unreliable across browsers (some clamp it back to the last keyframe's
|
||||
// timestamp, which can fire a "seeked" event before the frame is actually decoded).
|
||||
// The "seeked" listener also ignores events that don't land near that target time,
|
||||
// since loading a video can fire an initial seeked-to-0 before the real seek lands.
|
||||
export function extractLastFrameBlob(videoUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "auto";
|
||||
|
||||
let target = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
};
|
||||
const finish = (fn, arg) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
fn(arg);
|
||||
};
|
||||
|
||||
video.addEventListener("error", () => {
|
||||
finish(reject, new Error("Failed to load video for last-frame extraction"));
|
||||
});
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0) {
|
||||
finish(reject, new Error("Video has no readable duration"));
|
||||
return;
|
||||
}
|
||||
target = Math.max(0, video.duration - 0.05);
|
||||
video.currentTime = target;
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
if (target == null || Math.abs(video.currentTime - target) > 0.5) return;
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d").drawImage(video, 0, 0);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) finish(resolve, blob);
|
||||
else finish(reject, new Error("Failed to encode last frame"));
|
||||
}, "image/png");
|
||||
} catch (err) {
|
||||
finish(reject, err);
|
||||
}
|
||||
});
|
||||
|
||||
video.src = videoUrl;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Video workflow helpers — model-family detection and node manipulation for
|
||||
* MiniMax H3 / LTX-2.5 (Text・Image・First&Last-Frame to Video) / Wan2.2 (Text
|
||||
* to Video) workflows. Pure functions operating on UI-format workflow JSON,
|
||||
* with no DOM/state dependency, so both the Plan tab's per-block batch loop
|
||||
* and (previously) the single-shot form can share the exact same logic.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 動画モデルノードの動的特定(ノードIDをハードコードしない)
|
||||
// サブグラフの内部ノード型(Wan2.2のみサブグラフ名)で対象ワークフローの種類を判定し、
|
||||
// 以降はサブグラフが公開する入力のlabel(無ければname)で意味的にスロットを特定する。
|
||||
// これによりLTX-2.5の3バリアント間で公開入力のname(text/value/value_1...)が揺れて
|
||||
// いても、label(prompt/duration/width...)は共通しているため同じロジックで処理できる。
|
||||
// ============================================
|
||||
|
||||
// ノードのinputs[]のうちwidgetキーを持つものだけを抽出した並び順で、指定した入力定義の
|
||||
// インデックスを引く。widgets_values配列はこの並び順と1:1対応する(link接続されていても
|
||||
// widgetキーを持つ入力はwidgets_valuesにスロットを持つ「legacy full」形式のため)。
|
||||
function _widgetIndexOf(node, inputDef) {
|
||||
if (!inputDef) return -1;
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.indexOf(inputDef);
|
||||
}
|
||||
|
||||
// 上と同じ並び順ルールで、名前から直接インデックスを引く版(ResolutionSelectorや
|
||||
// LoadImageなど、モデルファミリー間で入力名が揺れないノードに使う)。
|
||||
export function widgetIndex(node, name) {
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.findIndex((inp) => inp.name === name);
|
||||
}
|
||||
|
||||
// サブグラフが公開する入力は、labelがあればlabel、無ければnameが意味的な役割を表す
|
||||
// (例: MiniMax H3の"width"はlabel無しでname自体が"width"、LTX-2.5の"value_2"はlabelが
|
||||
// "width")。
|
||||
function _findInputByLabel(node, label) {
|
||||
return (node?.inputs || []).find((inp) => (inp.label || inp.name) === label) || null;
|
||||
}
|
||||
|
||||
// noise_seedはLTX-2.5のText to Videoバリアントだけlabel="seed"を持つ(他は無label)ため、
|
||||
// 意味的ラベルではなくname自体(全バリアントで"noise_seed"に統一)で検索する。
|
||||
function _findInputByName(node, name) {
|
||||
return (node?.inputs || []).find((inp) => inp.name === name) || null;
|
||||
}
|
||||
|
||||
function _detectVideoSubgraph(workflow) {
|
||||
if (!Array.isArray(workflow?.nodes) || !Array.isArray(workflow?.links)) return null;
|
||||
const subgraphs = workflow.definitions?.subgraphs || [];
|
||||
|
||||
const minimax = subgraphs.find((sg) => (sg.nodes || []).some((n) => n.type === "MiniMaxH3ImageToVideo"));
|
||||
if (minimax) return { family: "minimax", sgDef: minimax };
|
||||
|
||||
// LTX-2.5のText/Image/First&Last-Frame to Videoはいずれも内部にLTXV*系ノードを持つ
|
||||
// サブグラフとして配布される。バリアントごとにサブグラフの公開入力nameが異なるため、
|
||||
// 内部ノード型のプレフィックスで検出する。
|
||||
const ltx = subgraphs.find((sg) => (sg.nodes || []).some((n) => typeof n.type === "string" && n.type.startsWith("LTXV")));
|
||||
if (ltx) return { family: "ltx25", sgDef: ltx };
|
||||
|
||||
// Wan2.2はUNETLoader/KSamplerAdvanced/EmptyHunyuanLatentVideoなど汎用ノードのみで
|
||||
// 構成されており、MiniMax H3/LTX-2.5のような固有ノード型による検出ができない。
|
||||
// そのためサブグラフ自体の名前(公式テンプレート由来の "...(Wan2.2)" 表記)で検出する。
|
||||
const wan22 = subgraphs.find((sg) => /wan\s*2\.2/i.test(sg.name || ""));
|
||||
if (wan22) return { family: "wan22", sgDef: wan22 };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function locateVideoModelNodes(workflow) {
|
||||
const detected = _detectVideoSubgraph(workflow);
|
||||
if (!detected) return null;
|
||||
const { family, sgDef } = detected;
|
||||
|
||||
const instanceNode = workflow.nodes.find((n) => n.type === sgDef.id);
|
||||
if (!instanceNode || !Array.isArray(instanceNode.widgets_values)) return null;
|
||||
|
||||
// Wan2.2の公開入力はプロンプトが label 無しの name="text" のまま(MiniMax H3の"prompt"や
|
||||
// LTX-2.5の label="prompt" と異なる)ため、"prompt" が見つからない場合は "text" にフォール
|
||||
// バックする。
|
||||
const promptInput = _findInputByLabel(instanceNode, "prompt") || _findInputByLabel(instanceNode, "text");
|
||||
const durationInput = _findInputByLabel(instanceNode, "duration");
|
||||
const widthInput = _findInputByLabel(instanceNode, "width");
|
||||
if (!promptInput || !durationInput || !widthInput) return null;
|
||||
|
||||
const promptIdx = _widgetIndexOf(instanceNode, promptInput);
|
||||
const durationIdx = _widgetIndexOf(instanceNode, durationInput);
|
||||
if (promptIdx === -1 || durationIdx === -1) return null;
|
||||
|
||||
const widthSlot = instanceNode.inputs.indexOf(widthInput);
|
||||
|
||||
// ワークフローにより有無・意味が異なる任意項目。見つからなければ全て-1を返し、
|
||||
// 呼び出し側(UI)はその項目を無効化・非ハイライトにする。
|
||||
const heightInput = _findInputByLabel(instanceNode, "height");
|
||||
const noiseSeedInput = _findInputByName(instanceNode, "noise_seed");
|
||||
// MiniMax H3は"turbo_mode"、Wan2.2は"enable_turbo_mode" — 同じ意味のトグルをUI上は
|
||||
// 1つのフィールドとして兼用するため、どちらか見つかった方を使う。
|
||||
const turboModeInput = _findInputByLabel(instanceNode, "turbo_mode") || _findInputByLabel(instanceNode, "enable_turbo_mode");
|
||||
const turboStrengthInput = _findInputByLabel(instanceNode, "turbo_model_strength");
|
||||
const turboStepsInput = _findInputByLabel(instanceNode, "turbo_steps");
|
||||
const promptEnhanceInput = _findInputByLabel(instanceNode, "prompt_enhance");
|
||||
const noiseSeedIdx = _widgetIndexOf(instanceNode, noiseSeedInput);
|
||||
const turboModeIdx = _widgetIndexOf(instanceNode, turboModeInput);
|
||||
const turboStrengthIdx = _widgetIndexOf(instanceNode, turboStrengthInput);
|
||||
const turboStepsIdx = _widgetIndexOf(instanceNode, turboStepsInput);
|
||||
const promptEnhanceIdx = _widgetIndexOf(instanceNode, promptEnhanceInput);
|
||||
|
||||
const findLinkedNode = (targetSlot) => {
|
||||
if (targetSlot === -1) return null;
|
||||
const link = workflow.links.find((l) => l[3] === instanceNode.id && l[4] === targetSlot);
|
||||
if (!link) return null;
|
||||
return workflow.nodes.find((n) => n.id === link[1]) || null;
|
||||
};
|
||||
|
||||
// 一部のLTX-2.5バリアント(First & Last Frame to Video等)はResolutionSelectorを介さず、
|
||||
// width/heightをサブグラフの直接ウィジェット値として持つ。その場合はresolutionNode: null
|
||||
// を返し、以降の処理ではaspect_ratio等の操作をスキップする(テンプレートのwidth/height値が
|
||||
// そのまま使われる)。
|
||||
const rawResolutionNode = findLinkedNode(widthSlot);
|
||||
const resolutionNode = (rawResolutionNode && rawResolutionNode.type === "ResolutionSelector") ? rawResolutionNode : null;
|
||||
|
||||
// first_frame/last_frameはoptional入力のため、対応するLoadImageノードが接続されて
|
||||
// いないワークフロー(テキストのみで動作するT2V構成など)もサポート対象とする。
|
||||
// 未接続の場合はnullのまま返し、必要ならapplyBlockToWorkflow側で動的にノードを注入する。
|
||||
const firstFrameInput = _findInputByLabel(instanceNode, "first_frame");
|
||||
const lastFrameInput = _findInputByLabel(instanceNode, "last_frame");
|
||||
const firstFrameSlot = firstFrameInput ? instanceNode.inputs.indexOf(firstFrameInput) : -1;
|
||||
const lastFrameSlot = lastFrameInput ? instanceNode.inputs.indexOf(lastFrameInput) : -1;
|
||||
|
||||
const rawLoadImageNode = findLinkedNode(firstFrameSlot);
|
||||
const loadImageNode = (rawLoadImageNode && rawLoadImageNode.type === "LoadImage") ? rawLoadImageNode : null;
|
||||
const rawLastLoadImageNode = findLinkedNode(lastFrameSlot);
|
||||
const lastLoadImageNode = (rawLastLoadImageNode && rawLastLoadImageNode.type === "LoadImage") ? rawLastLoadImageNode : null;
|
||||
|
||||
// width/heightはResolutionSelectorがあればその出力に接続されており(widthSlotの
|
||||
// link経由)、その場合は直接ウィジェット値を書き換えても実行時にリンク元の出力で
|
||||
// 上書きされ意味がない。resolutionNodeがnullのワークフロー(LTX-2.5 First&Last-Frame,
|
||||
// Wan2.2全種)でのみ、instanceNode自身のwidth/heightウィジェットを直接編集できる。
|
||||
const widthIdx = resolutionNode ? -1 : _widgetIndexOf(instanceNode, widthInput);
|
||||
const heightIdx = resolutionNode ? -1 : _widgetIndexOf(instanceNode, heightInput);
|
||||
|
||||
return {
|
||||
family, sgDef, instanceNode, loadImageNode, lastLoadImageNode, resolutionNode,
|
||||
firstFrameSlot, lastFrameSlot, promptIdx, durationIdx,
|
||||
widthIdx, heightIdx, noiseSeedIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
};
|
||||
}
|
||||
|
||||
// Reads the current values a freshly-loaded template workflow carries, so a UI
|
||||
// (the Plan tab's first block, previously the single-shot form) can seed its
|
||||
// fields from whatever the workflow file already had.
|
||||
export function readTemplateDefaults(nodes) {
|
||||
const {
|
||||
instanceNode, resolutionNode, loadImageNode, lastLoadImageNode, promptIdx, durationIdx,
|
||||
widthIdx, heightIdx, noiseSeedIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
} = nodes;
|
||||
const arIdx = widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = widgetIndex(resolutionNode, "multiple");
|
||||
const fnIdx = widgetIndex(loadImageNode, "image");
|
||||
const lastFnIdx = widgetIndex(lastLoadImageNode, "image");
|
||||
return {
|
||||
prompt: instanceNode.widgets_values[promptIdx] ?? "",
|
||||
duration: instanceNode.widgets_values[durationIdx] ?? 2,
|
||||
hasResolution: !!resolutionNode,
|
||||
aspectRatio: (resolutionNode && arIdx !== -1) ? resolutionNode.widgets_values[arIdx] : null,
|
||||
megapixels: (resolutionNode && mpIdx !== -1) ? resolutionNode.widgets_values[mpIdx] : null,
|
||||
multiple: (resolutionNode && multIdx !== -1) ? resolutionNode.widgets_values[multIdx] : null,
|
||||
firstImageFilename: (loadImageNode && fnIdx !== -1) ? loadImageNode.widgets_values[fnIdx] : null,
|
||||
lastImageFilename: (lastLoadImageNode && lastFnIdx !== -1) ? lastLoadImageNode.widgets_values[lastFnIdx] : null,
|
||||
// width/heightは resolutionNode がある場合は widthIdx/heightIdx が -1 になる
|
||||
// (locateVideoModelNodes参照) ため、常にnullが返り直接入力欄は無効化される。
|
||||
hasDirectResolution: widthIdx !== -1 && heightIdx !== -1,
|
||||
width: widthIdx !== -1 ? instanceNode.widgets_values[widthIdx] : null,
|
||||
height: heightIdx !== -1 ? instanceNode.widgets_values[heightIdx] : null,
|
||||
hasNoiseSeed: noiseSeedIdx !== -1,
|
||||
noiseSeed: noiseSeedIdx !== -1 ? instanceNode.widgets_values[noiseSeedIdx] : null,
|
||||
hasTurboMode: turboModeIdx !== -1,
|
||||
turboMode: turboModeIdx !== -1 ? instanceNode.widgets_values[turboModeIdx] : null,
|
||||
hasTurboStrength: turboStrengthIdx !== -1,
|
||||
turboStrength: turboStrengthIdx !== -1 ? instanceNode.widgets_values[turboStrengthIdx] : null,
|
||||
hasTurboSteps: turboStepsIdx !== -1,
|
||||
turboSteps: turboStepsIdx !== -1 ? instanceNode.widgets_values[turboStepsIdx] : null,
|
||||
hasPromptEnhance: promptEnhanceIdx !== -1,
|
||||
promptEnhance: promptEnhanceIdx !== -1 ? instanceNode.widgets_values[promptEnhanceIdx] : null,
|
||||
};
|
||||
}
|
||||
|
||||
// first_frame/last_frameが未接続のワークフローに対して、画像が指定された時だけ
|
||||
// LoadImageノードを新規注入する共通ヘルパー。ノードID/リンクIDはclone内の実際の
|
||||
// 最大値+1で採番するため、重複ID/リンクID不整合は起きない
|
||||
// (CLAUDE.md記載の既知の落とし穴への対策)。
|
||||
export function injectFrameNode(clone, instanceNode, targetSlot, filename) {
|
||||
const maxNodeId = Math.max(clone.last_node_id || 0, ...clone.nodes.map((n) => n.id));
|
||||
const maxLinkId = Math.max(clone.last_link_id || 0, ...clone.links.map((l) => l[0]));
|
||||
const newNodeId = maxNodeId + 1;
|
||||
const newLinkId = maxLinkId + 1;
|
||||
|
||||
clone.nodes.push({
|
||||
id: newNodeId,
|
||||
type: "LoadImage",
|
||||
pos: [(instanceNode.pos?.[0] || 0) - 260, (instanceNode.pos?.[1] || 0) + 40],
|
||||
size: [280, 314],
|
||||
flags: {},
|
||||
order: clone.nodes.length,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ localized_name: "image", name: "image", type: "COMBO", widget: { name: "image" }, link: null },
|
||||
{ localized_name: "choose file to upload", name: "upload", type: "IMAGEUPLOAD", widget: { name: "upload" }, link: null },
|
||||
],
|
||||
outputs: [
|
||||
{ localized_name: "IMAGE", name: "IMAGE", type: "IMAGE", links: [newLinkId] },
|
||||
{ localized_name: "MASK", name: "MASK", type: "MASK", links: null },
|
||||
],
|
||||
properties: { "Node name for S&R": "LoadImage" },
|
||||
widgets_values: [filename, "image"],
|
||||
});
|
||||
|
||||
clone.links.push([newLinkId, newNodeId, 0, instanceNode.id, targetSlot, "IMAGE"]);
|
||||
const targetInput = instanceNode.inputs[targetSlot];
|
||||
if (targetInput) targetInput.link = newLinkId;
|
||||
|
||||
clone.last_node_id = newNodeId;
|
||||
clone.last_link_id = newLinkId;
|
||||
}
|
||||
|
||||
// Writes one block's (or the old single-shot form's) values onto a workflow
|
||||
// clone already located via locateVideoModelNodes(clone) — `nodes`' node
|
||||
// references point into `clone` itself. First/last frame images are only
|
||||
// touched when a filename is actually given: an existing LoadImage node gets
|
||||
// its widget overwritten, a missing one gets injected on demand, and leaving
|
||||
// both unset keeps the slot disconnected (bypassed) exactly like the
|
||||
// template's own clean state.
|
||||
export function applyBlockToWorkflow(clone, nodes, params) {
|
||||
const {
|
||||
instanceNode, resolutionNode, loadImageNode, lastLoadImageNode, promptIdx, durationIdx, firstFrameSlot, lastFrameSlot,
|
||||
widthIdx, heightIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
} = nodes;
|
||||
|
||||
instanceNode.widgets_values[promptIdx] = params.prompt || "";
|
||||
instanceNode.widgets_values[durationIdx] = params.duration;
|
||||
|
||||
const arIdx = widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = widgetIndex(resolutionNode, "multiple");
|
||||
if (arIdx !== -1) resolutionNode.widgets_values[arIdx] = params.aspectRatio;
|
||||
if (mpIdx !== -1) resolutionNode.widgets_values[mpIdx] = params.megapixels;
|
||||
if (multIdx !== -1) resolutionNode.widgets_values[multIdx] = params.multiple;
|
||||
|
||||
// widthIdx/heightIdxはresolutionNodeがあるワークフローでは-1固定(locateVideoModelNodes
|
||||
// 参照)なので、そちらはaspect_ratio/megapixels/multiple経由の計算値のまま変更しない。
|
||||
if (widthIdx !== -1 && params.width != null) instanceNode.widgets_values[widthIdx] = params.width;
|
||||
if (heightIdx !== -1 && params.height != null) instanceNode.widgets_values[heightIdx] = params.height;
|
||||
// noise_seedはここでは書かない — comfyUI.generate()がAPI変換後のワークフロー全体から
|
||||
// "noise_seed"/"seed"キーを持つ全ノードを一括で上書きする(applySeedToWorkflow)ため、
|
||||
// ここで書いてもその後必ず上書きされる。Plan全体のシード管理は引き続きそちら任せでよい。
|
||||
if (turboModeIdx !== -1 && params.turboMode != null) instanceNode.widgets_values[turboModeIdx] = params.turboMode;
|
||||
if (turboStrengthIdx !== -1 && params.turboStrength != null) instanceNode.widgets_values[turboStrengthIdx] = params.turboStrength;
|
||||
if (turboStepsIdx !== -1 && params.turboSteps != null) instanceNode.widgets_values[turboStepsIdx] = params.turboSteps;
|
||||
if (promptEnhanceIdx !== -1 && params.promptEnhance != null) instanceNode.widgets_values[promptEnhanceIdx] = params.promptEnhance;
|
||||
|
||||
if (params.firstImageFilename) {
|
||||
if (loadImageNode) {
|
||||
const fnIdx = widgetIndex(loadImageNode, "image");
|
||||
if (fnIdx !== -1) loadImageNode.widgets_values[fnIdx] = params.firstImageFilename;
|
||||
} else if (firstFrameSlot !== -1) {
|
||||
injectFrameNode(clone, instanceNode, firstFrameSlot, params.firstImageFilename);
|
||||
}
|
||||
}
|
||||
if (params.lastImageFilename) {
|
||||
if (lastLoadImageNode) {
|
||||
const fnIdx = widgetIndex(lastLoadImageNode, "image");
|
||||
if (fnIdx !== -1) lastLoadImageNode.widgets_values[fnIdx] = params.lastImageFilename;
|
||||
} else if (lastFrameSlot !== -1) {
|
||||
injectFrameNode(clone, instanceNode, lastFrameSlot, params.lastImageFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { showToast, openModal, closeModal } from "./app.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { loadWorkflowIntoVideoEditor } from "./video-tab.js";
|
||||
import { t, getSummaryPrompt } from "./i18n.js";
|
||||
import { highlightJSON } from "./json-highlight.js";
|
||||
import { openBadgeEditModal } from "./models-tab.js";
|
||||
@@ -46,7 +47,7 @@ function saveBadgePalette(palette) {
|
||||
// Open in ComfyUI / Send to Canvas
|
||||
// ============================================
|
||||
|
||||
function sendToCanvas(workflowData) {
|
||||
function sendToCanvas(workflowData, filename) {
|
||||
try {
|
||||
// window.opener経由でComfyUIキャンバスに直接ロード(推奨)
|
||||
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
|
||||
@@ -54,14 +55,13 @@ function sendToCanvas(workflowData) {
|
||||
showToast(t("workflowSentToCanvasDirect"), "success");
|
||||
return;
|
||||
}
|
||||
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
|
||||
const fmt = comfyWorkflow.detectFormat(workflowData);
|
||||
if (fmt === "api") {
|
||||
showToast(t("apiFormatCanvasNoOpener"), "error");
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("wfm_pending_workflow", JSON.stringify(workflowData));
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
// フォールバック: Workflow Studio LibraryのWorkflows(W)タブを当該ワークフローで
|
||||
// 絞り込み表示する。以前はUI形式限定で「タイトルをドラッグ」させていたが、
|
||||
// サムネイルが見えず分かりにくい上、API形式のワークフローはそもそも非対応だった。
|
||||
// 絞り込み表示なら実物のワークフローカード(サムネイル付き)が出るので、
|
||||
// フォーマットを問わずユーザー自身がそこからドラッグできる。
|
||||
localStorage.setItem("wfm_library_filter_request", filename || "");
|
||||
showToast(t("workflowFilteredInLibrary"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
@@ -616,6 +616,8 @@ async function showSidePanel(wf, cardEl) {
|
||||
// Enable toolbar load buttons
|
||||
const listLoadBtn = document.getElementById("wfm-list-load-btn");
|
||||
if (listLoadBtn) { listLoadBtn.disabled = false; listLoadBtn.title = ""; }
|
||||
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
|
||||
if (listLoadVideoBtn) { listLoadVideoBtn.disabled = false; listLoadVideoBtn.title = ""; }
|
||||
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
|
||||
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = false; listOpenComfyBtn.title = ""; }
|
||||
|
||||
@@ -661,6 +663,8 @@ function closeSidePanel() {
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
const listLoadBtn = document.getElementById("wfm-list-load-btn");
|
||||
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
|
||||
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
|
||||
if (listLoadVideoBtn) { listLoadVideoBtn.disabled = true; listLoadVideoBtn.title = t("selectCardFirst"); }
|
||||
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
|
||||
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
|
||||
renderSideGroup(null);
|
||||
@@ -957,8 +961,6 @@ function openDetailModal(wf) {
|
||||
<textarea class="wfm-textarea" id="wfm-detail-memo" rows="4" placeholder="${t("memoPlaceholder")}">${meta.memo || ""}</textarea>
|
||||
</section>
|
||||
<div class="wfm-modal-actions">
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-detail-load">${t("loadInGenerate")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-open-comfyui">${t("sendToCanvas")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-set-default">${t("setAsDefault")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-change-thumb">${t("changeThumbnail")}</button>
|
||||
<input type="file" id="wfm-detail-thumb-file" accept="image/png,image/webp,image/jpeg" style="display:none">
|
||||
@@ -1095,29 +1097,6 @@ function openDetailModal(wf) {
|
||||
}
|
||||
});
|
||||
|
||||
// Load in GenerateUI
|
||||
document.getElementById("wfm-detail-load").addEventListener("click", async () => {
|
||||
try {
|
||||
const wfData = await getRawWorkflow(wf.filename);
|
||||
const loaded = await loadWorkflowIntoEditor(wfData, wf.filename);
|
||||
if (loaded === false) return;
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Send to Canvas
|
||||
document.getElementById("wfm-detail-open-comfyui")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const wfData = await getRawWorkflow(wf.filename);
|
||||
sendToCanvas(wfData);
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Set as default workflow
|
||||
document.getElementById("wfm-detail-set-default")?.addEventListener("click", async () => {
|
||||
try {
|
||||
@@ -1399,12 +1378,25 @@ export function initWorkflowTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar: Load in Video button (MiniMax H3 / LTX-2.5 / Wan2.2対応。非対応ワークフローはloadWorkflowIntoVideoEditor内でエラートースト表示済み)
|
||||
document.getElementById("wfm-list-load-video-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
try {
|
||||
const wfData = await getRawWorkflow(state.selectedWf.filename);
|
||||
const loaded = await loadWorkflowIntoVideoEditor(wfData, state.selectedWf.filename);
|
||||
if (loaded === false) return;
|
||||
document.querySelector('.wfm-tab[data-tab="video"]')?.click();
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar: Send to Canvas button
|
||||
document.getElementById("wfm-list-open-comfyui-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
try {
|
||||
const wfData = await getRawWorkflow(state.selectedWf.filename);
|
||||
sendToCanvas(wfData);
|
||||
sendToCanvas(wfData, state.selectedWf.filename);
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Regenerate README.index.md / DEVLOG.index.md from README.md / DEVLOG.md.
|
||||
|
||||
README.md and DEVLOG.md are excluded from graphify's semantic extraction
|
||||
(.graphifyignore) because they are too large for reliable local-Ollama
|
||||
extraction: big chunks make the model summarize in prose instead of emitting
|
||||
JSON, and time out on slower models/hardware. These condensed index files are
|
||||
extracted instead. Run this whenever README.md or DEVLOG.md changes, then
|
||||
`graphify --update` to refresh the graph.
|
||||
|
||||
Usage:
|
||||
python tools/generate_doc_index.py [readme|devlog|all]
|
||||
(defaults to "all")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def generate_readme_index() -> Path:
|
||||
text = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
details_re = re.compile(
|
||||
r"<details>\s*<summary><h3>(.*?)</h3></summary>\s*(.*?)</details>",
|
||||
re.S,
|
||||
)
|
||||
bullet_re = re.compile(r"^- \*\*(.+?)\*\*", re.M)
|
||||
|
||||
feature_lines: list[str] = []
|
||||
for title, body in details_re.findall(text):
|
||||
names = bullet_re.findall(body)
|
||||
feature_lines.append(f"### {title.strip()}")
|
||||
if names:
|
||||
feature_lines.append(", ".join(names))
|
||||
feature_lines.append("")
|
||||
|
||||
tail_start = text.find("## Installation")
|
||||
tail = text[tail_start:] if tail_start != -1 else ""
|
||||
|
||||
intro_end = text.find("## Screenshots")
|
||||
intro = text[:intro_end].strip() if intro_end != -1 else ""
|
||||
|
||||
out = [
|
||||
"# 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.",
|
||||
"",
|
||||
intro,
|
||||
"",
|
||||
"## Features (tab -> feature names)",
|
||||
"",
|
||||
*feature_lines,
|
||||
tail,
|
||||
]
|
||||
|
||||
content = "\n".join(out)
|
||||
out_path = ROOT / "README.index.md"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
def generate_devlog_index() -> Path:
|
||||
text = (ROOT / "DEVLOG.md").read_text(encoding="utf-8")
|
||||
|
||||
entries = re.split(r"^## ", text, flags=re.M)[1:]
|
||||
lines: list[str] = []
|
||||
for entry in entries:
|
||||
parts = entry.split("\n", 1)
|
||||
version_line = parts[0].strip()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
m = re.search(r"^### (.+)$", rest, re.M)
|
||||
subtitle = m.group(1).strip() if m else ""
|
||||
lines.append(
|
||||
f"- **{version_line}** — {subtitle}" if subtitle else f"- **{version_line}**"
|
||||
)
|
||||
|
||||
out = [
|
||||
"# 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.",
|
||||
"",
|
||||
*lines,
|
||||
"",
|
||||
]
|
||||
|
||||
content = "\n".join(out)
|
||||
out_path = ROOT / "DEVLOG.index.md"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
written: list[Path] = []
|
||||
if target in ("readme", "all"):
|
||||
written.append(generate_readme_index())
|
||||
if target in ("devlog", "all"):
|
||||
written.append(generate_devlog_index())
|
||||
if not written:
|
||||
print(f"unknown target {target!r}; use readme|devlog|all", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
for p in written:
|
||||
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size:,} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -673,9 +673,9 @@ window.wfmOpenMaskEditorForNode = async (imageDataUrl, maskDataUrl) => {
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Cross-window bridge for SPA "Send to Workflow"
|
||||
// Cross-window bridge for SPA "Send to LI node"
|
||||
// ============================================
|
||||
// WFS Image Edit タブから呼ばれる。アップロード済みの画像ファイル名を、現在ComfyUIキャンバス上で
|
||||
// WFS Image Edit / Galleryタブから呼ばれる。アップロード済みの画像ファイル名を、現在ComfyUIキャンバス上で
|
||||
// 選択中のノードの "image" ウィジェット(LoadImage / LoadImage 互換ノード)へ書き込む。
|
||||
// 選択ノードに該当ウィジェットが無ければグラフ内の最初の該当ノードにフォールバックする。
|
||||
// chat_TE カスタムノードの "Send to workflow" ボタンと同じロジック。
|
||||
@@ -2376,6 +2376,35 @@ const createDraggableItem = (label, type, data, subtitle) => {
|
||||
// Toggle & exports
|
||||
// ============================================
|
||||
|
||||
// Opens the Library panel on the Workflows (W) tab, filtered down to a single
|
||||
// workflow by filename. Used as the "Send to Canvas" fallback when window.opener
|
||||
// isn't available (or the workflow is API-format, which the title-drag mechanism
|
||||
// can't handle at all) — the user gets the actual workflow card, thumbnail
|
||||
// included, to drag onto the canvas themselves instead of a bare title element.
|
||||
export const showWorkflowInLibrary = async (filename) => {
|
||||
const panel = createPanel();
|
||||
state.visible = true;
|
||||
panel.style.display = "flex";
|
||||
|
||||
state.topTab = "workflows";
|
||||
panel.querySelectorAll(".wfm-nlp-top-tab").forEach(b => b.classList.toggle("active", b.dataset.toptab === "workflows"));
|
||||
state.wfSubTab = "wf-all";
|
||||
state.wfSubTab2 = null;
|
||||
|
||||
if (!state.wfLoaded) await loadWfData();
|
||||
|
||||
state.searchText = (filename || "").toLowerCase().trim();
|
||||
const searchInput = panel.querySelector(".wfm-nlp-search-input");
|
||||
if (searchInput) {
|
||||
searchInput.value = filename || "";
|
||||
searchInput.placeholder = "Search workflows...";
|
||||
}
|
||||
|
||||
rebuildSubTabs();
|
||||
renderContent();
|
||||
installCanvasDropHandler();
|
||||
};
|
||||
|
||||
export const togglePanel = async () => {
|
||||
const panel = createPanel();
|
||||
state.visible = !state.visible;
|
||||
@@ -2524,6 +2553,81 @@ 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)。static/js/metadata-tab.js の readMP4MetaTags() と同一ロジック。
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI→API変換(static/js/comfyui-workflow.js の convertUiToApi() 相当ロジックの複製)
|
||||
// web/comfyui/ は static/js/ と配信URLが異なりESモジュールをimportできないため、
|
||||
@@ -3301,6 +3405,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".
|
||||
@@ -3428,6 +3539,7 @@ 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;
|
||||
@@ -3463,6 +3575,17 @@ 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;
|
||||
@@ -3572,7 +3695,7 @@ const setupInfoDropHandlers = (container) => {
|
||||
const renderInfoModels = (container, meta) => {
|
||||
container.innerHTML = "";
|
||||
if (!meta) {
|
||||
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/JSON file to view model info</div>`;
|
||||
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/MP4/JSON file to view model info</div>`;
|
||||
return;
|
||||
}
|
||||
const sections = [
|
||||
@@ -3611,7 +3734,7 @@ const renderInfoModels = (container, meta) => {
|
||||
const renderInfoLoras = (container, meta) => {
|
||||
container.innerHTML = "";
|
||||
if (!meta || !meta.loras || meta.loras.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-info-empty">${meta ? "No LoRA found" : "Drop a PNG/WebP/JSON file to view LoRA info"}</div>`;
|
||||
container.innerHTML = `<div class="wfm-nlp-info-empty">${meta ? "No LoRA found" : "Drop a PNG/WebP/MP4/JSON file to view LoRA info"}</div>`;
|
||||
return;
|
||||
}
|
||||
for (const lora of meta.loras) {
|
||||
@@ -3660,7 +3783,7 @@ const renderInfoPrompts = (container, meta) => {
|
||||
const promptFull = panelEl?.querySelector("#wfm-nlp-info-prompt-full");
|
||||
const promptFullLabel = panelEl?.querySelector("#wfm-nlp-info-prompt-full-label");
|
||||
if (!meta) {
|
||||
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/JSON file to view prompts</div>`;
|
||||
container.innerHTML = `<div class="wfm-nlp-info-empty">Drop a PNG/WebP/MP4/JSON file to view prompts</div>`;
|
||||
return;
|
||||
}
|
||||
const allPrompts = [
|
||||
@@ -3725,8 +3848,8 @@ const renderInfoTab = (container) => {
|
||||
<div class="wfm-nlp-info-layout">
|
||||
<div class="wfm-nlp-info-drop" id="wfm-nlp-info-drop">
|
||||
<img id="wfm-nlp-info-preview-img" style="display:none;max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;">
|
||||
<span id="wfm-nlp-info-drop-label" style="font-size:11px;color:var(--descrip-text,#999);pointer-events:none;">Drop PNG / WebP / JSON</span>
|
||||
<input type="file" id="wfm-nlp-info-file-input" accept=".png,.webp,.json,image/png,image/webp,application/json" style="display:none;">
|
||||
<span id="wfm-nlp-info-drop-label" style="font-size:11px;color:var(--descrip-text,#999);pointer-events:none;">Drop PNG / WebP / MP4 / JSON</span>
|
||||
<input type="file" id="wfm-nlp-info-file-input" accept=".png,.webp,.mp4,.json,image/png,image/webp,video/mp4,application/json" style="display:none;">
|
||||
</div>
|
||||
<div id="wfm-nlp-info-fileinfo" class="wfm-nlp-info-fileinfo">—</div>
|
||||
<div id="wfm-nlp-info-subcontent" class="wfm-nlp-info-subcontent"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet, showWorkflowInLibrary } from "./node_sets_menu.js";
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
@@ -572,6 +572,23 @@ app.registerExtension({
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
// Send to Canvas fallback: when the Workflow Studio SPA can't reach this page
|
||||
// directly via window.opener (e.g. it isn't the opener that spawned it, or
|
||||
// the workflow is API-format and window.opener.wfmReceiveWorkflow can't
|
||||
// handle it either), the SPA writes the workflow's filename here instead of
|
||||
// the old title-drag mechanism. localStorage "storage" events fire in every
|
||||
// same-origin tab except the one that wrote the value, so this listener
|
||||
// picks it up here and opens the Library panel pre-filtered to that
|
||||
// workflow's card for the user to drag onto the canvas themselves.
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "wfm_library_filter_request" && e.newValue != null) {
|
||||
showWorkflowInLibrary(e.newValue);
|
||||
// Consume it so a future page load (which reads the stale value but
|
||||
// never sees a "storage" event for it) doesn't re-trigger the filter.
|
||||
localStorage.removeItem("wfm_library_filter_request");
|
||||
}
|
||||
});
|
||||
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
{
|
||||
"id": "8e2c1a4f-6b3d-4a9e-9c7f-1d5e8f3a2b6c",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 11,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
413,
|
||||
389
|
||||
],
|
||||
"size": [
|
||||
425.27801513671875,
|
||||
180.6060791015625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"text, watermark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
863,
|
||||
186
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "steps",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "sampler_name",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "scheduler",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "denoise",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
378124453179007,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1209,
|
||||
188
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1451,
|
||||
189
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
473,
|
||||
609
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "width",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "height",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "batch_size",
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
1024,
|
||||
1024,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
51.3,
|
||||
182.49999999999997
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
3,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"v1-5-pruned-emaonly-fp16.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "VAELoader",
|
||||
"pos": [
|
||||
61.897062649999256,
|
||||
347.8818042399999
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "vae_name",
|
||||
"name": "vae_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "vae_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {}
|
||||
},
|
||||
"Node name for S&R": "VAELoader"
|
||||
},
|
||||
"widgets_values": [
|
||||
"vae-ft-mse-840000-ema-pruned.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "ImpactWildcardEncode",
|
||||
"pos": [
|
||||
415,
|
||||
150
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
322
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"localized_name": "conditioning",
|
||||
"name": "conditioning",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "populated_text",
|
||||
"name": "populated_text",
|
||||
"type": "STRING",
|
||||
"slot_index": 3,
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImpactWildcardEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a photo of __sample_season/spring__, with __sample_lighting/natural__, highly detailed",
|
||||
"",
|
||||
"populate",
|
||||
"Select the LoRA to add to the text",
|
||||
"Select the Wildcard to add to the text",
|
||||
123456789,
|
||||
"randomize"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
1,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
4,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
9,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
0,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
11,
|
||||
4,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"MODEL"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": 1
|
||||
},
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": []
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||