Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74eb8b779f | ||
|
|
baed398fe8 | ||
|
|
77cfedb243 | ||
|
|
ad70622942 | ||
|
|
f726418aeb | ||
|
|
a917e82ad7 | ||
|
|
2fc2365461 | ||
|
|
337fd6ecb8 | ||
|
|
831ba1bd43 | ||
|
|
899feb1bb5 | ||
|
|
075e2a8c62 | ||
|
|
77163de202 | ||
|
|
7f0ecc2ba3 | ||
|
|
ac733db81e | ||
|
|
7f2720ff8f | ||
|
|
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 | ||
|
|
e9fbb6dd39 | ||
|
|
f858c5138a | ||
|
|
7d9c498234 | ||
|
|
d43b35dde3 | ||
|
|
b79f4e8fd0 | ||
|
|
87c53ef977 | ||
|
|
8b75d2a8d0 | ||
|
|
7aa2535964 | ||
|
|
7769de6fbb | ||
|
|
7eafe9a684 | ||
|
|
c9089c2892 | ||
|
|
9b3532704a | ||
|
|
f6f4b2c95c | ||
|
|
ac6a4f2dd6 | ||
|
|
e6c503ef75 | ||
|
|
e21f5596d8 | ||
|
|
61bf2c83da | ||
|
|
d000219b86 | ||
|
|
52ab637bd0 | ||
|
|
5e5709594b | ||
|
|
b20519c092 | ||
|
|
9d952c4b6e | ||
|
|
8248ff8ac7 | ||
|
|
7548c49910 | ||
|
|
e9f2c6171a | ||
|
|
90a7d824fb | ||
|
|
05e061d656 | ||
|
|
5afdec395c | ||
|
|
3ab4f3e36b | ||
|
|
4a23564c66 | ||
|
|
d8ffcc182e | ||
|
|
3d5bc47e20 | ||
|
|
35899bf53e | ||
|
|
f356826e9f | ||
|
|
003f3408fc | ||
|
|
54198f196a | ||
|
|
3ff76aacc6 | ||
|
|
ec8be7ee6f | ||
|
|
117fcad22a | ||
|
|
feed327c23 | ||
|
|
82ddbb340c | ||
|
|
31f02962f5 | ||
|
|
413f0d5068 | ||
|
|
bec84ac30b | ||
|
|
61e16ddce0 | ||
|
|
174a67a569 | ||
|
|
d903f33a31 |
@@ -0,0 +1,60 @@
|
||||
"""PostToolUse hook: regenerate README.index.md / DEVLOG.index.md right after
|
||||
Claude Code edits README.md or DEVLOG.md, so graphify's excluded-doc index
|
||||
stand-ins (see .graphifyignore) never drift from the source files within a
|
||||
session. A git pre-commit hook covers edits made outside Claude Code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGETS = {"readme.md": "readme", "devlog.md": "devlog"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
|
||||
if payload.get("tool_name") not in ("Edit", "Write"):
|
||||
return
|
||||
|
||||
file_path = payload.get("tool_input", {}).get("file_path", "")
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
name = Path(file_path).name.lower()
|
||||
target = TARGETS.get(name)
|
||||
if target is None:
|
||||
return
|
||||
|
||||
project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or str(Path(file_path).resolve().parent)
|
||||
script = Path(project_dir) / "tools" / "generate_doc_index.py"
|
||||
if not script.exists():
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), target],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(json.dumps({"systemMessage": f"[doc-index] {result.stdout.strip()}"}))
|
||||
else:
|
||||
print(
|
||||
json.dumps(
|
||||
{"systemMessage": f"[doc-index] generate_doc_index.py {target} failed: {result.stderr.strip()}"}
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/on_doc_edit.py\"",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# graphify: keep graph.json/graphify-out/ out of Claude Code's automatic
|
||||
# context so re-running `graphify extract` doesn't churn the prompt cache
|
||||
# on every rebuild. These are still tracked by git (see .gitignore).
|
||||
graph.json
|
||||
graphify-out/
|
||||
@@ -0,0 +1,11 @@
|
||||
# ComfyUI-Workflow-Studio - Unsloth backend (AI TOOL tab / sidebar A tab)
|
||||
#
|
||||
# Unsloth Desktop requires an API key even for local access
|
||||
# (Settings -> API in the Unsloth app, shown only once when issued).
|
||||
#
|
||||
# 1. Copy this file to ".env" (same directory as this example file)
|
||||
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
|
||||
# line becomes a comment and the key will not be loaded)
|
||||
# 3. Restart ComfyUI
|
||||
|
||||
UNSLOTH_API_KEY=
|
||||
@@ -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 }}
|
||||
|
||||
@@ -5,3 +5,12 @@ __pycache__/
|
||||
.venv
|
||||
*.egg-info/
|
||||
data/
|
||||
|
||||
# graphify: keep the knowledge graph itself under version control,
|
||||
# but exclude the run-by-run cost/token log, the local extraction
|
||||
# cache (both churn every rebuild and have no value to other clones),
|
||||
# and the machine-local interpreter/scan-root pointers.
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/.graphify_python
|
||||
graphify-out/.graphify_root
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Private/unpublished planning notes — never committed to git, must not leak
|
||||
# into the published graphify-out/ knowledge graph either.
|
||||
QWEN_CHAT_INTEGRATION_PLAN.md
|
||||
|
||||
# Too large for reliable local-Ollama semantic extraction (large chunks make the
|
||||
# model summarize in prose instead of emitting JSON, and time out on slower
|
||||
# models/hardware). README.index.md / DEVLOG.index.md are condensed stand-ins
|
||||
# that graphify extracts instead — regenerate them after editing the originals.
|
||||
README.md
|
||||
DEVLOG.md
|
||||
@@ -194,9 +194,34 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
|
||||
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
|
||||
2. `README.md` のバージョンバッジを更新(``)
|
||||
3. `README.md` の Changelog に新バージョンのエントリを追加
|
||||
4. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
5. 上記すべてを同一コミットにまとめてプッシュ
|
||||
6. `gh release create vX.Y.Z` でリリースを作成
|
||||
3. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
4. 上記すべてを同一コミットにまとめてプッシュ
|
||||
5. `gh release create vX.Y.Z` でリリースを作成(リリースノート本文に変更点を記載する — v0.3.90でREADME.mdのChangelogセクションは廃止したため、以後のバージョン履歴はGitHub Releaseのリリースノートのみで管理する)
|
||||
|
||||
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
|
||||
|
||||
<!-- graphify運用は一時停止中(2026-08-23〜、ユーザー指示により凍結)。再開時はこのコメントブロックを解除すること。graphify-out/ 自体は削除しておらずそのまま残っている。
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
### README.md / DEVLOG.md の索引ファイル
|
||||
|
||||
`README.md`/`DEVLOG.md`はサイズが大きすぎてローカルOllamaでの意味抽出が不安定になる(大きな塊を渡すとJSONではなく散文の要約を返す、または処理がタイムアウトする)ため、`.graphifyignore`でgraphifyの抽出対象から除外している。代わりに縮小版の`README.index.md`/`DEVLOG.index.md`を抽出対象にしている。
|
||||
|
||||
- 生成スクリプト: `python tools/generate_doc_index.py [readme|devlog|all]`
|
||||
- 自動化: `README.md`/`DEVLOG.md`を編集すると以下の2つのフックが自動でindexを再生成する
|
||||
- Claude Code PostToolUseフック(`.claude/hooks/on_doc_edit.py`)— Edit/Write直後に即時実行
|
||||
- git pre-commitフック(`.git/hooks/pre-commit`)— コミット時にstagedファイルを検知して再生成し、同じコミットに含める(他エディタでの編集にも対応)
|
||||
- 索引ファイルを手動で編集しない(次の生成で上書きされる)
|
||||
- 索引ファイル再生成後は`graphify update .`を実行してグラフに反映する
|
||||
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# 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.7.0(2026-09-17)** — プレビュー枠の高さ固定・AssetサブタブをPlan/Editと並ぶ中央下部へ移設・トリムパネルにClipchamp風スクラバー追加・Frame保存のVideo Assets登録とAsset→Plan画像連携
|
||||
- **v0.6.3** — Send CCボタンでMP4動画をComic Creatorの新設「動画ツール」へ送れるように対応
|
||||
- **v0.6.2** — Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
|
||||
- **v0.6.1** — Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
|
||||
- **v0.6.0** — Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
|
||||
- **v0.5.8** — Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
|
||||
- **v0.5.7** — 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
|
||||
- **v0.5.6** — バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
|
||||
- **v0.5.5** — Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
|
||||
- **v0.5.4** — 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
|
||||
- **v0.5.3** — バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
|
||||
- **v0.5.2** — 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
|
||||
- **v0.5.1** — 機能追加: mp4動画の埋め込みメタデータ対応(Gallery Prompt/JSON・Metadataサブタブ・サイドパネルIタブ)+ Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
|
||||
- **v0.5.0** — 機能追加: mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
|
||||
- **v0.4.8** — 機能追加: LabタブにPromptグループ一括適用+キーフレーム並べ替え + バグ修正: デフォルトワークフローの上書き保存が反映されない + Styleテーブルの「このファイルへ追加」対応
|
||||
- **v0.4.7** — 機能追加: Prompt Tab内部リファクタ + Gallery Promptタブ新設 + Send to Canvas改善 + Presetsソート
|
||||
- **v0.4.6** — 機能追加: MetadataタブをGalleryタブへ統合 + Promptタブに一覧編集可能なTable表示を新設
|
||||
- **v0.4.5** — 調査: トップバー保存ワークフローのRMBG不具合報告(原因なし) + バグ修正: LabタブModel列の無意味な空キーフレーム表示を解消
|
||||
- **v0.4.4** — 機能追加: Lab/GenerateUI ModelタブへLoRAバイパス機能 + AI TOOL Chatの画像生成パラメータ拡張
|
||||
- **v0.4.3** — バグ修正: 共有モーダルのお気に入り☆/リネームがLabモーダルへ残留 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
|
||||
- **v0.4.2** — 機能追加: Lab「Model」列統合(Checkpoint / LoRA Single / VAE)+ フィールド単位継承 + C/L/V表示切替
|
||||
- **v0.4.1** — UX改善: Mask Editor One連携ボタンを「選択中ノード優先」方式に再設計
|
||||
- **v0.4.0** — リファクタリング: image-edit-tab.js / models-tab.js の低凝集度モジュール分割
|
||||
- **v0.3.99** — 機能追加: AI TOOLタブ + サイドパネルAタブにUnslothバックエンド追加(APIキーは.env管理、SSRF脆弱性修正、reasoning_content対応)
|
||||
- **v0.3.98** — 機能追加・バグ修正: AI TOOL翻訳の信頼性向上 + GenerateUI Modelタブ再構成(ハイライト色・折りたたみ・並び替え)+ Thinking mode/Max tokens設定
|
||||
- **v0.3.97** — 機能追加・バグ修正: KREA-2ワークフロー対応 + Labタブ拡張(Clear/Get from Previous/Bypass/ライブ反映)+ Input Imageプレースホルダ機能
|
||||
- **v0.3.96** — バグ修正: カタログ作成バッチが特定件数で無言停止する問題の修正 + Style Catalog/Promptタブ機能拡張
|
||||
- **v0.3.95** — 機能追加: Style/Prompt→ImagePrompt改名 + Style_Catalogギャラリー新設 + GenerateUI「カタログ作成」機能
|
||||
- **v0.3.94** — 機能追加: Gallery タブのサブタブ化 + Style/Prompt ギャラリー新設(視覚的プロンプトビルダー)
|
||||
- **v0.3.93** — バグ修正・機能改善: Labタブ VAEキーフレーム不発バグの修正+Style適用UI刷新+Plan JSONスクロール同期修正+キーフレーム個別削除+Batch数超過警告+ワイルドカード対応+Save index image デフォルトON
|
||||
- **v0.3.92** — バグ修正: CLIPTextEncodeEditPlus使用ワークフローがGenerateUI経由だと直接実行と異なる画像になる問題(根本原因はconvertUiToApiのForceInput 未考慮バグ)+LoRA Stack機能の改善一式
|
||||
- **v0.3.91** — 機能拡張: Labインデックス画像にPlanメタデータをPNG埋め込み+README構成整理(Changelogセクション廃止)
|
||||
- **v0.3.90** — 機能拡張: Labタブの進化(ワークフロー呼び出し・T2I対応・Run時インデックス画像Output保存+Eagle連携・プラン接頭辞)+Galleryタブのルート表示簡略化
|
||||
- **v0.3.89** — バグ修正: v0.3.88で追加したLab機能への実動作確認フィードバック3件を反映
|
||||
- **v0.3.88** — 機能拡張: Labタブ Promptセルにスタイル適用・画像/GenerateUIからのプロンプト取得、Checkpoint/VAEフィルター、Plan JSONサブタブを追加
|
||||
- **v0.3.87** — 追加機能: GenerateUIタブに実験的な「Lab」サブタブを追加(I2Iバッチ生成・キーフレーム方式)
|
||||
- **v0.3.86** — バグ修正: 画像を一括生成(I2I)等の連続実行で "GalleryMetadataStore: save error: dictionary changed size during iteration" が発生
|
||||
- **v0.3.85** — 機能拡張: Generate UI連携ブリッジ(T2I)にデフォルトワークフロー・Negativeプロンプト対応を追加
|
||||
- **v0.3.84** — バグ修正: ernie_t2i.jsonをGenerateUIタブで実行すると "Prompt outputs failed validation" エラー
|
||||
- **v0.3.83** — 追加機能: Ernie Image(ernie-image / ernie-image-turbo)ワークフロー対応 + プロンプトがリンク経由のノードから供給されるパターンの修正
|
||||
- **v0.3.82** — バグ修正: MetadataタブとサイドパネルIタブでLongCat/Boogu/HiDream E1/FireRed等のプロンプトが表示されない不具合
|
||||
- **v0.3.81** — 追加機能: GenerateUIタブでFlux.2 Klein / LongCat / Boogu / HiDream E1 / FireRedのImage Editワークフローに対応
|
||||
- **v0.3.80** — 追加機能: Comic Creator「半自動マンガ作成」向けブリッジ(LLMプロンプト下書き・バッチ画像生成)
|
||||
- **v0.3.79** — 追加機能: サイドパネル(Workflow Studio Library)IタブのMage-Flow等サブグラフワークフロー対応
|
||||
- **v0.3.78** — 追加機能: Eagle自動保存のSVG対応
|
||||
- **v0.3.76** — 追加機能: Comic Creator向けI2I実行ブリッジ`_wfmReceiveI2IRunRequest`
|
||||
- **v0.3.77** — 追加機能: GalleryタブでSVGファイルの表示・管理に対応
|
||||
- **v0.3.75** — Lemonadeバックエンド追加(3つ目のAIバックエンド)
|
||||
- **v0.3.74** — AI TOOL Chatペインの画像添付対応 + I2I連携
|
||||
- **v0.3.73** — AI TOOL Chatペインからの対話式画像生成(Tool Calling)+ Checkpoint関連の安全策
|
||||
- **v0.3.72** — Comic Creator連携用の外部Inpaintエントリポイントを追加
|
||||
- **v0.3.71** — Inpaint機能の追加(GenerateUI Imageタブ + Image Edit「Inpaint」タブ)
|
||||
- **v0.3.70** — Workflow AI要約: Ollama 404エラー修正
|
||||
- **v0.3.69** — Gallery → ComfyUI Comic Creator 連携(Send CC ボタン)
|
||||
- **v0.3.68** — MASK EDITOR ONE セクション常時表示化
|
||||
- **v0.3.67** — Alt+Click Apply & Generate
|
||||
- **計画: Image Edit 拡張 — ABRブラシ連携 + Mask Editor One 統合** — フェーズ1: Draw ツール — ABRブラシ対応
|
||||
- **2026-06-29: v0.3.66 — Mask ツール 5サブツール追加(Color / Alpha / Text / Vector / Shape)・Support リンク追加** — 概要
|
||||
- **2026-06-29: v0.3.65 — Mask Editor One 連携修正・トップバーアイコン修正・Mask ABRブラシ対応** — 概要
|
||||
- **2026-06-28: v0.3.64 — Image Edit マスク Add/Subtract・BiRefNet BG Remove・SAM3 セグメンテーション・ブラシカーソルバグ修正** — 概要
|
||||
- **2026-06-28: v0.3.63 — ノードタブ複数選択・全タブフィルタークリア・検索Xボタン** — 概要
|
||||
- **2026-06-27: WFS_PromptText の GenerateUI 対応・ドロップ時 title 固定** — 概要
|
||||
- **2026-06-27: v0.3.62 — Send GenUI Image ボタン追加・tagger_settings.json バグ修正・README 更新** — 概要
|
||||
- **2026-06-27: データ管理バグ修正 — tagger_settings.json をエクスポート対象に追加** — 概要
|
||||
- **2026-06-27: v0.3.61 — G'MIC バグ修正・セキュリティ・Draw/Mask ブラシ操作性改善** — 概要
|
||||
- **2026-06-27: v0.3.60 — Image Edit Filter Tool (G'MIC-Qt 連携) 実装** — 概要
|
||||
- **2026-06-27: v0.3.59 — Image Edit Mask Tool 実装・Draw/Mask 描画バグ修正・クリッピング保存対応** — 概要
|
||||
- **2026-06-27: v0.3.58 — GenerateUI Model タブ GGUF対応拡張(Diffusion Model / Text Encoder)** — 概要
|
||||
- **2026-06-26: v0.3.57 — Image Edit Blur Tool / BG Remove Tool 追加** — 概要
|
||||
- **2026-06-26: v0.3.56 — Style機能追加(GenerateUI ツールバー & Batchタブ)** — 概要
|
||||
- **2026-06-25: v0.3.55 — グループ機能バグ修正(全タブ)** — 根本原因(データ消失): パス区切り文字の不一致
|
||||
- **2026-06-25: v0.3.54 — Image Edit Shape ツール追加・Models タブ overlay バグ修正** — Image Edit Tab — Shape ツール(□ / S キー)
|
||||
- **2026-06-25: v0.3.53 — Image Edit Tab 実装・レイヤーロック・ギャラリー保存** — Image Edit Tab(新規タブ)
|
||||
- **2026-06-25: GenUI設定タブ LATENT IMAGE にプリセット機能を追加** — 変更内容
|
||||
- **2026-06-24: GenUI ModelでLoRA適用後、Modelサブタブ再表示時に値がリセットされるバグ修正** — 問題
|
||||
- **2026-06-24: v0.3.51 — Embedding GenUI PP/NP対応 + GenerateUIプロンプトタブEmbeddingセレクタ追加** — 変更内容
|
||||
- **2026-06-24: v0.3.51 — GenerateUIタブ Hypernetwork対応 + ModelsタブGenUI Modelボタン追加** — 変更内容
|
||||
- **2026-06-24: v0.3.51 — Hypernetwork/Embeddingモデル未表示バグ修正** — 原因
|
||||
- **2026-06-24: v0.3.51 — ModelsタブサイドパネルにGenUI Modelボタン追加** — 変更内容
|
||||
- **2026-06-22: v0.3.50 — ギャラリータブ プロンプト検索負荷改善(バックグラウンドインデックス)** — 問題
|
||||
- **2026-06-22: v0.3.49 — 生成UIタブ A1111風ワイルドカード展開・ギャラリータブ バグ修正2件** — 生成UIタブ — A1111風SPAサイドワイルドカード展開
|
||||
- **2026-06-22: v0.3.48 — ギャラリータブ 画像ダウンロード・一括エクスポート機能追加** — 詳細パネルの画像プレビューにダウンロード機能を追加
|
||||
- **2026-06-22: v0.3.47 — モデルタブ ファイル移動後グループ状態修正** — モデル移動後のグループ状態不整合を修正
|
||||
- **2026-06-21: v0.3.46 — プロンプトタブ AI Assistant LM Studio対応・設定タブ AI Assistant設定に変更** — 設定タブ「AI Assistant設定(プロンプトタブ)」
|
||||
- **2026-06-21: v0.3.45 — グループ孤立エントリ自動クリーンアップ・Galleryタブ Shift+クリック範囲選択** — グループ孤立エントリ自動クリーンアップ
|
||||
- **2026-06-21: v0.3.44 — Galleryタブ高速化・比較モード・バルク強化・バルクバーUI整理** — フェーズ2-A: サーバーサイドサムネイル生成
|
||||
- **2026-06-21: ヘルプタブ改善 — Gallery mode 多言語対応・フォント拡大・検索機能追加** — Gallery mode の多言語対応
|
||||
- **2026-06-21: Send to Canvas — window.opener経由のキャンバス直接ロード対応**
|
||||
- **2026-06-20: API形式ワークフローのキャンバス読み込み修正・画像メタデータ埋め込み修正** — API形式ワークフローをキャンバスへドラッグすると空になる問題を修正
|
||||
- **2026-06-20: セキュリティ修正(XSS・パスバリデーション)** — 修正内容
|
||||
- **2026-06-20: WFS_GalleryFeeder — ComfyUI キャンバス上コントロール追加** — 新規ファイル(`web/comfyui/gallery_feeder_extension.js`)
|
||||
- **2026-06-20: __Feeder__ グループ保護 + サムネイル F ボタン + seed バグ修正** — `__Feeder__` グループの予約済み保護
|
||||
- **2026-06-20: Gallery Feeder 機能追加(WFS_GalleryFeeder ノード)** — 概要
|
||||
- **2026-06-19: Generate UI — ImpactWildcardEncode/Processor プロンプト検出バグ修正(v0.3.41)** — 修正内容(`static/js/comfyui-workflow.js`)
|
||||
- **2026-06-19: AI TOOL タブ Chat ペイン・ワイルドカード生成追加(v0.3.40)** — Chat ペイン(`templates/index.html`, `static/css/main.css`, `static/js/ai-tab.js`, `web/comfyui/node_sets_menu.js`)
|
||||
- **2026-06-18: Send to Canvas機能追加(ワークフロータブ・ギャラリータブ→LibraryタイトルDnD)** — ワークフロータブ — Send to Canvas(`static/js/workflow-tab.js`, `static/js/app.js`, `templates/index.html`)
|
||||
- **2026-06-15: TaggerシングルGenUI:Pボタン追加+生成UIのZITワークフロープロンプト解析修正** — TaggerシングルGenUI:Pボタン(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`)
|
||||
- **2026-06-15: Taggerバッチ.txt出力追加+Galleryタグ保存バグ修正** — Taggerバッチ.txt出力(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`, `py/routes/tagger_routes.py`, `py/services/tagger_service.py`)
|
||||
- **2026-06-15: Taggerタブ新規実装+ギャラリー詳細パネルUI改善+パストラバーサル修正** — Taggerタブ新規実装
|
||||
- **2026-06-15: 全選択ボタン追加+ギャラリーバルクバーi18n対応** — 変更内容
|
||||
- **2026-06-10: v0.3.36 — コードレビュー修正+リファクタリング+README刷新** — コードレビュー修正(v0.3.35の不具合10件)
|
||||
- **2026-06-10: v0.3.35 — テーブルソート・Load GenUI・ワークフロー保存** — 変更内容
|
||||
- **2026-06-09: v0.3.34 — Modelsタブ テーブルビュー強化・バルク操作改善** — 変更内容
|
||||
- **2026-06-09: v0.3.33 — LoRAペイン・ワークフロー解析バグ修正** — 変更内容
|
||||
- **2026-06-08: v0.3.32 — Lora Loader (LoraManager) LoRA検出対応・ライブラリ機能拡張** — 変更内容
|
||||
- **2026-06-08: v0.3.31 — LoRAペイン Single/Stack タブ分割・GenUI Model LoRA対応強化** — 変更内容
|
||||
- **2026-06-08: v0.3.30 — LoRAスタック トリガーワードバグ修正・ヘルプ更新** — 変更内容
|
||||
- **2026-06-08: v0.3.29 — LoRAセクション統合・ヘルプタブサイドバー・UI細部改善** — 変更内容
|
||||
- **2026-06-08: 生成UIタブ 複数改善 — LoRA Stackトリガーワード連携・Reset Workflowボタン・Inputタブタブ化** — 変更内容
|
||||
- **2026-06-08: Feederタブ UI改善 — (root)自動選択・PREVIEWペイン右移動・RUN中プレビュー反映** — 変更内容
|
||||
- **2026-06-07: v0.3.28 — Batchタブ Sampler/Scheduler対応 + Modelsサムネイルバグ修正** — 概要
|
||||
- **2026-06-07: v0.3.27 — model-and-prompt-from-metadata カスタムノード対応 + README修正** — 概要
|
||||
- **2026-06-07: v0.3.26 — Stack LORA SYNTAX 表示修正 + 全体強度調整 UI** — 概要
|
||||
- **2026-06-07: v0.3.25 — Lora Loader (LoraManager) 対応 + Stack 有効無効切り替え** — 概要
|
||||
- **2026-06-06: v0.3.24 — Lora Stack 登録機能 + 生成UI Model タブ 3ペイン化** — 概要
|
||||
- **2026-06-05: v0.3.23 — バグ修正2件** — バグ修正
|
||||
- **2026-06-05: v0.3.22 — カードビュー廃止 + Batch タイプ切り替え UI + 4タイプバッチ生成** — 概要
|
||||
- **2026-06-05: (未リリース) — 生成UIタブ Batch 4タイプ対応(Lora/Prompt/Workflow グループ選択 + BATCH QUEUE 4列化)** — 概要
|
||||
- **2026-06-05: (未リリース) — Prompt・Workflow Batchグループ登録UI** — 概要
|
||||
- **2026-06-04: (未リリース) — モデルBatchグループ登録UI(サムネイル/カード/テーブル・フィルター・一括解除)** — 概要
|
||||
- **2026-06-04: (未リリース) — Batch予約グループ・Presets Save後リセット・グループフィルター種別絞り込み** — 概要
|
||||
- **2026-05-31: v0.3.21 — CivitAI ホスト設定・Sample サブタブ・URL 修正・ヘルプ i18n 整備** — 概要
|
||||
- **2026-05-31: v0.3.20 — CivitAI 詳細パネル強化(Type / Hash 表示・URL 修正・画像別タブ)** — 概要
|
||||
- **2026-05-31: v0.3.19 — CivitAI 連携強化(バッチ高速化・リトライ・APIキー・フィールド拡充)** — 概要
|
||||
- **2026-05-31: v0.3.18 — Batch タブ追加・Models 複数選択強化** — 概要
|
||||
- **2026-05-31: v0.3.17 patch — SSLコンテキスト セキュリティ強化** — 概要
|
||||
- **2026-05-30: v0.3.17 — SSL証明書エラー修正・Lora Manager対応** — 概要
|
||||
- **2026-05-20: v0.3.16 — CivitAIプレビューフォールバック表示** — 概要
|
||||
- **2026-05-20: v0.3.15 — サンプルワークフロー同梱・CivitAI自動プレビュー・Create Tagsオプション** — 概要
|
||||
- **2026-05-20: AI TOOL タブ 英語化(SPA・Libraryパネル)** — 概要
|
||||
- **2026-05-19: v0.3.14 — AI TOOL タブ 3ペイン化・RAW JSON 文字色カスタマイズ** — 概要
|
||||
- **2026-05-19: v0.3.13 — AI タブ(A)追加** — 概要
|
||||
- **2026-05-18: v0.3.12 リリース — README スクリーンショット刷新 / workflow_analyzer 拡張** — 概要
|
||||
- **2026-05-17: RAW JSON パネルに検索機能を追加** — 概要
|
||||
- **2026-05-17: v0.3.11 Metadata タブ対応ノードタイプ拡張 / Settings テキストサイズ UI 追加** — 概要
|
||||
- **2026-05-17: v0.3.10 Information タブ キャンバスドラッグ対応 / ノードタイプ拡張 / バグ修正** — 概要
|
||||
- **2026-05-16: v0.3.9 サイドパネル I タブ追加 / トップバーアイコン修正** — 概要
|
||||
- **2026-05-16: v0.3.8 Metadata タブ: data9 対応 / プロンプト判別不能テキスト** — 概要
|
||||
- **2026-05-15: v0.3.7 Metadata タブ: Flux2 / Qwen / Z-Image 対応** — 概要
|
||||
- **2026-05-15: v0.3.6 Metadata タブ追加** — 概要
|
||||
- **2026-05-09: v0.3.5 GenerateUI — Feeder サブタブ・ワークフロー解析精度向上** — 概要
|
||||
- **2026-04-29: v0.3.4 GenerateUI — Checkpoint Batch 刷新・Settings 横並びレイアウト・ヘルプ i18n 修正** — 概要
|
||||
- **2026-04-28: v0.3.3 Promptタブ — ワイルドカード支援パネル・Impact Packシンボリックリンク連携・サブディレクトリ対応・GenerateUI修正** — 概要
|
||||
- **2026-04-28: v0.3.2 生成UIタブ — Checkpoint Batch・Seed UIレイアウト修正** — 概要
|
||||
- **2026-04-27: v0.3.1 ギャラリー拡張(フォルダ/ファイル操作・ワークフロー保存)** — 概要
|
||||
- **2026-04-18: v0.3.0 モデルグループ表示改善(全タイプ横断・サイドパネル修正)** — 概要
|
||||
- **2026-04-18: v0.2.9 モデル有効/無効・複数選択・一括削除** — 概要
|
||||
- **2026-04-14: v0.2.8 データ保存先変更・エクスポート/インポート機能追加** — 概要
|
||||
- **2026-04-06: v0.2.6 UI改善・バッジ保存修正・フォルダフィルター追加** — 概要
|
||||
- **2026-04-04: v0.2.5 追加修正** — 概要
|
||||
- **2026-04-03: v0.2.5 サイドパネル Category/Package サブタブ・テーマ設定・Workflow修正** — 概要
|
||||
- **2026-03-29: v0.2.4 GenerateUIタブ レイアウト再設計** — 概要
|
||||
- **2026-03-29: v0.2.3 バッジ統一・GenUI Model・サイドパネルModels拡充** — 概要
|
||||
- **2026-03-28: v0.2.2 Modelsタブ・CivitAI連携** — 概要
|
||||
- **2026-03-27: テーマカスタマイズ機能・設定タブ改善** — 概要
|
||||
- **2026-03-26: トップバーボタンアイコン修正・Appモードバッジ調査** — 概要
|
||||
- **2026-03-25: v0.2.0 プロンプトプリセット機能・Workflow Studio Library** — 概要
|
||||
- **2026-03-24: v0.1.9 サイドパネルUI改善・保存ダイアログ・バグ修正** — 概要
|
||||
- **2026-03-24: v0.1.8 WF & Node Library サイドパネル拡張** — 概要
|
||||
- **2026-03-23: v0.1.7 Nodes タブ追加** — 概要
|
||||
- **2026-03-23: v0.1.6 パストラバーサル脆弱性修正** — 概要
|
||||
- **2026-03-22: v0.1.5 テーマシステム追加** — 概要
|
||||
- **2026-03-22: v0.1.4 App形式対応・プリセットコピー・分析バグ修正** — 概要
|
||||
- **2026-03-21: v0.1.3 ヘルプ&サポートタブ追加** — 概要
|
||||
- **2026-03-19: v0.1.2 リリース** — 概要
|
||||
- **2026-03-19: v0.1.1 リリース** — 概要
|
||||
- **2026-03-18: プロジェクトリネーム & アイコン適用** — 概要
|
||||
- **2026-03-18: デフォルトビュー設定追加** — 概要
|
||||
- **2026-03-18: MarkdownNote表示専用ノード除外** — 概要
|
||||
- **2026-03-18: サブグラフワークフロー対応 & 変換精度修正** — 概要
|
||||
- **2026-03-18: Raw JSON同期修正** — 概要
|
||||
- **プロジェクト構成 (現在)**
|
||||
- **2026-03-18: JSONシンタックスハイライト** — 概要
|
||||
- **2026-03-18: プロンプトタブ 中国語↔英語翻訳追加** — 概要
|
||||
- **2026-03-18: v0.1.0 リリース** — 概要
|
||||
- **2026-04-13: Gallery タブ追加 & 全タブ Group UI 統一** — 概要
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ketle-man
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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.7.0)
|
||||
Dedicated video generation UI, independent of GenerateUI, Plan subtab, Base settings row, Edit subtab, Asset subtab, Project subtab, Two center previews, First Frame / Last Frame are both optional, Semantic node lookup, not hardcoded IDs, Field highlight color, Load in Video, Frame tab, GIF tab, Playback volume
|
||||
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, 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
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# Video Editサブタブ「簡易動画編集機能」実装計画
|
||||
|
||||
## Context
|
||||
|
||||
Video タブは Plan(バッチ動画生成)/ Asset・Project(生成済み動画の一覧)まで実装済みだが、Edit サブタブは `templates/index.html:2895` の空div `#wfm-video-subtab-edit`(「Editing tools coming soon」のプレースホルダーテキストのみ)で、JSロジックは一切無い。ユーザーからは「Adobe Premiereのような簡易動画編集機能」を追加したいという要望があり、調査の結果ComfyUI Core 0.35.0〜0.36.0(2026年9月時点の最新)で `VideoSlice`/`VideoTrim`/`VideoCrop`/`ConcatenateVideo` 等のネイティブ動画編集プリミティブが追加されたばかりであることが判明した。これらを活用しつつ、wf-manager独自のタイムラインUIを被せることで、フルスクラッチでの動画処理実装を避けつつ「複数クリップの配置・トリム・結合」という編集の核となる機能を提供する。
|
||||
|
||||
**方針確認済み(ユーザー承認):**
|
||||
- MVPスコープは最小限(タイムライン配置・トリム・結合書き出しのみ)とし、クロップ・テキストオーバーレイ・BGM合成は明確にPhase 2以降の拡張として切り離す。
|
||||
- グラフ組立(トリム/クロップ/結合)はComfyUIワークフローに丸投げする方式(Plan tabの`video-workflow.js`と同じ`/prompt`実行基盤を再利用)で進める。`VideoTrim`/`VideoCrop`がexperimental扱いであることのAPI変更リスクは、Phase 0での`/object_info`検証とクラス名参照で軽減する。
|
||||
|
||||
実装は下記のPlanエージェント設計をベースに、MVPスコープをPhase 1(タイムライン+トリム+結合)のみに絞り込んで進める。
|
||||
|
||||
---
|
||||
|
||||
## 1. スコープ定義
|
||||
|
||||
### 設計原則(切り分けの判断基準)
|
||||
|
||||
| 種別 | 判断基準 | 実装場所 |
|
||||
|---|---|---|
|
||||
| **プレビュー/操作UI**(scrub再生、タイムライン上でのドラッグ、トリムハンドル、テキスト位置決めなど、ユーザーが「見ながら触る」もの) | ブラウザの`<video>`/`<canvas>`だけで完結し、即座にフィードバックが必要 | wf-manager独自JS(video-edit-tab.js) |
|
||||
| **実際の書き出し処理**(トリム確定、複数クリップ結合、クロップ確定、テキスト焼き込み) | 重い処理・GPU不要・単純なメディア操作で、ComfyUI Coreの新ノード(VideoSlice/VideoTrim/VideoCrop/ConcatenateVideo)がほぼそのままカバーする | ComfyUIワークフローを動的組立して`/prompt`に投げる(Plan tabのvideo-workflow.jsパターンを踏襲) |
|
||||
| **既存方針(PyAV+Pillowのみ、ffmpeg非依存)で完結できないもの**(テキスト/字幕の焼き込み、BGM音量ミックス、簡単なフェード) | ComfyUI Coreノードにも無く、かつ静止画向けtext-overlay系カスタムノードのロジックを流用できる規模 | wf-manager独自Python(video_service.py拡張、PyAV decode + Pillow ImageDraw合成 + PyAV encode) |
|
||||
| **サムネイル/ファイル一覧/タグ管理** | 既存Galleryインフラで完結 | 既存流用(新規実装なし) |
|
||||
|
||||
### MVP(初回リリース、承認済み: 最小限スコープ)
|
||||
|
||||
以下の1〜4のみを初回リリース対象とする。クロップ/テキストオーバーレイ/BGM合成/プレビュー高度化は明確にPhase 2以降へ切り出す。
|
||||
|
||||
1. **タイムラインへのクリップ配置・並べ替え**
|
||||
- Asset タブ / Gallery からドラッグ or 「Editへ送る」ボタンで動画をタイムラインに追加。
|
||||
- 水平方向に複数クリップを並べたシンプルな1トラック構成(Adobe Premiereの「V1」トラック1本のみ、複数トラック無し)。
|
||||
- ドラッグで順序入れ替え、削除、複製。
|
||||
2. **クリップ単位のトリム(開始/終了)**
|
||||
- 各クリップサムネイル上でハンドルをドラッグしてin/outを指定 → プレビューは`<video>`要素のcurrentTimeシークのみ(デコード不要、軽量)。
|
||||
- 書き出し時にComfy Core `VideoSlice`("Trim Video")ノードで実処理。
|
||||
3. **複数クリップの結合書き出し**
|
||||
- タイムライン順に`ConcatenateVideo`ノードをAutogrowで連結し1本のVIDEO出力に。
|
||||
4. **プレビューと書き出し**
|
||||
- プレビューはMVPでは「各クリップを順番に`<video>`要素で連続再生する疑似プレビュー」(トリム区間のみ再生してクリップ切り替え)に留める。真の合成プレビューは行わない。
|
||||
- 「書き出し」ボタン押下でComfyUIワークフロー(`VideoSlice`×N → `ConcatenateVideo`(Autogrow) → `SaveVideo`)を動的組立し`/prompt`に投げ、進捗はWebSocketで表示、完了後Galleryの`VIDEO_GROUP`に自動登録。
|
||||
|
||||
### Phase 2以降(MVP対象外、将来拡張として明記)
|
||||
|
||||
- **クリップ単位の空間クロップ**: 矩形選択UI(Image Edit Tabのキャンバス選択パターンを流用)→ 書き出し時に`VideoCrop`ノードへ座標を渡す。
|
||||
- **簡易テキストオーバーレイ(クリップ単位、固定位置・フェード無し)**: テキスト内容・フォントサイズ・色・位置(9グリッドアンカー)・表示区間(開始秒-終了秒)を指定。書き出しはPyAVでフレームデコード→対象区間のフレームだけPillow ImageDraw.textで焼き込み→再エンコード(wf-manager独自Python実装。ComfyUI Coreに動画テキストオーバーレイノードは無いため)。
|
||||
- **BGM/音声トラック1本の合成**: 元動画の音声をmute/keep選択 + 1本のBGM音声ファイルを全体に重ねる(音量スライダーのみ、ミキシングはPyAVのAudioResampler+単純加算 or `CreateVideo`のcomplete_audio差し替え機能で代替)。複雑なマルチトラックオーディオミキシングは対象外。
|
||||
- 複数トラック(V1/V2でのピクチャーインピクチャー、オーバーレイ合成)
|
||||
- クリップ間トランジション(クロスフェード等) — フレーム単位のアルファブレンドはPyAVで可能だが計算コストが高く、CPUのみだと低速。GPU処理が要るなら別途ComfyUIノード化を検討。
|
||||
- 波形表示付きオーディオトラック編集
|
||||
- キーフレームアニメーション(テキストの移動/フェードイン等)
|
||||
- Undo/Redo履歴、複数プロジェクトの保存/読込(Video Planの`video_plan_service.py`と同様の永続化)
|
||||
- リアルタイム合成プレビュー(WebCodecs APIによるブラウザ内デコード合成、または低解像度プロキシファイルを都度サーバー生成してプレビュー用に配信)
|
||||
|
||||
---
|
||||
|
||||
## 2. アーキテクチャ
|
||||
|
||||
> **補足(実際のサブタブ構造)**: Videoタブは単純な「Plan/Asset/Edit」3分割ではなく、中央ペインが Plan/Edit の2-way切替(`_initCenterSubtabToggle()`, `static/js/video-tab.js:37`)、サイドバーが Asset/Project の独立ペア(`_initSidebarSubtabToggle()`, 同ファイル55行目)という構成。エントリポイントは `static/js/video-tab.js:317` の `export function initVideoTab()` で、ここに `initVideoEditTab()` の呼び出しを追加する。
|
||||
|
||||
### フロントエンド(JS)
|
||||
|
||||
新規ファイル: `static/js/video-edit-tab.js`
|
||||
|
||||
- `class VideoEditTab`(Image Edit Tabの`class ImageEditTab`パターンを踏襲。単一グローバルインスタンスをexportする形も選択可)
|
||||
- `constructor()`: タイムライン状態(`this.clips = []`、各要素は`{ sourceRef, inPoint, outPoint, cropRect, textOverlay, id }`)、選択中クリップID、DOM参照を初期化。
|
||||
- `init()`: DOM生成・イベント配線のエントリポイント。`video-tab.js`の`initVideoTab()`から`initVideoEditTab()`として呼ばれる形にする。
|
||||
- `_setupActionBar()`: Image Edit Tab 1385行目のアクションバー実装を参考に「クリップ追加」「書き出し」「プレビュー再生/停止」ボタン群を構築。
|
||||
- `_setupTimelinePanel()`: レイヤーパネル(`_setupLayerPanel()` 2194行目)のリスト+サムネ+ドラッグ並べ替えパターンを、縦のレイヤーリストではなく横のタイムラインクリップ列に転用。
|
||||
- `_setupTrimHandles()`: pointerdown/move/upベースのドラッグ操作(1130-1249行目のポインタイベント処理パターン)をトリムハンドルのドラッグに転用。
|
||||
- `_setupCropOverlay()`: 同じくpointerイベントパターンをクロップ矩形選択に転用(レイヤーごとに独立canvasを持つ構造は流用しないが、矩形描画のオーバーレイcanvas1枚で足りる)。
|
||||
- `_setupTextOverlayPanel()`: テキスト内容/フォント/色/アンカー/表示区間の入力フォーム。プレビューは選択中クリップの`<video>`上にCSS絶対配置したdiv要素でオーバーレイ表示(実際の焼き込みはサーバー側、プレビューは近似でよい)。
|
||||
- `_buildExportWorkflow()`: タイムライン状態からComfyUIワークフローJSON(内部APIフォーマット)を動的組立。`video-workflow.js`の`injectFrameNode()`のノード注入パターン(last_node_id/last_link_id採番、links[]整合性維持)を流用し、`LoadVideo → VideoSlice → (VideoCrop) → ConcatenateVideo(Autogrow) → SaveVideo`のグラフを構築。
|
||||
- `_exportTimeline()`: `comfyUI.queuePrompt()`/`comfyUI.trackProgress()`(comfyui-client.js既存メソッド、Plan tabと同じ呼び出し方)でグラフを実行し、進捗表示・完了後にGallery登録。
|
||||
- `_applyTextOverlayJob()`: テキストオーバーレイがある場合のみ、結合済み動画に対して追加で`/api/wfm/video/edit/overlay-text`(新設)を呼ぶ後処理ステップ(ComfyUIグラフでは焼き込めないため、書き出しフローの最終段に独立したPython後処理を挟む二段構成)。
|
||||
|
||||
- 既存ファイルへの変更:
|
||||
- `static/js/video-tab.js`: `initVideoEditTab()`のimportと`initVideoTab()`内呼び出しを追加。`_applyVideoI18n()`にEdit用キーを追加。
|
||||
- `templates/index.html`: `#wfm-video-subtab-edit`内にタイムライン/アクションバー/クロップオーバーレイ/テキストパネル用のDOM骨格を追加(Plan/Assetサブタブの既存マークアップ構造に合わせる)。
|
||||
- `static/js/i18n.js`: `videoEditPlaceholder`を削除し、新規i18nキー群(`videoEditTimeline`, `videoEditTrim`, `videoEditCrop`, `videoEditTextOverlay`, `videoEditBgm`, `videoEditExport`等、英/日/中3言語)を追加。
|
||||
- `static/css/video-tab.css`(存在すれば。無ければ新規`video-edit-tab.css`): タイムラインのクリップブロック、トリムハンドル、クロップオーバーレイのスタイル。
|
||||
|
||||
- Asset→Edit連携用の変更:
|
||||
- `static/js/video-asset-tab.js`: 詳細パネル(`_renderDetail()`)に「Editへ送る」ボタンを追加。クリック時、選択中動画の`{filename, subfolder, type}`または`{kind:"local", file}`参照を`video-edit-tab.js`のエクスポート関数(例: `addClipFromAsset(ref)`)に渡し、Editサブタブに切り替えてタイムライン末尾に追加。
|
||||
|
||||
### バックエンド(Python)
|
||||
|
||||
判断基準: **ComfyUIワークフローに丸投げできる処理(VIDEO型を保つ、GPU不要でもCPUノードとしてグラフ内で完結できる処理)は`/prompt`実行に任せ、グラフ側に存在しない処理(テキスト焼き込み、音声ミックス、詳細メタ取得)だけを独自Pythonで実装する。** 理由: ワークフロー実行に丸投げすることで実行キュー管理・WebSocket進捗通知・キャンセル(`interrupt`)・履歴(`/history`)を無料で使い回せる一方、独自Python実装は同期/非同期の進捗通知を自前で作る必要があるため、既存インフラで代替できるものは極力グラフに寄せる。
|
||||
|
||||
- `py/routes/video_routes.py` 追加エンドポイント:
|
||||
- `POST /api/wfm/video/edit/probe`: アップロード済み/output上の動画の基本メタ(duration, fps, width, height, has_audio)をPyAVで取得。タイムラインUIがクリップ追加時に必要な長さ・解像度をComfyUIの`/prompt`実行なしで即座に得るため(`GetVideoComponents`ノードを都度実行するのは重く実行キューも消費するため避ける)。
|
||||
- `POST /api/wfm/video/edit/overlay-text`: 結合済み動画1本 + テキストオーバーレイ指定リスト(区間・アンカー・文字列・色・フォントサイズ)を受け取り、PyAVでデコード→対象区間のみPillow ImageDraw合成→PyAV(av.open(mode="w"))で再エンコードしoutputへ保存。`video_service.convert_to_gif()`のデコード/エンコードパターンを流用。
|
||||
- `POST /api/wfm/video/edit/mix-audio`(BGM合成が`CreateVideo`ノードのcomplete_audio差し替えだけでは足りない場合の予備、例えば音量調整付き重ね合わせが必要な時): PyAVのAudioResamplerで元音声とBGMをリサンプル→単純加算ミックス→動画に再アタッチ。
|
||||
- Phase 1では上記のうち`probe`のみ必須、`overlay-text`はテキストオーバーレイ機能実装時に追加、`mix-audio`は`CreateVideo`の`complete_audio`で足りるなら不要(後述リスク参照)。
|
||||
|
||||
- `py/services/video_service.py` 追加メソッド:
|
||||
- `probe_video(path) -> dict`: `av.open()`でstreams[0]の`duration`, `average_rate`, `width`, `height`、`streams.audio`の有無を返す。
|
||||
- `overlay_text_on_video(src_path, overlays, fps=None) -> dict`: 既存`convert_to_gif`と同じ`_resolve_media_path`を使った安全なパス解決 → フレームループでtがoverlayの区間に該当する時だけPillowで文字を描画 → `av.open(dst, mode="w")`で同じcodec/fpsで書き出し。既存クラスの`_resolve_media_path()`を再利用。
|
||||
- `mix_audio(...)`: 必要になった場合のみ追加。
|
||||
|
||||
- ワークフロー動的組立は**フロントエンドJS側**(`_buildExportWorkflow()`)で行い、バックエンドは実行そのものに関与しない(Plan tabと同じ役割分担: `video-workflow.js`はJSでグラフJSONを操作し、`comfyUI.queuePrompt()`で`/prompt`に投げるのみ)。バックエンドに「ワークフロー組立API」を新設する必要は無い。
|
||||
|
||||
### 判断基準まとめ(再掲)
|
||||
- VIDEO型のまま完結する操作(トリム/クロップ/結合/保存) → ComfyUIグラフ(フロントJSで組立、`/prompt`実行)
|
||||
- VIDEO型を抜けてピクセル単位の追加合成が要る操作(文字焼き込み、音声ミックス) → wf-manager独自Python(PyAV+Pillow)
|
||||
- 操作中のインタラクティブ操作(scrub、トリムハンドルのドラッグ、矩形選択) → ブラウザJSのみ、サーバー往復なし
|
||||
|
||||
---
|
||||
|
||||
## 3. 既存資産の再利用ポイント(具体的な参照箇所)
|
||||
|
||||
| 転用元 | 参照箇所 | 転用先 |
|
||||
|---|---|---|
|
||||
| Image Edit Tab アクションバー | `static/js/image-edit-tab.js:1385` `_setupActionBar()` | video-edit-tab.jsのツールバー(クリップ追加/書き出し/再生ボタン) |
|
||||
| Image Edit Tab レイヤーパネル | `static/js/image-edit-tab.js:2194` `_setupLayerPanel()`(リスト/サムネ/透明度スライダー) | タイムラインのクリップリストUI(サムネ表示・並べ替え・削除) |
|
||||
| Image Edit Tab pointerイベント | `static/js/image-edit-tab.js:1130-1249` | トリムハンドルドラッグ、クロップ矩形選択のドラッグ操作 |
|
||||
| Image Edit Tab レイヤー合成構造 | `static/js/image-edit-tab.js:2035,2067,2081` | (参考程度)テキストオーバーレイのプレビュー用オーバーレイdiv重畳の考え方 |
|
||||
| Gallery mp4サムネ/プレビュー | `static/js/gallery-tab.js:63` `isVideoFile()`、`972-985,1750-1766` `<video controls>` | クリップサムネイル生成、プレビューエリアの`<video>`要素の扱い |
|
||||
| Gallery mp4サーバー側サムネ | `py/services/gallery_metadata.py:1349-1364` `serve_thumbnail`(PyAVで先頭フレームJPEG化) | クリップ追加時のサムネイル取得APIとして直接再利用可能(新規実装不要、`/wfm/gallery/image/thumb`をそのまま叩く) |
|
||||
| Gallery mp4メタ | `py/services/gallery_metadata.py:766` `_read_mp4_metadata()` | `probe_video()`実装の参考(既存関数を直接呼ぶかロジック共有できないか要確認) |
|
||||
| video-workflow.js ノード検出/注入パターン | `static/js/video-workflow.js` `locateVideoModelNodes()`, `injectFrameNode()`(last_node_id/last_link_id採番、links[]整合性) | `_buildExportWorkflow()`でVideoSlice/VideoCrop/ConcatenateVideoノードを動的追加する際のID採番・リンク整合性ロジックをそのまま流用 |
|
||||
| comfyui-client.js 実行基盤 | `static/js/comfyui-client.js:196` `queuePrompt()`, `217` `trackProgress()`, `312` `generate()` | 書き出し実行・進捗表示(新規実装不要) |
|
||||
| Video Asset Galleryグループ | `static/js/video-asset-tab.js` の`VIDEO_GROUP`/`VTEMP_GROUP`活用パターン | 書き出し完了後の動画を`VIDEO_GROUP`へ自動登録する処理の参考 |
|
||||
| video_service.py デコード/エンコードパターン | `py/services/video_service.py:60` `convert_to_gif()`(PyAV decode→Pillow処理→再エンコード、`_resolve_media_path`によるパストラバーサル対策) | `overlay_text_on_video()`実装のテンプレートとしてほぼそのまま流用 |
|
||||
|
||||
---
|
||||
|
||||
## 3.5. 外部カスタムノードの参考実装(追加調査、ユーザー指定4件)
|
||||
|
||||
Video Edit実装の設計判断を補強するため、既に「フルスクリーンタイムラインエディタ」を実装している既存カスタムノードを調査した。**これらをそのまま依存として組み込むのではなく、UI/UX設計とノード分割方針の参考にする**(wf-managerはComfyUI Coreの`VideoSlice`/`ConcatenateVideo`丸投げ方針を維持するため、方式は異なる)。
|
||||
|
||||
| リポジトリ | 関連度 | 参考にすべき点 |
|
||||
|---|---|---|
|
||||
| [SonderSaid/ComfyUI-Sonder-Editor](https://github.com/SonderSaid/ComfyUI-Sonder-Editor) | **高** | マルチレーン(Video/Audio/Prompt/Guide/Driverレーン)タイムラインUI、クリップのドラッグ/トリム/分割/ミュート、レーンのロック/非表示など、Premiereライクな編集UIの完成度が高い実装例。ただし**FFmpeg 7.0以上を必須依存**としており、wf-managerの「ffmpeg非依存(PyAV+Pillowのみ)」方針とは真逆。UIレイアウト(左サイドバー=ギャラリー、中央=ビューポート+トランスポート、下部=マルチレーンタイムライン)は`video-edit-tab.js`のDOM構成の参考にする価値が高いが、実装(FFmpeg CLI呼び出し)はそのまま流用しない。 |
|
||||
| [shootthesound/ComfyUI-H3Studio](https://github.com/shootthesound/ComfyUI-H3Studio) | **高** | MiniMax H3特化の統合編集ツール。単一ノードから「⤢ open timeline editor」ボタンでフルスクリーンタイムラインを起動する導線、クリップのトリム/クロスフェード/音声ミックスをまとめた「リール」書き出し機能、フィルムストリップ表示・波形表示が参考になる。**「追加のPythonライブラリ不要」と明記**されており、wf-managerの依存最小化方針と親和性が高い(実装詳細は非公開のため、"ノードのカスタムウィジェットからフルスクリーンモーダルを開く"というUIパターンのみ参考にする)。 |
|
||||
| [domg73/ComfyUI-LoadVideoCrop](https://github.com/domg73/ComfyUI-LoadVideoCrop) | **中(Phase 2クロップ機能に直結)** | 公式`LoadVideo`を拡張し、動画プレビュー上でWYSIWYGにクロップ矩形を描画できる`Load Video Crop`ノード。アスペクト比ロック(1:1/2:3/3:2/3:4/4:3/9:16/16:9/21:9/Free)、`start_time`/`duration`/`strict_duration`によるロード時トリムも兼備。**「追加依存ライブラリ不要」**と明記。Phase 2の`_setupCropOverlay()`実装時、このノードの隠し入力設計(`crop_x/crop_y/crop_w/crop_h`を0..1正規化値で渡す)をComfyUI Core `VideoCrop`ノードとのパラメータ形式比較の参考にする。ノード自体を依存に追加するかはPhase 0で`VideoCrop`(Core)とどちらが安定・高機能か比較検討する。 |
|
||||
| [astropuzzo/ComfyUI-MiniMax-H3-Image-Studio](https://github.com/astropuzzo/ComfyUI-MiniMax-H3-Image-Studio) | **低** | MiniMax H3モデルを使った**静止画**のT2I/I2I/参照編集ツールで、動画のタイムライン編集や結合とは無関係(対象読者はH3の画像生成ワークフロー利用者)。Video Edit実装への直接的な参考価値は無いため、調査対象から除外し将来のGenerateUI/画像生成系機能検討時の参考候補としてのみ記録する。 |
|
||||
|
||||
**設計への反映:**
|
||||
- `_setupTimelinePanel()`(video-edit-tab.js)のDOM構成は、Sonder Editor/H3Studioのレイアウト(サイドバー=クリップ一覧、中央=プレビュー+トランスポート、下部=タイムライン)を踏襲する。ただし将来のマルチレーン拡張(Phase 2以降のBGMトラック等)を見越し、MVPの単一トラックでも「レーン」という概念でデータ構造を設計しておく(`this.clips`を`this.lanes = [{type:"video", clips:[...]}]`のような形にしておくと将来拡張が容易)。
|
||||
- Phase 2のクロップUIは、LoadVideoCropの「正規化座標(0..1)でのクロップ矩形」という設計をそのまま踏襲し、ComfyUI Core `VideoCrop`ノードの実際のINPUT_TYPES(Phase 0で`/object_info`確認)と突き合わせて座標系を合わせる。
|
||||
- H3Studioの「ノードのカスタムウィジェットからフルスクリーンモーダルを開く」という導線は、wf-managerでは既にVideo Editが独立サブタブとして存在するため直接は不要だが、将来「ワークフローグラフ上のノードからEditタブへジャンプする」ショートカットを追加する際の参考にする。
|
||||
|
||||
---
|
||||
|
||||
## 4. 段階的な実装ステップ(Phase分け)
|
||||
|
||||
### Phase 0: 下調査・技術検証(実装前必須)— **完了(2026-09-16、ComfyUI_5実機・ポート8189で検証済み)**
|
||||
|
||||
**確定事項(`_buildExportWorkflow()`実装時はこの仕様に厳密に従うこと):**
|
||||
|
||||
1. **【2026-09-16 訂正】`VideoSlice`は「クラス名にスペースが入る」ため`/object_info/VideoSlice`が空に見えていただけで、実際には`"Video Slice"`(スペース込み)という正式なclass_typeで存在する。** ソース(`comfy_extras/nodes_video.py:448`)の`node_id="Video Slice"`を確認して判明。**トリムはこちらを使う** — `video`/`start_time`(FLOAT)/`duration`(FLOAT, 0で無制限)/`strict_duration`(BOOLEAN)というフラットな入力のみで、`VIDEO_EDIT`型のネスト構造が一切無く、`experimental`でもない。実機で`start_time=2.0, duration=3.0`を指定し、出力が実際に3.0秒になることをPyAV probeで確認済み(**当初の`VideoTrim`検証は`execution_success`だけを見て「動いた」と誤判定していた——後述の訂正参照**)。
|
||||
2. **【訂正の経緯・重要な教訓】`VideoTrim`の`trim`入力(`VIDEO_EDIT`型)に一見素直なフラット値`{"start_time":.., "duration":..}`を渡すと、`node_errors`無しで`execution_success`するが、実際には一切トリムされず元動画がそのまま出力される(サイレント無視)。** 原因はソース(`comfy_extras/nodes_video.py:524`)の`execute()`が`(trim or {}).get("trim")`と一段余計にネストを剥がしてから`apply_video_trim()`に渡す実装になっており、`apply_video_trim()`は`trim=None`のとき`start_time=0.0, duration=0.0`とみなして**無条件に元動画をそのまま返す**(`comfy_extras/nodes_video.py:417-424`)ため。正しく`VideoTrim`を使うならフラットではなく**二重に入れ子**にした`{"trim": {"start_time": 0.0, "duration": 1.5}}`が必要。ただし本実装ではより単純な`Video Slice`を採用したため、`VideoTrim`は不使用。**教訓: `execution_success`は「グラフの型検証を通った」ことしか意味せず、パラメータが実際に意図通り効いたかは出力を実際にprobeして確認しない限り分からない。** 以後の実機検証では必ず出力の実測値まで確認すること。
|
||||
3. **`VideoCrop`の`crop`入力も`VideoTrim`と同じ二重ネスト`.get("crop")`パターン(`comfy_extras/nodes_video.py:555`)。** Phase 2で採用する場合は`{"crop": {"x":0,"y":0,"width":W,"height":H}}`のように二重に入れ子にする必要がある(`VideoCrop`はスペース無しの`"VideoCrop"`のままで、`Video Slice`のような別名は無い)。Phase 2着手時に必ず出力を実測して再確認する。
|
||||
4. **`ConcatenateVideo`の`videos`(`COMFY_AUTOGROW_V3`)は、ネストしたJSONオブジェクトではなく、ドット区切りの単一文字列キーをinputsのトップレベルに並べる。** これが最大のハマりどころで、`{"videos": {"video0": [...]}}` 等の直感的なネスト表現は**すべて`required_input_missing`で弾かれる**。正しい形式:
|
||||
```json
|
||||
"inputs": {
|
||||
"videos.video0": ["<node_id>", 0],
|
||||
"videos.video1": ["<node_id>", 0],
|
||||
"codec": "auto"
|
||||
}
|
||||
```
|
||||
(0始まり、`prefix + index`を単純結合した文字列がそのままキー名。ComfyUI Core `comfy_api/latest/_io.py`の`Autogrow.TemplatePrefix`/`finalize_prefix()`のドット結合ロジックに由来)。実機で2クリップ結合の`execution_success`を確認済み。
|
||||
5. **`ConcatenateVideo`は解像度不一致で実行時ハードエラーになる。** 実機で960x540と1024x576の2クリップを結合させたところ`ValueError: Accumulated videos have incompatible frame dimensions`で失敗(`comfy_extras/nodes_video.py:178`)。事前チェック無しでの結合は失敗するため、**Phase 1のタイムラインUI側で解像度不一致クリップの警告表示・結合前チェックを必須実装**とする(リスク節で「推奨」としていたものを「必須」に格上げ)。
|
||||
6. 動画アップロードは標準の`/upload/image`エンドポイント(`OPTIONS`で200確認済み)をそのまま使う想定(`LoadVideo`の`file`ウィジェットが`video_upload: true`を持ち、Core LoadVideo/VHS双方がこのエンドポイントを動画にも流用する既知の挙動)。Phase 1実装時に実際のアップロード→`LoadVideo`参照までを通しで確認する。
|
||||
7. `LoadVideo`の`file`はCOMBO(inputフォルダ内動画ファイル名の列挙)。Asset/GalleryからEditタブへ渡す動画がComfyUI input配下に無い場合(Gallery出力やVTEMP_GROUP由来)は、書き出し前に`/upload/image`でinputへコピーしてからファイル名を`LoadVideo.file`に渡す必要がある。
|
||||
|
||||
### Phase 1: MVP基盤(タイムライン表示・単純トリム結合書き出しのみ)
|
||||
- 新規: `static/js/video-edit-tab.js`(クラス骨格、タイムラインリスト、クリップ追加/削除/並べ替え)
|
||||
- 変更: `templates/index.html`(Edit subtab DOM骨格)
|
||||
- 変更: `static/js/video-tab.js`(init呼び出し追加)
|
||||
- 変更: `static/js/i18n.js`(新規キー)
|
||||
- 新規API: `py/routes/video_routes.py`に`/api/wfm/video/edit/probe`追加
|
||||
- 変更: `py/services/video_service.py`に`probe_video()`追加
|
||||
- 変更: `static/js/video-asset-tab.js`(「Editへ送る」ボタン)
|
||||
- 実装内容: クリップ追加→トリムハンドルUI→書き出し(VideoSlice+ConcatenateVideo+SaveVideoのグラフ組立て`/prompt`実行)まで通す。
|
||||
|
||||
### Phase 2: クロップ機能
|
||||
- video-edit-tab.jsに`_setupCropOverlay()`追加
|
||||
- `_buildExportWorkflow()`を拡張し`VideoCrop`ノードを挿入
|
||||
|
||||
### Phase 3: テキストオーバーレイ
|
||||
- video-edit-tab.jsに`_setupTextOverlayPanel()`追加
|
||||
- 新規API: `/api/wfm/video/edit/overlay-text`
|
||||
- `video_service.py`に`overlay_text_on_video()`追加
|
||||
- 書き出しフローを「グラフ実行→結合済みファイル取得→overlay-text後処理呼び出し」の2段構成に変更
|
||||
|
||||
### Phase 4: BGM/音声合成
|
||||
- `CreateVideo`ノードのcomplete_audio機能で足りるか検証した上で、足りなければ`mix-audio` API追加
|
||||
- video-edit-tab.jsにBGMアップロード/音量UI追加
|
||||
|
||||
### Phase 5: 仕上げ・永続化(任意)
|
||||
- タイムライン状態の保存/読込(`video_plan_service.py`と同様のJSON永続化を検討、`VideoEditProjectService`として新設するか判断)
|
||||
- Undo/Redo(将来拡張スコープだが、Phase 5で簡易版のみ着手する場合はここ)
|
||||
|
||||
---
|
||||
|
||||
## 5. リスク・技術的懸念点
|
||||
|
||||
1. **VideoTrim/VideoCropのexperimental扱いリスク**
|
||||
- ComfyUI Core側で今後のマイナーバージョンでノード名・入出力仕様が変更される可能性がある。`io.VideoEdit`ウィジェット型はフロントエンドのインタラクティブUI前提の設計であり、wf-managerは独自UIから直接パラメータをJSON投入する形になるため、公式フロントエンドUIの挙動と乖離があっても気づきにくい。
|
||||
- 対策: 表示名ではなくノードのクラス名で参照する。最終的にトリムは`experimental`でも`VIDEO_EDIT`ネストでもない`"Video Slice"`(スペース込みのclass_type、`node_id="Video Slice"`)を採用したため、この項目のリスクは実質解消した。Phase 2で`VideoCrop`(こちらは`experimental`かつ`VIDEO_EDIT`の二重ネスト、代替ノード無し)を使う際は本リスクが残るため、必ず出力を実測確認する。
|
||||
- CLAUDE.mdの「ワークフローJSON整合性ルール」(last_node_id/last_link_id、links[]の3箇所整合)を`_buildExportWorkflow()`実装時に厳守する。
|
||||
|
||||
2. **ffmpeg非依存方針の機能的限界**
|
||||
- トランジション(クロスフェード等)やアルファブレンド合成はComfyUI Coreノードに無く、PyAVで自前実装するとフレーム単位のデコード→NumPy/Pillow合成→再エンコードとなり、CPU処理でかなり低速になる(特に長尺・高解像度動画)。MVPではトランジション無しとし、将来拡張でも「対応するなら低解像度プレビュー限定」等のスコープ限定を検討すべき。
|
||||
- コーデック非互換なクリップ同士の結合は`ConcatenateVideo`が自動的に一度デコードしてから再結合するため、異なる解像度/フレームレートのクリップを混在させると処理コストが跳ね上がる可能性がある。タイムラインUI側で「解像度/fps不一致クリップ」を警告表示する簡易チェックをPhase 1に含めることを推奨。
|
||||
- テキストオーバーレイの再エンコード(Phase 3)は全フレームを一度デコード・Pillow処理・再エンコードするため、長尺動画では書き出しに時間がかかる。プログレス表示(Frame N/Total等)をAPIレスポンスではなくポーリング or 簡易WebSocket通知で出す設計を検討(現状の`convert_to_gif`は同期一括処理でプログレス無し、これをそのまま踏襲すると長尺でUIが固まって見える点に注意)。
|
||||
|
||||
3. **既存Video Plan/Assetタブとのデータ連携**
|
||||
- AssetタブからEditタブへの動画受け渡しは、Asset側が持つ参照形式(Galleryの絶対パス`img.path`)とEdit側のタイムラインが必要とする形式(ComfyUIの`{filename, subfolder, type}`、またはローカルFileオブジェクト)の変換が必要。`video-asset-tab.js`の`_loadIntoSourcePreview()`が既にこの変換(Blob取得→File化→`kind:"local"`)を行っているので、同じパターンをEditタブのクリップ追加処理でも使う。ただし書き出しグラフの`LoadVideo`ノードはComfyUI input/output/temp配下のファイルを期待するため、ローカルFileの場合は動画アップロードAPIを経由してから`LoadVideo`に渡す必要がある(Phase 0の技術検証項目)。
|
||||
- Video Planタブが生成した動画は`VTEMP_GROUP`に入るため、Editタブの「クリップ追加」ソースとして「Video Plan実行結果」も選択できるようにする導線(Assetタブ経由で十分か、Editタブ内に直接「最近の生成結果」ショートカットを設けるか)はUX判断が必要。
|
||||
- タイムライン編集結果を保存/読込する永続化機能(Phase 5想定)を作る場合、`video_plan_service.py`のディレクトリ構成・ファイル命名規則を踏襲し、別サービスクラス(`VideoEditProjectService`)として分離するか、既存`VideoPlanService`を拡張するかの設計判断が必要(データモデルが「バッチ生成の入力」と「編集後のタイムライン状態」で意味的に異なるため、別クラス推奨)。
|
||||
|
||||
4. **並行実行時のリソース競合**
|
||||
- Edit書き出しはPlan tabのバッチ生成と同じ`/prompt`キューを使うため、Plan実行中にEdit書き出しを投げると同じキューに並ぶ。ユーザーへの「実行中」表示や二重実行防止のUI制御をPlan tab側の既存パターン(生成中はボタンdisable等)に倣って実装する。
|
||||
|
||||
5. **開発・デプロイ運用(CLAUDE.md既存ルール)**
|
||||
- 開発元リポジトリ(`c:\Users\statsu-11\Desktop\now_work\comfyUI-wf-maneger\ComfyUI-Workflow-Studio`)と実行時`custom_nodes`(`...\ComfyUI_5\custom_nodes\comfyui-workflow-studio`)は別実体。実機確認のたびに同期スクリプトでの全体反映が必要(部分同期は原因不明のエラーの元)。
|
||||
- `_buildExportWorkflow()`実装時はワークフローJSON整合性ルール(`last_node_id`/`last_link_id`が最大値以上、`links[]`とノード両端の`links`/`link`の3箇所整合)を厳守する。
|
||||
- Python側変更(`video_service.py`, `video_routes.py`)反映にはComfyUI完全再起動が必須(`py_compile`キャッシュではなくインポート自体が起動時一度きりのため)。
|
||||
|
||||
---
|
||||
|
||||
## 6. UI再設計(2026-09-16、ユーザー指示によりPlanタブ寄りに変更)
|
||||
|
||||
初回MVP実装(縦リスト+各行ボタン方式)を、以下のとおりPlan subtabのUIパターンに揃える形で再設計・実機確認済み。
|
||||
|
||||
- **タイムライン**: 縦リスト(`wfm-video-edit-clip-list`)から、Planタブと同じ横トラック構造(`wfm-video-edit-timeline-track`)へ変更。ただしPlanのブロックは`flex-grow`でトラック幅いっぱいに引き伸ばされるのに対し、Editのブロックは実秒数×固定px/秒(20px/秒、最小56px)の幅を持ち**左詰め**で並ぶ(空きトラックはそのまま空く)。
|
||||
- **並べ替えの共有化**: 各クリップ行が持っていた▲▼⧉✕ボタンを廃止し、タイムライン直下の共有ツールバー(◀ 左へ移動 / ▶ 右へ移動 / 複製 / 削除 / クリア)に統合。Planタブの「+ Split / + Block / Delete」ツールバーと同じ「選択中の対象に対して働く共通ボタン」方式。
|
||||
- **ドラッグ並べ替え**: ネイティブHTML5 D&Dでブロック同士のドラッグ&ドロップによる並べ替えにも対応(ボタンと併用可)。`_reorderByDrop()`は「削除→対象の新インデックスを探索→挿入」の順で行い、素朴な二段splice実装にありがちなオフバイワンを回避している。
|
||||
- **Clearボタン新設**: タイムライン全体をクリアする専用ボタン(`videoEditConfirmClear`で確認ダイアログ表示)。
|
||||
- **上部「動画ソース」パネルとの連携**: 自動連携ではなく、Video Sourceパネルに明示的な「Editに追加」ボタン(`#wfm-video-source-add-to-edit`)を新設。Frame/GIF目的の単発動画ドロップでEditタイムラインが意図せず汚れないよう、ユーザーが選択・確認済み。ローカルFile/サーバー参照(`kind:"local"`/`"input"`/`"output"`)の両方に対応し、クリックでEditサブタブへ自動切替。
|
||||
- Frame抽出・GIF化(右ペイン、単独動画操作)とEdit(タイムライン編集・結合、複数クリップ操作)の役割分担は従来通り変更なし——ユーザーからの確認事項としてこの分離を維持している。
|
||||
|
||||
実機(ComfyUI_5・ポート8189、Kapture)で以下を確認済み: 左詰めタイムライン描画、Asset選択→「Editに追加」経路、2クリップの◀移動での順序入れ替え、複製、削除、クリア(確認ダイアログのi18n適用含む)、クリア後の単一クリップ書き出し(22.13秒で正常出力)。
|
||||
|
||||
## 7. レイアウト修正(2026-09-16、追加指示)
|
||||
|
||||
UI再設計直後、実機で「空のタイムライン/書き出しパネルが中央に縮んで表示され、Planタブのように横幅いっぱい・右端固定にならない」という不具合をユーザーが発見。原因はCSSのバグで、修正内容は以下の3点。
|
||||
|
||||
1. **Editタブ自身のドロップゾーンを削除**: `#wfm-video-edit-drop-zone`をtemplates/index.htmlから削除し、`video-edit-tab.js`の`_wireAddClipDropZone()`も削除。クリップ追加は Asset タブの「Editへ送る」ボタンと、Video Sourceパネルの「Editに追加」ボタン(第4節参照)の2経路に一本化。
|
||||
2. **【根本原因】`static/css/video-tab.css`に旧プレースホルダー時代の重複`.wfm-video-edit-panel`ルールが残存していた。** Edit サブタブが「Editing tools coming soon」という1行プレースホルダーだった頃の`display:flex; align-items:center; justify-content:center; border:1px dashed ...`というルール(中央に1行だけ表示するための定義)が、実装を追加した後も削除されておらず、CSSカスケード順(後勝ち)で新しい`.wfm-video-edit-panel { padding: 0 16px 16px; }`の直後に来て`align-items:center`等を上書きし続けていた。これによりタイムライン・トリムパネル・書き出しパネルの全てが横方向に縮んで中央寄せされていた。該当ルールを完全に削除して解消。
|
||||
3. `.wfm-video-edit-timeline-track`に`width:100%; box-sizing:border-box; flex:none;`を明示し、クリップの有無に関わらずトラック自体がPlanの`.wfm-video-timeline-track`と同様に横幅いっぱい・高さ固定のバーであり続けるようにした(今回のバグの根本原因はCSSの重複ルールだったが、将来の再発防止として明示指定を追加)。
|
||||
|
||||
修正後、実機で「空のタイムラインが横幅いっぱいに固定表示される」「クリップ追加後もトラックがフル幅を保ち、クリップは左詰め」「書き出しパネルがPlanのRun/Planパネルと同じ右端固定位置に表示される」ことをスクリーンショットで確認済み。
|
||||
|
||||
## 8. トリムパネル・ツールバーの細部調整(2026-09-16、追加指示)
|
||||
|
||||
- **開始/終了(秒)入力欄+「現在位置を使用」ボタン**: 従来は横並び(`display:flex`)で、ボタンのテキストが枠からはみ出しコンテナ幅を超えていた。入力欄とボタンを縦積み(`.wfm-video-edit-trim-field { flex-direction:column }`)にし、`.wfm-video-edit-trim-row`のグリッド列幅を`minmax(0,160px)`に縮小。ボタンは`.wfm-video-edit-playhead-btn`(font-size:10px、padding:2px 6px)で一回り小さくし、入力欄との見分けを明確化。
|
||||
- **タイムライン総合時間の表示**: ツールバーのClearボタン左隣に、全クリップのトリム後長さ合計を表示する`#wfm-video-edit-total-duration`(`合計: 22.1s`のような表示)を追加。`_updateTotalDuration()`が`_updateToolbarState()`(タイムライン再描画のたびに呼ばれる)から自動更新される。
|
||||
|
||||
## 9. タイムライン連続プレビュー(2026-09-16、追加指示)
|
||||
|
||||
「タイムラインの動画を上部の『生成された動画』プレビューで表示したい。このプレビューの動画=書き出しされる動画としたい」という要望に対応。新規に`<video>`要素を追加するのではなく、Exportの結果が最終的に書き出される**同じ「Result」プレビュー枠**(`#wfm-video-preview-video`、`video-preview.js`の`setResultPreview()`が使う枠)を共有し、タイムライン上の全クリップ(トリム区間反映済み)を順番に連続再生する「疑似合成プレビュー」を実装した。当初計画のMVP項目4「各クリップを順番に`<video>`要素で連続再生する疑似プレビュー」を正式に実装したものにあたる。
|
||||
|
||||
- **UI**: タイムラインツールバーに「▶ プレビュー / ■ 停止」トグルボタンを追加(Delete と 合計時間表示の間)。クリップが0件のときは無効化。
|
||||
- **実装**(`video-edit-tab.js`): `_startPreview()`が`serverRef`ありかつエラー無しのクリップを`_previewClips`として捕捉し先頭から`setResultPreview()`で読み込み・`trimStart`へシーク・再生。`timeupdate`イベントで`currentTime >= trimEnd`を検知した時点、または`ended`イベントで次クリップへ自動遷移(`_advancePreview()`)。全クリップ終了で自動停止。
|
||||
- `video-preview.js`に`getResultPreviewVideoElement()`を新規export(Resultペインの`<video>`要素へ直接アクセスし、`timeupdate`/`ended`購読や`currentTime`制御を行うため。`setResultPreview()`自体はsrc差し替えのみで再生制御はできないため)。
|
||||
- クリップ削除・クリア・書き出し開始時には`_stopPreview()`を呼び、再生中の疑似プレビューを確実に停止する(書き出し完了後は同じ枠に実際の書き出し結果が上書き表示される——「プレビューの動画=書き出しされる動画」という要望通り、同一枠が編集中プレビューと最終結果の両方を担う)。
|
||||
|
||||
実機で2クリップ(22.1s+19.1s)の連続プレビュー再生→クリップ境界での自動切替→停止→書き出し(結合後41.2s)→Resultペインへの結果反映、という一連の流れを確認済み。
|
||||
|
||||
## 10. 静止画/アニメーション画像クリップ対応(2026-09-16、追加指示)
|
||||
|
||||
「Assetの画像(静止画)を動画ソースでプレビュー、Editに追加可能にしたい。静止画、アニメーション画像をタイムラインに追加時間を設定できるようにしたい」という要望に対応。既存のAsset一覧(`__Video Assets__`/`__Video Temp__`グループ、ユーザー選択によりこの2グループのみを対象、Gallery全体には拡張しない)には元々ビデオ専用フィルタが無く、手動でタグ付けされた画像もそのまま一覧に出ていたが、プレビューが常に`<video>`要素前提だったため画像では何も表示されず、Editに追加しても動画として扱われ破綻していた。
|
||||
|
||||
**設計判断(ユーザー承認事項):**
|
||||
- 静止画/アニメーション画像は区別せず、どちらも「表示時間(秒)」を指定するhold型クリップとして統一的に扱う。
|
||||
- 画像クリップの解像度が他の動画クリップと不一致の場合は、`ImageScale`ノード(`crop="center"`)で自動的に動画側の解像度へフィット(ブロックしない)。
|
||||
|
||||
**実装:**
|
||||
- `video-preview.js`: Source/Resultの両プレビュー枠に`<img>`コンパニオン要素を追加し、`setSourcePreview()`/`setResultPreview()`に`mediaType`("video"|"image")引数を追加。`getActivePreviewVideoElement()`は画像表示中は`null`を返す(Frame/GIFツールの誤動作防止)。
|
||||
- `gallery-tab.js`: `isVideoFile()`をexport。
|
||||
- `video-asset-tab.js`/`video-tab.js`: プレビュー読み込み・「Editへ送る」/「Editに追加」の各経路で`isVideoFile()`により`mediaType`を判定して渡すよう修正。Video Sourceドロップゾーンも`image/*`を受け付けるよう拡張。
|
||||
- `video-edit-tab.js`: クリップに`kind:"video"|"image"`を追加。画像クリップは`file.type`から自動判定(`addClipFromFile()`)、サーバーへアップロード後、寸法はPyAV probeではなくブラウザの`Image()`で読み取り(`_readImageDimensions()`、PyAVは任意の静止画形式を安定して開けないため)。トリムパネルはStart/Endペアの代わりに単一の「表示時間(秒)」入力に分岐。タイムラインブロックには🖼/🎬の種別アイコンを付与。解像度不一致ガード(`_findResolutionMismatch`)は`kind==="video"`同士のみ比較するよう変更(画像は自動フィットで解決するため対象外)。
|
||||
- **書き出しグラフ(画像クリップ)**: `LoadImage` → (解像度不一致時のみ)`ImageScale(crop=center)` → `RepeatImageBatch(amount = 表示秒数 × 24fps)` → `CreateVideo(fps=24, codec="auto")`。実機で48フレーム/24fps→1.998秒の正確な動画生成を確認済み。
|
||||
- **【追加発見・修正】音声トラック不整合によるConcatenateVideo失敗**: 動画クリップ(音声あり、例: 32kHz stereo)と画像由来クリップ(音声なし)を結合すると`ConcatenateVideo`が`audio layout: expected None, got 'stereo'`で失敗することを実機で発見。対策として、**2クリップ以上を結合する場合のみ**、動画クリップも`GetVideoComponents`→`CreateVideo`(audio未指定)で無音再構成してから結合するよう統一。単一クリップの書き出し(結合が発生しない)は従来通り音声を保持する。**この結果、複数クリップを結合した書き出しは音声を持たないという制約がMVPの既知の限界として残る**(Phase 2以降で音声トラックの取り扱いを検討する余地あり)。
|
||||
|
||||
実機で以下を確認済み: 静止画の`<img>`プレビュー表示、Editへの追加(🖼アイコン・表示時間入力)、単独画像クリップの書き出し(2.998秒の正確な動画化)、動画クリップ+画像クリップ混在時の自動解像度フィット・音声除去による結合成功(5.996秒)。
|
||||
|
||||
---
|
||||
|
||||
### Critical Files for Implementation
|
||||
- static/js/video-edit-tab.js(新規、中心となるクラス実装)
|
||||
- templates/index.html(Edit subtab DOM骨格、2895行目付近を拡張)
|
||||
- static/js/video-workflow.js(ノード注入・ID採番パターンの流用元)
|
||||
- py/services/video_service.py(probe_video/overlay_text_on_video追加)
|
||||
- py/routes/video_routes.py(新規APIエンドポイント追加)
|
||||
- static/js/image-edit-tab.js(アクションバー・レイヤーパネル・pointerイベントパターンの流用元、1385行目・2194行目・1130-1249行目)
|
||||
- static/js/video-asset-tab.js(Asset→Edit連携ボタン追加)
|
||||
|
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 |
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"0": "escapeHtml",
|
||||
"1": "prompt-table.js",
|
||||
"2": "models_routes.py",
|
||||
"3": "generate-tab.js",
|
||||
"4": "_openCellModal",
|
||||
"5": "gallery_routes.py",
|
||||
"6": "gallery-tab.js",
|
||||
"7": "ImageEditTab",
|
||||
"8": "ai-tab.js",
|
||||
"9": "eagle_routes.py",
|
||||
"10": "feeder-tab.js",
|
||||
"11": "NodesService",
|
||||
"12": "settings_routes.py",
|
||||
"13": "metadata-tab.js",
|
||||
"14": "node_sets_menu.js",
|
||||
"15": "GalleryService",
|
||||
"16": "settings-tab.js",
|
||||
"17": "tagger_routes.py",
|
||||
"18": "WildcardService",
|
||||
"19": "tagger-tab.js",
|
||||
"20": "renderContent",
|
||||
"21": "comfyui-editor.js",
|
||||
"22": "ComfyUI-Workflow-Studio",
|
||||
"23": "TaggerService",
|
||||
"24": "top_menu_extension.js",
|
||||
"25": "nodes-tab.js",
|
||||
"26": "setupAiHandlers",
|
||||
"27": "workflow-tab.js",
|
||||
"28": "workflow_routes.py",
|
||||
"29": "GalleryMetadataStore",
|
||||
"30": "WorkflowService",
|
||||
"31": "LabPlanService",
|
||||
"32": "skill_routes.py",
|
||||
"33": "LayerManager",
|
||||
"34": "SelectTool",
|
||||
"35": "CivitaiService",
|
||||
"36": "DrawTool",
|
||||
"37": "MaskTool",
|
||||
"38": "index.html Template",
|
||||
"39": "gallery_service.py",
|
||||
"40": "FileExport",
|
||||
"41": "image-prompt-tab.js",
|
||||
"42": "KSampler Node",
|
||||
"43": "image-edit-tab.js",
|
||||
"44": "style-catalog-tab.js",
|
||||
"45": "add",
|
||||
"46": "comfyui-workflow.js",
|
||||
"47": "BlurTool",
|
||||
"48": "MaskShapeTool",
|
||||
"49": "MaskVectorTool",
|
||||
"50": "ShapeTool",
|
||||
"51": "gmic_routes.py",
|
||||
"52": "ollama_routes.py",
|
||||
"53": "TextTool",
|
||||
"54": "analyze_workflow",
|
||||
"55": "MaskTextTool",
|
||||
"56": "lab-tab.js",
|
||||
"57": ".list_images",
|
||||
"58": "MaskAlphaTool",
|
||||
"59": "MaskColorTool",
|
||||
"60": "gallery_feeder_extension.js",
|
||||
"61": "createPanel",
|
||||
"62": "_runLabBatch",
|
||||
"63": "BgRemove",
|
||||
"64": "Workflow JSON",
|
||||
"65": "WFS_GalleryFeeder",
|
||||
"66": "initGenerateTab",
|
||||
"67": "InpaintI2IActions",
|
||||
"68": "Sam3Segmentation",
|
||||
"69": "fromWorkflow",
|
||||
"70": "_extractPromptsAPI",
|
||||
"71": "generate_doc_index.py",
|
||||
"72": "WS Inpaint Basic Workflow",
|
||||
"73": "._bg_index_folder",
|
||||
"74": "aiohttp",
|
||||
"75": "import_style_prompt_seed.py",
|
||||
"76": "Image Edit (FireRed Image Edit 1.1) node",
|
||||
"77": "Version 0.3.9",
|
||||
"78": "DWPose Image Feeder Workflow",
|
||||
"79": "wfm.py",
|
||||
"80": "README.index.md",
|
||||
"81": "WFS_PromptText",
|
||||
"82": "workflow_service.py",
|
||||
"83": "SPA",
|
||||
"84": "prompt-ai-chat.js",
|
||||
"85": "Tagger Tab requirements.txt",
|
||||
"86": "data directory",
|
||||
"87": "t",
|
||||
"88": "Chinese Language Support",
|
||||
"89": "Version 0.3.95",
|
||||
"90": "Windows",
|
||||
"91": "add_routes",
|
||||
"92": "Bounded Cache",
|
||||
"93": "Business Logic",
|
||||
"94": "check_lazy_status",
|
||||
"95": "HF_HOME",
|
||||
"96": "IS_CHANGED",
|
||||
"97": "Lab Subtab",
|
||||
"98": "lab_routes.py",
|
||||
"99": "PowerShell",
|
||||
"100": "prestartup_script.py",
|
||||
"101": ".get_image_prompt_root",
|
||||
"102": ".get_style_catalog_root",
|
||||
"103": ".start_background_index",
|
||||
"104": ".update_workflows_dir",
|
||||
"105": "Version 0.4.2",
|
||||
"106": "Workflow Studio Banner",
|
||||
"107": "Workflow Studio Icon",
|
||||
"108": "BiRefNet Model",
|
||||
"109": "Chinese",
|
||||
"110": "SDXL 2D Pose Editor I2I Workflow",
|
||||
"111": "ComfyUI Manager",
|
||||
"112": "Tagger Tab Screenshot (WD Tagger/DeepDanbooru)",
|
||||
"113": "Models Tab UI Screenshot",
|
||||
"114": "Workflow Studio Topbar Icon Strip",
|
||||
"115": "GenerateUI LoRA Stack Tab Screenshot",
|
||||
"116": "Workflow Studio Favicon Icon",
|
||||
"117": "2D Pose Editor to Image Edit (FireRed) Workflow",
|
||||
"118": "3D Pose Editor to Image Edit (Flux.2 Klein) Workflow",
|
||||
"119": "SDXL DWPose ControlNet Workflow",
|
||||
"120": "SDXL From Metadata T2I Workflow",
|
||||
"121": "SDXL Img2Img + FaceDetailer Workflow",
|
||||
"122": "SDXL Image Feeder Img2Img Workflow",
|
||||
"123": "SDXL T2I + FaceDetailer Workflow",
|
||||
"124": "GenerateUI Batch Tab Screenshot",
|
||||
"125": "Multiple Select Menu Screenshot",
|
||||
"126": "Image Edit Tab Screenshot",
|
||||
"127": "Workflow Tab Screenshot",
|
||||
"128": "Prompt Tab Input Assistance Screenshot",
|
||||
"129": "GenerateUI Feeder Tab Screenshot",
|
||||
"130": "Workflow Studio Library Panel (Lora Info View)",
|
||||
"131": "Library Panel I Tab (Prompts) Screenshot",
|
||||
"132": "Settings Tab Screenshot (Language, Theme)",
|
||||
"133": "ComfyUI Workflow Studio Thumbnail",
|
||||
"134": "English",
|
||||
"135": "Feeder Subtab",
|
||||
"136": "folder_paths",
|
||||
"137": "Help & Support Tab",
|
||||
"138": "i18n",
|
||||
"139": "Japanese",
|
||||
"140": "Metadata Tab",
|
||||
"141": "NODE_CLASS_MAPPINGS",
|
||||
"142": "OrderedDict",
|
||||
"143": "get_output_directory",
|
||||
"144": "comfyui-workflow-studio",
|
||||
"145": "ComfyUI PromptServer",
|
||||
"146": "py/__init__.py",
|
||||
"147": "nodes/__init__.py",
|
||||
"148": "routes/__init__.py",
|
||||
"149": "services/__init__.py",
|
||||
"150": "python-dotenv",
|
||||
"151": "Silent Failure Diagnosis",
|
||||
"152": "get_temp_directory",
|
||||
"153": "prompts_routes.py",
|
||||
"154": "VALIDATE_INPUTS",
|
||||
"155": "SD1.5 Basic Text-to-Image Workflow",
|
||||
"156": "SDXL Text-to-Image Basic Workflow Screenshot",
|
||||
"157": "SettingsService",
|
||||
"158": "on_doc_edit.py",
|
||||
"159": "ComfyUI-Custom-Scripts",
|
||||
"160": "ComfyUI-Gallery",
|
||||
"161": "ComfyUI-Lora-Manager",
|
||||
"162": "Eagle",
|
||||
"163": "Fooocus",
|
||||
"164": "Gallery Tab",
|
||||
"165": "GenerateUI Tab",
|
||||
"166": "Image Edit Tab",
|
||||
"167": "infinite-image-browsing",
|
||||
"168": "Lemonade Server",
|
||||
"169": "LM Studio",
|
||||
"170": "Ollama",
|
||||
"171": "Settings Tab",
|
||||
"172": "Tagger Tab",
|
||||
"173": "Unsloth",
|
||||
"174": "app.js",
|
||||
"175": "PromptsService",
|
||||
"176": "util.js",
|
||||
"177": "civitai_service.py",
|
||||
"178": "config.py",
|
||||
"179": "createModelItem",
|
||||
"180": "_renderBatchPreview"
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
# Graph Report - ComfyUI-Workflow-Studio (2026-08-22)
|
||||
|
||||
## Corpus Check
|
||||
- 113 files · ~567,985 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 2314 nodes · 5664 edges · 181 communities (87 shown, 94 thin omitted)
|
||||
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 302 edges (avg confidence: 0.88)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `ad9d06d3`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- escapeHtml
|
||||
- prompt-table.js
|
||||
- models_routes.py
|
||||
- generate-tab.js
|
||||
- _openCellModal
|
||||
- gallery_routes.py
|
||||
- gallery-tab.js
|
||||
- ImageEditTab
|
||||
- ai-tab.js
|
||||
- eagle_routes.py
|
||||
- feeder-tab.js
|
||||
- NodesService
|
||||
- settings_routes.py
|
||||
- metadata-tab.js
|
||||
- node_sets_menu.js
|
||||
- GalleryService
|
||||
- settings-tab.js
|
||||
- tagger_routes.py
|
||||
- WildcardService
|
||||
- tagger-tab.js
|
||||
- renderContent
|
||||
- comfyui-editor.js
|
||||
- ComfyUI-Workflow-Studio
|
||||
- TaggerService
|
||||
- top_menu_extension.js
|
||||
- nodes-tab.js
|
||||
- setupAiHandlers
|
||||
- workflow-tab.js
|
||||
- workflow_routes.py
|
||||
- GalleryMetadataStore
|
||||
- WorkflowService
|
||||
- LabPlanService
|
||||
- skill_routes.py
|
||||
- LayerManager
|
||||
- SelectTool
|
||||
- CivitaiService
|
||||
- DrawTool
|
||||
- MaskTool
|
||||
- index.html Template
|
||||
- gallery_service.py
|
||||
- FileExport
|
||||
- image-prompt-tab.js
|
||||
- KSampler Node
|
||||
- image-edit-tab.js
|
||||
- style-catalog-tab.js
|
||||
- add
|
||||
- comfyui-workflow.js
|
||||
- BlurTool
|
||||
- MaskShapeTool
|
||||
- MaskVectorTool
|
||||
- ShapeTool
|
||||
- gmic_routes.py
|
||||
- ollama_routes.py
|
||||
- TextTool
|
||||
- analyze_workflow
|
||||
- MaskTextTool
|
||||
- lab-tab.js
|
||||
- .list_images
|
||||
- MaskAlphaTool
|
||||
- MaskColorTool
|
||||
- gallery_feeder_extension.js
|
||||
- createPanel
|
||||
- _runLabBatch
|
||||
- BgRemove
|
||||
- Workflow JSON
|
||||
- WFS_GalleryFeeder
|
||||
- initGenerateTab
|
||||
- InpaintI2IActions
|
||||
- Sam3Segmentation
|
||||
- fromWorkflow
|
||||
- _extractPromptsAPI
|
||||
- generate_doc_index.py
|
||||
- WS Inpaint Basic Workflow
|
||||
- ._bg_index_folder
|
||||
- aiohttp
|
||||
- import_style_prompt_seed.py
|
||||
- Image Edit (FireRed Image Edit 1.1) node
|
||||
- Version 0.3.9
|
||||
- DWPose Image Feeder Workflow
|
||||
- wfm.py
|
||||
- README.index.md
|
||||
- WFS_PromptText
|
||||
- workflow_service.py
|
||||
- SPA
|
||||
- prompt-ai-chat.js
|
||||
- Tagger Tab requirements.txt
|
||||
- data directory
|
||||
- t
|
||||
- Chinese Language Support
|
||||
- Version 0.3.95
|
||||
- Windows
|
||||
- add_routes
|
||||
- Bounded Cache
|
||||
- Business Logic
|
||||
- check_lazy_status
|
||||
- HF_HOME
|
||||
- IS_CHANGED
|
||||
- Lab Subtab
|
||||
- lab_routes.py
|
||||
- PowerShell
|
||||
- prestartup_script.py
|
||||
- .get_image_prompt_root
|
||||
- .get_style_catalog_root
|
||||
- .start_background_index
|
||||
- .update_workflows_dir
|
||||
- Version 0.4.2
|
||||
- Workflow Studio Banner
|
||||
- Workflow Studio Icon
|
||||
- BiRefNet Model
|
||||
- Chinese
|
||||
- SDXL 2D Pose Editor I2I Workflow
|
||||
- ComfyUI Manager
|
||||
- Tagger Tab Screenshot (WD Tagger/DeepDanbooru)
|
||||
- Models Tab UI Screenshot
|
||||
- Workflow Studio Topbar Icon Strip
|
||||
- GenerateUI LoRA Stack Tab Screenshot
|
||||
- Workflow Studio Favicon Icon
|
||||
- 2D Pose Editor to Image Edit (FireRed) Workflow
|
||||
- 3D Pose Editor to Image Edit (Flux.2 Klein) Workflow
|
||||
- SDXL DWPose ControlNet Workflow
|
||||
- SDXL From Metadata T2I Workflow
|
||||
- SDXL Img2Img + FaceDetailer Workflow
|
||||
- SDXL Image Feeder Img2Img Workflow
|
||||
- SDXL T2I + FaceDetailer Workflow
|
||||
- GenerateUI Batch Tab Screenshot
|
||||
- Multiple Select Menu Screenshot
|
||||
- Image Edit Tab Screenshot
|
||||
- Workflow Tab Screenshot
|
||||
- Prompt Tab Input Assistance Screenshot
|
||||
- GenerateUI Feeder Tab Screenshot
|
||||
- Workflow Studio Library Panel (Lora Info View)
|
||||
- Library Panel I Tab (Prompts) Screenshot
|
||||
- Settings Tab Screenshot (Language, Theme)
|
||||
- ComfyUI Workflow Studio Thumbnail
|
||||
- English
|
||||
- Feeder Subtab
|
||||
- folder_paths
|
||||
- Help & Support Tab
|
||||
- i18n
|
||||
- Japanese
|
||||
- Metadata Tab
|
||||
- NODE_CLASS_MAPPINGS
|
||||
- OrderedDict
|
||||
- get_output_directory
|
||||
- comfyui-workflow-studio
|
||||
- ComfyUI PromptServer
|
||||
- python-dotenv
|
||||
- Silent Failure Diagnosis
|
||||
- get_temp_directory
|
||||
- prompts_routes.py
|
||||
- VALIDATE_INPUTS
|
||||
- SD1.5 Basic Text-to-Image Workflow
|
||||
- SDXL Text-to-Image Basic Workflow Screenshot
|
||||
- SettingsService
|
||||
- on_doc_edit.py
|
||||
- ComfyUI-Custom-Scripts
|
||||
- ComfyUI-Gallery
|
||||
- ComfyUI-Lora-Manager
|
||||
- Eagle
|
||||
- Fooocus
|
||||
- Gallery Tab
|
||||
- GenerateUI Tab
|
||||
- Image Edit Tab
|
||||
- infinite-image-browsing
|
||||
- Lemonade Server
|
||||
- LM Studio
|
||||
- Ollama
|
||||
- Settings Tab
|
||||
- Tagger Tab
|
||||
- Unsloth
|
||||
- app.js
|
||||
- PromptsService
|
||||
- util.js
|
||||
- civitai_service.py
|
||||
- config.py
|
||||
- createModelItem
|
||||
- _renderBatchPreview
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `t()` - 233 edges
|
||||
2. `showToast()` - 192 edges
|
||||
3. `escapeHtml()` - 73 edges
|
||||
4. `ImageEditTab` - 53 edges
|
||||
5. `GalleryService` - 45 edges
|
||||
6. `setup_routes()` - 32 edges
|
||||
7. `initSettingsTab()` - 31 edges
|
||||
8. `add()` - 31 edges
|
||||
9. `renderModelGrid()` - 29 edges
|
||||
10. `TaggerService` - 26 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `beforeRegisterNodeDef()` --indirect_call--> `saveSelectedAsNodeSet()` [INFERRED]
|
||||
web/comfyui/top_menu_extension.js → web/comfyui/node_sets_menu.js
|
||||
- `Publish to Comfy Registry Workflow` --conceptually_related_to--> `index.html Template` [INFERRED]
|
||||
.github/workflows/publish.yml → templates/index.html
|
||||
- `Version 0.3.9` --references--> `Models Tab` [EXTRACTED]
|
||||
DEVLOG.index.md → README.index.md
|
||||
- `Version 0.3.9` --references--> `Nodes Tab` [EXTRACTED]
|
||||
DEVLOG.index.md → README.index.md
|
||||
- `Version 0.3.9` --references--> `Prompt Tab` [EXTRACTED]
|
||||
DEVLOG.index.md → README.index.md
|
||||
|
||||
## Import Cycles
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/prompt-presets.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/prompt-ai-chat.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/prompt-styles.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/prompt-wildcards.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/image-prompt-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/Sam3Segmentation.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/GmicIntegration.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/FileExport.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/MaskEditorOneBridge.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BgRemove.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BlurTool.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/InpaintI2IActions.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Architecture Layers** — workflowstudio, add_routes, py_wfm, py_routes, py_services, business_logic, web_comfyui, static, templates, data, settings_json, metadata_json, node_metadata_json [EXTRACTED 0.80]
|
||||
- **Graphify Index Generation** — readme_index, devlog_index, generate_doc_index, graphifyignore, readme, devlog, claude_hooks, git_hooks [EXTRACTED 0.85]
|
||||
- **Workflow Studio UI Navigation Tabs** — templates_index_html_workflow, templates_index_html_nodes, templates_index_html_models, templates_index_html_generate, templates_index_html_prompt, templates_index_html_gallery, templates_index_html_image_edit, templates_index_html_tagger, templates_index_html_settings, templates_index_html_help, templates_index_html_ai [EXTRACTED 0.85]
|
||||
- **Workflow JSON Consistency Group** — last_node_id, last_link_id, links, workflow_json [EXTRACTED 0.85]
|
||||
- **i18n Languages** — i18n, english, japanese, chinese [EXTRACTED 0.90]
|
||||
- **Mask Editor One Inpainting Pipeline** — comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_load_checkpoint_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_mask_editor_one_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_positive_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_negative_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_vae_encode_for_inpainting_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_save_image_node [INFERRED]
|
||||
|
||||
## Communities (181 total, 94 thin omitted)
|
||||
|
||||
### Community 0 - "escapeHtml"
|
||||
Cohesion: 0.09
|
||||
Nodes (80): openModal(), comfyUI, badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange() (+72 more)
|
||||
|
||||
### Community 1 - "prompt-table.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (99): closeModal(), refreshStylesList(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), clearBatchPresets(), createPmItem(), esc() (+91 more)
|
||||
|
||||
### Community 2 - "models_routes.py"
|
||||
Cohesion: 0.06
|
||||
Nodes (55): handle_change_preview(), handle_civitai_batch(), handle_civitai_cache(), handle_civitai_fetch(), handle_delete_models(), handle_get_disabled(), handle_get_filepath(), handle_get_groups() (+47 more)
|
||||
|
||||
### Community 3 - "generate-tab.js"
|
||||
Cohesion: 0.09
|
||||
Nodes (41): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _ckptBatch, _ckptState (+33 more)
|
||||
|
||||
### Community 4 - "_openCellModal"
|
||||
Cohesion: 0.24
|
||||
Nodes (12): _applyStyleToText(), _cellHtml(), _cellLabel(), _effectiveDisplayValue(), _isEmptyValue(), _isLiveDisplay(), _liveValueFor(), _openCellModal() (+4 more)
|
||||
|
||||
### Community 5 - "gallery_routes.py"
|
||||
Cohesion: 0.10
|
||||
Nodes (54): add_to_group(), bulk_favorite(), bulk_group(), clear_group_images(), create_folder_route(), create_group(), delete_folder_route(), delete_group() (+46 more)
|
||||
|
||||
### Community 6 - "gallery-tab.js"
|
||||
Cohesion: 0.10
|
||||
Nodes (53): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+45 more)
|
||||
|
||||
### Community 8 - "ai-tab.js"
|
||||
Cohesion: 0.09
|
||||
Nodes (52): appendChatBubble(), _appendSkillSaveButton(), _appendSvgPreview(), _applyGenOptions(), buildTranslationMessages(), callChat(), callLLM(), callVLM() (+44 more)
|
||||
|
||||
### Community 9 - "eagle_routes.py"
|
||||
Cohesion: 0.14
|
||||
Nodes (20): _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test(), Application, Request, Response (+12 more)
|
||||
|
||||
### Community 10 - "feeder-tab.js"
|
||||
Cohesion: 0.10
|
||||
Nodes (50): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+42 more)
|
||||
|
||||
### Community 11 - "NodesService"
|
||||
Cohesion: 0.09
|
||||
Nodes (27): handle_create_set(), handle_delete_set(), handle_export_set(), handle_get_groups(), handle_get_metadata(), handle_list_sets(), handle_save_groups(), handle_save_metadata() (+19 more)
|
||||
|
||||
### Community 12 - "settings_routes.py"
|
||||
Cohesion: 0.09
|
||||
Nodes (44): _apply_import_bundle(), _build_export_bundle(), _find_style(), _get_comfyui_output_dir(), handle_create_style(), handle_delete_style(), handle_export(), handle_get() (+36 more)
|
||||
|
||||
### Community 13 - "metadata-tab.js"
|
||||
Cohesion: 0.11
|
||||
Nodes (40): buildLoRAItem(), buildModelItem(), buildPromptItem(), collectAllNodes(), collectUnique(), extractAllMetadata(), fromWorkflow(), extractCheckpoints() (+32 more)
|
||||
|
||||
### Community 14 - "node_sets_menu.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (38): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+30 more)
|
||||
|
||||
### Community 15 - "GalleryService"
|
||||
Cohesion: 0.10
|
||||
Nodes (13): GalleryService, Path, 許可するルートパスを更新する(Settings変更時に呼ぶ), ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ), パスが許可ルート配下かチェック(パストラバーサル防止)。 Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの…, outputフォルダのフォルダツリーを返す, 画像と同名の.txtサイドカーにプロンプトテキストを保存する, ワークフローを抽出する。 優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow] (+5 more)
|
||||
|
||||
### Community 16 - "settings-tab.js"
|
||||
Cohesion: 0.09
|
||||
Nodes (40): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+32 more)
|
||||
|
||||
### Community 17 - "tagger_routes.py"
|
||||
Cohesion: 0.14
|
||||
Nodes (24): Connection, handle_batch_start(), handle_batch_status(), handle_batch_stop(), handle_db_delete(), handle_db_export(), handle_db_list(), handle_db_save() (+16 more)
|
||||
|
||||
### Community 18 - "WildcardService"
|
||||
Cohesion: 0.12
|
||||
Nodes (21): handle_create_link(), handle_delete(), handle_get_content(), handle_link_status(), handle_list(), handle_remove_link(), handle_save(), Application (+13 more)
|
||||
|
||||
### Community 19 - "tagger-tab.js"
|
||||
Cohesion: 0.14
|
||||
Nodes (29): _applyI18n(), _batchStart(), _batchStop(), _dbDelete(), _dbExport(), _dbLoad(), _dbSave(), _dbSearch() (+21 more)
|
||||
|
||||
### Community 20 - "renderContent"
|
||||
Cohesion: 0.16
|
||||
Nodes (28): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), esc(), extractPackageName(), getNodeCategory(), getNodePackage(), getWfBadges() (+20 more)
|
||||
|
||||
### Community 21 - "comfyui-editor.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (28): _applyLoraToNode(), _attachPromptWeightControl(), _buildLoraManagerSyntax(), _buildLoraSyntax(), _buildPresetOptions(), _compositeImageWithMask(), _I2I_PLACEHOLDER_DEFAULT, _imageInputToDataURL() (+20 more)
|
||||
|
||||
### Community 22 - "ComfyUI-Workflow-Studio"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): CivitAI, ComfyUI-Workflow-Studio, Mask Editor One, Version 0.3.65
|
||||
|
||||
### Community 23 - "TaggerService"
|
||||
Cohesion: 0.14
|
||||
Nodes (5): Image, Path, WD Tagger / DeepDanbooru / Ollama VLM 推論サービス。, パストラバーサル防止: セパレータ・NUL・相対参照を拒否, TaggerService
|
||||
|
||||
### Community 24 - "top_menu_extension.js"
|
||||
Cohesion: 0.14
|
||||
Nodes (27): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), NODE_SETS_TOOLTIP, attachTopMenuButton(), beforeRegisterNodeDef(), captureCanvasSnapshot(), compareVersions() (+19 more)
|
||||
|
||||
### Community 25 - "nodes-tab.js"
|
||||
Cohesion: 0.11
|
||||
Nodes (40): _applyNodeSelectionToDOM(), bulkNodeAddToGroup(), bulkNodeCreateAndAddToGroup(), bulkNodeRemoveFromGroup(), bulkNodeSetFavorite(), categoryBadgeHtml(), createNodeCard(), createNodeSet() (+32 more)
|
||||
|
||||
### Community 26 - "setupAiHandlers"
|
||||
Cohesion: 0.11
|
||||
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
|
||||
|
||||
### Community 27 - "workflow-tab.js"
|
||||
Cohesion: 0.13
|
||||
Nodes (34): readJsonStorage(), badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows() (+26 more)
|
||||
|
||||
### Community 28 - "workflow_routes.py"
|
||||
Cohesion: 0.15
|
||||
Nodes (26): handle_analyze(), handle_change_thumbnail(), handle_delete(), handle_import(), handle_list(), handle_metadata(), handle_raw(), handle_reanalyze_all() (+18 more)
|
||||
|
||||
### Community 29 - "GalleryMetadataStore"
|
||||
Cohesion: 0.13
|
||||
Nodes (9): GalleryMetadataStore, Path, グループ名を変更し、全画像のgroupsフィールドも更新する, gallery_metadata.json の構造: { "images": { "<abs_path>": { "favorite": bool,…, 存在しないパスをグループから一括削除する(1回の保存で完結), グループメンバーのパスをsetで返す(高速フィルタ用), グループ内の全画像を除外する(グループ自体は残す), 画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ) (+1 more)
|
||||
|
||||
### Community 30 - "WorkflowService"
|
||||
Cohesion: 0.13
|
||||
Nodes (13): Get raw workflow JSON content., Save metadata (tags, memo, summary, etc.) for a workflow., Manages workflow files and metadata., Import workflow files. Returns list of results., Rename workflow and its associated thumbnail., Delete workflow JSON and associated thumbnail., Re-analyze a workflow and save results to metadata., Re-analyze all workflows and update metadata. (+5 more)
|
||||
|
||||
### Community 31 - "LabPlanService"
|
||||
Cohesion: 0.23
|
||||
Nodes (4): LabPlanService, Path, Lab tab plan file management service (JSON plan + optional PNG index thumbnail)., Saves the contact-sheet index image into ComfyUI's own output folder, alongside…
|
||||
|
||||
### Community 32 - "skill_routes.py"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): handle_delete(), handle_get_content(), handle_list(), handle_save(), Application, Request, Response, AI skill (.md system prompt) file management API routes. (+8 more)
|
||||
|
||||
### Community 35 - "CivitaiService"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): CivitaiService, Build request headers, optionally including Bearer token., Calculate SHA256 hash of a file., Fetch model version info from CivitAI by SHA256 hash (GET). 429/5xx…, POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。 レスポンスの…, Extract relevant fields from CivitAI API response., Batch fetch CivitAI info for multiple model files. Phase 1: SHA256…, Clear cache for a specific hash or all. (+3 more)
|
||||
|
||||
### Community 38 - "index.html Template"
|
||||
Cohesion: 0.10
|
||||
Nodes (22): actions/checkout@v4, Comfy-Org/publish-node-action@main, DEVLOG.md, Publish to Comfy Registry Workflow, graphifyignore, main Branch, pyproject.toml, README.md (+14 more)
|
||||
|
||||
### Community 39 - "gallery_service.py"
|
||||
Cohesion: 0.10
|
||||
Nodes (13): Gallery Metadata Store - ギャラリー画像のメタデータ永続化, _clean_vault_tags(), _decode_image_data_url(), _FolderCache, _parse_vault_yaml_leaves(), Gallery Service - outputフォルダの画像管理、メタデータ閲覧, フォルダ単位の画像スキャン結果キャッシュ。 フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。, data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。 ヘッダが無ければPNG扱い(旧互換)。 (+5 more)
|
||||
|
||||
### Community 41 - "image-prompt-tab.js"
|
||||
Cohesion: 0.21
|
||||
Nodes (20): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+12 more)
|
||||
|
||||
### Community 42 - "KSampler Node"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): CLIP Text Encode Negative Prompt Node, CLIP Text Encode Positive Prompt Node, Inpaint with Mask Editor One (Workflow Screenshot), Load Checkpoint Node, Mask Editor One Node, Save Image Node, VAE Encode (for Inpainting) Node, 3D Pose Editor Node (comfyui-vrm-pose-editor) (+12 more)
|
||||
|
||||
### Community 43 - "image-edit-tab.js"
|
||||
Cohesion: 0.12
|
||||
Nodes (8): comfyEditor, comfyWorkflow, GmicIntegration, MaskEditorOneBridge, MASK_TEXT_FONTS, fitToCanvas(), TOOL_DEFS, TEXT_FONTS
|
||||
|
||||
### Community 44 - "style-catalog-tab.js"
|
||||
Cohesion: 0.22
|
||||
Nodes (18): selectStyleByName(), activateStyleCatalogTab(), API, apiFetch(), copyText(), createThumbCard(), debounce(), initStyleCatalogTab() (+10 more)
|
||||
|
||||
### Community 45 - "add"
|
||||
Cohesion: 0.22
|
||||
Nodes (19): add(), fetchWorkflowRaw(), getCanvasDropPos(), handleInfoFile(), installCanvasDropHandler(), loadWorkflowOnCanvas(), placeClipTextEncodeNode(), placeLoraMgrNode() (+11 more)
|
||||
|
||||
### Community 46 - "comfyui-workflow.js"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): _CONTROL_AFTER_GENERATE, _findInjectedWidgetIndex(), _flattenSubgraphs(), _getDynamicComboSubNames(), _getWidgetInputNames(), _getWidgetInputTypes(), _isExtraWidgetValue(), _isLinkedWidgetName() (+5 more)
|
||||
|
||||
### Community 51 - "gmic_routes.py"
|
||||
Cohesion: 0.21
|
||||
Nodes (14): _gmic_run_gui(), handle_open(), handle_result(), handle_status(), Application, Request, Response, G'MIC-Qt integration API routes. (+6 more)
|
||||
|
||||
### Community 52 - "ollama_routes.py"
|
||||
Cohesion: 0.23
|
||||
Nodes (14): _get_ollama_config(), handle_chat(), handle_models(), handle_test(), Application, Request, Response, Ollama API proxy routes. (+6 more)
|
||||
|
||||
### Community 54 - "analyze_workflow"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): analyze_workflow(), _clip_type_from_ui_node(), _collect_all_ui_nodes(), _detect_model_type_from_name(), _model_name_from_api_node(), _model_name_from_ui_node(), Workflow analyzer - detects model types and counts input/output nodes., Analyze workflow JSON and return model types, input/output node counts. (+5 more)
|
||||
|
||||
### Community 56 - "lab-tab.js"
|
||||
Cohesion: 0.12
|
||||
Nodes (41): _applyPlanData(), _buildIndexImageDataUrl(), _buildPlanData(), _clearPlan(), COLUMN_KEYS, _defaultValueFor(), _emptyColumns(), _emptyLabState() (+33 more)
|
||||
|
||||
### Community 57 - ".list_images"
|
||||
Cohesion: 0.17
|
||||
Nodes (6): os.scandir() で画像ファイルを1回のシステムコールで列挙。 キャッシュがあればそれを返す。 Returns: [(name,…, os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。 ImagePrompt/Style…, 指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める), 画像のプロンプトテキストを返す。 1. 画像と同名の .txt サイドカーがあればそれを使う 2. 無ければ ponyxlWildcardsVault 形式…, image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ…, ponyxlWildcardsVault形式のフォールバック解決。 thumbnails_option2 の "{leaf}.preview3.ext"…
|
||||
|
||||
### Community 60 - "gallery_feeder_extension.js"
|
||||
Cohesion: 0.36
|
||||
Nodes (11): beforeRegisterNodeDef(), fetchGroupImages(), getInputWidget(), getOrCreate(), _ns, setWidgetValue(), startLoop(), stopLoop() (+3 more)
|
||||
|
||||
### Community 61 - "createPanel"
|
||||
Cohesion: 0.22
|
||||
Nodes (13): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), injectStyles() (+5 more)
|
||||
|
||||
### Community 62 - "_runLabBatch"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): _annotatedImageRef(), _applyLabLoraToWorkflow(), _buildLoraPromptInjection(), _buildWorkflowForIteration(), _fetchLabLoraMetadataCache(), _labTriggerWordsFor(), _runLabBatch(), _setRunUiState() (+2 more)
|
||||
|
||||
### Community 64 - "Workflow JSON"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): EXTRA_PNGINFO, last_link_id, last_node_id, links array, Position Index, PROMPT, widgets_values, workflow_analyzer.py (+2 more)
|
||||
|
||||
### Community 65 - "WFS_GalleryFeeder"
|
||||
Cohesion: 0.27
|
||||
Nodes (4): Path, WFS_GalleryFeeder – Feeds images from a gallery group into a workflow., Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。, WFS_GalleryFeeder
|
||||
|
||||
### Community 66 - "initGenerateTab"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): initGenerateTab(), _loadStyles(), _renderStyleDropdown(), updateStatus(), highlightJSON(), syncJsonHighlight(), syncScroll()
|
||||
|
||||
### Community 69 - "fromWorkflow"
|
||||
Cohesion: 0.36
|
||||
Nodes (10): _collectAllNodes(), _collectUnique(), fromWorkflow(), _extractCheckpoints(), _extractDiffusionModels(), _extractLoRAs(), _extractMarkdownNoteModels(), addU() (+2 more)
|
||||
|
||||
### Community 70 - "_extractPromptsAPI"
|
||||
Cohesion: 0.33
|
||||
Nodes (10): _extractPrompts(), _extractPromptsAPI(), _extractPromptsFromNodeSet(), _extractPromptsLiteGraph(), _isPromptStylerNode(), _isSamplerNode(), _isTextEncoderNode(), _resolveEditPlusText() (+2 more)
|
||||
|
||||
### Community 71 - "generate_doc_index.py"
|
||||
Cohesion: 0.53
|
||||
Nodes (5): generate_devlog_index(), generate_readme_index(), main(), Path, Regenerate README.index.md / DEVLOG.index.md from README.md / DEVLOG.md.…
|
||||
|
||||
### Community 72 - "WS Inpaint Basic Workflow"
|
||||
Cohesion: 0.53
|
||||
Nodes (9): CLIP Text Encode (Prompt) - negative conditioning, CLIP Text Encode (Prompt) - positive conditioning, KSampler node (ddim sampler, 30 steps, cfg 4.0, denoise 1.00), Load Checkpoint node (hayochamixIDXL_v11+_bl checkpoint), Load Image node (clipspace painted mask input), Save Image node (ComfyUI filename prefix), VAE Decode node, VAE Encode (for Inpainting) node with grow_mask_by (+1 more)
|
||||
|
||||
### Community 73 - "._bg_index_folder"
|
||||
Cohesion: 0.25
|
||||
Nodes (5): Event, バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。 10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。, PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す, PNGのtEXtチャンクからメタデータを抽出, JPEGのEXIF/commentからメタデータを抽出(簡易)
|
||||
|
||||
### Community 74 - "aiohttp"
|
||||
Cohesion: 0.16
|
||||
Nodes (14): aiohttp, py/routes, _get_api_key(), handle_proxy(), _is_allowed_base_url(), Application, Request, Response (+6 more)
|
||||
|
||||
### Community 75 - "import_style_prompt_seed.py"
|
||||
Cohesion: 0.36
|
||||
Nodes (8): clean_tags(), import_category(), main(), parse_yaml_leaves(), Path, Style/Prompt ギャラリー用シードデータ投入スクリプト。 comfyui_prompt_gallery / prompt_builder_proto…, comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。 「key:」行の直後が「-…, 1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)
|
||||
|
||||
### Community 76 - "Image Edit (FireRed Image Edit 1.1) node"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): 3D Pose Editor node, FireRed-Image-Edit-1.1_fp8mixed_comfy.safetensors, FireRed-Image-Edit-1.0-Lightning-8steps-v1.0.safetensors (LoRA), Image Edit (FireRed Image Edit 1.1) node, qwen_2.5_vl_7b_fp8_scaled.safetensors (CLIP), qwen_image_vae.safetensors (VAE), Save Image node, 3D Pose Editor to Image Edit (Fire Red) Workflow
|
||||
|
||||
### Community 77 - "Version 0.3.9"
|
||||
Cohesion: 0.18
|
||||
Nodes (11): AI TOOL Tab, Models Tab, Nodes Tab, Prompt Tab, Version 0.1.2, Version 0.1.7, Version 0.2.3, Version 0.3.14 (+3 more)
|
||||
|
||||
### Community 78 - "DWPose Image Feeder Workflow"
|
||||
Cohesion: 0.52
|
||||
Nodes (7): DWPose Image Feeder Workflow, Apply ControlNet Node, DWPose Estimator Node (comfyui-controlnet-aux), Image Feeder Node (comfyui-image-feeder), KSampler Node, Load Checkpoint Node (animagineXLRealistic_v6), Load ControlNet Model Node (OpenPoseXL2)
|
||||
|
||||
### Community 79 - "wfm.py"
|
||||
Cohesion: 0.21
|
||||
Nodes (12): Jinja2, Request, Response, Serve the main SPA page., Serve files from the current workflows directory (dynamic path)., Serve files (index thumbnails) from the Lab plan directory., Main entry point for Workflow Studio plugin., Register all routes with ComfyUI's server. (+4 more)
|
||||
|
||||
### Community 80 - "README.index.md"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): on_doc_edit.py, DEVLOG.index.md, generate_doc_index.py, pre-commit, graphify, README.index.md
|
||||
|
||||
### Community 81 - "WFS_PromptText"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): WFS_PromptText – Prompt preset node with positive/negative STRING outputs., Outputs positive and negative prompt strings., WFS_PromptText
|
||||
|
||||
### Community 82 - "workflow_service.py"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): extract_png_workflow(), Extract ComfyUI workflow JSON from PNG metadata., Extract ComfyUI workflow JSON from PNG byte data. Returns dict or None., Workflow CRUD and metadata management service.
|
||||
|
||||
### Community 83 - "SPA"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): ComfyUI, SPA, static, templates, web/comfyui
|
||||
|
||||
### Community 84 - "prompt-ai-chat.js"
|
||||
Cohesion: 0.34
|
||||
Nodes (14): addChatMessage(), applyToGenerateUI(), attachFile(), chatWithAi(), clearChat(), fetchAiModels(), getPromptAiConfig(), initAiChatUI() (+6 more)
|
||||
|
||||
### Community 85 - "Tagger Tab requirements.txt"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Tagger Tab requirements.txt, onnxruntime (WD Tagger / SwinV2), piexif (JPEG EXIF tag writing), python-dotenv (Unsloth .env loading), tensorflow (optional, DeepDanbooru)
|
||||
|
||||
### Community 86 - "data directory"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): data directory, metadata.json, node_metadata.json, settings.json
|
||||
|
||||
### Community 87 - "t"
|
||||
Cohesion: 0.25
|
||||
Nodes (20): showToast(), _applyDefaultCheckpointIfEnabled(), loadWorkflowIntoEditor(), saveCurrentWorkflow(), t(), _initPlanDropZone(), _loadPlanFromFile(), _loadPlanFromIndexImage() (+12 more)
|
||||
|
||||
### Community 88 - "Chinese Language Support"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): Chinese Language Support, English Language Support, Japanese Language Support
|
||||
|
||||
### Community 89 - "Version 0.3.95"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): ImagePrompt Gallery Subtab, Style_Catalog Subtab, Version 0.3.95
|
||||
|
||||
### Community 90 - "Windows"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): LongPathsEnabled, MAX_PATH, Windows
|
||||
|
||||
### Community 98 - "lab_routes.py"
|
||||
Cohesion: 0.42
|
||||
Nodes (10): handle_delete(), handle_get_content(), handle_list(), handle_save(), handle_save_index_to_output(), Application, Request, Response (+2 more)
|
||||
|
||||
### Community 153 - "prompts_routes.py"
|
||||
Cohesion: 0.25
|
||||
Nodes (13): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+5 more)
|
||||
|
||||
### Community 157 - "SettingsService"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over…, Merge updates into existing settings., Manages application settings (data/settings.json)., SettingsService
|
||||
|
||||
### Community 174 - "app.js"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize(), getSettings()
|
||||
|
||||
### Community 176 - "util.js"
|
||||
Cohesion: 0.43
|
||||
Nodes (7): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), _joinBytes(), _n2b(), _pngCrc32()
|
||||
|
||||
### Community 177 - "civitai_service.py"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): _get_ssl_context(), _make_ssl_context(), CivitAI API integration service., Return an SSL context with CA verification. 1. certifi CA bundle (available in…, Download an image from URL and save to save_path. Returns True on success.
|
||||
|
||||
### Community 178 - "config.py"
|
||||
Cohesion: 0.22
|
||||
Nodes (5): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Node metadata and node sets management service., Prompt presets management service., Settings management service.
|
||||
|
||||
### Community 179 - "createModelItem"
|
||||
Cohesion: 0.57
|
||||
Nodes (7): createModelItem(), fetchModelList(), matchesModelSearch(), renderModelAll(), renderModelByType(), renderModelFavorites(), renderModelGroups()
|
||||
|
||||
### Community 180 - "_renderBatchPreview"
|
||||
Cohesion: 0.20
|
||||
Nodes (18): _buildSimpleGroupList(), _getGroupSelCount(), _getGroupSelCountFrom(), initBatchTab(), _loadBatchCheckpointGroups(), _loadBatchLoraGroups(), _loadPromptGroupsForBatch(), _loadWorkflowGroupsForBatch() (+10 more)
|
||||
|
||||
## Knowledge Gaps
|
||||
- **216 isolated node(s):** `comfyui-workflow-studio`, `LANG_NAMES`, `VLM_PROMPTS`, `IMAGE_GEN_TOOLS`, `skillFiles` (+211 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **94 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `showToast()` connect `t` to `escapeHtml`, `prompt-table.js`, `generate-tab.js`, `_openCellModal`, `gallery-tab.js`, `ImageEditTab`, `ai-tab.js`, `feeder-tab.js`, `settings-tab.js`, `tagger-tab.js`, `comfyui-editor.js`, `nodes-tab.js`, `workflow-tab.js`, `FileExport`, `image-prompt-tab.js`, `image-edit-tab.js`, `style-catalog-tab.js`, `app.js`, `BlurTool`, `lab-tab.js`, `_runLabBatch`, `BgRemove`, `initGenerateTab`, `InpaintI2IActions`, `Sam3Segmentation`, `prompt-ai-chat.js`?**
|
||||
_High betweenness centrality (0.071) - this node is a cross-community bridge._
|
||||
- **Why does `t()` connect `t` to `escapeHtml`, `prompt-table.js`, `generate-tab.js`, `_openCellModal`, `gallery-tab.js`, `ai-tab.js`, `feeder-tab.js`, `metadata-tab.js`, `settings-tab.js`, `tagger-tab.js`, `comfyui-editor.js`, `nodes-tab.js`, `workflow-tab.js`, `image-prompt-tab.js`, `style-catalog-tab.js`, `app.js`, `lab-tab.js`, `_runLabBatch`, `initGenerateTab`, `prompt-ai-chat.js`?**
|
||||
_High betweenness centrality (0.058) - this node is a cross-community bridge._
|
||||
- **Why does `ImageEditTab` connect `ImageEditTab` to `image-edit-tab.js`, `app.js`?**
|
||||
_High betweenness centrality (0.028) - this node is a cross-community bridge._
|
||||
- **What connects `comfyui-workflow-studio`, `LANG_NAMES`, `VLM_PROMPTS` to the rest of the system?**
|
||||
_216 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `escapeHtml` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.09288389513108614 - nodes in this community are weakly interconnected._
|
||||
- **Should `prompt-table.js` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.06366691560866318 - nodes in this community are weakly interconnected._
|
||||
- **Should `models_routes.py` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.0554954954954955 - nodes in this community are weakly interconnected._
|
||||
@@ -0,0 +1,890 @@
|
||||
{
|
||||
"CLAUDE.md": {
|
||||
"mtime": 1787279658.5963283,
|
||||
"seen": 1787380810.3754587,
|
||||
"ast_hash": "b2c014309df240903aa90a26914c8c19",
|
||||
"semantic_hash": "b2c014309df240903aa90a26914c8c19"
|
||||
},
|
||||
".claude/settings.json": {
|
||||
"mtime": 1787279595.344592,
|
||||
"seen": 1787380810.2925637,
|
||||
"ast_hash": "cd2c4308b55c5d1aa1b647cdec814b79",
|
||||
"semantic_hash": "cd2c4308b55c5d1aa1b647cdec814b79"
|
||||
},
|
||||
"__init__.py": {
|
||||
"mtime": 1781939762.254596,
|
||||
"seen": 1787279199.0011544,
|
||||
"ast_hash": "72d9ef06026dff7be74da81ff98a306a",
|
||||
"semantic_hash": "72d9ef06026dff7be74da81ff98a306a"
|
||||
},
|
||||
"prestartup_script.py": {
|
||||
"mtime": 1787036267.332648,
|
||||
"seen": 1787279199.0011563,
|
||||
"ast_hash": "7b5d482ef21a480d1b38356e8d82970b",
|
||||
"semantic_hash": "7b5d482ef21a480d1b38356e8d82970b"
|
||||
},
|
||||
"py/__init__.py": {
|
||||
"mtime": 1773797715.629029,
|
||||
"seen": 1787279199.0011575,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/config.py": {
|
||||
"mtime": 1786245317.3352194,
|
||||
"seen": 1787279199.001159,
|
||||
"ast_hash": "b013c27bb7cd9a79449961539fdcd30f",
|
||||
"semantic_hash": "b013c27bb7cd9a79449961539fdcd30f"
|
||||
},
|
||||
"py/nodes/__init__.py": {
|
||||
"mtime": 1774423045.6646452,
|
||||
"seen": 1787279199.00116,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/nodes/gallery_feeder_node.py": {
|
||||
"mtime": 1781943269.5205867,
|
||||
"seen": 1787279199.0011609,
|
||||
"ast_hash": "d37c520b0f76e70e60ec7c72db904bf4",
|
||||
"semantic_hash": "d37c520b0f76e70e60ec7c72db904bf4"
|
||||
},
|
||||
"py/nodes/prompt_text.py": {
|
||||
"mtime": 1774423045.6669831,
|
||||
"seen": 1787279199.0011618,
|
||||
"ast_hash": "43cf16f182b9eda5fade2b52c2439372",
|
||||
"semantic_hash": "43cf16f182b9eda5fade2b52c2439372"
|
||||
},
|
||||
"py/routes/__init__.py": {
|
||||
"mtime": 1773797716.663036,
|
||||
"seen": 1787279199.0011628,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/routes/eagle_routes.py": {
|
||||
"mtime": 1785464911.9023821,
|
||||
"seen": 1787279199.0011635,
|
||||
"ast_hash": "edd32039935a892317c55b434c9839ab",
|
||||
"semantic_hash": "edd32039935a892317c55b434c9839ab"
|
||||
},
|
||||
"py/routes/gallery_routes.py": {
|
||||
"mtime": 1786682156.6899943,
|
||||
"seen": 1787279199.001165,
|
||||
"ast_hash": "59e4c5d34ee2329ab9e0a66f841d1676",
|
||||
"semantic_hash": "59e4c5d34ee2329ab9e0a66f841d1676"
|
||||
},
|
||||
"py/routes/gmic_routes.py": {
|
||||
"mtime": 1782544784.03979,
|
||||
"seen": 1787279199.0011659,
|
||||
"ast_hash": "b909c65af6590c0ca058136cb0b9234e",
|
||||
"semantic_hash": "b909c65af6590c0ca058136cb0b9234e"
|
||||
},
|
||||
"py/routes/lab_routes.py": {
|
||||
"mtime": 1786322410.571655,
|
||||
"seen": 1787279199.0011666,
|
||||
"ast_hash": "48e94f71b11f2e494bb98bb7113e2baa",
|
||||
"semantic_hash": "48e94f71b11f2e494bb98bb7113e2baa"
|
||||
},
|
||||
"py/routes/models_routes.py": {
|
||||
"mtime": 1782289767.1073005,
|
||||
"seen": 1787279199.0011675,
|
||||
"ast_hash": "067aa9c89085fa3e077fdfacbb6be2cd",
|
||||
"semantic_hash": "067aa9c89085fa3e077fdfacbb6be2cd"
|
||||
},
|
||||
"py/routes/nodes_routes.py": {
|
||||
"mtime": 1774255599.0841086,
|
||||
"seen": 1787279199.0011685,
|
||||
"ast_hash": "dc5f7b12dce346631ff945c31bf03ac9",
|
||||
"semantic_hash": "dc5f7b12dce346631ff945c31bf03ac9"
|
||||
},
|
||||
"py/routes/ollama_routes.py": {
|
||||
"mtime": 1784126582.1613884,
|
||||
"seen": 1787279199.0011694,
|
||||
"ast_hash": "80552c0676f7bbd4209eb95cf7a03f5b",
|
||||
"semantic_hash": "80552c0676f7bbd4209eb95cf7a03f5b"
|
||||
},
|
||||
"py/routes/prompts_routes.py": {
|
||||
"mtime": 1774423045.6696556,
|
||||
"seen": 1787279199.0011702,
|
||||
"ast_hash": "53b5641f3ad6f65e8ae665601c4739d7",
|
||||
"semantic_hash": "53b5641f3ad6f65e8ae665601c4739d7"
|
||||
},
|
||||
"py/routes/settings_routes.py": {
|
||||
"mtime": 1786681377.025748,
|
||||
"seen": 1787279199.001171,
|
||||
"ast_hash": "bb2f332c04474ee39319c2d0b8107db9",
|
||||
"semantic_hash": "bb2f332c04474ee39319c2d0b8107db9"
|
||||
},
|
||||
"py/routes/skill_routes.py": {
|
||||
"mtime": 1785634791.033795,
|
||||
"seen": 1787279199.0011723,
|
||||
"ast_hash": "3318c506163c720c5412ec0f8ff3a28a",
|
||||
"semantic_hash": "3318c506163c720c5412ec0f8ff3a28a"
|
||||
},
|
||||
"py/routes/tagger_routes.py": {
|
||||
"mtime": 1781503496.8879485,
|
||||
"seen": 1787279199.001173,
|
||||
"ast_hash": "9c0559b594a69354882da0b1643e11f0",
|
||||
"semantic_hash": "9c0559b594a69354882da0b1643e11f0"
|
||||
},
|
||||
"py/routes/unsloth_routes.py": {
|
||||
"mtime": 1787036726.267709,
|
||||
"seen": 1787279199.001174,
|
||||
"ast_hash": "93d4de97937f472f9618a35a0b4211d6",
|
||||
"semantic_hash": "93d4de97937f472f9618a35a0b4211d6"
|
||||
},
|
||||
"py/routes/wildcard_routes.py": {
|
||||
"mtime": 1777370036.4121041,
|
||||
"seen": 1787279199.0011756,
|
||||
"ast_hash": "1d0ea981a44ffab77d62c214951f4536",
|
||||
"semantic_hash": "1d0ea981a44ffab77d62c214951f4536"
|
||||
},
|
||||
"py/routes/workflow_routes.py": {
|
||||
"mtime": 1773896239.095392,
|
||||
"seen": 1787279199.0011766,
|
||||
"ast_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3",
|
||||
"semantic_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3"
|
||||
},
|
||||
"py/services/__init__.py": {
|
||||
"mtime": 1773797717.739297,
|
||||
"seen": 1787279199.0011773,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/services/civitai_service.py": {
|
||||
"mtime": 1780233122.7587051,
|
||||
"seen": 1787279199.001178,
|
||||
"ast_hash": "3b4a2a118bc56dd27e768a2819b401e7",
|
||||
"semantic_hash": "3b4a2a118bc56dd27e768a2819b401e7"
|
||||
},
|
||||
"py/services/gallery_metadata.py": {
|
||||
"mtime": 1786170828.9941547,
|
||||
"seen": 1787279199.0011792,
|
||||
"ast_hash": "9d02a71d56b7ca2a88cdf8c56fadf912",
|
||||
"semantic_hash": "9d02a71d56b7ca2a88cdf8c56fadf912"
|
||||
},
|
||||
"py/services/gallery_service.py": {
|
||||
"mtime": 1786638406.9058151,
|
||||
"seen": 1787279199.0011802,
|
||||
"ast_hash": "c8b9858f015f7f4a934c4b630e905107",
|
||||
"semantic_hash": "c8b9858f015f7f4a934c4b630e905107"
|
||||
},
|
||||
"py/services/lab_plan_service.py": {
|
||||
"mtime": 1786322396.0192728,
|
||||
"seen": 1787279199.001181,
|
||||
"ast_hash": "5d2c928c077ad7b788f15419b39ef51f",
|
||||
"semantic_hash": "5d2c928c077ad7b788f15419b39ef51f"
|
||||
},
|
||||
"py/services/models_service.py": {
|
||||
"mtime": 1782393318.5989842,
|
||||
"seen": 1787279199.0011816,
|
||||
"ast_hash": "0885cc6852199fa45271192ea0d6713a",
|
||||
"semantic_hash": "0885cc6852199fa45271192ea0d6713a"
|
||||
},
|
||||
"py/services/nodes_service.py": {
|
||||
"mtime": 1774255599.0862236,
|
||||
"seen": 1787279199.0011828,
|
||||
"ast_hash": "8819590ffdc136fb2912d9c5696e26e0",
|
||||
"semantic_hash": "8819590ffdc136fb2912d9c5696e26e0"
|
||||
},
|
||||
"py/services/png_extractor.py": {
|
||||
"mtime": 1773797967.5810304,
|
||||
"seen": 1787279199.0011837,
|
||||
"ast_hash": "1282b6032d78cba2a4bbbfb876126bc1",
|
||||
"semantic_hash": "1282b6032d78cba2a4bbbfb876126bc1"
|
||||
},
|
||||
"py/services/prompts_service.py": {
|
||||
"mtime": 1774423045.6711988,
|
||||
"seen": 1787279199.0011845,
|
||||
"ast_hash": "79a98e220d2b556106e8530054547155",
|
||||
"semantic_hash": "79a98e220d2b556106e8530054547155"
|
||||
},
|
||||
"py/services/settings_service.py": {
|
||||
"mtime": 1773798498.9270506,
|
||||
"seen": 1787279199.0011854,
|
||||
"ast_hash": "dade5ea667a2c6408434ad3061c76db7",
|
||||
"semantic_hash": "dade5ea667a2c6408434ad3061c76db7"
|
||||
},
|
||||
"py/services/skill_service.py": {
|
||||
"mtime": 1785637306.8654232,
|
||||
"seen": 1787279199.0011866,
|
||||
"ast_hash": "5ebe579995c1c674e4219e58b3bd5855",
|
||||
"semantic_hash": "5ebe579995c1c674e4219e58b3bd5855"
|
||||
},
|
||||
"py/services/tagger_db_service.py": {
|
||||
"mtime": 1781503496.8949444,
|
||||
"seen": 1787279199.0011873,
|
||||
"ast_hash": "de0ca007db5159484e8ff3b8a36a3b84",
|
||||
"semantic_hash": "de0ca007db5159484e8ff3b8a36a3b84"
|
||||
},
|
||||
"py/services/tagger_service.py": {
|
||||
"mtime": 1781503496.8969479,
|
||||
"seen": 1787279199.0011883,
|
||||
"ast_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9",
|
||||
"semantic_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9"
|
||||
},
|
||||
"py/services/wildcard_service.py": {
|
||||
"mtime": 1777380000.0054882,
|
||||
"seen": 1787279199.0011892,
|
||||
"ast_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd",
|
||||
"semantic_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd"
|
||||
},
|
||||
"py/services/workflow_analyzer.py": {
|
||||
"mtime": 1785474766.4736228,
|
||||
"seen": 1787279199.00119,
|
||||
"ast_hash": "937d6b02a6235795cb22ceb39e81f719",
|
||||
"semantic_hash": "937d6b02a6235795cb22ceb39e81f719"
|
||||
},
|
||||
"py/services/workflow_service.py": {
|
||||
"mtime": 1775439760.4805496,
|
||||
"seen": 1787279199.0011911,
|
||||
"ast_hash": "a2e47c0b8780ec7ca292310b16733a8c",
|
||||
"semantic_hash": "a2e47c0b8780ec7ca292310b16733a8c"
|
||||
},
|
||||
"py/wfm.py": {
|
||||
"mtime": 1787036294.0749958,
|
||||
"seen": 1787279199.0011919,
|
||||
"ast_hash": "cb1c96536f67edb380d1010b6165d74d",
|
||||
"semantic_hash": "cb1c96536f67edb380d1010b6165d74d"
|
||||
},
|
||||
"pyproject.toml": {
|
||||
"mtime": 1787378263.2900941,
|
||||
"seen": 1787380810.3265483,
|
||||
"ast_hash": "f514fae02a3b48726f5073e932cfa1b3",
|
||||
"semantic_hash": "f514fae02a3b48726f5073e932cfa1b3"
|
||||
},
|
||||
"static/js/ai-tab.js": {
|
||||
"mtime": 1787293482.4888227,
|
||||
"seen": 1787380810.3265514,
|
||||
"ast_hash": "960b474155386a36000e6961b0b2e0c3",
|
||||
"semantic_hash": "960b474155386a36000e6961b0b2e0c3"
|
||||
},
|
||||
"static/js/app.js": {
|
||||
"mtime": 1787377516.2436306,
|
||||
"seen": 1787380810.3265538,
|
||||
"ast_hash": "7df994a3e8f6c34d27a1ccd88b207a5c",
|
||||
"semantic_hash": "7df994a3e8f6c34d27a1ccd88b207a5c"
|
||||
},
|
||||
"static/js/comfyui-client.js": {
|
||||
"mtime": 1786676208.2330563,
|
||||
"seen": 1787279199.0011961,
|
||||
"ast_hash": "bb546a6ae7bd35d1c16fee311681be73",
|
||||
"semantic_hash": "bb546a6ae7bd35d1c16fee311681be73"
|
||||
},
|
||||
"static/js/comfyui-editor.js": {
|
||||
"mtime": 1787293462.7015421,
|
||||
"seen": 1787380810.3274822,
|
||||
"ast_hash": "fe27626a41d18f02bdea2b89aaaf740b",
|
||||
"semantic_hash": "fe27626a41d18f02bdea2b89aaaf740b"
|
||||
},
|
||||
"static/js/comfyui-workflow.js": {
|
||||
"mtime": 1787293410.2500806,
|
||||
"seen": 1787380810.3274848,
|
||||
"ast_hash": "255cfe0c2172639ed698a5e9397d7144",
|
||||
"semantic_hash": "255cfe0c2172639ed698a5e9397d7144"
|
||||
},
|
||||
"static/js/feeder-tab.js": {
|
||||
"mtime": 1781939989.0796032,
|
||||
"seen": 1787279199.0011988,
|
||||
"ast_hash": "dcd5977c5fe691b87e2379cd0e018a12",
|
||||
"semantic_hash": "dcd5977c5fe691b87e2379cd0e018a12"
|
||||
},
|
||||
"static/js/gallery-tab.js": {
|
||||
"mtime": 1787368748.1550386,
|
||||
"seen": 1787380810.328425,
|
||||
"ast_hash": "0144bbe48e21eccc498714404e29e29e",
|
||||
"semantic_hash": "0144bbe48e21eccc498714404e29e29e"
|
||||
},
|
||||
"static/js/generate-tab.js": {
|
||||
"mtime": 1787401222.9019487,
|
||||
"seen": 1787401432.6967273,
|
||||
"ast_hash": "dbfc96b6400bd780990725e390f275c9",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"static/js/i18n.js": {
|
||||
"mtime": 1787377822.448605,
|
||||
"seen": 1787380810.32843,
|
||||
"ast_hash": "d925b681cc485326659fbc24f5a87dda",
|
||||
"semantic_hash": "d925b681cc485326659fbc24f5a87dda"
|
||||
},
|
||||
"static/js/image-edit-tab.js": {
|
||||
"mtime": 1787199901.562169,
|
||||
"seen": 1787279199.001202,
|
||||
"ast_hash": "be7b15d85bb185e2fd3492f4d5657f21",
|
||||
"semantic_hash": "be7b15d85bb185e2fd3492f4d5657f21"
|
||||
},
|
||||
"static/js/image-edit/BgRemove.js": {
|
||||
"mtime": 1787126632.8601232,
|
||||
"seen": 1787279199.0012033,
|
||||
"ast_hash": "add6a58068bdadc0c7641202f36ddb27",
|
||||
"semantic_hash": "add6a58068bdadc0c7641202f36ddb27"
|
||||
},
|
||||
"static/js/image-edit/BlurTool.js": {
|
||||
"mtime": 1787126263.0542421,
|
||||
"seen": 1787279199.0012043,
|
||||
"ast_hash": "1e52bc3757110a271ec17d923784a91b",
|
||||
"semantic_hash": "1e52bc3757110a271ec17d923784a91b"
|
||||
},
|
||||
"static/js/image-edit/DrawTool.js": {
|
||||
"mtime": 1782717315.6436062,
|
||||
"seen": 1787279199.001205,
|
||||
"ast_hash": "a97a6a97ae28c804a7c5fa50448a2c87",
|
||||
"semantic_hash": "a97a6a97ae28c804a7c5fa50448a2c87"
|
||||
},
|
||||
"static/js/image-edit/FileExport.js": {
|
||||
"mtime": 1787192008.741717,
|
||||
"seen": 1787279199.0012057,
|
||||
"ast_hash": "cf645b775e537ad276c9e070c7849349",
|
||||
"semantic_hash": "cf645b775e537ad276c9e070c7849349"
|
||||
},
|
||||
"static/js/image-edit/GmicIntegration.js": {
|
||||
"mtime": 1787125777.3889306,
|
||||
"seen": 1787279199.0012069,
|
||||
"ast_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca",
|
||||
"semantic_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca"
|
||||
},
|
||||
"static/js/image-edit/InpaintI2IActions.js": {
|
||||
"mtime": 1787127273.4351766,
|
||||
"seen": 1787279199.0012076,
|
||||
"ast_hash": "9ea57922985b63d4ec8eb44219a8341b",
|
||||
"semantic_hash": "9ea57922985b63d4ec8eb44219a8341b"
|
||||
},
|
||||
"static/js/image-edit/LayerManager.js": {
|
||||
"mtime": 1782717943.772222,
|
||||
"seen": 1787279199.0012085,
|
||||
"ast_hash": "596cd1906208fe72e1c2953d4d7f7135",
|
||||
"semantic_hash": "596cd1906208fe72e1c2953d4d7f7135"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneBridge.js": {
|
||||
"mtime": 1787199887.897264,
|
||||
"seen": 1787279199.0012095,
|
||||
"ast_hash": "db2ef8c024c7e72baa0dfdb79b8abd4d",
|
||||
"semantic_hash": "db2ef8c024c7e72baa0dfdb79b8abd4d"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneTools.js": {
|
||||
"mtime": 1782719665.7021136,
|
||||
"seen": 1787279199.0012105,
|
||||
"ast_hash": "b6b16c43ce528f7c9921eab0f4eda335",
|
||||
"semantic_hash": "b6b16c43ce528f7c9921eab0f4eda335"
|
||||
},
|
||||
"static/js/image-edit/MaskTool.js": {
|
||||
"mtime": 1782699721.4711535,
|
||||
"seen": 1787279199.0012114,
|
||||
"ast_hash": "116469f33829c5b2e6270f4b278b2b9c",
|
||||
"semantic_hash": "116469f33829c5b2e6270f4b278b2b9c"
|
||||
},
|
||||
"static/js/image-edit/Sam3Segmentation.js": {
|
||||
"mtime": 1787126893.774897,
|
||||
"seen": 1787279199.0012121,
|
||||
"ast_hash": "98c3a330ae7ea3369c00168c54c66d53",
|
||||
"semantic_hash": "98c3a330ae7ea3369c00168c54c66d53"
|
||||
},
|
||||
"static/js/image-edit/SelectTool.js": {
|
||||
"mtime": 1782368193.2572634,
|
||||
"seen": 1787279199.0012128,
|
||||
"ast_hash": "cb8db6bd5ee742b15f3beee4a5173377",
|
||||
"semantic_hash": "cb8db6bd5ee742b15f3beee4a5173377"
|
||||
},
|
||||
"static/js/image-edit/ShapeTool.js": {
|
||||
"mtime": 1782375200.0253434,
|
||||
"seen": 1787279199.001214,
|
||||
"ast_hash": "36d00bf6b80ebea13314d2853528c44f",
|
||||
"semantic_hash": "36d00bf6b80ebea13314d2853528c44f"
|
||||
},
|
||||
"static/js/image-edit/TextTool.js": {
|
||||
"mtime": 1782368193.2592669,
|
||||
"seen": 1787279199.0012147,
|
||||
"ast_hash": "88f9e60251c2f78957e42c33ed5196f8",
|
||||
"semantic_hash": "88f9e60251c2f78957e42c33ed5196f8"
|
||||
},
|
||||
"static/js/image-prompt-tab.js": {
|
||||
"mtime": 1786638406.9362252,
|
||||
"seen": 1787279199.0012157,
|
||||
"ast_hash": "cf39285a30e3f8b79f6f51dfe13f86b5",
|
||||
"semantic_hash": "cf39285a30e3f8b79f6f51dfe13f86b5"
|
||||
},
|
||||
"static/js/json-highlight.js": {
|
||||
"mtime": 1773834925.1977189,
|
||||
"seen": 1787279199.0012164,
|
||||
"ast_hash": "e83e7a7a507d160b9cd503235a79135c",
|
||||
"semantic_hash": "e83e7a7a507d160b9cd503235a79135c"
|
||||
},
|
||||
"static/js/lab-tab.js": {
|
||||
"mtime": 1787310963.3056493,
|
||||
"seen": 1787380810.343902,
|
||||
"ast_hash": "11a5776c4163e2a97ef490e85de7db63",
|
||||
"semantic_hash": "11a5776c4163e2a97ef490e85de7db63"
|
||||
},
|
||||
"static/js/metadata-tab.js": {
|
||||
"mtime": 1787377525.9536967,
|
||||
"seen": 1787380810.3439052,
|
||||
"ast_hash": "5ac004dd3708aa4dc8890256232fd99e",
|
||||
"semantic_hash": "5ac004dd3708aa4dc8890256232fd99e"
|
||||
},
|
||||
"static/js/models-tab.js": {
|
||||
"mtime": 1787145102.2314196,
|
||||
"seen": 1787279199.001219,
|
||||
"ast_hash": "dcee8a68713cd300d0862b1c376d442f",
|
||||
"semantic_hash": "dcee8a68713cd300d0862b1c376d442f"
|
||||
},
|
||||
"static/js/models/badges.js": {
|
||||
"mtime": 1787143926.3653927,
|
||||
"seen": 1787279199.00122,
|
||||
"ast_hash": "0c1901a90a12f1b23df416d9eef11e1d",
|
||||
"semantic_hash": "0c1901a90a12f1b23df416d9eef11e1d"
|
||||
},
|
||||
"static/js/models/detail-panel.js": {
|
||||
"mtime": 1787144898.6748104,
|
||||
"seen": 1787279199.0012212,
|
||||
"ast_hash": "9907a4af0524b35d80e3b9fc7925324e",
|
||||
"semantic_hash": "9907a4af0524b35d80e3b9fc7925324e"
|
||||
},
|
||||
"static/js/models/filters.js": {
|
||||
"mtime": 1787144201.7602522,
|
||||
"seen": 1787279199.001222,
|
||||
"ast_hash": "afd754f68d01f64c51995929b3841e56",
|
||||
"semantic_hash": "afd754f68d01f64c51995929b3841e56"
|
||||
},
|
||||
"static/js/models/grid-view.js": {
|
||||
"mtime": 1787144745.5522888,
|
||||
"seen": 1787279199.0012226,
|
||||
"ast_hash": "492e91fff2b8743a5243f85a736eb689",
|
||||
"semantic_hash": "492e91fff2b8743a5243f85a736eb689"
|
||||
},
|
||||
"static/js/models/helpers.js": {
|
||||
"mtime": 1787144193.741004,
|
||||
"seen": 1787279199.0012236,
|
||||
"ast_hash": "1c7b796f2507f55403fa5d11f304580a",
|
||||
"semantic_hash": "1c7b796f2507f55403fa5d11f304580a"
|
||||
},
|
||||
"static/js/models/selection-bulk.js": {
|
||||
"mtime": 1787144787.0092065,
|
||||
"seen": 1787279199.0012245,
|
||||
"ast_hash": "d445ff4dc4e9810afea73ff53c76b05b",
|
||||
"semantic_hash": "d445ff4dc4e9810afea73ff53c76b05b"
|
||||
},
|
||||
"static/js/models/state.js": {
|
||||
"mtime": 1787143674.705312,
|
||||
"seen": 1787279199.0012252,
|
||||
"ast_hash": "172ced5fdc4c96095f2760d7ef12d77d",
|
||||
"semantic_hash": "172ced5fdc4c96095f2760d7ef12d77d"
|
||||
},
|
||||
"static/js/nodes-tab.js": {
|
||||
"mtime": 1782619197.3828287,
|
||||
"seen": 1787279199.0012262,
|
||||
"ast_hash": "3467d4682a8c20a2b209b5db224ce6e2",
|
||||
"semantic_hash": "3467d4682a8c20a2b209b5db224ce6e2"
|
||||
},
|
||||
"static/js/prompt-tab.js": {
|
||||
"mtime": 1787399045.1690862,
|
||||
"seen": 1787399276.0028682,
|
||||
"ast_hash": "184961647ceccdf98c8c031c4da96889",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"static/js/settings-tab.js": {
|
||||
"mtime": 1787227602.6323576,
|
||||
"seen": 1787279199.001228,
|
||||
"ast_hash": "85b55cac9901afc9e94e32f0aa220d2b",
|
||||
"semantic_hash": "85b55cac9901afc9e94e32f0aa220d2b"
|
||||
},
|
||||
"static/js/style-catalog-tab.js": {
|
||||
"mtime": 1786678976.6511285,
|
||||
"seen": 1787279199.001229,
|
||||
"ast_hash": "a2956544bf800ac34f8084b8acd0714b",
|
||||
"semantic_hash": "a2956544bf800ac34f8084b8acd0714b"
|
||||
},
|
||||
"static/js/tagger-tab.js": {
|
||||
"mtime": 1781503496.933307,
|
||||
"seen": 1787279199.0012298,
|
||||
"ast_hash": "5e1c858a13e95148eeac1d5b5be5e4f3",
|
||||
"semantic_hash": "5e1c858a13e95148eeac1d5b5be5e4f3"
|
||||
},
|
||||
"static/js/util.js": {
|
||||
"mtime": 1787036300.7369778,
|
||||
"seen": 1787279199.001231,
|
||||
"ast_hash": "29774ed440f2b60a708c70db1b3ae208",
|
||||
"semantic_hash": "29774ed440f2b60a708c70db1b3ae208"
|
||||
},
|
||||
"static/js/workflow-tab.js": {
|
||||
"mtime": 1785474064.011484,
|
||||
"seen": 1787279199.001232,
|
||||
"ast_hash": "65caea21e8e02d81f2e85c75c7231dca",
|
||||
"semantic_hash": "65caea21e8e02d81f2e85c75c7231dca"
|
||||
},
|
||||
"tools/import_style_prompt_seed.py": {
|
||||
"mtime": 1786616195.405381,
|
||||
"seen": 1787279199.0012326,
|
||||
"ast_hash": "02997888c17a6061b7910cddfdd4d89a",
|
||||
"semantic_hash": "02997888c17a6061b7910cddfdd4d89a"
|
||||
},
|
||||
"web/comfyui/gallery_feeder_extension.js": {
|
||||
"mtime": 1781943835.232655,
|
||||
"seen": 1787279199.0012345,
|
||||
"ast_hash": "0048b031e64a433e29a5f7112e82012d",
|
||||
"semantic_hash": "0048b031e64a433e29a5f7112e82012d"
|
||||
},
|
||||
"web/comfyui/node_sets_menu.js": {
|
||||
"mtime": 1787199930.9575465,
|
||||
"seen": 1787279199.0012355,
|
||||
"ast_hash": "a9d7ad5d194ac2f4b125978a7b048042",
|
||||
"semantic_hash": "a9d7ad5d194ac2f4b125978a7b048042"
|
||||
},
|
||||
"web/comfyui/top_menu_extension.js": {
|
||||
"mtime": 1782696309.9055538,
|
||||
"seen": 1787279199.0012362,
|
||||
"ast_hash": "2c74498722b46d42bcc15f61ec35dfc4",
|
||||
"semantic_hash": "2c74498722b46d42bcc15f61ec35dfc4"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).json": {
|
||||
"mtime": 1779256606.5720088,
|
||||
"seen": 1787279199.0012372,
|
||||
"ast_hash": "52801ad2b42eca5e3a5f8fc51332d2c3",
|
||||
"semantic_hash": "52801ad2b42eca5e3a5f8fc51332d2c3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).json": {
|
||||
"mtime": 1779257129.3572881,
|
||||
"seen": 1787279199.001238,
|
||||
"ast_hash": "8260ca467693c21c497f98113abf37f9",
|
||||
"semantic_hash": "8260ca467693c21c497f98113abf37f9"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).json": {
|
||||
"mtime": 1779257864.2496417,
|
||||
"seen": 1787279199.001239,
|
||||
"ast_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e",
|
||||
"semantic_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.json": {
|
||||
"mtime": 1779255324.1402953,
|
||||
"seen": 1787279199.0012398,
|
||||
"ast_hash": "282ce14b11b8b88ddc91e1d318756c0d",
|
||||
"semantic_hash": "282ce14b11b8b88ddc91e1d318756c0d"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.json": {
|
||||
"mtime": 1784801838.7836998,
|
||||
"seen": 1787279199.0012407,
|
||||
"ast_hash": "43e83496dec4856ab9d97c8b950148ec",
|
||||
"semantic_hash": "43e83496dec4856ab9d97c8b950148ec"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.json": {
|
||||
"mtime": 1784801946.0336668,
|
||||
"seen": 1787279199.001242,
|
||||
"ast_hash": "ac0a865c1dacd2b30c489f08f8b4df4d",
|
||||
"semantic_hash": "ac0a865c1dacd2b30c489f08f8b4df4d"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.json": {
|
||||
"mtime": 1779253098.1947012,
|
||||
"seen": 1787279199.0012426,
|
||||
"ast_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51",
|
||||
"semantic_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.json": {
|
||||
"mtime": 1779256158.4671156,
|
||||
"seen": 1787279199.0012436,
|
||||
"ast_hash": "cfe237775d6fb844ac726639e3ae928b",
|
||||
"semantic_hash": "cfe237775d6fb844ac726639e3ae928b"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.json": {
|
||||
"mtime": 1779255768.6302803,
|
||||
"seen": 1787279199.0012448,
|
||||
"ast_hash": "0e1955da91149ba6280d39db7a54db6f",
|
||||
"semantic_hash": "0e1955da91149ba6280d39db7a54db6f"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.json": {
|
||||
"mtime": 1779255037.8698418,
|
||||
"seen": 1787279199.0012455,
|
||||
"ast_hash": "d670222d71df92e262ce8cb7299fd531",
|
||||
"semantic_hash": "d670222d71df92e262ce8cb7299fd531"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.json": {
|
||||
"mtime": 1779254360.9712114,
|
||||
"seen": 1787279199.0012462,
|
||||
"ast_hash": "463fca53dd0a4030555ff639661f2772",
|
||||
"semantic_hash": "463fca53dd0a4030555ff639661f2772"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.json": {
|
||||
"mtime": 1779253668.0067616,
|
||||
"seen": 1787279199.0012474,
|
||||
"ast_hash": "e2c677ee6670e37b466829c43ff9cd96",
|
||||
"semantic_hash": "e2c677ee6670e37b466829c43ff9cd96"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.json": {
|
||||
"mtime": 1779254714.0149894,
|
||||
"seen": 1787279199.0012484,
|
||||
"ast_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79",
|
||||
"semantic_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.json": {
|
||||
"mtime": 1779253180.3940487,
|
||||
"seen": 1787279199.0012493,
|
||||
"ast_hash": "beacbe857606b44d13aa2fc2f4872b76",
|
||||
"semantic_hash": "beacbe857606b44d13aa2fc2f4872b76"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.json": {
|
||||
"mtime": 1779253367.7975926,
|
||||
"seen": 1787279199.00125,
|
||||
"ast_hash": "0706c31d93f980b581e0e6eb376c0bcc",
|
||||
"semantic_hash": "0706c31d93f980b581e0e6eb376c0bcc"
|
||||
},
|
||||
"DEVLOG.index.md": {
|
||||
"mtime": 1787378424.0888915,
|
||||
"seen": 1787399276.0029197,
|
||||
"ast_hash": "f5e13974b9f8770e6f49bf74fc2c421e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"README.index.md": {
|
||||
"mtime": 1787378423.9177885,
|
||||
"seen": 1787399276.0029216,
|
||||
"ast_hash": "4eaa4581c6f969c663176b216fde4596",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"requirements.txt": {
|
||||
"mtime": 1787036261.1817627,
|
||||
"seen": 1787279199.0012598,
|
||||
"ast_hash": "5942e1febf9611227a9145b3f9a91c6e",
|
||||
"semantic_hash": "5942e1febf9611227a9145b3f9a91c6e"
|
||||
},
|
||||
"assets/banner.svg": {
|
||||
"mtime": 1776081443.419675,
|
||||
"seen": 1787279199.0012608,
|
||||
"ast_hash": "059ec317eaba9e353d220d883477927a",
|
||||
"semantic_hash": "059ec317eaba9e353d220d883477927a"
|
||||
},
|
||||
"assets/icon.svg": {
|
||||
"mtime": 1776081443.4219253,
|
||||
"seen": 1787279199.0012615,
|
||||
"ast_hash": "9689aa0178236c19d5240250e08a303e",
|
||||
"semantic_hash": "9689aa0178236c19d5240250e08a303e"
|
||||
},
|
||||
"docs/10_GenUI_Batch.png": {
|
||||
"mtime": 1781057457.561953,
|
||||
"seen": 1787279199.0012627,
|
||||
"ast_hash": "351c1647aa5becfb4a2dbfa7da043b24",
|
||||
"semantic_hash": "351c1647aa5becfb4a2dbfa7da043b24"
|
||||
},
|
||||
"docs/11_multiple_select_menu.png": {
|
||||
"mtime": 1781057528.0140572,
|
||||
"seen": 1787279199.0012636,
|
||||
"ast_hash": "b3082324b8200a4eac8868f94ec4e6c7",
|
||||
"semantic_hash": "b3082324b8200a4eac8868f94ec4e6c7"
|
||||
},
|
||||
"docs/12_Tagger.png": {
|
||||
"mtime": 1782551031.7107866,
|
||||
"seen": 1787279199.0012643,
|
||||
"ast_hash": "42806369c4c08140c87daea348b6fc0f",
|
||||
"semantic_hash": "42806369c4c08140c87daea348b6fc0f"
|
||||
},
|
||||
"docs/13_ImageEdit.png": {
|
||||
"mtime": 1782550946.6263509,
|
||||
"seen": 1787279199.0012655,
|
||||
"ast_hash": "25a313806ba3c2396c9361b42599b063",
|
||||
"semantic_hash": "25a313806ba3c2396c9361b42599b063"
|
||||
},
|
||||
"docs/1_workflowtab.png": {
|
||||
"mtime": 1779078100.5980048,
|
||||
"seen": 1787279199.0012662,
|
||||
"ast_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297",
|
||||
"semantic_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297"
|
||||
},
|
||||
"docs/2_modelstab.png": {
|
||||
"mtime": 1779077971.8302422,
|
||||
"seen": 1787279199.0012672,
|
||||
"ast_hash": "2c89e877f01e8de3151e655f305df6aa",
|
||||
"semantic_hash": "2c89e877f01e8de3151e655f305df6aa"
|
||||
},
|
||||
"docs/3_PromptInputAssistance.png": {
|
||||
"mtime": 1779077804.0445502,
|
||||
"seen": 1787279199.001268,
|
||||
"ast_hash": "6634ec20d561648ce223807d2fb010cc",
|
||||
"semantic_hash": "6634ec20d561648ce223807d2fb010cc"
|
||||
},
|
||||
"docs/4_GenUI_feeder.png": {
|
||||
"mtime": 1779077729.1484556,
|
||||
"seen": 1787279199.001269,
|
||||
"ast_hash": "e6b3607b7c53a2f601aff487c278a3fb",
|
||||
"semantic_hash": "e6b3607b7c53a2f601aff487c278a3fb"
|
||||
},
|
||||
"docs/5_topbar.png": {
|
||||
"mtime": 1779076905.6012611,
|
||||
"seen": 1787279199.0012698,
|
||||
"ast_hash": "bafc74e7452f83ae9c585aa11f76f208",
|
||||
"semantic_hash": "bafc74e7452f83ae9c585aa11f76f208"
|
||||
},
|
||||
"docs/6_ws_library.png": {
|
||||
"mtime": 1781057870.2308166,
|
||||
"seen": 1787279199.0012705,
|
||||
"ast_hash": "6645a6c3d9ea09d75d34255c76cfc68b",
|
||||
"semantic_hash": "6645a6c3d9ea09d75d34255c76cfc68b"
|
||||
},
|
||||
"docs/7_library_Infomation.png": {
|
||||
"mtime": 1779076410.2629254,
|
||||
"seen": 1787279199.0012715,
|
||||
"ast_hash": "acb54f299c244fd41457f8fe4ccfc1d6",
|
||||
"semantic_hash": "acb54f299c244fd41457f8fe4ccfc1d6"
|
||||
},
|
||||
"docs/8_Customize.png": {
|
||||
"mtime": 1779077388.8494852,
|
||||
"seen": 1787279199.0012722,
|
||||
"ast_hash": "c9f979d40753ce41c015b2d594007658",
|
||||
"semantic_hash": "c9f979d40753ce41c015b2d594007658"
|
||||
},
|
||||
"docs/9_GenUI_LoraStack.png": {
|
||||
"mtime": 1781057821.3736062,
|
||||
"seen": 1787279199.0012734,
|
||||
"ast_hash": "e2b08e5f2059554d305563c63286bd74",
|
||||
"semantic_hash": "e2b08e5f2059554d305563c63286bd74"
|
||||
},
|
||||
"docs/thumb.png": {
|
||||
"mtime": 1778457744.7955222,
|
||||
"seen": 1787279199.0012743,
|
||||
"ast_hash": "92a0dcf2c3457777f2945f6df2dff017",
|
||||
"semantic_hash": "92a0dcf2c3457777f2945f6df2dff017"
|
||||
},
|
||||
"static/favicon.svg": {
|
||||
"mtime": 1773877378.0368707,
|
||||
"seen": 1787279199.0012753,
|
||||
"ast_hash": "0cd547c25c922d632890be287d6084ff",
|
||||
"semantic_hash": "0cd547c25c922d632890be287d6084ff"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).png": {
|
||||
"mtime": 1779256606.562932,
|
||||
"seen": 1787279199.0012763,
|
||||
"ast_hash": "79b9d1e5e1303b523349b539d3dc1af3",
|
||||
"semantic_hash": "79b9d1e5e1303b523349b539d3dc1af3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).png": {
|
||||
"mtime": 1779257129.3453796,
|
||||
"seen": 1787279199.001277,
|
||||
"ast_hash": "02c572ab1c659d71dfd56e6b2b0334a5",
|
||||
"semantic_hash": "02c572ab1c659d71dfd56e6b2b0334a5"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).png": {
|
||||
"mtime": 1779257864.2375357,
|
||||
"seen": 1787279199.001278,
|
||||
"ast_hash": "bb03ed86c7d4460a3568a4a8f90e5f40",
|
||||
"semantic_hash": "bb03ed86c7d4460a3568a4a8f90e5f40"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.png": {
|
||||
"mtime": 1779255324.1356418,
|
||||
"seen": 1787279199.0012789,
|
||||
"ast_hash": "e5db6889f59078e4ead6b89b8c07e91e",
|
||||
"semantic_hash": "e5db6889f59078e4ead6b89b8c07e91e"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.png": {
|
||||
"mtime": 1784801838.7777002,
|
||||
"seen": 1787279199.00128,
|
||||
"ast_hash": "79321edb85aed7c0a8c777accc005486",
|
||||
"semantic_hash": "79321edb85aed7c0a8c777accc005486"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.png": {
|
||||
"mtime": 1784801946.027666,
|
||||
"seen": 1787279199.001281,
|
||||
"ast_hash": "bdd62facd6a8176c50e92264994dbbc2",
|
||||
"semantic_hash": "bdd62facd6a8176c50e92264994dbbc2"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.png": {
|
||||
"mtime": 1779253098.1897035,
|
||||
"seen": 1787279199.0012817,
|
||||
"ast_hash": "9582bb698439fff1a748315145538777",
|
||||
"semantic_hash": "9582bb698439fff1a748315145538777"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.png": {
|
||||
"mtime": 1779256158.4621174,
|
||||
"seen": 1787279199.001283,
|
||||
"ast_hash": "bb7b9e26eae992abe62cd50299da9cb2",
|
||||
"semantic_hash": "bb7b9e26eae992abe62cd50299da9cb2"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.png": {
|
||||
"mtime": 1779255768.6191878,
|
||||
"seen": 1787279199.0012836,
|
||||
"ast_hash": "ac0fc8184ff7e42389fd0d562daa88c4",
|
||||
"semantic_hash": "ac0fc8184ff7e42389fd0d562daa88c4"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.png": {
|
||||
"mtime": 1779255037.8606594,
|
||||
"seen": 1787279199.0012846,
|
||||
"ast_hash": "564f58d85a0b960154bd0a30ea111599",
|
||||
"semantic_hash": "564f58d85a0b960154bd0a30ea111599"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.png": {
|
||||
"mtime": 1779254360.9671092,
|
||||
"seen": 1787279199.0012853,
|
||||
"ast_hash": "98dfdb3ba374b0952c06995843f45171",
|
||||
"semantic_hash": "98dfdb3ba374b0952c06995843f45171"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.png": {
|
||||
"mtime": 1779253668.0007572,
|
||||
"seen": 1787279199.0012865,
|
||||
"ast_hash": "7285bb2a06aaf8dc7c5b37b84261be61",
|
||||
"semantic_hash": "7285bb2a06aaf8dc7c5b37b84261be61"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.png": {
|
||||
"mtime": 1779254714.0099926,
|
||||
"seen": 1787279199.0012872,
|
||||
"ast_hash": "13d4e7f87a87fd4c9a78427b38800cc6",
|
||||
"semantic_hash": "13d4e7f87a87fd4c9a78427b38800cc6"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.png": {
|
||||
"mtime": 1779253180.3890488,
|
||||
"seen": 1787279199.0012882,
|
||||
"ast_hash": "9d8dda899602b8d74c6b23350e58a0d0",
|
||||
"semantic_hash": "9d8dda899602b8d74c6b23350e58a0d0"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.png": {
|
||||
"mtime": 1779253367.7925913,
|
||||
"seen": 1787279199.0012891,
|
||||
"ast_hash": "51cae742b39316023cf5332f1d794854",
|
||||
"semantic_hash": "51cae742b39316023cf5332f1d794854"
|
||||
},
|
||||
".claude/hooks/on_doc_edit.py": {
|
||||
"mtime": 1787279578.9362085,
|
||||
"seen": 1787380810.2925577,
|
||||
"ast_hash": "1a39a0e2ffaac441e8decccad0e1c42c",
|
||||
"semantic_hash": "1a39a0e2ffaac441e8decccad0e1c42c"
|
||||
},
|
||||
"tools/generate_doc_index.py": {
|
||||
"mtime": 1787279532.9148839,
|
||||
"seen": 1787380810.3572273,
|
||||
"ast_hash": "d0cdaf32b36fdef641a9396fd4ec403f",
|
||||
"semantic_hash": "d0cdaf32b36fdef641a9396fd4ec403f"
|
||||
},
|
||||
".github/workflows/publish.yml": {
|
||||
"mtime": 1778460176.5699222,
|
||||
"seen": 1787380810.3754551,
|
||||
"ast_hash": "a83dab23e8d09fdc8626689e96dfeb0e",
|
||||
"semantic_hash": "a83dab23e8d09fdc8626689e96dfeb0e"
|
||||
},
|
||||
"templates/index.html": {
|
||||
"mtime": 1787377487.6004047,
|
||||
"seen": 1787380810.3754609,
|
||||
"ast_hash": "639c4691eb3439d1707cd972bbed1402",
|
||||
"semantic_hash": "639c4691eb3439d1707cd972bbed1402"
|
||||
},
|
||||
"static/js/prompt-table.js": {
|
||||
"mtime": 1787400288.156035,
|
||||
"seen": 1787400419.0870566,
|
||||
"ast_hash": "bb722a8d63b1a7578f136bf99798f679",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"static/js/prompt-ai-chat.js": {
|
||||
"mtime": 1787398970.5236483,
|
||||
"seen": 1787399276.0028632,
|
||||
"ast_hash": "cdb87c58d22faa43d64b4f71a8d8e0f7",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"static/js/prompt-presets.js": {
|
||||
"mtime": 1787398926.1807213,
|
||||
"seen": 1787399276.0028653,
|
||||
"ast_hash": "7fd37f214f881138d21559c267241c5d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"static/js/prompt-styles.js": {
|
||||
"mtime": 1787399033.0976152,
|
||||
"seen": 1787399276.0028667,
|
||||
"ast_hash": "800e1d042f6800d057b60801461d80aa",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"static/js/prompt-wildcards.js": {
|
||||
"mtime": 1787399711.3928645,
|
||||
"seen": 1787399943.1509204,
|
||||
"ast_hash": "0e1312911fa197ab5a1858fa449dfc69",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Runs before ComfyUI imports any custom node — the only place early enough
|
||||
to populate os.environ for code that reads env vars at import/request time.
|
||||
|
||||
Loads a .env file (if present) from this plugin's root directory so the
|
||||
Unsloth backend's API key (UNSLOTH_API_KEY) can be set without editing
|
||||
settings.json or exposing it to the frontend.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = PLUGIN_DIR / ".env"
|
||||
|
||||
if ENV_FILE.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(ENV_FILE)
|
||||
except ImportError:
|
||||
print(
|
||||
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
|
||||
@@ -70,6 +70,8 @@ PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
|
||||
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
|
||||
VIDEO_PLAN_DIR = DATA_DIR / "video_plan"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
@@ -43,6 +41,16 @@ def _init_allowed_root() -> None:
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
|
||||
|
||||
# ImagePrompt/Style Catalogギャラリー用に実outputフォルダを常に別途保持する
|
||||
# (Settingsで_allowed_rootが別フォルダに変更されていても影響しないようにするため)
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir and Path(output_dir).is_dir():
|
||||
_service.set_comfy_output_root(output_dir)
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir for image-prompt root: %s", e)
|
||||
|
||||
|
||||
_init_allowed_root()
|
||||
|
||||
@@ -55,7 +63,12 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/image/serve", serve_image)
|
||||
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
|
||||
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image-prompt/root", get_image_prompt_root)
|
||||
app.router.add_post("/wfm/gallery/image/image-prompt", save_image_prompt)
|
||||
app.router.add_get("/wfm/gallery/style-catalog/root", get_style_catalog_root)
|
||||
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
|
||||
app.router.add_post("/wfm/gallery/image/save-to-folder", save_image_to_folder_route)
|
||||
app.router.add_post("/wfm/gallery/output-image/delete", delete_output_image)
|
||||
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
|
||||
app.router.add_get("/wfm/gallery/groups", list_groups)
|
||||
app.router.add_post("/wfm/gallery/groups", create_group)
|
||||
@@ -75,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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -104,6 +121,7 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
|
||||
tag_filter = request.rel_url.query.get("tag", "")
|
||||
group_filter = request.rel_url.query.get("group", "")
|
||||
recursive = request.rel_url.query.get("recursive", "false") == "true"
|
||||
|
||||
if not folder:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
@@ -117,6 +135,7 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
favorite_only=favorite_only,
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
recursive=recursive,
|
||||
)
|
||||
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
|
||||
if not search:
|
||||
@@ -148,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)
|
||||
|
||||
@@ -160,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)
|
||||
|
||||
@@ -182,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)
|
||||
|
||||
@@ -248,6 +279,36 @@ async def save_image_meta(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_image_prompt_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/image-prompt/root — ImagePromptギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_image_prompt_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def save_image_prompt(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/image-prompt — 画像の.txtサイドカーにプロンプトを保存"""
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
prompt = body.get("prompt", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
ok = _service.save_image_prompt(img_path, prompt)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_style_catalog_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/style-catalog/root — Style Catalogギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_style_catalog_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -260,15 +321,6 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
|
||||
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
|
||||
@@ -283,33 +335,64 @@ async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
if _service._allowed_root is None:
|
||||
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
|
||||
|
||||
# data URL から MIME タイプを判定し、拡張子を決定する
|
||||
raw = image_data
|
||||
mime = "image/png"
|
||||
if raw.startswith("data:"):
|
||||
header, _, raw = raw.partition(",")
|
||||
m = re.match(r"data:([^;]+)", header)
|
||||
if m:
|
||||
mime = m.group(1)
|
||||
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
|
||||
result = _service.save_image_to_folder(str(_service._allowed_root), filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
|
||||
# ファイル名サニタイズ(OSで使えない文字を除去)
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
|
||||
image_bytes = base64.b64decode(raw)
|
||||
|
||||
save_path = _service._allowed_root / safe
|
||||
save_path.write_bytes(image_bytes)
|
||||
|
||||
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
|
||||
return web.json_response({"ok": True, "path": str(save_path)})
|
||||
logger.info("save_image_to_gallery: saved %s", result["path"])
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_gallery error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def save_image_to_folder_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/save-to-folder — 任意フォルダへ画像を保存(同名なら上書き)。
|
||||
Style Catalogのカタログ作成で、スタイル名をファイル名として使う際に利用する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
folder = body.get("folder", "").strip()
|
||||
filename = body.get("filename", "").strip()
|
||||
image_data = body.get("imageData", "")
|
||||
|
||||
if not folder or not filename or not image_data:
|
||||
return web.json_response({"error": "folder, filename and imageData required"}, status=400)
|
||||
|
||||
result = _service.save_image_to_folder(folder, filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_folder_route error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_output_image(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/output-image/delete — ComfyUI標準の画像参照(filename/subfolder/type)から
|
||||
実ファイルを削除する。Style Catalogのカタログ作成で「Outputフォルダに画像を残さない」
|
||||
オプションが有効な時、カタログへコピー保存した直後の元画像を消すために使う。
|
||||
type="output" 以外は拒否する(temp/inputの誤削除を防止)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = (body.get("filename") or "").strip()
|
||||
subfolder = body.get("subfolder") or ""
|
||||
img_type = body.get("type") or ""
|
||||
if not filename or img_type != "output":
|
||||
return web.json_response({"error": "filename required and type must be 'output'"}, status=400)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
target = output_dir / subfolder / filename
|
||||
|
||||
result = await asyncio.to_thread(_service.delete_images, [str(target)])
|
||||
if result["deleted"]:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"error": "; ".join(result["errors"]) or "File not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("delete_output_image error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -332,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:
|
||||
@@ -501,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)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Lab tab plan file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import LAB_PLAN_DIR
|
||||
from ..services.lab_plan_service import LabPlanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = LabPlanService(LAB_PLAN_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/lab/plans", handle_list)
|
||||
app.router.add_get("/api/wfm/lab/plans/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/lab/plans/save", handle_save)
|
||||
app.router.add_post("/api/wfm/lab/plans/delete", handle_delete)
|
||||
app.router.add_post("/api/wfm/lab/index-image/save-to-output", handle_save_index_to_output)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing lab plans: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting lab plan content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_service.delete_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_index_to_output(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.save_index_image_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab index image to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -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,7 +41,12 @@ 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)
|
||||
app.router.add_delete("/api/wfm/styles/{name}", handle_delete_style)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -245,18 +254,174 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory."""
|
||||
# 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"
|
||||
styles = []
|
||||
if not style_dir.is_dir():
|
||||
return styles
|
||||
for json_file in sorted(style_dir.glob("*.json")):
|
||||
return []
|
||||
return sorted(style_dir.glob("*.json"))
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory.
|
||||
各エントリに定義元ファイル名を "file" として付与する(Promptタブの一覧表示・
|
||||
「このファイルへ追加」機能で使用)。"""
|
||||
styles = []
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
styles.extend(data)
|
||||
for entry in data:
|
||||
if isinstance(entry, dict):
|
||||
entry = dict(entry)
|
||||
entry["file"] = json_file.name
|
||||
styles.append(entry)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
return styles
|
||||
@@ -270,3 +435,139 @@ async def handle_get_styles(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error loading styles: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# Promptタブ「Style」サブタブでの新規作成分の保存先。既存スタイルの編集は
|
||||
# 元々そのスタイルが記載されているファイルをそのまま書き換える(他ファイルへは移動しない)。
|
||||
_CUSTOM_STYLE_FILENAME = "custom.json"
|
||||
|
||||
|
||||
def _find_style(name: str):
|
||||
"""名前が一致する最初のスタイルを探し、(ファイルパス, そのファイルの全データ, インデックス) を返す。
|
||||
見つからなければ None。"""
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
continue
|
||||
if not isinstance(data, list):
|
||||
continue
|
||||
for i, entry in enumerate(data):
|
||||
if isinstance(entry, dict) and entry.get("name") == name:
|
||||
return json_file, data, i
|
||||
return None
|
||||
|
||||
|
||||
def _write_style_file(path: Path, data: list) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
async def handle_create_style(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/styles - 新規スタイルを作成する。
|
||||
body.file を指定すると既存のそのファイルへ追記する(Promptタブの「このファイルへ追加」用、
|
||||
ファイル名はstyleディレクトリに実在するもののみ許可しパストラバーサルを防ぐ)。
|
||||
未指定ならデフォルトのcustom.jsonへ追記する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
target_file = (body.get("file") or "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _create():
|
||||
if _find_style(name) is not None:
|
||||
return "duplicate"
|
||||
style_dir = DATA_DIR / "style"
|
||||
style_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target_file:
|
||||
path = next((f for f in _style_files() if f.name == target_file), None)
|
||||
if path is None:
|
||||
return "file_not_found"
|
||||
else:
|
||||
path = style_dir / _CUSTOM_STYLE_FILENAME
|
||||
|
||||
data = []
|
||||
if path.is_file():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, list):
|
||||
data = loaded
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load %s, recreating: %s", path.name, e)
|
||||
data.append({"name": name, "prompt": prompt, "negative_prompt": negative_prompt})
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_create)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{name}" already exists'}, status=400)
|
||||
if result == "file_not_found":
|
||||
return web.json_response({"error": f'File "{target_file}" not found'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error creating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_update_style(request: web.Request) -> web.Response:
|
||||
"""PUT /api/wfm/styles/{name} - 既存スタイルを更新する(記載元のファイルをそのまま書き換え、リネームも可)。"""
|
||||
try:
|
||||
original_name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
new_name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
if not new_name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _update():
|
||||
found = _find_style(original_name)
|
||||
if found is None:
|
||||
return "not_found"
|
||||
path, data, idx = found
|
||||
if new_name != original_name and _find_style(new_name) is not None:
|
||||
return "duplicate"
|
||||
data[idx] = {"name": new_name, "prompt": prompt, "negative_prompt": negative_prompt}
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_update)
|
||||
if result == "not_found":
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{new_name}" already exists'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error updating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_style(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/wfm/styles/{name} - スタイルを削除する(記載元のファイルから該当エントリのみ除去)。"""
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
|
||||
def _delete():
|
||||
found = _find_style(name)
|
||||
if found is None:
|
||||
return False
|
||||
path, data, idx = found
|
||||
del data[idx]
|
||||
_write_style_file(path, data)
|
||||
return True
|
||||
|
||||
ok = await asyncio.to_thread(_delete)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -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,99 @@
|
||||
"""Unsloth API proxy routes.
|
||||
|
||||
Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop requires an API key even
|
||||
for local access (Authorization: Bearer sk-unsloth-...). The key lives in
|
||||
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
|
||||
never sent to the frontend — the frontend calls this proxy with the target
|
||||
path/method/payload, and the server attaches the Authorization header before
|
||||
relaying to Unsloth's OpenAI-compatible API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
|
||||
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions"}
|
||||
# baseUrl is client-supplied (so a custom Unsloth port works), but the
|
||||
# Authorization header carries a real secret — restrict the host to loopback
|
||||
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
|
||||
# server (SSRF). Port is unrestricted.
|
||||
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _get_api_key():
|
||||
"""Return the Unsloth API key from the environment (.env), or None."""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def _is_allowed_base_url(base_url):
|
||||
"""Only relay (and attach the API key) to a loopback host."""
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register Unsloth API routes."""
|
||||
app.router.add_post("/api/wfm/unsloth/proxy", handle_proxy)
|
||||
|
||||
|
||||
async def handle_proxy(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
|
||||
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
body = await request.json()
|
||||
base_url = (body.get("baseUrl") or UNSLOTH_DEFAULT_URL).rstrip("/")
|
||||
path = body.get("path") or "/v1/models"
|
||||
method = (body.get("method") or "GET").upper()
|
||||
payload = body.get("payload")
|
||||
|
||||
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
|
||||
return web.json_response({"message": "Unsupported proxy target"}, status=400)
|
||||
|
||||
if not _is_allowed_base_url(base_url):
|
||||
return web.json_response({
|
||||
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
|
||||
}, status=400)
|
||||
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
return web.json_response({
|
||||
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI.",
|
||||
}, status=401)
|
||||
|
||||
def _fetch():
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
return web.json_response(data)
|
||||
|
||||
except HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
|
||||
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
|
||||
except URLError as e:
|
||||
logger.warning("Unsloth proxy connection error: %s", e)
|
||||
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
|
||||
except Exception as e:
|
||||
logger.error("Unsloth proxy error: %s", e)
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""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/edit/probe", handle_probe_video)
|
||||
|
||||
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_probe_video(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
subfolder = request.rel_url.query.get("subfolder", "")
|
||||
type_ = request.rel_url.query.get("type", "input")
|
||||
if not filename:
|
||||
return web.json_response({"error": "filename required"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.probe_video, filename, subfolder, type_)
|
||||
return web.json_response(result)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error probing video: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_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,8 +1,10 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,14 +26,27 @@ class GalleryMetadataStore:
|
||||
{"name": str}
|
||||
]
|
||||
}
|
||||
|
||||
self._data(特に "images" 辞書)は、バックグラウンドのフォルダインデックススレッド
|
||||
(gallery_service.pyの_bg_index_folder)と、リクエストハンドラを処理するメインスレッドの
|
||||
双方から並行に読み書きされる。ロックなしで辞書を反復(.items()/.values()/for key in ...や
|
||||
json.dumpによるシリアライズ)している最中に別スレッドがキーを追加/削除すると
|
||||
"dictionary changed size during iteration" が発生するため、_data へアクセスする
|
||||
全メソッドを self._lock で保護する(re-entrant: ensure_group()がcreate_group()を
|
||||
内部呼び出しするため、通常のLockではなくRLockを使う)。
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._lock = threading.RLock()
|
||||
# ディスクへの実書き込み(JSONシリアライズ+ファイルI/O)を直列化するための
|
||||
# 別ロック。_save_to_disk() を参照。
|
||||
self._write_lock = threading.Lock()
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
# コンストラクタからのみ呼ばれ、この時点ではまだ他スレッドと共有されていないためロック不要
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
@@ -42,145 +57,233 @@ class GalleryMetadataStore:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
# 破損ファイルをリネームしてバックアップしておく。ここでリネームせずに
|
||||
# 空の self._data のまま次の save() が走ると、破損直前までの全メタデータ
|
||||
# (タグ/メモ/グループ登録)が跡形もなく上書きされ、二度と復元できなくなる
|
||||
# (実際に発生した不具合: _save_to_disk が非アトミック書き込みだったため、
|
||||
# 大量画像フォルダのBGインデックス処理中の頻繁な保存とComfyUIの終了タイミング
|
||||
# が重なるとファイルが不完全な状態のまま残ることがあった — _save_to_disk側は
|
||||
# os.replace()によるアトミック書き込みに修正済みだが、過去に壊れたファイルや
|
||||
# 他要因による破損に備えてここでも保護しておく)。
|
||||
try:
|
||||
backup_path = self.file_path.with_name(self.file_path.name + ".corrupt")
|
||||
self.file_path.replace(backup_path)
|
||||
logger.warning("GalleryMetadataStore: corrupt file backed up to %s", backup_path)
|
||||
except Exception as backup_err:
|
||||
logger.warning("GalleryMetadataStore: failed to back up corrupt file: %s", backup_err)
|
||||
|
||||
def _save_to_disk(self):
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド。
|
||||
# ここではself._lock配下でスナップショット(deepcopy)を取るだけに留め、実際のJSON
|
||||
# シリアライズ+ファイルI/Oはロックの外、別途self._write_lockで直列化して行う。
|
||||
# 理由: gallery_metadata.jsonは画像数が多い環境では1MBを超えることがあり、
|
||||
# self._lockを保持したままjson.dump+ファイル書き込みまで行うと、その間
|
||||
# 他スレッドのget()(検索リクエストなど)が丸ごとブロックされてしまう
|
||||
# (実際に発生した不具合: バックグラウンドインデックス処理が10枚ごとに保存する際、
|
||||
# 検索リクエストのget()呼び出しがロック待ちになり、50枚程度のフォルダに対する
|
||||
# 検索でも数秒〜十数秒かかっていた)。deepcopy自体はメモリ内操作でファイルI/Oより
|
||||
# 大幅に軽いため、ロック内に残しても実用上のブロック時間は無視できるレベルになる。
|
||||
snapshot = copy.deepcopy(self._data)
|
||||
return self._write_snapshot(snapshot)
|
||||
|
||||
def _write_snapshot(self, snapshot: dict) -> bool:
|
||||
"""self._lockの外から呼ばれる。一時ファイルに書き出してから置き換える
|
||||
(os.replace相当のPath.replace)ことで書き込みをアトミックにする。素朴な
|
||||
open(path, "w") での直接上書きだと、書き込み完了前に他プロセスが読むと
|
||||
不完全なJSONを掴む(torn read)うえ、書き込み中にComfyUIプロセスが終了すると
|
||||
ファイルが壊れたまま残る — 大量画像フォルダのBGインデックス処理で書き込み
|
||||
頻度が高い環境ほど発生しやすく、次回起動時のロード失敗→空データでの全上書き
|
||||
という形でユーザーの登録済みグループ/タグ/メモが消失する原因になっていた。
|
||||
self._write_lockで直列化し、複数スレッドの書き込みが競合して古いスナップショット
|
||||
が新しいものを上書きしないようにする。"""
|
||||
with self._write_lock:
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.file_path.with_name(self.file_path.name + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(snapshot, f, ensure_ascii=False, indent=2)
|
||||
tmp_path.replace(self.file_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
|
||||
def _normalize_path(self, image_path: str) -> str:
|
||||
return str(Path(image_path)).replace("\\", "/")
|
||||
|
||||
def get(self, image_path: str) -> dict:
|
||||
key = self._normalize_path(image_path)
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
with self._lock:
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
with self._lock:
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
def save_batch(self, entries: dict[str, dict]) -> bool:
|
||||
"""複数画像のメタデータを1回のディスク書き込みでまとめて保存する。
|
||||
バックグラウンドインデックス処理のように多数の画像を連続して保存する場合、
|
||||
1件ずつsave()を呼ぶと画像の数だけJSONシリアライズ+ファイルI/Oが発生してしまう
|
||||
(実際に発生した不具合: gallery_metadata.jsonが1MBを超える環境で10枚ごとに
|
||||
個別save()していたため、検索リクエストが完了するまで数秒〜十数秒かかっていた)。"""
|
||||
if not entries:
|
||||
return True
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
for image_path, data in entries.items():
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return list(self._data.get("groups", []))
|
||||
with self._lock:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
with self._lock:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
|
||||
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
with self._lock:
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
"""グループ内の全画像を除外する(グループ自体は残す)"""
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
"""グループが存在しない場合のみ作成する"""
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
with self._lock:
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
|
||||
def delete(self, image_path: str) -> bool:
|
||||
"""画像のメタデータエントリを削除する"""
|
||||
key = self._normalize_path(image_path)
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def rename_path(self, old_path: str, new_path: str) -> bool:
|
||||
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
|
||||
old_key = self._normalize_path(old_path)
|
||||
new_key = self._normalize_path(new_path)
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> 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 += "/"
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if key.startswith(folder_key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
|
||||
def _in_scope(key: str) -> bool:
|
||||
if not key.startswith(folder_key):
|
||||
return False
|
||||
if recursive:
|
||||
return True
|
||||
return "/" not in key[len(folder_key):]
|
||||
|
||||
with self._lock:
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if _in_scope(key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -15,8 +16,65 @@ 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 = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
def _decode_image_data_url(image_data: str) -> tuple[bytes, str]:
|
||||
"""data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。
|
||||
ヘッダが無ければPNG扱い(旧互換)。"""
|
||||
raw = image_data
|
||||
mime = "image/png"
|
||||
if raw.startswith("data:"):
|
||||
header, _, raw = raw.partition(",")
|
||||
m = re.match(r"data:([^;]+)", header)
|
||||
if m:
|
||||
mime = m.group(1)
|
||||
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
|
||||
return base64.b64decode(raw), ext
|
||||
|
||||
|
||||
def _sanitize_save_filename(filename: str, ext: str) -> str:
|
||||
"""OSで使えない文字を除去し、拡張子が無ければ付与する"""
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
return safe
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
@@ -30,6 +88,54 @@ _JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 60.0
|
||||
|
||||
# ImagePromptギャラリー: 画像と同名の.txtサイドカーに保存されたプロンプトテキストの最大読み込みサイズ
|
||||
_SIDECAR_PROMPT_MAX = 20_000
|
||||
|
||||
# ImagePromptギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
|
||||
IMAGE_PROMPT_FOLDER_NAME = "ws_image_prompt"
|
||||
|
||||
# Style Catalogギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
|
||||
STYLE_CATALOG_FOLDER_NAME = "ws_style_catalog"
|
||||
|
||||
# ponyxlWildcardsVault形式(.yaml + thumbnails/)を検出するための、画像の祖先フォルダ探索上限
|
||||
_VAULT_ROOT_SEARCH_DEPTH = 8
|
||||
_VAULT_THUMB_DIR_NAMES = ("thumbnails", "thumbnails_option2")
|
||||
|
||||
|
||||
def _clean_vault_tags(text: str) -> str:
|
||||
"""タグ文字列の前後の空白・カンマを整理する(tools/import_style_prompt_seed.py と同じ処理)"""
|
||||
text = text.strip()
|
||||
text = re.sub(r"^[,\s]+|[,\s]+$", "", text)
|
||||
text = re.sub(r"\s*,\s*", ", ", text)
|
||||
return text
|
||||
|
||||
|
||||
def _parse_vault_yaml_leaves(yaml_path: Path) -> dict[str, str]:
|
||||
"""comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。
|
||||
「key:」行の直後が「- タグ文字列」で始まる行なら key をリーフとみなす。
|
||||
戻り値: {leaf_name(小文字): タグ文字列}
|
||||
"""
|
||||
leaves: dict[str, str] = {}
|
||||
try:
|
||||
lines = yaml_path.read_text(encoding="utf-8", errors="replace").split("\n")
|
||||
except OSError:
|
||||
return leaves
|
||||
for i, line in enumerate(lines):
|
||||
trimmed = line.strip()
|
||||
if not trimmed or trimmed.startswith("#") or not trimmed.endswith(":"):
|
||||
continue
|
||||
if i + 1 >= len(lines):
|
||||
continue
|
||||
next_trimmed = lines[i + 1].strip()
|
||||
if not next_trimmed.startswith("-"):
|
||||
continue
|
||||
key = trimmed[:-1].strip()
|
||||
tags = next_trimmed[1:].strip()
|
||||
if not tags or key.lower() == "skip":
|
||||
continue
|
||||
leaves[key.lower()] = _clean_vault_tags(tags)
|
||||
return leaves
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
"""フォルダ単位の画像スキャン結果キャッシュ。
|
||||
@@ -73,15 +179,32 @@ class GalleryService:
|
||||
self.data_dir = data_dir
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
self._allowed_root: Path | None = None
|
||||
# ComfyUI実outputフォルダの不変ルート。Settingsでユーザーが_allowed_rootを
|
||||
# 別フォルダに変更しても、ImagePrompt/Style Catalogギャラリー(常に実output配下)への
|
||||
# アクセスが塞がれないようにするため_allowed_rootとは別管理にする
|
||||
self._comfy_output_root: Path | None = None
|
||||
self._folder_cache = _FolderCache()
|
||||
self._bg_cancel = threading.Event()
|
||||
self._bg_cancel.set() # 初期状態: キャンセル済み
|
||||
# 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変更時に呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._allowed_root = p
|
||||
|
||||
def set_comfy_output_root(self, root_path: str) -> None:
|
||||
"""ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._comfy_output_root = p
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バックグラウンドインデックス
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -134,27 +257,37 @@ 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:
|
||||
logger.debug("BG index error: %s", e)
|
||||
|
||||
def _check_path_allowed(self, path: Path) -> bool:
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
|
||||
if self._allowed_root is None:
|
||||
return False
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)。
|
||||
Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの
|
||||
_comfy_output_root のいずれか配下であれば許可する。"""
|
||||
resolved = path.resolve()
|
||||
if self._allowed_root is not None:
|
||||
try:
|
||||
resolved.relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
if self._comfy_output_root is not None:
|
||||
try:
|
||||
resolved.relative_to(self._comfy_output_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
@@ -183,11 +316,13 @@ class GalleryService:
|
||||
except PermissionError:
|
||||
pass
|
||||
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
|
||||
total_count = image_count + sum(c["image_count_total"] for c in children_nodes)
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": rel if rel != "." else "",
|
||||
"abs_path": str(path).replace("\\", "/"),
|
||||
"image_count": image_count,
|
||||
"image_count_total": total_count,
|
||||
"children": children_nodes,
|
||||
}
|
||||
|
||||
@@ -210,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,
|
||||
))
|
||||
@@ -230,6 +372,48 @@ class GalleryService:
|
||||
self._folder_cache.set(folder, entries)
|
||||
return entries
|
||||
|
||||
def _scan_folder_recursive(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""フォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
|
||||
ImagePrompt/Style Catalogギャラリーのように深い階層に画像が置かれるケース用。
|
||||
キャッシュはしない(フォルダ数が少なく、都度スキャンしても軽い想定)。
|
||||
os.walk()はDirEntryを介さずPath.stat()(follow_symlinks=True相当)を呼ぶため、
|
||||
_scan_folder()と同じ理由でフォルダ内ファイル数に対して極端に遅くなる。
|
||||
os.scandir()を自前で再帰し、follow_symlinks=Falseでstatする。
|
||||
"""
|
||||
entries = []
|
||||
|
||||
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(
|
||||
self,
|
||||
folder_path: str,
|
||||
@@ -238,8 +422,9 @@ class GalleryService:
|
||||
favorite_only: bool = False,
|
||||
tag_filter: str = "",
|
||||
group_filter: str = "",
|
||||
recursive: bool = False,
|
||||
) -> list[dict]:
|
||||
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
|
||||
"""指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
@@ -253,11 +438,18 @@ class GalleryService:
|
||||
group_member_set = self.metadata_store.get_group_member_set(group_filter)
|
||||
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder(folder)
|
||||
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:
|
||||
@@ -289,14 +481,16 @@ class GalleryService:
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト)
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト・.txtサイドカー)
|
||||
if search:
|
||||
s = search.lower()
|
||||
sidecar_prompt = self._read_sidecar_prompt(Path(abs_path)) or ""
|
||||
if not (
|
||||
s in name.lower()
|
||||
or s in item["memo"].lower()
|
||||
or any(s in t.lower() for t in tags)
|
||||
or s in meta.get("prompt_cache", "").lower()
|
||||
or s in sidecar_prompt.lower()
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -314,6 +508,139 @@ class GalleryService:
|
||||
|
||||
return results
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# ImagePromptギャラリー: .txtサイドカーによるプロンプト保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_image_prompt_root(self) -> str | None:
|
||||
"""ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を
|
||||
返す。無ければ作成する。"""
|
||||
if self._comfy_output_root is None:
|
||||
return None
|
||||
root = self._comfy_output_root / IMAGE_PROMPT_FOLDER_NAME
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return str(root).replace("\\", "/")
|
||||
|
||||
def get_style_catalog_root(self) -> str | None:
|
||||
"""Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を
|
||||
返す。無ければ作成する。"""
|
||||
if self._comfy_output_root is None:
|
||||
return None
|
||||
root = self._comfy_output_root / STYLE_CATALOG_FOLDER_NAME
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return str(root).replace("\\", "/")
|
||||
|
||||
def save_image_to_folder(self, folder_path: str, filename: str, image_data: str) -> dict:
|
||||
"""任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。
|
||||
Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return {"ok": False, "error": "Destination folder not found"}
|
||||
if not self._check_path_allowed(folder):
|
||||
return {"ok": False, "error": "Access denied"}
|
||||
try:
|
||||
image_bytes, ext = _decode_image_data_url(image_data)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Invalid imageData: {e}"}
|
||||
safe = _sanitize_save_filename(filename, ext)
|
||||
if not safe:
|
||||
return {"ok": False, "error": "Invalid filename"}
|
||||
save_path = folder / safe
|
||||
try:
|
||||
save_path.write_bytes(image_bytes)
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {"ok": True, "path": str(save_path).replace("\\", "/")}
|
||||
|
||||
def _sidecar_path(self, image_path: Path) -> Path:
|
||||
return image_path.with_suffix(".txt")
|
||||
|
||||
def _read_sidecar_prompt(self, image_path: Path) -> str | None:
|
||||
"""画像のプロンプトテキストを返す。
|
||||
1. 画像と同名の .txt サイドカーがあればそれを使う
|
||||
2. 無ければ ponyxlWildcardsVault 形式 (*.yaml + thumbnails/) をフォールバックとして
|
||||
その場で解決する(インポート未実施のフォルダをそのまま置いた場合に対応するため)
|
||||
"""
|
||||
sidecar = self._sidecar_path(image_path)
|
||||
try:
|
||||
if sidecar.is_file() and sidecar.stat().st_size <= _SIDECAR_PROMPT_MAX:
|
||||
text = sidecar.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if text:
|
||||
return text
|
||||
except OSError:
|
||||
pass
|
||||
return self._read_vault_prompt(image_path)
|
||||
|
||||
def _find_vault_category_root(self, image_path: Path) -> Path | None:
|
||||
"""image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ
|
||||
(=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):
|
||||
result = cur
|
||||
break
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
|
||||
self._vault_root_cache[parent_key] = (time.monotonic(), result)
|
||||
return result
|
||||
|
||||
def _load_vault_leaves(self, category_root: Path) -> dict[str, str]:
|
||||
"""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 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] = (time.monotonic(), leaves)
|
||||
return leaves
|
||||
|
||||
def _read_vault_prompt(self, image_path: Path) -> str | None:
|
||||
"""ponyxlWildcardsVault形式のフォールバック解決。
|
||||
thumbnails_option2 の "{leaf}.preview3.ext" のような二重拡張子にも対応するため、
|
||||
ファイル名の最初の "." より前をリーフ名候補として使う。"""
|
||||
category_root = self._find_vault_category_root(image_path)
|
||||
if category_root is None:
|
||||
return None
|
||||
leaves = self._load_vault_leaves(category_root)
|
||||
if not leaves:
|
||||
return None
|
||||
leaf_candidate = image_path.name.split(".")[0].lower()
|
||||
return leaves.get(leaf_candidate)
|
||||
|
||||
def save_image_prompt(self, image_path: str, text: str) -> bool:
|
||||
"""画像と同名の.txtサイドカーにプロンプトテキストを保存する"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return False
|
||||
if not self._check_path_allowed(p):
|
||||
return False
|
||||
try:
|
||||
self._sidecar_path(p).write_text((text or "").strip(), encoding="utf-8")
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("save_image_prompt: failed for %s: %s", p, e)
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -332,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 キーを除く文字列フィールドをプロンプトキャッシュとして保存(検索用)
|
||||
@@ -351,10 +681,14 @@ 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", ""),
|
||||
"groups": saved.get("groups", []),
|
||||
"image_prompt": self._read_sidecar_prompt(path),
|
||||
}
|
||||
|
||||
def _read_png_metadata(self, path: Path) -> dict:
|
||||
@@ -429,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
|
||||
@@ -518,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)
|
||||
@@ -653,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
|
||||
@@ -663,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():
|
||||
@@ -702,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:
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Lab tab plan file management service (JSON plan + optional PNG index thumbnail)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable lab plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/lab_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"batch_count": data.get("batch_count", 1),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Lab_index") -> dict:
|
||||
"""Saves the contact-sheet index image into ComfyUI's own output folder,
|
||||
alongside the images generated during the run (unlike save_index_image,
|
||||
which saves it next to the plan file in plan_dir)."""
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def delete_plan(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -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,159 @@
|
||||
"""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 probe_video(self, filename: str, subfolder: str, type_: str) -> dict:
|
||||
"""Reads duration/dimensions/fps/audio presence via PyAV only (no ffmpeg
|
||||
subprocess) — used by the Video Edit tab right after a clip is added, so
|
||||
the timeline UI can show its length and the export-time resolution-match
|
||||
check has real numbers to compare against."""
|
||||
import av # type: ignore
|
||||
|
||||
src_path = self._resolve_media_path(filename, subfolder, type_)
|
||||
container = av.open(str(src_path))
|
||||
try:
|
||||
stream = container.streams.video[0]
|
||||
fps = float(stream.average_rate) if stream.average_rate else 0.0
|
||||
if stream.duration is not None and stream.time_base is not None:
|
||||
duration = float(stream.duration * stream.time_base)
|
||||
elif container.duration is not None:
|
||||
duration = float(container.duration) / 1_000_000
|
||||
else:
|
||||
duration = 0.0
|
||||
width = stream.codec_context.width
|
||||
height = stream.codec_context.height
|
||||
has_audio = len(container.streams.audio) > 0
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": fps,
|
||||
"has_audio": has_audio,
|
||||
}
|
||||
|
||||
def convert_to_gif(
|
||||
self,
|
||||
filename: str,
|
||||
subfolder: str,
|
||||
type_: str,
|
||||
start_time: float = 0.0,
|
||||
end_time=None,
|
||||
fps: float = 10.0,
|
||||
max_width=None,
|
||||
filename_prefix: str = "video/GIF",
|
||||
) -> dict:
|
||||
"""Decodes the given video (PyAV), samples it down to `fps`, optionally
|
||||
downscales to `max_width`, and encodes the result as an animated GIF
|
||||
(Pillow) into ComfyUI's own output folder."""
|
||||
import av # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
|
||||
if _RESAMPLE is None:
|
||||
raise RuntimeError("Pillow is not available")
|
||||
|
||||
src_path = self._resolve_media_path(filename, subfolder, type_)
|
||||
fps = max(0.1, float(fps))
|
||||
|
||||
container = av.open(str(src_path))
|
||||
try:
|
||||
stream = container.streams.video[0]
|
||||
source_fps = float(stream.average_rate) if stream.average_rate else 24.0
|
||||
step = max(1, round(source_fps / fps))
|
||||
|
||||
pil_frames = []
|
||||
idx = 0
|
||||
for frame in container.decode(stream):
|
||||
t = float(frame.pts * stream.time_base) if frame.pts is not None else None
|
||||
if t is not None:
|
||||
if t < start_time:
|
||||
idx += 1
|
||||
continue
|
||||
if end_time is not None and t > end_time:
|
||||
break
|
||||
if idx % step == 0:
|
||||
im = frame.to_image()
|
||||
if max_width and im.width > max_width:
|
||||
ratio = max_width / im.width
|
||||
im = im.resize((max_width, max(1, round(im.height * ratio))), _RESAMPLE)
|
||||
pil_frames.append(im.convert("RGB"))
|
||||
idx += 1
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
if not pil_frames:
|
||||
raise ValueError("No frames found in the specified range")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, out_filename, counter, out_subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{out_filename}_{counter:05}_.gif"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
|
||||
duration_ms = max(20, round(1000 / fps))
|
||||
pil_frames[0].save(
|
||||
save_path,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=pil_frames[1:],
|
||||
duration=duration_ms,
|
||||
loop=0,
|
||||
optimize=True,
|
||||
)
|
||||
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": len(pil_frames)}
|
||||
@@ -4,7 +4,7 @@ import jinja2
|
||||
from aiohttp import web
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR, VIDEO_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +41,38 @@ async def serve_workflow_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Lab plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = LAB_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
async def serve_video_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Video plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = VIDEO_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowStudio:
|
||||
"""Main entry point for Workflow Studio plugin."""
|
||||
|
||||
@@ -57,15 +89,22 @@ class WorkflowStudio:
|
||||
# Dynamic workflow file serving (thumbnails etc.)
|
||||
app.router.add_get("/wfm_data/workflows/{filename}", serve_workflow_file)
|
||||
|
||||
# Dynamic Lab plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
|
||||
|
||||
# Dynamic Video plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/video_plan/{filename}", serve_video_plan_file)
|
||||
|
||||
# Main page
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_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)
|
||||
ollama_routes.setup_routes(app)
|
||||
unsloth_routes.setup_routes(app)
|
||||
eagle_routes.setup_routes(app)
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
@@ -75,5 +114,7 @@ class WorkflowStudio:
|
||||
tagger_routes.setup_routes(app)
|
||||
gmic_routes.setup_routes(app)
|
||||
skill_routes.setup_routes(app)
|
||||
lab_routes.setup_routes(app)
|
||||
video_routes.setup_routes(app)
|
||||
|
||||
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.3.80"
|
||||
license = "MIT"
|
||||
version = "0.7.0"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
|
||||
@@ -19,3 +19,17 @@ piexif>=1.1.3
|
||||
# Omit if you do not use DeepDanbooru models.
|
||||
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
|
||||
# tensorflow>=2.10
|
||||
|
||||
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
|
||||
python-dotenv>=1.0,<2.0
|
||||
|
||||
# -- Multi-layer PSD export (Gallery tab bulk action) --
|
||||
# Pure-Python, ships prebuilt wheels (no C compiler required on Windows).
|
||||
psd-tools>=1.10
|
||||
|
||||
# -- MP4 thumbnail generation (Gallery tab) --
|
||||
# Uses PyAV (av) to decode the first frame. ComfyUI core already requires av>=17.0.0,
|
||||
# so it is normally pre-installed in python_embeded and no action is needed here.
|
||||
# If missing, mp4 thumbnail generation simply fails and falls back to a placeholder
|
||||
# (Gallery itself keeps working). Not pinned here on purpose — see torch note above.
|
||||
# av>=17.0.0
|
||||
|
||||
@@ -2,11 +2,45 @@
|
||||
Gallery Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── サブタブ (Output / Style-Prompt) ──────────────────────── */
|
||||
|
||||
.wfm-gallery-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-gallery-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-gallery-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-gallery-panel.active { display: flex; }
|
||||
|
||||
/* ── 3カラムレイアウト ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px));
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
@@ -256,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;
|
||||
@@ -431,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;
|
||||
@@ -499,6 +514,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-image-prompt-text {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-gallery-tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -788,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 {
|
||||
@@ -833,3 +872,114 @@
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ImagePrompt / Style_Catalog ギャラリー (右ペイン)
|
||||
============================================================ */
|
||||
|
||||
.wfm-imageprompt-detail-panel {
|
||||
width: 520px;
|
||||
min-width: 520px;
|
||||
max-width: 520px;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-left {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-section {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-prompt-box {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wfm-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row .wfm-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
background: rgba(79,142,247,0.15);
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove:hover { opacity: 1; }
|
||||
|
||||
.wfm-imageprompt-final-section {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-final-textarea {
|
||||
margin-top: 4px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/* Lab tab — experimental I2I batch generation with per-column keyframes */
|
||||
|
||||
/* .wfm-gen-subtab-content.active defaults to flex-direction:row (main.css);
|
||||
Lab needs its Setting/Results nav stacked above the panel body. */
|
||||
#wfm-gen-subtab-lab.active {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-lab-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: calc(100% - 33px);
|
||||
}
|
||||
|
||||
.wfm-lab-panel.active { display: flex; }
|
||||
|
||||
.wfm-lab-planjson-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 4px 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------------- Setting layout: left | center (4 columns) | right ---------------- */
|
||||
|
||||
.wfm-lab-setting-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-pane:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-left { width: 220px; flex-shrink: 0; }
|
||||
.wfm-lab-center { flex: 1; padding: 0; }
|
||||
.wfm-lab-right { width: 260px; flex-shrink: 0; overflow-y: auto; }
|
||||
|
||||
.wfm-lab-side-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-plan-workflow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-secondary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-plan-workflow-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-note {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------- Keyframe columns ---------------- */
|
||||
|
||||
.wfm-lab-columns {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
row-gap: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-col-btns { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-col-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Model column header: C/L/V toggle that switches which single field its cells
|
||||
summarize (see _modelViewMode in lab-tab.js) — not part of the Plan data. */
|
||||
.wfm-lab-model-view-toggle { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-model-view-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-model-view-btn:hover { color: var(--wfm-text-primary); border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-model-view-btn.active {
|
||||
color: #fff;
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-lab-col-cells {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell {
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-cell:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-cell-index {
|
||||
font-weight: 600;
|
||||
color: var(--wfm-primary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-value {
|
||||
color: var(--wfm-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-revert .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Setting 1, not yet explicitly overridden: showing the live GenerateUI value instead of
|
||||
its own stored one (see _isLiveDisplay in lab-tab.js). Dashed border distinguishes it
|
||||
from a saved override (solid border) at a glance. */
|
||||
.wfm-lab-cell.wfm-lab-cell-live {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-live .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
/* This keyframe row is skipped entirely (see _resolveKeyframe's bypassed handling) —
|
||||
amber to read as "temporarily disabled", distinct from the neutral gray used for
|
||||
revert/live. Takes priority over both of those in _cellHtml. */
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed {
|
||||
border-color: var(--wfm-warning);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed .wfm-lab-cell-value {
|
||||
color: var(--wfm-warning);
|
||||
}
|
||||
|
||||
/* LoRA node-bypass (strength forced to 0 at apply-time) — red to distinguish from the
|
||||
amber row-level bypassed above; the row itself still applies, only the LoRA doesn't. */
|
||||
.wfm-lab-cell.wfm-lab-cell-node-bypassed {
|
||||
border-color: var(--wfm-danger);
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-node-bypassed .wfm-lab-cell-value {
|
||||
color: var(--wfm-danger);
|
||||
}
|
||||
|
||||
/* Single-click selection target for the column header's up/down reorder buttons —
|
||||
layered as a box-shadow (not border-color) so it never conflicts with the
|
||||
revert/live/bypassed/node-bypassed border-color signaling above. */
|
||||
.wfm-lab-cell.wfm-lab-cell-selected {
|
||||
box-shadow: 0 0 0 2px var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-lab-cell-applied {
|
||||
margin-top: 3px;
|
||||
padding-top: 3px;
|
||||
border-top: 1px dashed var(--wfm-border);
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
/* Shown instead of a keyframe list when the loaded workflow has no node this Model
|
||||
sub-column (checkpoint/lora/vae) could override — see _hasNodeForColumn in lab-tab.js. */
|
||||
.wfm-lab-cell-empty {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-prompt-line {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- Modal form (checkpoint/vae/prompt/ksampler edit) ---------------- */
|
||||
|
||||
.wfm-lab-modal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-btnrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-hint {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------------- Results layout ---------------- */
|
||||
|
||||
.wfm-lab-results-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-results-left {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-results-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-lab-results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
border: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-result-item { position: relative; }
|
||||
|
||||
.wfm-lab-result-index {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -2342,6 +2355,280 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Col3 (Wildcard/Style) タブ切り替え */
|
||||
.wfm-prompt-col3-tabs {
|
||||
display: flex;
|
||||
background: var(--wfm-surface);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-prompt-col3-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
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-col3-tab:hover { color: var(--wfm-text); }
|
||||
.wfm-prompt-col3-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-prompt-col3-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
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;
|
||||
@@ -2885,6 +3172,29 @@ body {
|
||||
Image Input (I2I) Slots
|
||||
============================================ */
|
||||
|
||||
/* Container/header styling comes from .wfm-settings-section / .wfm-settings-summary
|
||||
(shared details/summary accordion used by the Settings tab) — collapsed by default
|
||||
since this is a low-frequency feature. */
|
||||
|
||||
.wfm-i2i-placeholder-mode {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-i2i-placeholder-mode label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-i2i-placeholder-fields input[type="color"] {
|
||||
padding: 2px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.wfm-i2i-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -2948,6 +3258,12 @@ body {
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
.wfm-i2i-drop-zone.wfm-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-i2i-drop-label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
@@ -4957,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,801 @@
|
||||
/* =============================================
|
||||
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-content { padding: 0 16px 16px; }
|
||||
|
||||
.wfm-video-asset-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wfm-video-asset-list-col {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-detail-col {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-video-asset-view-btn.active {
|
||||
background: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-video-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table-wrap {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table th,
|
||||
.wfm-video-asset-table td {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--wfm-bg-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-asset-row { cursor: pointer; }
|
||||
.wfm-video-asset-row:hover { background: var(--wfm-bg-hover); }
|
||||
.wfm-video-asset-row.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Edit: クリップタイムライン(Planタブと同じ横トラック+共有ツールバー方式)・
|
||||
トリムパネル・書き出しパネル ──
|
||||
Planの.wfm-video-timeline-trackはブロックをflex-grow(duration比率)で幅一杯に
|
||||
引き伸ばすのに対し、Editのブロックは実秒数×px/秒の固定幅で「左詰め」に並べる
|
||||
(空きトラックはそのまま空く)ため、専用クラスに分けている。 */
|
||||
.wfm-video-edit-panel { padding: 0 16px 16px; }
|
||||
|
||||
.wfm-video-edit-timeline-track {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 48px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
flex: none; /* stay a fixed-height full-width bar; never shrink/stretch with clip content */
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-block {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
background: var(--wfm-bg-hover, rgba(255, 255, 255, 0.04));
|
||||
cursor: grab;
|
||||
overflow: hidden;
|
||||
transition: outline 0.1s;
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-block:active { cursor: grabbing; }
|
||||
.wfm-video-edit-timeline-block.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-edit-timeline-block-name {
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-block-meta {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-edit-main-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 160px));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-row label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 開始/終了の入力欄+「現在位置を使用」ボタンを縦積みにする。以前は横並びで
|
||||
ボタンのラベルがはみ出し、かつ入力欄とボタンの見分けがつきにくかった。 */
|
||||
.wfm-video-edit-trim-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-field input { width: 100%; box-sizing: border-box; }
|
||||
|
||||
.wfm-video-edit-playhead-btn {
|
||||
align-self: flex-start;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-total-duration {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── トリムスクラバー: clipchamp風の時間目盛り+シークバー+トリムハンドル。
|
||||
すべて%位置指定(clip.duration基準)にすることでpx/秒換算を持たずに済ませている。 */
|
||||
.wfm-video-trim-scrubber {
|
||||
margin-top: 10px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wfm-video-trim-ruler {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-trim-ruler-tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-trim-track {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
margin-top: 4px;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-video-trim-range {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(70, 130, 230, 0.3);
|
||||
border-top: 2px solid var(--wfm-primary);
|
||||
border-bottom: 2px solid var(--wfm-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-video-trim-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
margin-left: -5px;
|
||||
background: var(--wfm-primary);
|
||||
border-radius: 2px;
|
||||
cursor: ew-resize;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.wfm-video-trim-playhead {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
background: #fff;
|
||||
/* Below the trim handles (z-index:3) so a handle sitting at the same
|
||||
position (e.g. right after "Set from playhead") stays grabbable instead
|
||||
of the playhead's hit area stealing the pointer. */
|
||||
z-index: 2;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.wfm-video-trim-playhead::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wfm-video-trim-badge {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-video-trim-badge-playhead {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.wfm-video-edit-export-panel {
|
||||
width: 220px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ソース動画/画像のネイティブサイズがフレームより大きい場合のみ縮小し、
|
||||
小さい場合は拡大しない(枠のheightを固定にしてPlan/Editの位置がずれないようにするため) */
|
||||
.wfm-video-preview-el {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/* ── 右ペイン: プロパティ (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";
|
||||
@@ -50,33 +50,102 @@ function fileToBase64(file) {
|
||||
});
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
// ============================================
|
||||
// Thinking mode / Max tokens (shared by callLLM / callChat / callVLM)
|
||||
// ============================================
|
||||
|
||||
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
|
||||
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
|
||||
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
|
||||
// <think>タグ除去でも担保する(stripThinkingTagsを参照)。
|
||||
function _applyGenOptions(body, backend, settings) {
|
||||
const maxTokens = parseInt(settings?.maxTokens, 10);
|
||||
if (backend === "ollama") {
|
||||
body.think = !!settings?.thinkingMode;
|
||||
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
|
||||
} else if (maxTokens > 0) {
|
||||
body.max_tokens = maxTokens;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function stripThinkingTags(text) {
|
||||
return (text || "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).response || "";
|
||||
const text = (await res.json()).response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
const text = (await res.json()).choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Unsloth proxy (server relays the request, attaching the API key from .env
|
||||
// so it never reaches the frontend — see py/routes/unsloth_routes.py)
|
||||
// ============================================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
|
||||
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
|
||||
// `reasoning_content` field. Fold it back into a <think> block so the
|
||||
// existing Thinking-mode show/strip logic (_applyGenOptions/stripThinkingTags)
|
||||
// still applies uniformly. Without this, a low max_tokens can make the model
|
||||
// spend its whole budget reasoning and return an empty `content` with no
|
||||
// visible explanation.
|
||||
function _unslothContent(message) {
|
||||
const reasoning = message?.reasoning_content;
|
||||
const content = message?.content || "";
|
||||
return reasoning ? `<think>${reasoning}</think>${content}` : content;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// URL validation
|
||||
// ============================================
|
||||
@@ -100,6 +169,9 @@ async function fetchModels(url, backend) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.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 {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
@@ -114,30 +186,37 @@ async function testConnection(url, backend) {
|
||||
return models.length;
|
||||
}
|
||||
|
||||
async function callLLM(url, backend, model, prompt) {
|
||||
async function callLLM(url, backend, model, prompt, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.response || "";
|
||||
const text = data.response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content || "";
|
||||
const text = data.choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,11 +224,41 @@ async function callLLM(url, backend, model, prompt) {
|
||||
// Translation prompt builder
|
||||
// ============================================
|
||||
|
||||
function buildTranslationPrompt(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "Auto") : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English") : LANG_NAMES[dstLang];
|
||||
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
|
||||
return `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
|
||||
// Uses a system role (via callChat) rather than a single raw prompt string —
|
||||
// local models follow "translate only, no commentary" far more reliably
|
||||
// when it's a system instruction instead of buried inside the user text.
|
||||
function buildTranslationMessages(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "").trim() : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English").trim() || "English" : LANG_NAMES[dstLang];
|
||||
const fromPart = srcName ? ` from ${srcName}` : "";
|
||||
const system = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
|
||||
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
|
||||
+ `no surrounding quotes, and do not repeat or echo the original text.`;
|
||||
return [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: text },
|
||||
];
|
||||
}
|
||||
|
||||
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
|
||||
function cleanTranslationOutput(raw) {
|
||||
let s = (raw || "").trim();
|
||||
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
|
||||
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
|
||||
if (s.length >= 2) {
|
||||
const first = s[0], last = s[s.length - 1];
|
||||
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
|
||||
if (pairs[first] === last) s = s.slice(1, -1).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Heuristic: if the cleaned output is (near-)identical to the input, the
|
||||
// model most likely echoed the source instead of translating it.
|
||||
function looksUntranslated(input, output) {
|
||||
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
|
||||
const a = normalize(input), b = normalize(output);
|
||||
return a.length > 0 && a === b;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -217,11 +326,19 @@ function initTranslateTab() {
|
||||
outputEl.value = "";
|
||||
|
||||
try {
|
||||
const prompt = buildTranslationPrompt(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
outputEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
const messages = buildTranslationMessages(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const { content } = await callChat(url, backend, model, messages, undefined, settings);
|
||||
const cleaned = cleanTranslationOutput(content);
|
||||
outputEl.value = cleaned;
|
||||
|
||||
if (srcLangSel.value !== dstLangSel.value && looksUntranslated(text, cleaned)) {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastMaybeNotTranslated"), "error");
|
||||
} else {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
@@ -288,6 +405,11 @@ function initSettingsTab() {
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
const thinkingModeCheckbox = document.getElementById("wfm-ai-thinking-mode-checkbox");
|
||||
if (thinkingModeCheckbox) thinkingModeCheckbox.checked = !!saved.thinkingMode;
|
||||
const maxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (maxTokensInput && saved.maxTokens) maxTokensInput.value = saved.maxTokens;
|
||||
|
||||
// Switch URL to the new backend's default when the user changes backend
|
||||
backendRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
@@ -334,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());
|
||||
|
||||
@@ -395,6 +531,8 @@ function initSettingsTab() {
|
||||
const chatGenDedicatedFilename = chatGenSelect?.value || "";
|
||||
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
|
||||
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
|
||||
const thinkingMode = !!thinkingModeCheckbox?.checked;
|
||||
const maxTokens = Math.max(0, parseInt(maxTokensInput?.value, 10) || 0);
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -403,6 +541,7 @@ function initSettingsTab() {
|
||||
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
thinkingMode, maxTokens,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
@@ -423,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"],
|
||||
},
|
||||
@@ -453,10 +602,10 @@ function _formatMessagesForBackend(messages, backend) {
|
||||
});
|
||||
}
|
||||
|
||||
async function callChat(url, backend, model, messages, tools) {
|
||||
async function callChat(url, backend, model, messages, tools, settings = {}) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) body.tools = tools;
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
@@ -465,9 +614,17 @@ async function callChat(url, backend, model, messages, tools) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const message = data.choices?.[0]?.message || {};
|
||||
const content = _unslothContent(message);
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
@@ -476,7 +633,8 @@ async function callChat(url, backend, model, messages, tools) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).choices?.[0]?.message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,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();
|
||||
@@ -526,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>)
|
||||
@@ -832,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;
|
||||
@@ -909,7 +1087,7 @@ function initChatTab() {
|
||||
const messagesToSend = skillPrompt
|
||||
? [{ role: "system", content: skillPrompt }, ...chatHistory]
|
||||
: chatHistory;
|
||||
const reply = await callChat(url, backend, model, messagesToSend, tools);
|
||||
const reply = await callChat(url, backend, model, messagesToSend, tools, settings);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
@@ -921,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");
|
||||
@@ -1069,7 +1247,7 @@ function initVlmTab() {
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
|
||||
try {
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
const result = await callLLM(url, backend, model, prompt, settings);
|
||||
resultEl.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
@@ -1095,7 +1273,7 @@ function initVlmTab() {
|
||||
resultEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType, settings);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
|
||||
@@ -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,20 +245,86 @@ 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");
|
||||
if (galleryOpenTaggerBtn) galleryOpenTaggerBtn.textContent = t("tabTagger");
|
||||
|
||||
// Gallery サブタブ (Output / ImagePrompt / Style_Catalog)
|
||||
const galleryOutputSubtab = document.getElementById("wfm-gallery-subtab-output");
|
||||
if (galleryOutputSubtab) galleryOutputSubtab.textContent = t("galleryTabOutput");
|
||||
const galleryImagePromptSubtab = document.getElementById("wfm-gallery-subtab-imageprompt");
|
||||
if (galleryImagePromptSubtab) galleryImagePromptSubtab.textContent = t("galleryTabImagePrompt");
|
||||
const galleryStyleCatalogSubtab = document.getElementById("wfm-gallery-subtab-styleCatalog");
|
||||
if (galleryStyleCatalogSubtab) galleryStyleCatalogSubtab.textContent = t("galleryTabStyleCatalog");
|
||||
const galleryMetadataSubtab = document.getElementById("wfm-gallery-subtab-metadata");
|
||||
if (galleryMetadataSubtab) galleryMetadataSubtab.textContent = t("galleryTabMetadata");
|
||||
|
||||
// ImagePrompt ギャラリーパネル
|
||||
const imagePromptSearch = document.getElementById("wfm-imageprompt-search");
|
||||
if (imagePromptSearch) imagePromptSearch.placeholder = t("gallerySearchPlaceholder");
|
||||
const imagePromptRefreshBtn = document.getElementById("wfm-imageprompt-refresh-btn");
|
||||
if (imagePromptRefreshBtn) imagePromptRefreshBtn.textContent = t("refresh");
|
||||
const imagePromptTreeTitle = document.getElementById("wfm-imageprompt-tree-title");
|
||||
if (imagePromptTreeTitle) imagePromptTreeTitle.textContent = t("galleryFolders");
|
||||
const imagePromptListPlaceholder = document.getElementById("wfm-imageprompt-list-placeholder");
|
||||
if (imagePromptListPlaceholder) imagePromptListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const imagePromptNoSelection = document.getElementById("wfm-imageprompt-no-selection");
|
||||
if (imagePromptNoSelection) imagePromptNoSelection.textContent = t("galleryNoSelection");
|
||||
const imagePromptSelectedPromptTitle = document.getElementById("wfm-imageprompt-selected-prompt-title");
|
||||
if (imagePromptSelectedPromptTitle) imagePromptSelectedPromptTitle.textContent = t("imagePromptSelectedPrompt");
|
||||
const imagePromptSelectedPrompt = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
if (imagePromptSelectedPrompt) imagePromptSelectedPrompt.placeholder = t("imagePromptNoPrompt");
|
||||
const imagePromptSavePromptBtn = document.getElementById("wfm-imageprompt-save-prompt-btn");
|
||||
if (imagePromptSavePromptBtn) imagePromptSavePromptBtn.textContent = t("save");
|
||||
const imagePromptAddBtn = document.getElementById("wfm-imageprompt-add-btn");
|
||||
if (imagePromptAddBtn) imagePromptAddBtn.textContent = t("imagePromptAddBtn");
|
||||
const imagePromptChipsTitle = document.getElementById("wfm-imageprompt-chips-title");
|
||||
if (imagePromptChipsTitle) imagePromptChipsTitle.textContent = t("imagePromptChipsTitle");
|
||||
const imagePromptClearBtn = document.getElementById("wfm-imageprompt-clear-btn");
|
||||
if (imagePromptClearBtn) imagePromptClearBtn.textContent = t("imagePromptClearBtn");
|
||||
const imagePromptFinalPromptTitle = document.getElementById("wfm-imageprompt-final-prompt-title");
|
||||
if (imagePromptFinalPromptTitle) imagePromptFinalPromptTitle.textContent = t("imagePromptFinalPromptTitle");
|
||||
const imagePromptCopyBtn = document.getElementById("wfm-imageprompt-copy-btn");
|
||||
if (imagePromptCopyBtn) imagePromptCopyBtn.textContent = t("copy");
|
||||
|
||||
// Style_Catalog ギャラリーパネル
|
||||
const scSearch = document.getElementById("wfm-stylecatalog-search");
|
||||
if (scSearch) scSearch.placeholder = t("stylecatalogSearchPlaceholder");
|
||||
const scRefreshBtn = document.getElementById("wfm-stylecatalog-refresh-btn");
|
||||
if (scRefreshBtn) scRefreshBtn.textContent = t("refresh");
|
||||
const scTreeTitle = document.getElementById("wfm-stylecatalog-tree-title");
|
||||
if (scTreeTitle) scTreeTitle.textContent = t("galleryFolders");
|
||||
const scListPlaceholder = document.getElementById("wfm-stylecatalog-list-placeholder");
|
||||
if (scListPlaceholder) scListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const scNoSelection = document.getElementById("wfm-stylecatalog-no-selection");
|
||||
if (scNoSelection) scNoSelection.textContent = t("galleryNoSelection");
|
||||
const scPositiveTitle = document.getElementById("wfm-stylecatalog-positive-title");
|
||||
if (scPositiveTitle) scPositiveTitle.textContent = t("stylecatalogPositive");
|
||||
const scCopyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
|
||||
if (scCopyPositiveBtn) scCopyPositiveBtn.textContent = t("copy");
|
||||
const scNegativeTitle = document.getElementById("wfm-stylecatalog-negative-title");
|
||||
if (scNegativeTitle) scNegativeTitle.textContent = t("stylecatalogNegative");
|
||||
const scCopyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
|
||||
if (scCopyNegativeBtn) scCopyNegativeBtn.textContent = t("copy");
|
||||
const scSelectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
|
||||
if (scSelectStyleBtn) scSelectStyleBtn.textContent = t("stylecatalogSelectStyleBtn");
|
||||
const scLoadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
|
||||
if (scLoadGenuiBtn) scLoadGenuiBtn.textContent = t("stylecatalogLoadGenuiBtn");
|
||||
const scOpenMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
|
||||
if (scOpenMetadataBtn) scOpenMetadataBtn.textContent = t("stylecatalogOpenMetadataBtn");
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
const presetsHeader = document.querySelector(".wfm-prompt-split-right .wfm-prompt-split-header");
|
||||
if (presetsHeader) presetsHeader.textContent = t("presetsSubtab");
|
||||
const ollamaRefreshBtn = document.getElementById("wfm-ollama-refresh-btn");
|
||||
if (ollamaRefreshBtn) ollamaRefreshBtn.textContent = t("refresh");
|
||||
const ollamaTestBtn = document.getElementById("wfm-ollama-test-btn");
|
||||
if (ollamaTestBtn) ollamaTestBtn.textContent = t("test");
|
||||
const ollamaSettingsBtn = document.getElementById("wfm-ollama-settings-btn");
|
||||
if (ollamaSettingsBtn) ollamaSettingsBtn.title = t("settings");
|
||||
const ollamaAttachBtn = document.getElementById("wfm-ollama-attach-btn");
|
||||
if (ollamaAttachBtn) ollamaAttachBtn.textContent = t("attachImage");
|
||||
const ollamaClearBtn = document.getElementById("wfm-ollama-clear-btn");
|
||||
@@ -302,6 +396,14 @@ function applyI18nToHtml() {
|
||||
}
|
||||
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
|
||||
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
|
||||
const aiGenerationTitle = document.getElementById("wfm-ai-settings-generation-title");
|
||||
if (aiGenerationTitle) aiGenerationTitle.textContent = t("aiSettingsGenerationTitle");
|
||||
const aiThinkingModeLabel = document.getElementById("wfm-ai-thinking-mode-label");
|
||||
if (aiThinkingModeLabel) aiThinkingModeLabel.textContent = t("aiSettingsThinkingMode");
|
||||
const aiMaxTokensLabel = document.getElementById("wfm-ai-max-tokens-label");
|
||||
if (aiMaxTokensLabel) aiMaxTokensLabel.textContent = t("aiSettingsMaxTokens");
|
||||
const aiMaxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (aiMaxTokensInput) aiMaxTokensInput.placeholder = t("aiSettingsMaxTokensPlaceholder");
|
||||
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
|
||||
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
|
||||
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
|
||||
@@ -380,6 +482,14 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
|
||||
"wfm-help-gen-17": "helpGen17", "wfm-help-gen-18": "helpGen18",
|
||||
"wfm-help-gen-19": "helpGen19",
|
||||
"wfm-help-gen-20": "helpGen20", "wfm-help-gen-21": "helpGen21",
|
||||
"wfm-help-gen-22": "helpGen22", "wfm-help-gen-23": "helpGen23",
|
||||
"wfm-help-gen-24": "helpGen24", "wfm-help-gen-25": "helpGen25",
|
||||
"wfm-help-gen-26": "helpGen26", "wfm-help-gen-27": "helpGen27",
|
||||
"wfm-help-gen-28": "helpGen28",
|
||||
"wfm-help-gen-29": "helpGen29",
|
||||
"wfm-help-gen-30": "helpGen30",
|
||||
"wfm-help-gen-31": "helpGen31",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
@@ -394,18 +504,55 @@ function applyI18nToHtml() {
|
||||
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
|
||||
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
|
||||
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
|
||||
"wfm-help-lab-title": "helpLabTitle",
|
||||
"wfm-help-lab-desc": "helpLabDesc",
|
||||
"wfm-help-lab-card-columns-title": "helpLabCardColumnsTitle",
|
||||
"wfm-help-lab-1": "helpLab1", "wfm-help-lab-2": "helpLab2",
|
||||
"wfm-help-lab-3": "helpLab3", "wfm-help-lab-4": "helpLab4",
|
||||
"wfm-help-lab-5": "helpLab5", "wfm-help-lab-6": "helpLab6",
|
||||
"wfm-help-lab-7": "helpLab7", "wfm-help-lab-8": "helpLab8",
|
||||
"wfm-help-lab-9": "helpLab9", "wfm-help-lab-10": "helpLab10",
|
||||
"wfm-help-lab-11": "helpLab11", "wfm-help-lab-12": "helpLab12",
|
||||
"wfm-help-lab-13": "helpLab13", "wfm-help-lab-14": "helpLab14",
|
||||
"wfm-help-lab-15": "helpLab15",
|
||||
"wfm-help-lab-16": "helpLab16", "wfm-help-lab-17": "helpLab17",
|
||||
"wfm-help-lab-18": "helpLab18", "wfm-help-lab-19": "helpLab19",
|
||||
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
|
||||
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
|
||||
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
|
||||
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
|
||||
"wfm-help-lab-plan-7": "helpLabPlan7", "wfm-help-lab-plan-8": "helpLabPlan8",
|
||||
"wfm-help-lab-plan-9": "helpLabPlan9", "wfm-help-lab-plan-10": "helpLabPlan10",
|
||||
"wfm-help-lab-plan-11": "helpLabPlan11",
|
||||
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
|
||||
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
|
||||
"wfm-help-lab-results-3": "helpLabResults3",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
|
||||
"wfm-help-prompt-9": "helpPrompt9",
|
||||
"wfm-help-prompt-card-style-title": "helpPromptCardStyleTitle",
|
||||
"wfm-help-prompt-10": "helpPrompt10", "wfm-help-prompt-11": "helpPrompt11",
|
||||
"wfm-help-prompt-12": "helpPrompt12", "wfm-help-prompt-13": "helpPrompt13",
|
||||
"wfm-help-prompt-14": "helpPrompt14",
|
||||
"wfm-help-prompt-card-toplevel-title": "helpPromptCardToplevel",
|
||||
"wfm-help-prompt-15": "helpPrompt15",
|
||||
"wfm-help-prompt-card-table-title": "helpPromptCardTable",
|
||||
"wfm-help-prompt-16": "helpPrompt16", "wfm-help-prompt-17": "helpPrompt17",
|
||||
"wfm-help-prompt-18": "helpPrompt18", "wfm-help-prompt-19": "helpPrompt19",
|
||||
"wfm-help-prompt-20": "helpPrompt20", "wfm-help-prompt-21": "helpPrompt21",
|
||||
"wfm-help-prompt-22": "helpPrompt22", "wfm-help-prompt-23": "helpPrompt23",
|
||||
"wfm-help-prompt-24": "helpPrompt24", "wfm-help-prompt-25": "helpPrompt25",
|
||||
"wfm-help-prompt-26": "helpPrompt26",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13",
|
||||
"wfm-help-settings-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",
|
||||
@@ -425,7 +572,16 @@ 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",
|
||||
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
|
||||
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
|
||||
@@ -471,13 +627,16 @@ 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",
|
||||
"wfm-help-imageedit-24": "helpImageEdit24", "wfm-help-imageedit-25": "helpImageEdit25",
|
||||
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
|
||||
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
|
||||
"wfm-help-imageedit-30": "helpImageEdit30",
|
||||
"wfm-help-imageedit-31": "helpImageEdit31", "wfm-help-imageedit-32": "helpImageEdit32",
|
||||
"wfm-help-imageedit-33": "helpImageEdit33", "wfm-help-imageedit-34": "helpImageEdit34",
|
||||
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
|
||||
"wfm-help-imageedit-8b": "helpImageEdit8b",
|
||||
@@ -497,6 +656,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
|
||||
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
|
||||
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
|
||||
"wfm-help-imageedit-mask-14": "helpImageEditMask14",
|
||||
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
|
||||
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
|
||||
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
|
||||
@@ -517,6 +677,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
|
||||
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
|
||||
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
|
||||
"wfm-help-trouble-9": "helpTrouble9",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
@@ -534,6 +695,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-card-seed-title": "helpGenCardSeed",
|
||||
"wfm-help-gen-card-batchsetup-title": "helpGenCardBatchSetup",
|
||||
"wfm-help-gen-card-batchtypes-title": "helpGenCardBatchTypes",
|
||||
"wfm-help-gen-card-lora-title": "helpGenCardLora",
|
||||
"wfm-help-prompt-title": "helpPromptTitle",
|
||||
"wfm-help-prompt-card-ai-title": "helpPromptCardAi",
|
||||
"wfm-help-prompt-card-presets-title": "helpPromptCardPresets",
|
||||
@@ -576,6 +738,14 @@ function applyI18nToHtml() {
|
||||
"wfm-help-trouble-card-connection-title": "helpTroubleCardConnection",
|
||||
"wfm-help-trouble-card-workflow-title": "helpTroubleCardWorkflow",
|
||||
"wfm-help-trouble-card-tagger-title": "helpTroubleCardTagger",
|
||||
"wfm-help-video-title": "helpVideoTitle",
|
||||
"wfm-help-video-desc": "helpVideoDesc",
|
||||
"wfm-help-video-1": "helpVideo1", "wfm-help-video-2": "helpVideo2",
|
||||
"wfm-help-video-3": "helpVideo3", "wfm-help-video-4": "helpVideo4",
|
||||
"wfm-help-video-5": "helpVideo5", "wfm-help-video-6": "helpVideo6",
|
||||
"wfm-help-video-7": "helpVideo7", "wfm-help-video-8": "helpVideo8",
|
||||
"wfm-help-video-9": "helpVideo9", "wfm-help-video-10": "helpVideo10",
|
||||
"wfm-help-video-11": "helpVideo11", "wfm-help-video-12": "helpVideo12",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -658,13 +828,15 @@ import { initWorkflowTab } from "./workflow-tab.js";
|
||||
import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
|
||||
import { initPromptTableTab } from "./prompt-table.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors, applyModelTabActiveColor } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
import { initMetadataTab } from "./metadata-tab.js";
|
||||
import { initAiTab } from "./ai-tab.js";
|
||||
import { initTaggerTab } from "./tagger-tab.js";
|
||||
import { imageEditTab } from "./image-edit-tab.js";
|
||||
import { initVideoTab } from "./video-tab.js";
|
||||
|
||||
// Apply saved theme immediately to prevent flash of default theme
|
||||
applyTheme(getSavedTheme());
|
||||
@@ -674,6 +846,7 @@ try {
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
applyJsonColors(_s.jsonColors);
|
||||
applyModelTabActiveColor(_s.modelTabActiveColor);
|
||||
} catch {}
|
||||
|
||||
function initHelpTab() {
|
||||
@@ -691,6 +864,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -706,11 +882,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initModelsTab();
|
||||
initGenerateTab();
|
||||
initPromptTab();
|
||||
initPromptTableTab();
|
||||
initMetadataTab();
|
||||
initGalleryTab();
|
||||
initAiTab();
|
||||
initTaggerTab();
|
||||
imageEditTab.init();
|
||||
initVideoTab();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -18,8 +18,15 @@ export const comfyUI = {
|
||||
connected: false,
|
||||
generating: false,
|
||||
currentPromptId: null,
|
||||
// trackProgress() 実行中の Promise を promptId -> reject関数 で保持。
|
||||
// WebSocket切断時に、待機中のPromiseを解決させずに放置してバッチループが
|
||||
// 無言のまま停止する(例: カタログ作成バッチが特定件数で止まる)のを防ぐ。
|
||||
_pendingTrackers: new Map(),
|
||||
currentWorkflow: null,
|
||||
currentAnalysis: null,
|
||||
// ワークフロー読み込み時点のLora Loader (LoraManager)ノードの loras/text 状態。
|
||||
// Stackグループチェックボックスのトグル時に、OFF側の「読み込み時の状態へ戻す」用に使う。
|
||||
loraManagerSnapshots: {},
|
||||
|
||||
updateUrl(url) {
|
||||
if (!url) {
|
||||
@@ -66,10 +73,18 @@ export const comfyUI = {
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.onopen = () => resolve(true);
|
||||
this.socket.onerror = () => resolve(false);
|
||||
this.socket.onclose = () => { this.socket = null; };
|
||||
this.socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this._rejectPendingTrackers(new Error("WebSocket disconnected"));
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_rejectPendingTrackers(err) {
|
||||
for (const rejectFn of this._pendingTrackers.values()) rejectFn(err);
|
||||
this._pendingTrackers.clear();
|
||||
},
|
||||
|
||||
// Node info fetching
|
||||
async fetchObjectInfo(nodeClass) {
|
||||
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
|
||||
@@ -196,12 +211,29 @@ export const comfyUI = {
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
trackProgress(promptId, progressCallback) {
|
||||
// timeoutMs: この時間内に完了/エラー/中断通知が来なければ強制的にreject。
|
||||
// WebSocketの取りこぼしやexecution_interrupted未処理でPromiseが永久に
|
||||
// ぶら下がり続け、バッチループが無言で停止するのを防ぐための安全弁。
|
||||
trackProgress(promptId, progressCallback, timeoutMs = 10 * 60 * 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error("WebSocket not connected"));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (isResolve, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.socket?.removeEventListener("message", handler);
|
||||
clearTimeout(timer);
|
||||
this._pendingTrackers.delete(promptId);
|
||||
if (isResolve) resolve(value); else reject(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(false, new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s (prompt_id=${promptId})`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
@@ -211,17 +243,19 @@ export const comfyUI = {
|
||||
}
|
||||
if (msg.type === "executing" && msg.data?.prompt_id === promptId) {
|
||||
if (msg.data.node === null) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
resolve();
|
||||
finish(true);
|
||||
}
|
||||
}
|
||||
if (msg.type === "execution_error" && msg.data?.prompt_id === promptId) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
reject(new Error(msg.data.exception_message || "Execution error"));
|
||||
finish(false, new Error(msg.data.exception_message || "Execution error"));
|
||||
}
|
||||
if (msg.type === "execution_interrupted" && msg.data?.prompt_id === promptId) {
|
||||
finish(false, new Error("Execution interrupted"));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
this.socket.addEventListener("message", handler);
|
||||
this._pendingTrackers.set(promptId, (err) => finish(false, err));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -276,7 +310,7 @@ export const comfyUI = {
|
||||
|
||||
// High-level generate
|
||||
async generate(workflow, options = {}) {
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError } = options;
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError, timeoutMs } = options;
|
||||
|
||||
this.generating = true;
|
||||
try {
|
||||
@@ -298,7 +332,7 @@ export const comfyUI = {
|
||||
this.currentPromptId = result.prompt_id;
|
||||
|
||||
// Track progress
|
||||
await this.trackProgress(result.prompt_id, onProgress);
|
||||
await this.trackProgress(result.prompt_id, onProgress, timeoutMs);
|
||||
|
||||
// Get history/results
|
||||
const history = await this.getHistory(result.prompt_id);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { showToast } from "./app.js";
|
||||
|
||||
// ── Latent Image preset state ─────────────────────────────
|
||||
const _LATENT_PRESET_KEY = "wfm_latent_presets";
|
||||
@@ -27,6 +28,114 @@ function _saveLatentPresets(list) {
|
||||
localStorage.setItem(_LATENT_PRESET_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
// ── I2I placeholder image (Input tab / Image sub-panel) ──────────────────
|
||||
// One shared default (color-generated or a fixed uploaded image), remembered across
|
||||
// sessions. Each LoadImage slot's own "Placeholder" button applies this same default
|
||||
// to just that slot — see renderImageTab.
|
||||
const _I2I_PLACEHOLDER_KEY = "wfm_i2i_placeholder";
|
||||
const _I2I_PLACEHOLDER_DEFAULT = { mode: "color", color: "#808080", width: 512, height: 512, imageFilename: "" };
|
||||
|
||||
function _loadI2IPlaceholderConfig() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_I2I_PLACEHOLDER_KEY);
|
||||
return raw ? { ..._I2I_PLACEHOLDER_DEFAULT, ...JSON.parse(raw) } : { ..._I2I_PLACEHOLDER_DEFAULT };
|
||||
} catch { return { ..._I2I_PLACEHOLDER_DEFAULT }; }
|
||||
}
|
||||
|
||||
function _saveI2IPlaceholderConfig(cfg) {
|
||||
localStorage.setItem(_I2I_PLACEHOLDER_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
// Renders a solid-color PNG on an offscreen canvas and returns it as a File, ready to
|
||||
// upload through comfyUI.uploadImage() the same way a picked/dropped file would be.
|
||||
function _generateColorImageFile(width, height, color) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, width || 512);
|
||||
canvas.height = Math.max(1, height || 512);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = color || "#808080";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { reject(new Error("Failed to render placeholder image")); return; }
|
||||
resolve(new File([blob], `placeholder_${canvas.width}x${canvas.height}.png`, { type: "image/png" }));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
// Wires the placeholder config panel at the top of the Image sub-panel (mode switch,
|
||||
// color/size fields, default-image drop zone). Every change is persisted immediately via
|
||||
// _saveI2IPlaceholderConfig so it's remembered next time the panel is rendered — this
|
||||
// panel is display-only config, it doesn't touch any workflow node itself; only each
|
||||
// slot's own Placeholder button (see renderImageTab) does that.
|
||||
function _wireI2IPlaceholderConfig(scopeEl) {
|
||||
const colorModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-color");
|
||||
const imageModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-image");
|
||||
const colorFields = scopeEl.querySelector("#wfm-i2i-ph-color-fields");
|
||||
const imageFields = scopeEl.querySelector("#wfm-i2i-ph-image-fields");
|
||||
const widthInput = scopeEl.querySelector("#wfm-i2i-ph-width");
|
||||
const heightInput = scopeEl.querySelector("#wfm-i2i-ph-height");
|
||||
const colorInput = scopeEl.querySelector("#wfm-i2i-ph-color");
|
||||
const dropZone = scopeEl.querySelector("#wfm-i2i-ph-drop");
|
||||
const fileInput = scopeEl.querySelector("#wfm-i2i-ph-file");
|
||||
const previewWrap = scopeEl.querySelector("#wfm-i2i-ph-preview-wrap");
|
||||
const previewImg = scopeEl.querySelector("#wfm-i2i-ph-preview-img");
|
||||
const statusEl = scopeEl.querySelector("#wfm-i2i-ph-status");
|
||||
if (!colorModeRadio) return; // no LoadImage slots rendered — panel doesn't exist
|
||||
|
||||
const setMode = (mode) => {
|
||||
if (colorFields) colorFields.style.display = mode === "color" ? "" : "none";
|
||||
if (imageFields) imageFields.style.display = mode === "image" ? "" : "none";
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), mode });
|
||||
};
|
||||
colorModeRadio.addEventListener("change", () => { if (colorModeRadio.checked) setMode("color"); });
|
||||
imageModeRadio?.addEventListener("change", () => { if (imageModeRadio.checked) setMode("image"); });
|
||||
|
||||
widthInput?.addEventListener("change", () => {
|
||||
const width = Math.max(1, parseInt(widthInput.value, 10) || 512);
|
||||
widthInput.value = width;
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), width });
|
||||
});
|
||||
heightInput?.addEventListener("change", () => {
|
||||
const height = Math.max(1, parseInt(heightInput.value, 10) || 512);
|
||||
heightInput.value = height;
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), height });
|
||||
});
|
||||
colorInput?.addEventListener("change", () => {
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), color: colorInput.value });
|
||||
});
|
||||
|
||||
// Default image: uploaded to ComfyUI immediately (its filename is what actually gets
|
||||
// reused later, not the raw file) so every slot's Placeholder button can apply it
|
||||
// without re-uploading.
|
||||
const applyDefaultImageFile = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
if (statusEl) statusEl.textContent = t("i2iPlaceholderUploading");
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), imageFilename: result.name });
|
||||
if (previewImg) previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
};
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyDefaultImageFile(fileInput.files[0]);
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyDefaultImageFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _buildPresetOptions(customPresets) {
|
||||
const customs = customPresets.map((p) =>
|
||||
`<option value="${p.w}x${p.h}">${p.w}x${p.h}</option>`
|
||||
@@ -48,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 };
|
||||
|
||||
@@ -344,11 +463,11 @@ export const comfyEditor = {
|
||||
return found || name;
|
||||
},
|
||||
|
||||
renderAll(analysis, workflow) {
|
||||
renderAll(analysis, workflow, opts = {}) {
|
||||
this.renderPromptTab(analysis, "wfm-gen-prompt-fields");
|
||||
this.renderImageTab(analysis, "wfm-gen-image-fields");
|
||||
this.renderModelTab(analysis, "wfm-gen-model-fields");
|
||||
this.renderLoraPane(analysis, "wfm-gen-lora-fields"); // async, fires independently
|
||||
this.renderLoraPane(analysis, "wfm-gen-lora-fields", opts); // async, fires independently
|
||||
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
|
||||
_syncRawJson();
|
||||
},
|
||||
@@ -367,7 +486,7 @@ export const comfyEditor = {
|
||||
<label>Positive Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${positiveNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
@@ -379,7 +498,7 @@ export const comfyEditor = {
|
||||
<label>Negative Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${negativeNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
@@ -508,10 +627,14 @@ export const comfyEditor = {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
const sections = [
|
||||
// 使用頻度の高い順: Checkpoint/VAE/Diffusion Model/Text Encoder は常時表示。
|
||||
// ControlNet/Hypernetwork は意図的に使う場面が限られるため折りたたみにする。
|
||||
const mainSections = [
|
||||
{ label: "Checkpoint", key: "checkpoints", nodes: analysis.checkpoint_nodes, inputKey: "ckpt_name" },
|
||||
{ label: "VAE", key: "vaes", nodes: analysis.vae_nodes, inputKey: "vae_name" },
|
||||
{ label: "Diffusion Model", key: "diffusionModels", nodes: analysis.diffusion_model_nodes, inputKey: "unet_name" },
|
||||
];
|
||||
const collapsibleSections = [
|
||||
{ label: "ControlNet", key: "controlNets", nodes: analysis.controlnet_nodes, inputKey: "control_net_name" },
|
||||
{
|
||||
label: "Hypernetwork", key: "hypernetworks", nodes: analysis.hypernetwork_nodes, inputKey: "hypernetwork_name",
|
||||
@@ -519,48 +642,70 @@ export const comfyEditor = {
|
||||
},
|
||||
];
|
||||
|
||||
el.innerHTML = sections
|
||||
.map((s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
const renderSectionBody = (s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label>${s.label}</label>
|
||||
<input type="text" class="wfm-input wfm-model-filter" placeholder="Filter..." data-target="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
return `
|
||||
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input wfm-model-filter" id="wfm-model-${s.key}-filter" placeholder="Filter..." data-target="wfm-model-${s.key}">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-model-${s.key}-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("") + '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
};
|
||||
|
||||
const mainHtml = mainSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label class="${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</label>
|
||||
${renderSectionBody(s)}
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const teHtml = '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
|
||||
const collapsibleHtml = collapsibleSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary ${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</summary>
|
||||
<div class="wfm-form-group" style="margin-bottom:0;">
|
||||
${renderSectionBody(s)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join("");
|
||||
|
||||
el.innerHTML = mainHtml + teHtml + collapsibleHtml;
|
||||
|
||||
// Filter inputs
|
||||
el.querySelectorAll(".wfm-model-filter").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const targetId = input.dataset.target;
|
||||
const targetId = input.dataset.target;
|
||||
const rebuild = () => {
|
||||
const select = document.getElementById(targetId);
|
||||
if (!select) return;
|
||||
const filter = input.value.toLowerCase();
|
||||
@@ -570,7 +715,9 @@ export const comfyEditor = {
|
||||
.filter((m) => m.toLowerCase().includes(filter))
|
||||
.map((m) => `<option value="${m}">${m}</option>`)
|
||||
.join("");
|
||||
});
|
||||
};
|
||||
input.addEventListener("input", rebuild);
|
||||
setupSearchClearBtn(input.id, `${input.id}-clear`, rebuild);
|
||||
});
|
||||
|
||||
// Apply buttons
|
||||
@@ -606,13 +753,104 @@ export const comfyEditor = {
|
||||
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
|
||||
},
|
||||
|
||||
async renderLoraPane(analysis, containerId) {
|
||||
// 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;
|
||||
|
||||
const loraNodes = analysis.lora_nodes || [];
|
||||
document.getElementById("wfm-gen-lora-col-title")?.classList.toggle("wfm-model-label-active", loraNodes.length > 0);
|
||||
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);
|
||||
|
||||
// ワークフロー新規読み込み時のみ: LoraManagerノードのloras/text状態をスナップショットし、
|
||||
// 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;
|
||||
const wfNode = comfyUI.currentWorkflow?.[n.id];
|
||||
if (!wfNode?.inputs) continue;
|
||||
comfyUI.loraManagerSnapshots[n.id] = {
|
||||
loras: Array.isArray(wfNode.inputs.loras?.__value__) ? JSON.parse(JSON.stringify(wfNode.inputs.loras.__value__)) : [],
|
||||
text: wfNode.inputs.text ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
const nodeOpts = loraNodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
@@ -637,26 +875,53 @@ export const comfyEditor = {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Stack group, metadata, CivitAI cache in parallel
|
||||
// Stack表示の取得元: ON(Use Stack Group)ならModelsタブの登録済みStackグループ、
|
||||
// OFF(既定)ならLoraManagerノードに実際に設定されているLoRAをそのまま表示する。
|
||||
let stackModels = [];
|
||||
let metadata = {};
|
||||
let civitaiCache = {};
|
||||
const useWorkflowLoraState = hasLoraManager && !stackModeChecked;
|
||||
try {
|
||||
const [grpRes, metaRes, civRes] = await Promise.all([
|
||||
fetch("/api/wfm/models/groups?type=lora"),
|
||||
const [metaRes, civRes] = await Promise.all([
|
||||
fetch("/api/wfm/models/metadata"),
|
||||
fetch("/api/wfm/models/civitai/cache"),
|
||||
]);
|
||||
const groups = grpRes.ok ? await grpRes.json() : {};
|
||||
stackModels = groups["Stack"] || [];
|
||||
metadata = metaRes.ok ? await metaRes.json() : {};
|
||||
civitaiCache = civRes.ok ? await civRes.json() : {};
|
||||
} catch { /* ignore */ }
|
||||
|
||||
stackModels.forEach((m) => {
|
||||
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
|
||||
if (_stackActive[m] === undefined) _stackActive[m] = true;
|
||||
});
|
||||
if (stackModeChecked) {
|
||||
try {
|
||||
const grpRes = await fetch("/api/wfm/models/groups?type=lora");
|
||||
const groups = grpRes.ok ? await grpRes.json() : {};
|
||||
stackModels = groups["Stack"] || [];
|
||||
} catch { /* ignore */ }
|
||||
stackModels.forEach((m) => {
|
||||
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
|
||||
if (_stackActive[m] === undefined) _stackActive[m] = true;
|
||||
});
|
||||
} else if (useWorkflowLoraState) {
|
||||
const wfNode = comfyUI.currentWorkflow?.[defaultStackTarget];
|
||||
const wfLoras = wfNode?.inputs?.loras?.__value__;
|
||||
// resetStackMode(新規読み込み)/syncFromWorkflow(チェックボックスOFF切替直後)の時だけ
|
||||
// ワークフローの実データで強制上書きする。タブ切替等の単なる再描画では、
|
||||
// 既にメモリ上に値があればそれを維持し、Stackパネルでの一時的なON/OFF・強度編集を
|
||||
// 再描画のたびに消してしまわないようにする。
|
||||
const shouldSync = opts.resetStackMode || opts.syncFromWorkflow;
|
||||
if (Array.isArray(wfLoras)) {
|
||||
stackModels = wfLoras.map((l) => {
|
||||
const found = (this.models.loras || []).find((m) => _loraBasename(m) === l.name);
|
||||
const full = found || l.name;
|
||||
if (shouldSync || _stackStrengths[full] === undefined) {
|
||||
_stackStrengths[full] = { m: parseFloat(l.strength) || 1.0, c: parseFloat(l.clipStrength ?? l.strength) || 1.0 };
|
||||
}
|
||||
if (shouldSync || _stackActive[full] === undefined) {
|
||||
_stackActive[full] = l.active !== false;
|
||||
}
|
||||
return full;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build Stack trigger words
|
||||
const activeTriggerWords = [];
|
||||
@@ -673,7 +938,9 @@ export const comfyEditor = {
|
||||
? activeTriggerWords.map((w) => `<span class="wfm-lora-trigger-word">${w}</span>`).join(" ")
|
||||
: `<span style="color:var(--wfm-text-secondary);font-size:12px;">—</span>`;
|
||||
const stackLoraSyntax = _buildLoraSyntax(stackModels);
|
||||
const allActive = stackModels.every((m) => _stackActive[m] !== false);
|
||||
// stackModelsが空だと every() が空配列に対し true を返す(空虚な真)ため、
|
||||
// 何も無いのに「トグルオール」チェックボックスがON表示になるのを防ぐ
|
||||
const allActive = stackModels.length > 0 && stackModels.every((m) => _stackActive[m] !== false);
|
||||
const anyActive = stackModels.some((m) => _stackActive[m] !== false);
|
||||
|
||||
const stackModelRows = stackModels.map((m) => {
|
||||
@@ -691,7 +958,10 @@ export const comfyEditor = {
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
|
||||
let _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
|
||||
// 新規読み込み時、LoraManagerノードに実際に複数LoRAが設定されている場合は
|
||||
// Single(1件しか表示できない)ではなくStackタブを初期表示にする
|
||||
if (opts.resetStackMode && useWorkflowLoraState && stackModels.length > 1) _prevActiveTab = "stack";
|
||||
// applyToGenUIで設定したSingle表示の状態を保存(再描画後に復元)
|
||||
const _prevSingleSyntax = document.getElementById("wfm-lora-single-syntax")?.textContent || "";
|
||||
const _prevSingleTriggers = document.getElementById("wfm-lora-single-triggers")?.innerHTML || "";
|
||||
@@ -700,11 +970,19 @@ export const comfyEditor = {
|
||||
<div class="wfm-lora-tab-header">
|
||||
<button class="wfm-lora-tab-btn active" data-tab="single">Single</button>
|
||||
<button class="wfm-lora-tab-btn" data-tab="stack">Stack</button>
|
||||
${hasLoraManager ? `
|
||||
<label class="wfm-lora-stack-mode-label" style="margin-left:auto;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;" title="ON: 登録済みStackグループを読み込む / OFF: ワークフローに実際に設定されているLoRAを表示・編集する">
|
||||
<input type="checkbox" id="wfm-lora-stack-mode-toggle" ${stackModeChecked ? "checked" : ""}>
|
||||
Use Stack Group
|
||||
</label>` : ""}
|
||||
</div>
|
||||
|
||||
<!-- Single tab -->
|
||||
<div class="wfm-lora-tab-content" id="wfm-lora-panel-single">
|
||||
<input type="text" class="wfm-input" id="wfm-lora-filter" placeholder="Filter...">
|
||||
<div class="wfm-search-wrap" style="width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input" id="wfm-lora-filter" placeholder="Filter...">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-lora-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select" id="wfm-lora-select">
|
||||
${loras.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
@@ -731,6 +1009,7 @@ export const comfyEditor = {
|
||||
|
||||
<!-- Stack tab -->
|
||||
<div class="wfm-lora-tab-content" id="wfm-lora-panel-stack" style="display:none;">
|
||||
${hasLoraManager ? `<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:6px;">${useWorkflowLoraState ? "Showing: LoRAs currently in workflow" : "Showing: registered Stack group"}</div>` : ""}
|
||||
<div class="wfm-lora-stack-header">
|
||||
<select class="wfm-select" id="wfm-lora-stack-target" style="flex:1;min-width:0;">${stackTargetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-lora-stack-apply" title="Apply Stack to node and sync to Positive prompt">Apply</button>
|
||||
@@ -765,7 +1044,7 @@ export const comfyEditor = {
|
||||
<div id="wfm-lora-stack-triggers" class="wfm-lora-stack-triggers">${stackTriggerHtml}</div>
|
||||
</div>
|
||||
<div class="wfm-lora-stack-models">
|
||||
${stackModelRows || `<p class="wfm-placeholder">No models in Stack group</p>`}
|
||||
${stackModelRows || `<p class="wfm-placeholder">${useWorkflowLoraState ? "No LoRAs set on this node" : "No models in Stack group"}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -803,9 +1082,63 @@ export const comfyEditor = {
|
||||
document.getElementById("wfm-lora-panel-stack").style.display = "";
|
||||
}
|
||||
|
||||
// ── Stackグループ使用チェックボックス ─────────────────
|
||||
// ON: LoraManagerノードへStackグループを(クリアしてから)読み込む(既存のStack Applyを流用)
|
||||
// OFF: ワークフロー読み込み時点のloras/text状態へ(クリアしてから)戻す
|
||||
document.getElementById("wfm-lora-stack-mode-toggle")?.addEventListener("change", async (e) => {
|
||||
const nodeId = document.getElementById("wfm-lora-stack-target")?.value || defaultStackTarget;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
|
||||
if (e.target.checked) {
|
||||
// stackModelsはこの時点では「ワークフロー state」を指したままのため、
|
||||
// 先に再描画してStackグループを取得させてからApplyする
|
||||
await this.renderLoraPane(analysis, containerId);
|
||||
document.getElementById("wfm-lora-stack-apply")?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const snap = comfyUI.loraManagerSnapshots?.[nodeId] || { loras: [], text: "" };
|
||||
comfyUI.currentWorkflow[nodeId].inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras)) };
|
||||
comfyUI.currentWorkflow[nodeId].inputs.text = snap.text;
|
||||
_syncRawJson();
|
||||
|
||||
// ON時にPositiveプロンプトへ挿入されたStack由来の<lora:...>構文とトリガーワードを除去する
|
||||
// (新規に追加はしない。ワークフロー本来のプロンプトへ戻すだけ)
|
||||
const posTextarea = document.getElementById("wfm-prompt-pos-text");
|
||||
if (posTextarea && stackModels.length > 0) {
|
||||
let cleaned = posTextarea.value;
|
||||
for (const m of stackModels) {
|
||||
const stem = _loraBasename(m).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
cleaned = cleaned.replace(new RegExp(`,?\\s*<lora:${stem}:[^>]*>`, "gi"), "");
|
||||
}
|
||||
const stackWords = new Set();
|
||||
stackModels.forEach((m) => {
|
||||
const sha = (metadata[m] || {}).sha256;
|
||||
const civInfo = sha && civitaiCache[sha];
|
||||
civInfo?.trainedWords?.forEach((w) => stackWords.add(w.trim().toLowerCase()));
|
||||
});
|
||||
if (stackWords.size > 0) {
|
||||
cleaned = cleaned.split(",").map((p) => p.trim()).filter((p) => p && !stackWords.has(p.toLowerCase())).join(", ");
|
||||
}
|
||||
posTextarea.value = cleaned.replace(/,\s*$/, "").trim();
|
||||
|
||||
const posNodeId = document.getElementById("wfm-prompt-pos-target")?.value;
|
||||
if (posNodeId && comfyUI.currentWorkflow?.[posNodeId]) {
|
||||
const promptNode = comfyUI.currentAnalysis?.prompt_nodes?.find((n) => String(n.id) === String(posNodeId));
|
||||
comfyUI.currentWorkflow[posNodeId].inputs[promptNode?.textKey || "text"] = posTextarea.value;
|
||||
_syncRawJson();
|
||||
}
|
||||
}
|
||||
|
||||
// syncFromWorkflow: OFFへ戻した直後は、たった今書き戻したloras/text状態と
|
||||
// Stackパネルの強度/有効状態表示を一致させるため強制同期する
|
||||
this.renderLoraPane(analysis, containerId, { syncFromWorkflow: true });
|
||||
});
|
||||
|
||||
// ── Single: filter ───────────────────────────────────
|
||||
document.getElementById("wfm-lora-filter")?.addEventListener("input", (e) => {
|
||||
const filter = e.target.value.toLowerCase();
|
||||
const loraFilterInput = document.getElementById("wfm-lora-filter");
|
||||
const rebuildLoraSingleList = () => {
|
||||
const filter = loraFilterInput?.value.toLowerCase() || "";
|
||||
const select = document.getElementById("wfm-lora-select");
|
||||
if (!select) return;
|
||||
select.innerHTML = loras
|
||||
@@ -813,7 +1146,9 @@ export const comfyEditor = {
|
||||
.map((m) => `<option value="${m}">${m}</option>`)
|
||||
.join("");
|
||||
_refreshLoraSingleDynamic(metadata, civitaiCache);
|
||||
});
|
||||
};
|
||||
loraFilterInput?.addEventListener("input", rebuildLoraSingleList);
|
||||
setupSearchClearBtn("wfm-lora-filter", "wfm-lora-filter-clear", rebuildLoraSingleList);
|
||||
|
||||
// ── Single: update SYNTAX/TRIGGERS on model or strength change ──
|
||||
document.getElementById("wfm-lora-select")?.addEventListener("change", () => _refreshLoraSingleDynamic(metadata, civitaiCache));
|
||||
@@ -830,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
|
||||
@@ -962,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
|
||||
@@ -1030,23 +1375,39 @@ export const comfyEditor = {
|
||||
const sampler = analysis.sampler_nodes?.[0];
|
||||
const latent = analysis.latent_nodes?.[0];
|
||||
|
||||
// "Advanced Sampling" workflows (Flux.1/Flux.2, SD3.5, Chroma...) spread seed/steps/cfg/
|
||||
// sampler/scheduler/denoise across RandomNoise + CFGGuider + KSamplerSelect + a *Scheduler
|
||||
// node instead of one KSampler. sampler.*NodeId (set in analyzeWorkflow) says which real
|
||||
// node each field writes back to; null/"" means that workflow has no such control at all
|
||||
// (e.g. Flux2Scheduler has no named "scheduler" combo) so the field is disabled here.
|
||||
const schedulerAvailable = !!sampler && sampler.schedulerNodeId != null;
|
||||
const denoiseAvailable = !!sampler && sampler.denoiseNodeId != null;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;padding-right:14px;border-right:1px solid var(--wfm-border);">
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">${sampler?.advanced ? "Sampler (Advanced)" : "KSampler"}</h3>
|
||||
${sampler ? `
|
||||
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}" data-seed-key="${sampler.seedKey || "seed"}">
|
||||
<input type="hidden" id="wfm-settings-sampler-id"
|
||||
data-seed-key="${sampler.seedKey || "seed"}"
|
||||
data-seed-node-id="${sampler.seedNodeId ?? sampler.id}"
|
||||
data-steps-node-id="${sampler.stepsNodeId ?? sampler.id}"
|
||||
data-cfg-key="${sampler.cfgKey || "cfg"}"
|
||||
data-cfg-node-id="${sampler.cfgNodeId ?? sampler.id}"
|
||||
data-sampler-node-id="${sampler.samplerNodeId ?? sampler.id}"
|
||||
data-scheduler-node-id="${sampler.schedulerNodeId ?? ""}"
|
||||
data-denoise-node-id="${sampler.denoiseNodeId ?? ""}">
|
||||
<div class="wfm-form-group">
|
||||
<label>Seed</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? -1}">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? ""}" placeholder="${sampler.seed === undefined ? "linked" : ""}">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Steps</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? 20}" min="1" max="200">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? ""}" placeholder="${sampler.steps === undefined ? "linked" : ""}" min="1" max="200">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>CFG</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? 7}" step="0.5" min="0">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? ""}" placeholder="${sampler.cfg === undefined ? "linked" : ""}" step="0.5" min="0">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Sampler</label>
|
||||
@@ -1056,13 +1417,15 @@ export const comfyEditor = {
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Scheduler</label>
|
||||
<select class="wfm-select" id="wfm-settings-scheduler">
|
||||
${this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")}
|
||||
<select class="wfm-select" id="wfm-settings-scheduler" ${schedulerAvailable ? "" : "disabled"}>
|
||||
${schedulerAvailable
|
||||
? this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")
|
||||
: `<option value="">N/A</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Denoise</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? 1.0}" step="0.05" min="0" max="1">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? ""}" placeholder="${denoiseAvailable && sampler.denoise === undefined ? "linked" : ""}" step="0.05" min="0" max="1" ${denoiseAvailable ? "" : "disabled"}>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
|
||||
@@ -1073,17 +1436,24 @@ export const comfyEditor = {
|
||||
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
|
||||
<div class="wfm-form-group">
|
||||
<label>Width</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? 512}" step="8" min="64">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? ""}" placeholder="${latent.width === undefined ? "linked" : ""}" step="8" min="64">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Height</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? 512}" step="8" min="64">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? ""}" placeholder="${latent.height === undefined ? "linked" : ""}" step="8" min="64">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<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;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
|
||||
${[512, 768, 1024, 2048].map((s) => `<button class="wfm-btn wfm-btn-sm wfm-latent-sq-btn" data-size="${s}">${s}</button>`).join("")}
|
||||
@@ -1109,17 +1479,21 @@ export const comfyEditor = {
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", (e) => {
|
||||
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
// KSamplerAdvanced uses noise_seed; fall back to seed
|
||||
const seedKey = document.getElementById("wfm-settings-sampler-id")?.dataset?.seedKey || "seed";
|
||||
inputs[seedKey] = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
|
||||
inputs.steps = parseInt(document.getElementById("wfm-settings-steps")?.value) || 20;
|
||||
inputs.cfg = parseFloat(document.getElementById("wfm-settings-cfg")?.value) || 7;
|
||||
inputs.sampler_name = document.getElementById("wfm-settings-sampler-name")?.value;
|
||||
inputs.scheduler = document.getElementById("wfm-settings-scheduler")?.value;
|
||||
inputs.denoise = parseFloat(document.getElementById("wfm-settings-denoise")?.value) || 1.0;
|
||||
const ds = document.getElementById("wfm-settings-sampler-id")?.dataset || {};
|
||||
const write = (nodeId, key, value) => {
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
comfyUI.currentWorkflow[nodeId].inputs[key] = value;
|
||||
};
|
||||
const seedEl = document.getElementById("wfm-settings-seed");
|
||||
if (seedEl?.value !== "") write(ds.seedNodeId, ds.seedKey || "seed", parseInt(seedEl.value) || -1);
|
||||
const stepsEl = document.getElementById("wfm-settings-steps");
|
||||
if (stepsEl?.value !== "") write(ds.stepsNodeId, "steps", parseInt(stepsEl.value) || 20);
|
||||
const cfgEl = document.getElementById("wfm-settings-cfg");
|
||||
if (cfgEl?.value !== "") write(ds.cfgNodeId, ds.cfgKey || "cfg", parseFloat(cfgEl.value) || 7);
|
||||
write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
|
||||
if (ds.schedulerNodeId) write(ds.schedulerNodeId, "scheduler", document.getElementById("wfm-settings-scheduler")?.value);
|
||||
const denoiseEl = document.getElementById("wfm-settings-denoise");
|
||||
if (ds.denoiseNodeId && denoiseEl?.value !== "") write(ds.denoiseNodeId, "denoise", parseFloat(denoiseEl.value) || 1.0);
|
||||
_syncRawJson();
|
||||
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
|
||||
});
|
||||
@@ -1128,9 +1502,14 @@ export const comfyEditor = {
|
||||
const nodeId = document.getElementById("wfm-settings-latent-id")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
inputs.width = parseInt(document.getElementById("wfm-settings-width")?.value) || 512;
|
||||
inputs.height = parseInt(document.getElementById("wfm-settings-height")?.value) || 512;
|
||||
const wEl = document.getElementById("wfm-settings-width");
|
||||
const hEl = document.getElementById("wfm-settings-height");
|
||||
// Blank = leave the (linked) width/height alone instead of clobbering it with a default
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1212,8 +1591,34 @@ export const comfyEditor = {
|
||||
const slots = loadNodes.slice(0, 4);
|
||||
// Mask Editor One ノード(画像+マスクを内包、常に両方必須)— 別枠で最大2件表示
|
||||
const meoSlots = meoNodes.slice(0, 2);
|
||||
const phCfg = _loadI2IPlaceholderConfig();
|
||||
const isColorMode = phCfg.mode !== "image";
|
||||
el.innerHTML = `
|
||||
${slots.length === 0 ? "" : `
|
||||
<details class="wfm-settings-section wfm-i2i-placeholder-config">
|
||||
<summary class="wfm-settings-summary">${t("i2iPlaceholderTitle")}</summary>
|
||||
<div class="wfm-i2i-placeholder-mode">
|
||||
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-color" value="color" ${isColorMode ? "checked" : ""}> ${t("i2iPlaceholderColor")}</label>
|
||||
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-image" value="image" ${isColorMode ? "" : "checked"}> ${t("i2iPlaceholderImage")}</label>
|
||||
</div>
|
||||
<div id="wfm-i2i-ph-color-fields" class="wfm-i2i-placeholder-fields wfm-lab-modal-inline" style="${isColorMode ? "" : "display:none;"}">
|
||||
<div><label>${t("i2iPlaceholderWidth")}</label><input type="number" min="1" id="wfm-i2i-ph-width" class="wfm-input" value="${Number(phCfg.width) || 512}"></div>
|
||||
<div><label>${t("i2iPlaceholderHeight")}</label><input type="number" min="1" id="wfm-i2i-ph-height" class="wfm-input" value="${Number(phCfg.height) || 512}"></div>
|
||||
<div><label>${t("i2iPlaceholderColorLabel")}</label><input type="color" id="wfm-i2i-ph-color" class="wfm-input" value="${escapeHtml(/^#[0-9a-fA-F]{6}$/.test(phCfg.color) ? phCfg.color : "#808080")}"></div>
|
||||
</div>
|
||||
<div id="wfm-i2i-ph-image-fields" class="wfm-i2i-placeholder-fields" style="${isColorMode ? "display:none;" : ""}">
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-i2i-ph-preview-wrap" style="${phCfg.imageFilename ? "" : "display:none;"}">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-i2i-ph-preview-img" src="${phCfg.imageFilename ? `/view?filename=${encodeURIComponent(phCfg.imageFilename)}&type=input` : ""}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-i2i-ph-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
${t("i2iPlaceholderDropImage")}
|
||||
<input type="file" accept="image/*" id="wfm-i2i-ph-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-ph-status"></span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="wfm-i2i-grid">
|
||||
${slots.map((node, i) => `
|
||||
<div class="wfm-i2i-slot" data-slot="${i}" data-node-id="${node.id}">
|
||||
@@ -1247,8 +1652,10 @@ export const comfyEditor = {
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-i2i-apply" data-slot="${i}" disabled>Apply</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-placeholder-btn" data-slot="${i}">${t("i2iPlaceholderApply")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-clear" data-slot="${i}">${t("clear")}</button>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-status-${i}"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1409,8 +1816,64 @@ export const comfyEditor = {
|
||||
applyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear: reset this slot's staged/preview state back to empty. Doesn't touch
|
||||
// the workflow node itself — LoadImage always needs *some* valid filename, so
|
||||
// there's nothing meaningful to "unset" there; this just lets the user cancel
|
||||
// a pending pick and start over.
|
||||
const clearBtn = el.querySelector(`.wfm-i2i-clear[data-slot="${i}"]`);
|
||||
clearBtn?.addEventListener("click", () => {
|
||||
pendingFile = null;
|
||||
previewImg.src = "";
|
||||
previewWrap.style.display = "none";
|
||||
filenameEl.textContent = "";
|
||||
if (fileInput) fileInput.value = "";
|
||||
applyBtn.disabled = !pendingMaskFile;
|
||||
statusEl.textContent = "";
|
||||
});
|
||||
|
||||
// Placeholder: generate (color mode) or reuse (image mode) the shared default
|
||||
// configured at the top of the panel, and replace this slot's image
|
||||
// immediately — no separate Apply click needed, matching the "one button,
|
||||
// one slot" behavior described for this feature.
|
||||
const placeholderBtn = el.querySelector(`.wfm-i2i-placeholder-btn[data-slot="${i}"]`);
|
||||
placeholderBtn?.addEventListener("click", async () => {
|
||||
const cfg = _loadI2IPlaceholderConfig();
|
||||
placeholderBtn.disabled = true;
|
||||
statusEl.textContent = t("i2iPlaceholderGenerating");
|
||||
try {
|
||||
let filename;
|
||||
if (cfg.mode === "image") {
|
||||
if (!cfg.imageFilename) throw new Error(t("i2iPlaceholderNoDefaultImage"));
|
||||
filename = cfg.imageFilename;
|
||||
} else {
|
||||
const file = await _generateColorImageFile(cfg.width, cfg.height, cfg.color);
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
filename = result.name;
|
||||
}
|
||||
if (comfyUI.currentWorkflow?.[node.id]) {
|
||||
comfyUI.currentWorkflow[node.id].inputs.image = filename;
|
||||
}
|
||||
pendingFile = null;
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||||
previewWrap.style.display = "";
|
||||
filenameEl.textContent = filename;
|
||||
applyBtn.disabled = !pendingMaskFile;
|
||||
statusEl.textContent = `✓ ${filename}`;
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
_syncRawJson();
|
||||
} catch (err) {
|
||||
statusEl.textContent = `✗ ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
} finally {
|
||||
placeholderBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
_wireI2IPlaceholderConfig(el);
|
||||
|
||||
// Initialize each Mask Editor One slot(画像+マスク両方が揃って初めてApply可能)
|
||||
meoSlots.forEach((node, i) => {
|
||||
const fileInput = document.getElementById(`wfm-meo-file-${i}`);
|
||||
@@ -1626,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");
|
||||
@@ -1752,7 +2284,7 @@ async function _initTextEncoderSection(analysis, textEncoders, containerEl) {
|
||||
const deviceOpts = ["default", "cpu"].map(d => `<option value="${d}" ${d === currentDevice ? "selected" : ""}>${d}</option>`).join("");
|
||||
|
||||
section.innerHTML = `
|
||||
<label>Text Encoder</label>
|
||||
<label class="wfm-model-label-active">Text Encoder</label>
|
||||
<input type="text" class="wfm-input" id="wfm-te-filter" placeholder="Filter..." style="margin-bottom:4px;">
|
||||
<select class="wfm-select" id="wfm-te-clip1" style="margin-bottom:4px;">${clip1Opts}</select>
|
||||
${isDual ? `<select class="wfm-select" id="wfm-te-clip2" style="margin-bottom:4px;">${clip2Opts}</select>` : ""}
|
||||
|
||||
@@ -18,31 +18,42 @@ let _lastBypassedNodes = [];
|
||||
let _lastMutedNodes = [];
|
||||
|
||||
/**
|
||||
* Trace a link's source through any Bypass-mode (mode 4) nodes, matching each bypassed
|
||||
* node's output slot to an input slot of the same type and following the connection
|
||||
* upstream (recursively, for chained bypasses). Mirrors ComfyUI's own Bypass behavior,
|
||||
* where a bypassed node is removed but its wires are reconnected end-to-end.
|
||||
* Trace a link's source through any Bypass-mode (mode 4) nodes AND Reroute nodes, matching each
|
||||
* one's output slot to an input slot of the same type and following the connection upstream
|
||||
* (recursively, for chains of either/both). Mirrors ComfyUI's own Bypass behavior, where a
|
||||
* bypassed node is removed but its wires are reconnected end-to-end — Reroute needs the identical
|
||||
* treatment because it's a UI-only passthrough node with no backend node class at all: leaving it
|
||||
* in the API payload makes ComfyUI reject the whole prompt with "missing_node_type" (Reroute
|
||||
* always has to be resolved through, regardless of its mode).
|
||||
* Returns null when no pass-through source exists (the matching input is unconnected),
|
||||
* in which case the caller should treat the input as unlinked.
|
||||
*/
|
||||
function _resolveBypassSource(nodeById, linkMap, nodeId, slot, visited) {
|
||||
const idStr = String(nodeId);
|
||||
const node = nodeById[idStr];
|
||||
if (!node || node.mode !== 4) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass chain guard
|
||||
if (!node || (node.mode !== 4 && node.type !== "Reroute")) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass/reroute chain guard
|
||||
|
||||
visited.add(idStr);
|
||||
const outputs = node.outputs || [];
|
||||
const inputs = node.inputs || [];
|
||||
const outType = outputs[slot]?.type;
|
||||
|
||||
let inIdx = -1;
|
||||
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
|
||||
inIdx = slot;
|
||||
let inIdx;
|
||||
if (node.type === "Reroute") {
|
||||
// Reroute is always exactly one input -> one output. Its stored input type is commonly
|
||||
// the generic "*" even when the output shows the propagated concrete type (e.g. MODEL),
|
||||
// so the type-match search used for Bypass below would fail to find it — just take the
|
||||
// sole input.
|
||||
inIdx = 0;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
const outType = outputs[slot]?.type;
|
||||
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
|
||||
inIdx = slot;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
}
|
||||
}
|
||||
if (inIdx === -1) return null;
|
||||
if (inIdx === -1 || inIdx === undefined) return null;
|
||||
|
||||
const upstream = linkMap[idStr][inIdx];
|
||||
if (!upstream) return null;
|
||||
@@ -64,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 [];
|
||||
@@ -74,31 +114,20 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// Required inputs: skip types that are linked (have uppercase first letter = node output types)
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
// forceInput: true — link-only socket even though its type is a widget type
|
||||
// (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(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 (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (_resolveWidgetType(type, spec)) names.push(name);
|
||||
}
|
||||
|
||||
return names;
|
||||
@@ -117,25 +146,15 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
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(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);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
const resolved = _resolveWidgetType(type, spec);
|
||||
if (resolved) types.push(resolved);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
@@ -143,6 +162,13 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
/**
|
||||
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
|
||||
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
|
||||
*
|
||||
* Includes both required AND optional sub-fields: ComfyUI serializes every sub-field of the
|
||||
* selected option into widgets_values regardless of which bucket it's declared in (e.g.
|
||||
* TextGenerate's "on" option puts temperature/top_k/.../seed under required but
|
||||
* presence_penalty under optional — all 7 still occupy a widgets_values slot). Dropping the
|
||||
* optional ones here under-counts how many slots the combo actually consumed, misaligning
|
||||
* every widget declared after it in the node's schema.
|
||||
*/
|
||||
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
|
||||
const info = objectInfo[classType];
|
||||
@@ -150,7 +176,10 @@ function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey
|
||||
const options = spec?.[1]?.options;
|
||||
if (!Array.isArray(options)) return [];
|
||||
const opt = options.find((o) => o.key === selectedKey) || options[0];
|
||||
return Object.keys(opt?.inputs?.required || {});
|
||||
return [
|
||||
...Object.keys(opt?.inputs?.required || {}),
|
||||
...Object.keys(opt?.inputs?.optional || {}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +199,140 @@ function _isExtraWidgetValue(val, expectedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a subgraph-template node's widget input (by name) is actually wired end-to-end —
|
||||
* either redirected from an outer parent-graph link, or wired to another internal node within the
|
||||
* same subgraph template (boundary links from the subgraph's own input ports, origin_id === -10,
|
||||
* don't count: those resolve to a plain injected widget value instead, handled separately).
|
||||
*/
|
||||
function _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name) {
|
||||
const inputDef = (remappedNode.inputs || []).find((i) => i.widget && i.name === name);
|
||||
if (!inputDef) return false;
|
||||
const slotIdx = remappedNode.inputs.indexOf(inputDef);
|
||||
if (redirectedTargets.has(`${remappedNode.id}:${slotIdx}`)) return true;
|
||||
return inputDef.link != null && (sgDef.links || []).some(
|
||||
(l) => l.id === inputDef.link && String(l.target_id) === origInternalId && l.target_slot === slotIdx && l.origin_id !== -10
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate consuming widgets_values against object_info's widget name/type order, accounting for
|
||||
* frontend-only "control_after_generate" extras (e.g. "fixed" after a seed) that occupy one extra
|
||||
* slot and shift every later index by one. When skipLinked is true, widgets the caller's
|
||||
* isLinkedName() flags as linked are treated as consuming zero widgets_values entries (the
|
||||
* "modern" ComfyUI format); with it false, every widget is assumed present (the "legacy full"
|
||||
* format some subgraph templates store). Returns each widget's actual widgets_values index
|
||||
* (or -1 if the array ran out), plus consumedIndices — every index a widget's own turn advanced
|
||||
* past (normally just its value, but two when a control_after_generate extra was skipped) — and
|
||||
* how many entries were consumed in total (the latter is how callers detect which of the two
|
||||
* formats a given widgets_values array actually follows).
|
||||
*
|
||||
* consumedIndices matters because convertUiToApi's own widget-mapping loop only skips a
|
||||
* control_after_generate extra while advancing past the *next* widget's turn (see its
|
||||
* `!linkedSlotNames.has(name)` branch) — if that next widget turns out to be linked, the loop
|
||||
* `continue`s before ever looking at the extra, leaving it unconsumed and silently misread as the
|
||||
* value of whatever non-linked widget comes after. Stripping a linked widget's position alone
|
||||
* (without its swallowed extra) reproduces exactly that bug one level up, in the injection step.
|
||||
*
|
||||
* comboExpander(name, selectedKey) — when a widget's type is COMFY_DYNAMICCOMBO_V3, its own turn
|
||||
* only consumes the *selector* slot (the chosen option's key); the selected option's sub-fields
|
||||
* (e.g. TextGenerate's sampling_mode → temperature/top_k/.../seed/presence_penalty) each occupy
|
||||
* one more widgets_values slot right after it. Without expanding those here too, every widget
|
||||
* declared later in the node's schema (e.g. TextGenerate's "thinking") gets matched against a
|
||||
* slot that's actually still inside the combo's sub-field span, corrupting both values.
|
||||
*/
|
||||
function _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, skipLinked, comboExpander) {
|
||||
let idx = 0;
|
||||
const positions = {};
|
||||
const consumedIndices = {};
|
||||
for (let nIdx = 0; nIdx < widgetNames.length; nIdx++) {
|
||||
const name = widgetNames[nIdx];
|
||||
if (skipLinked && isLinkedName(name)) {
|
||||
positions[name] = -1;
|
||||
consumedIndices[name] = [];
|
||||
continue;
|
||||
}
|
||||
if (idx >= widgetsValues.length) {
|
||||
positions[name] = -1;
|
||||
consumedIndices[name] = [];
|
||||
continue;
|
||||
}
|
||||
const consumed = [idx];
|
||||
let p = idx;
|
||||
idx++;
|
||||
if (_isExtraWidgetValue(widgetsValues[p], widgetTypes[nIdx]) && idx < widgetsValues.length) {
|
||||
consumed.push(idx);
|
||||
p = idx;
|
||||
idx++;
|
||||
}
|
||||
if (widgetTypes[nIdx] === "COMFY_DYNAMICCOMBO_V3" && comboExpander) {
|
||||
const subNames = comboExpander(name, widgetsValues[p]);
|
||||
for (let s = 0; s < subNames.length && idx < widgetsValues.length; s++) {
|
||||
consumed.push(idx);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
positions[name] = p;
|
||||
consumedIndices[name] = consumed;
|
||||
}
|
||||
return { positions, consumedIndices, consumed: idx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a subgraph-template node's widgets_values entries belonging to widget slots that are
|
||||
* actually linked (see _isLinkedWidgetName), so convertUiToApi's own widget-mapping loop — which
|
||||
* assumes the "modern" format where linked widgets contribute zero widgets_values entries — maps
|
||||
* every remaining widget to the right value instead of drifting by however many linked entries
|
||||
* were left in place. No-op when widgets_values already follows the modern format (nothing to
|
||||
* strip) or the node has no widget inputs at all.
|
||||
*/
|
||||
function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets) {
|
||||
const widgetsValues = remappedNode.widgets_values;
|
||||
const allWidgetInputs = (remappedNode.inputs || []).filter((i) => i.widget);
|
||||
if (!widgetsValues || allWidgetInputs.length === 0) return;
|
||||
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
|
||||
// If skipping linked widgets already accounts for every entry, widgets_values is already in
|
||||
// the modern (no dangling linked entries) format — nothing to strip.
|
||||
const skipSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
if (skipSim.consumed === widgetsValues.length) return;
|
||||
|
||||
// Otherwise it's "legacy full": recompute with every widget present to find exactly which
|
||||
// indices the linked ones occupy — including any control_after_generate extra a linked
|
||||
// widget's own turn would have consumed, which must be dropped too (see _simulateWidgetValues).
|
||||
const fullSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, false, comboExpander);
|
||||
const linkedIdx = new Set();
|
||||
for (const name of widgetNames) {
|
||||
if (isLinkedName(name)) {
|
||||
for (const i of fullSim.consumedIndices[name] || []) linkedIdx.add(i);
|
||||
}
|
||||
}
|
||||
if (linkedIdx.size > 0) {
|
||||
remappedNode.widgets_values = widgetsValues.filter((_, i) => !linkedIdx.has(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the widgets_values array index that corresponds to a given widget input name on a
|
||||
* subgraph-template node, for injecting an outer subgraph-node's un-linked widget value into it.
|
||||
* Assumes _stripLegacyLinkedWidgetValues has already normalized widgets_values to the modern
|
||||
* format (linked widgets contribute zero entries), matching what convertUiToApi's own
|
||||
* widget-mapping loop expects.
|
||||
*/
|
||||
function _findInjectedWidgetIndex(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetName) {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const widgetsValues = remappedNode.widgets_values || [];
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
const sim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
return sim.positions[targetName] ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a subgraph-containing UI workflow into a plain UI workflow.
|
||||
*
|
||||
@@ -226,16 +389,20 @@ function _flattenSubgraphs(workflow, objectInfo) {
|
||||
}
|
||||
|
||||
// === Map subgraph input ports → internal target {nodeId, slot} ===
|
||||
// A single boundary port can fan out to MULTIPLE internal targets (e.g. a "width" pin
|
||||
// feeding both EmptyFlux2LatentImage.width as a widget AND a PreviewAny.source wildcard
|
||||
// link purely for canvas display) — collect all of them, not just the last one seen.
|
||||
const inputPortTargets = {};
|
||||
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
|
||||
const port = sgDef.inputs[portIdx];
|
||||
for (const linkId of (port.linkIds || [])) {
|
||||
const iLink = (sgDef.links || []).find(l => l.id === linkId);
|
||||
if (iLink && iLink.origin_id === -10) {
|
||||
inputPortTargets[portIdx] = {
|
||||
if (!inputPortTargets[portIdx]) inputPortTargets[portIdx] = [];
|
||||
inputPortTargets[portIdx].push({
|
||||
targetNodeId: nodeIdRemap[iLink.target_id],
|
||||
targetSlot: iLink.target_slot,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +437,11 @@ function _flattenSubgraphs(workflow, objectInfo) {
|
||||
? (sgDef.inputs || []).findIndex(p => p.name === inputName)
|
||||
: -1;
|
||||
if (portIdx === -1) portIdx = dstSlot;
|
||||
const target = inputPortTargets[portIdx];
|
||||
// Only the port's first fanned-out target is redirected here (a real outer link
|
||||
// feeding a fanned-out port is rare — not seen in any known template so far).
|
||||
// The far more common case (an outer *widget value*, no real link, fanning out to
|
||||
// multiple internal targets) is handled fully below, for every target.
|
||||
const target = inputPortTargets[portIdx]?.[0];
|
||||
if (target) {
|
||||
link[3] = target.targetNodeId;
|
||||
link[4] = target.targetSlot;
|
||||
@@ -289,80 +460,102 @@ function _flattenSubgraphs(workflow, objectInfo) {
|
||||
|
||||
// === Add internal nodes (remapped IDs) ===
|
||||
const remappedByOrigId = {};
|
||||
const normalizedNodeIds = new Set();
|
||||
for (const iNode of sgDef.nodes) {
|
||||
const remapped = JSON.parse(JSON.stringify(iNode));
|
||||
remapped.id = nodeIdRemap[iNode.id];
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format
|
||||
// (one entry per widget input, including linked ones), while convertUiToApi's
|
||||
// widget-mapping loop assumes the "modern" format (linked widget inputs have no
|
||||
// entry at all). Detect the full format by comparing lengths and strip the
|
||||
// entries belonging to inputs that are *actually* linked end-to-end (per
|
||||
// redirectedTargets above) so downstream mapping stays index-aligned. Boundary
|
||||
// links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// stripped — their value lives in the outer node's widgets_values instead and
|
||||
// is injected below.
|
||||
const allWidgetInputs = (remapped.inputs || []).filter((i) => i.widget);
|
||||
if (remapped.widgets_values && remapped.widgets_values.length === allWidgetInputs.length) {
|
||||
const linkedIdx = new Set();
|
||||
allWidgetInputs.forEach((inp, i) => {
|
||||
const slotIdx = remapped.inputs.indexOf(inp);
|
||||
if (redirectedTargets.has(`${remapped.id}:${slotIdx}`)) linkedIdx.add(i);
|
||||
});
|
||||
if (linkedIdx.size > 0) {
|
||||
remapped.widgets_values = remapped.widgets_values.filter((_, i) => !linkedIdx.has(i));
|
||||
normalizedNodeIds.add(remapped.id);
|
||||
}
|
||||
// Propagate the outer subgraph-node's Mute/Bypass mode onto every internal node.
|
||||
// Without this, muting or bypassing a whole subgraph instance (e.g. disabling one of
|
||||
// several parallel Flux.2 sampling pipelines via Ctrl-B) leaves its internal nodes at
|
||||
// their template-authored mode (normally 0/enabled), so convertUiToApi's mode check
|
||||
// below never excludes them — they end up in the API payload and can be picked up by
|
||||
// node-existence-based UI logic (e.g. "first matching node found") ahead of the nodes
|
||||
// actually driving the enabled pipeline.
|
||||
if (node.mode === 2 || node.mode === 4) {
|
||||
remapped.mode = node.mode;
|
||||
}
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format (one
|
||||
// entry per widget input, including ones that are actually linked — either redirected
|
||||
// from an outer parent-graph link, or wired to another internal node within the same
|
||||
// subgraph template, e.g. EmptyFlux2LatentImage.width <- GetImageSize). Strip those
|
||||
// entries so convertUiToApi's widget-mapping loop (which assumes the "modern" format:
|
||||
// linked widgets contribute zero entries) doesn't drift every subsequent widget by
|
||||
// however many linked entries were left in place — see _stripLegacyLinkedWidgetValues.
|
||||
// Boundary links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// treated as linked here — those resolve via the outer-value injection step below.
|
||||
_stripLegacyLinkedWidgetValues(objectInfo, remapped, sgDef, String(iNode.id), redirectedTargets);
|
||||
|
||||
addedNodes.push(remapped);
|
||||
remappedByOrigId[iNode.id] = remapped;
|
||||
}
|
||||
|
||||
// === Inject outer subgraph-node widget values into internal nodes ===
|
||||
// Only un-linked widget inputs — linked ones are already redirected above.
|
||||
// Only un-linked widget inputs — linked ones are already redirected above. A port can fan
|
||||
// out to several internal targets (see inputPortTargets collection above) — apply the same
|
||||
// value to every one of them, not just the first. Iterating per PORT (rather than per
|
||||
// outer widget) matters for unfilled templates whose outer widgets_values is empty ([]) —
|
||||
// there is then no outer override to apply, but the fan-out's widget-type target (e.g.
|
||||
// EmptyFlux2LatentImage.width) still holds its own template-authored default, and that
|
||||
// same value is what a sibling non-widget target (e.g. a PreviewAny used to render the
|
||||
// value into a text template) needs too.
|
||||
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
|
||||
outerWidgetInputs.forEach((inp, widgetIdx) => {
|
||||
if (inp.link != null) return;
|
||||
if (widgetIdx >= (node.widgets_values || []).length) return;
|
||||
const outerValue = node.widgets_values[widgetIdx];
|
||||
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
|
||||
const targets = inputPortTargets[portIdx];
|
||||
if (!targets || targets.length === 0) continue;
|
||||
|
||||
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
|
||||
if (portIdx === -1) return;
|
||||
const target = inputPortTargets[portIdx];
|
||||
if (!target) return;
|
||||
const port = sgDef.inputs[portIdx];
|
||||
const outerInp = outerWidgetInputs.find((inp) => inp.name === port.name);
|
||||
const outerWidgetIdx = outerInp ? outerWidgetInputs.indexOf(outerInp) : -1;
|
||||
const hasOuterOverride = !!outerInp && outerInp.link == null && outerWidgetIdx !== -1
|
||||
&& outerWidgetIdx < (node.widgets_values || []).length;
|
||||
const outerValue = hasOuterOverride ? node.widgets_values[outerWidgetIdx] : undefined;
|
||||
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) return;
|
||||
// Value to apply to non-widget (wildcard) fan-out targets: the outer override if
|
||||
// present, otherwise whatever a sibling widget-type target ends up holding (its own
|
||||
// unmodified template default, when there is no outer override).
|
||||
let fallbackLiteral = outerValue;
|
||||
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) return;
|
||||
for (const target of targets) {
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) continue;
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) continue;
|
||||
|
||||
// When the internal node's widgets_values was normalized above (stripped of
|
||||
// linked-slot entries), the index must be computed against that same reduced
|
||||
// set of widget names — object_info's full name order (which still includes
|
||||
// e.g. width/height) would be off by however many entries were stripped.
|
||||
let iWidgetIdx;
|
||||
if (normalizedNodeIds.has(remappedNode.id)) {
|
||||
const remainingNames = (remappedNode.inputs || [])
|
||||
.filter((i) => i.widget)
|
||||
.filter((i) => {
|
||||
const slotIdx = remappedNode.inputs.indexOf(i);
|
||||
return !redirectedTargets.has(`${remappedNode.id}:${slotIdx}`);
|
||||
})
|
||||
.map((i) => i.name);
|
||||
iWidgetIdx = remainingNames.indexOf(targetInputDef.name);
|
||||
} else {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
iWidgetIdx = widgetNames.indexOf(targetInputDef.name);
|
||||
// Compute where in widgets_values this widget's value actually lives — accounting
|
||||
// for linked slots (which consume no entry) and control_after_generate-style
|
||||
// extras (which shift later indices by one). See _findInjectedWidgetIndex for why
|
||||
// a plain name-index lookup silently corrupts sibling widgets here.
|
||||
const iWidgetIdx = _findInjectedWidgetIndex(
|
||||
objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetInputDef.name
|
||||
);
|
||||
if (iWidgetIdx === -1) continue;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
if (hasOuterOverride) remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
if (fallbackLiteral === undefined) fallbackLiteral = remappedNode.widgets_values[iWidgetIdx];
|
||||
}
|
||||
if (iWidgetIdx === -1) return;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
});
|
||||
if (fallbackLiteral === undefined) continue;
|
||||
|
||||
for (const target of targets) {
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) continue;
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef || targetInputDef.widget) continue;
|
||||
|
||||
// Not a widget-representable input — typically a required wildcard ("*") link slot
|
||||
// with no widget UI (e.g. a "Preview as Text" node used to render a width/height
|
||||
// value into a prompt template string). There is no upstream node to link it to
|
||||
// once flattened (the value only ever existed as this outer widget or a sibling
|
||||
// target's own default), so stash it for convertUiToApi() to inject as a plain
|
||||
// literal value instead of a link.
|
||||
if (!remappedNode._wfmLiteralInputs) remappedNode._wfmLiteralInputs = {};
|
||||
remappedNode._wfmLiteralInputs[targetInputDef.name] = fallbackLiteral;
|
||||
}
|
||||
}
|
||||
|
||||
// === Add internal links (object format → array format) ===
|
||||
for (const iLink of (sgDef.links || [])) {
|
||||
@@ -423,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 = [];
|
||||
@@ -459,6 +664,10 @@ export const comfyWorkflow = {
|
||||
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
// Reroute: UI-only passthrough node, no backend node class — including it in the API
|
||||
// payload triggers a "missing_node_type" 400 from ComfyUI. Its wires are already
|
||||
// resolved through via _resolveBypassSource() below, same as a Bypassed node's.
|
||||
if (node.type === "Reroute") continue;
|
||||
// Skip note/display-only nodes that are unknown to ComfyUI backend
|
||||
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
|
||||
const nodeId = String(node.id);
|
||||
@@ -606,6 +815,15 @@ export const comfyWorkflow = {
|
||||
inputs["wildcard_text"] = widgets[0];
|
||||
}
|
||||
|
||||
// Literal values for non-widget (e.g. wildcard "*") inputs that a subgraph boundary
|
||||
// widget fanned out to — see the injection step in _flattenSubgraphs for why these
|
||||
// can't be represented as widgets_values entries or resolved as a link.
|
||||
if (node._wfmLiteralInputs) {
|
||||
for (const [k, v] of Object.entries(node._wfmLiteralInputs)) {
|
||||
if (!(k in inputs)) inputs[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
api[nodeId] = {
|
||||
class_type: node.type,
|
||||
inputs,
|
||||
@@ -682,6 +900,7 @@ export const comfyWorkflow = {
|
||||
prompt_nodes: [],
|
||||
sampler_nodes: [],
|
||||
latent_nodes: [],
|
||||
resolution_selector_nodes: [],
|
||||
lora_nodes: [],
|
||||
vae_nodes: [],
|
||||
save_nodes: [],
|
||||
@@ -715,15 +934,56 @@ export const comfyWorkflow = {
|
||||
|
||||
// KSamplerAdvanced uses noise_seed instead of seed
|
||||
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
|
||||
const seedVal = inputs.seed ?? inputs.noise_seed;
|
||||
// seed/steps/cfg/denoise are sometimes wired to Primitive nodes (e.g. a shared
|
||||
// "steps" value switched between two LoRA presets) instead of holding a direct
|
||||
// number — leave those undefined so the settings-tab UI shows "linked" instead of
|
||||
// a link-reference array, and Apply doesn't clobber the link with a literal value.
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title,
|
||||
seed: inputs.seed ?? inputs.noise_seed,
|
||||
seedKey,
|
||||
steps: inputs.steps, cfg: inputs.cfg,
|
||||
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
|
||||
denoise: inputs.denoise,
|
||||
seed: typeof seedVal === "number" ? seedVal : undefined,
|
||||
seedKey, seedNodeId: id,
|
||||
steps: typeof inputs.steps === "number" ? inputs.steps : undefined, stepsNodeId: id,
|
||||
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined, cfgNodeId: id,
|
||||
sampler_name: inputs.sampler_name, samplerNodeId: id,
|
||||
scheduler: inputs.scheduler, schedulerNodeId: id,
|
||||
denoise: typeof inputs.denoise === "number" ? inputs.denoise : undefined, denoiseNodeId: id,
|
||||
});
|
||||
}
|
||||
|
||||
// CFGGuider / BasicGuider / DualCFGGuider — used by "Advanced Sampling" workflows
|
||||
// (Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.) in place of KSampler's
|
||||
// positive/negative inputs. These feed SamplerCustomAdvanced rather than driving a
|
||||
// sampler themselves, so role propagation must start here too, or CLIPTextEncode
|
||||
// nodes downstream stay "unknown" role.
|
||||
if (ct === "CFGGuider") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
if (ct === "BasicGuider") {
|
||||
if (Array.isArray(inputs.conditioning)) samplerPositiveRef[inputs.conditioning[0]] = true;
|
||||
}
|
||||
// DualCFGGuider (HiDream E1 instruct-pix2pix style edit): cond1/cond2 both derive from
|
||||
// the positive text (often via InstructPixToPixConditioning), negative is separate —
|
||||
// cond2 is left unmarked since it doesn't cleanly map to either role.
|
||||
if (ct === "DualCFGGuider") {
|
||||
if (Array.isArray(inputs.cond1)) samplerPositiveRef[inputs.cond1[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// SamplerCustom (older/simpler counterpart to SamplerCustomAdvanced): holds
|
||||
// positive/negative directly instead of going through a separate Guider node.
|
||||
if (ct === "SamplerCustom") {
|
||||
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) ===
|
||||
@@ -737,6 +997,7 @@ export const comfyWorkflow = {
|
||||
"ControlNetApply", "ControlNetApplyAdvanced",
|
||||
"IPAdapterApply", "IPAdapterApplyFaceID",
|
||||
"StyleModelApply",
|
||||
"FluxGuidance", "FluxKontextMultiReferenceLatentMethod",
|
||||
]);
|
||||
|
||||
for (let iter = 0; iter < 5; iter++) {
|
||||
@@ -791,6 +1052,67 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
}
|
||||
// InstructPixToPixConditioning (HiDream E1 edit) — unlike COND_PASSTHROUGH nodes,
|
||||
// this has *separate* positive/negative inputs feeding separate outputs, so each
|
||||
// role must route only to its own input (not both) or the negative CLIPTextEncode
|
||||
// would incorrectly get marked positive too via the positive-output branch.
|
||||
if (ct === "InstructPixToPixConditioning") {
|
||||
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
|
||||
// statically, so propagate the role through both branches — only the branch that
|
||||
// resolves to a literal string source (e.g. PrimitiveStringMultiline) will end up
|
||||
// producing a prompt_nodes entry in Pass 2.
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// TextEncodeQwenImageEditPlus/TextEncodeQwenImageEdit — propagate role through its
|
||||
// linked "prompt" input when prompt-enhancement wiring feeds it via a switch chain
|
||||
// instead of a literal string (e.g. Krea-2's image-reference workflow).
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
const tv = inputs.prompt;
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
// PreviewAny — generic value tap/relay used mid-chain in prompt-enhancement wiring
|
||||
// (e.g. Krea-2's "Refine Prompt?" switch output tap); pass role through its
|
||||
// "source" input unchanged.
|
||||
if (ct === "PreviewAny") {
|
||||
const sv = inputs.source;
|
||||
if (Array.isArray(sv)) {
|
||||
if (isPos) samplerPositiveRef[sv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[sv[0]] = true;
|
||||
}
|
||||
}
|
||||
// StringConcatenate — appends e.g. a LoRA trigger word onto the prompt text
|
||||
// (Krea-2); propagate role through both operands so the upstream literal is found.
|
||||
if (ct === "StringConcatenate") {
|
||||
for (const key of ["string_a", "string_b"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,6 +1134,72 @@ export const comfyWorkflow = {
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
// --- Advanced Sampler: SamplerCustomAdvanced ---
|
||||
// "Advanced Sampling" pattern used by Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.:
|
||||
// instead of one KSampler node holding seed/steps/cfg/sampler/scheduler, those values
|
||||
// are spread across RandomNoise (noise_seed) → a Guider node (cfg) → KSamplerSelect
|
||||
// (sampler_name) → a *Scheduler node (steps/denoise), all feeding SamplerCustomAdvanced.
|
||||
// Collect them into one composite entry so Settings-tab editing still works; each
|
||||
// value's *NodeId tracks which real node to write back to (null = no such control
|
||||
// exists on this workflow, e.g. Flux2Scheduler has no named "scheduler" combo).
|
||||
if (ct === "SamplerCustomAdvanced") {
|
||||
const noiseId = Array.isArray(inputs.noise) ? String(inputs.noise[0]) : null;
|
||||
const guiderId = Array.isArray(inputs.guider) ? String(inputs.guider[0]) : null;
|
||||
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
|
||||
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
|
||||
|
||||
const noiseInputs = (noiseId && workflow[noiseId]?.inputs) || {};
|
||||
const guiderNode = guiderId ? workflow[guiderId] : null;
|
||||
const guiderInputs = guiderNode?.inputs || {};
|
||||
// DualCFGGuider (HiDream E1 instruct-pix2pix edit) has no plain "cfg" input — its
|
||||
// two CFG knobs are cfg_conds/cfg_cond2_negative. Use cfg_conds as the
|
||||
// representative value so Settings still shows/edits *something* meaningful.
|
||||
const guiderCfgKey = guiderNode?.class_type === "DualCFGGuider" ? "cfg_conds" : "cfg";
|
||||
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
|
||||
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
|
||||
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title, advanced: true,
|
||||
seed: typeof noiseInputs.noise_seed === "number" ? noiseInputs.noise_seed : undefined,
|
||||
seedKey: "noise_seed", seedNodeId: noiseId,
|
||||
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
|
||||
stepsNodeId: schedulerId,
|
||||
cfg: typeof guiderInputs[guiderCfgKey] === "number" ? guiderInputs[guiderCfgKey] : undefined,
|
||||
cfgNodeId: guiderCfgKey in guiderInputs ? guiderId : null,
|
||||
cfgKey: guiderCfgKey,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
scheduler: undefined, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced Sampler: SamplerCustom ---
|
||||
// Older/simpler counterpart to SamplerCustomAdvanced: no RandomNoise/Guider
|
||||
// indirection — noise_seed and cfg are its own widgets, and positive/negative feed it
|
||||
// directly. sampler_name/steps/scheduler/denoise still come from separate
|
||||
// KSamplerSelect/*Scheduler nodes via its sampler/sigmas inputs.
|
||||
if (ct === "SamplerCustom") {
|
||||
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
|
||||
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
|
||||
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
|
||||
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
|
||||
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title, advanced: true,
|
||||
seed: typeof inputs.noise_seed === "number" ? inputs.noise_seed : undefined,
|
||||
seedKey: "noise_seed", seedNodeId: id,
|
||||
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
|
||||
stepsNodeId: schedulerId,
|
||||
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined,
|
||||
cfgNodeId: id,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
scheduler: schedulerInputs.scheduler, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Checkpoint nodes ---
|
||||
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
|
||||
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
|
||||
@@ -878,13 +1266,34 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen-based image+text encoder
|
||||
if (ct === "TextEncodeQwenImageEditPlus") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: inputs.prompt || "",
|
||||
textKey: "prompt",
|
||||
});
|
||||
// Qwen-based image+text encoder (Plus variant takes up to 3 reference images;
|
||||
// the base variant takes one — both share the same "prompt" widget). Only add when
|
||||
// "prompt" is a direct string; if linked (e.g. Krea-2's prompt-enhancement wiring),
|
||||
// the upstream literal node is resolved instead via the role propagation above.
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
const textVal = inputs.prompt;
|
||||
if (typeof textVal === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TextEncodeBooguEdit (Boogu image-edit model) — prompt + negative_prompt in one node
|
||||
if (ct === "TextEncodeBooguEdit") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
@@ -956,15 +1365,62 @@ 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 ---
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
|
||||
// EmptyFlux2LatentImage (Flux.2 templates) often has width/height wired to a
|
||||
// GetImageSize node (auto-follows the reference image) rather than a direct number —
|
||||
// leave those as undefined, same treatment as TextEncodeMageFlowEdit below, so the
|
||||
// settings-tab UI shows "linked" instead of a stale default and Apply doesn't clobber it.
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: inputs.width, height: inputs.height,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1120,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,13 +5,16 @@
|
||||
|
||||
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";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
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";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,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 フィールドを持つため両対応。
|
||||
export 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へ送る」ボタンから
|
||||
@@ -163,12 +184,28 @@ window._wfmReceiveLLMPromptRequest = async (context) => {
|
||||
// ComfyUI Comic Creater からiframe越しに画像プロンプト・サイズを受け取り、Generate UIで
|
||||
// テキストから画像を生成して結果URLを返す(半自動マンガ作成のコマ単位バッチ画像生成用)。
|
||||
// Comic Creater側のスクリプトタブ「画像を一括生成」から
|
||||
// iframe.contentWindow._wfmReceiveGenerateRequest(prompt, width, height) として呼ばれる。
|
||||
// I2I/Inpaintと異なり画像アップロードは行わず(txt2img)、専用ワークフロー選択には未対応で
|
||||
// 常にGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveGenerateRequest = async (prompt, width, height) => {
|
||||
// iframe.contentWindow._wfmReceiveGenerateRequest(prompt, width, height, negative, workflowData?, workflowFilename?)
|
||||
// として呼ばれる。I2I/Inpaintと異なり画像アップロードは行わない(txt2img)。
|
||||
// workflowData が渡された場合(Comic Creater側のT2I設定でデフォルトワークフローが有効な時)は、
|
||||
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (!window._wfmGenerateTab?.generate) return { ok: false, message: "Generate UI is not ready yet" };
|
||||
|
||||
if (workflowData) {
|
||||
try {
|
||||
if (!comfyUI.connected) {
|
||||
await comfyUI.checkConnection();
|
||||
}
|
||||
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
|
||||
await comfyEditor.loadModelLists();
|
||||
}
|
||||
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
|
||||
} catch (e) {
|
||||
console.warn("[T2I] failed to load default workflow:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!comfyUI.currentWorkflow || !analysis) return { ok: false, message: "No workflow loaded in Generate UI" };
|
||||
|
||||
@@ -180,6 +217,7 @@ window._wfmReceiveGenerateRequest = async (prompt, width, height) => {
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negative) comfyEditor.setPromptText("negative", negative, { workflow, analysis });
|
||||
|
||||
await window._wfmGenerateTab.generate(workflow);
|
||||
|
||||
@@ -195,6 +233,9 @@ window._wfmReceiveGenerateRequest = async (prompt, width, height) => {
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
let _scrollObserver = null;
|
||||
let _loadImagesAbortController = null;
|
||||
let _searchDebounceTimer = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// ── 状態 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -234,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));
|
||||
@@ -289,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() {
|
||||
@@ -384,7 +456,7 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
// フォルダ名
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? `[root] ${node.name}` : node.name;
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
if (node.image_count > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
@@ -434,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);
|
||||
}
|
||||
@@ -444,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>`;
|
||||
|
||||
@@ -455,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,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" : ""}`;
|
||||
@@ -858,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;
|
||||
@@ -894,15 +1007,45 @@ async function loadImageDetail(img) {
|
||||
state.embeddedWorkflow = wfRes.has_workflow ? wfRes.workflow : null;
|
||||
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");
|
||||
if (!section || !text) return;
|
||||
if (imagePrompt) {
|
||||
text.textContent = imagePrompt;
|
||||
section.style.display = "";
|
||||
} else {
|
||||
section.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTagsDisplay(tags) {
|
||||
const container = document.getElementById("wfm-gallery-tags-display");
|
||||
container.innerHTML = "";
|
||||
@@ -931,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) {
|
||||
@@ -938,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);
|
||||
@@ -947,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,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 {
|
||||
@@ -1012,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() {
|
||||
// ツールバー: グループフィルタ
|
||||
@@ -1091,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>
|
||||
`;
|
||||
@@ -1178,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");
|
||||
@@ -1507,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>
|
||||
@@ -1520,6 +1763,7 @@ function openLightbox(img) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
if (isVideoFile(img)) applyStoredVideoVolume(overlay.querySelector("video"));
|
||||
}
|
||||
|
||||
// ── 画像比較ライトボックス ─────────────────────────────────────
|
||||
@@ -1531,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">
|
||||
@@ -1550,6 +1799,7 @@ function openCompare(paths) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelectorAll("video").forEach(applyStoredVideoVolume);
|
||||
}
|
||||
|
||||
// ── outputパス取得 ────────────────────────────────────────────
|
||||
@@ -1589,6 +1839,9 @@ export function initGalleryTab() {
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
_initGallerySubtabToggle();
|
||||
initImagePromptTab();
|
||||
initStyleCatalogTab();
|
||||
}
|
||||
|
||||
let _initialized = false;
|
||||
@@ -1600,6 +1853,24 @@ function onGalleryTabActivated() {
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog / Metadata) ────────────
|
||||
|
||||
function _initGallerySubtabToggle() {
|
||||
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.gallerySubtab;
|
||||
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// リフレッシュ
|
||||
document.getElementById("wfm-gallery-refresh-btn")?.addEventListener("click", () => {
|
||||
@@ -1608,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 タブへ送信
|
||||
@@ -1654,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");
|
||||
@@ -1672,6 +1986,7 @@ function bindEvents() {
|
||||
return;
|
||||
}
|
||||
const url = API.serveImage(state.selectedImage.path);
|
||||
const isVideo = isVideoFile(state.selectedImage);
|
||||
|
||||
// I2I送信元がImageタブだった場合はそちらへ画像を読み込ませる(_ccI2ITargetModeはComic Creater側が
|
||||
// 「I2Iへ送る」ボタン押下時にセットするフラグ。undefined/'layout'なら従来通りコマ/オーバーレイへ挿入)
|
||||
@@ -1682,6 +1997,11 @@ function bindEvents() {
|
||||
targetMode = null;
|
||||
}
|
||||
if (targetMode === "image") {
|
||||
// Imageタブは動画未対応(Comic Creator側の動画ツールはレイアウトタブのみ対応)
|
||||
if (isVideo) {
|
||||
showToast(t("galleryVideoNotSupportedInImageTab"), "info");
|
||||
return;
|
||||
}
|
||||
let imageTab = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent._ccImageTab?.loadFromUrl === "function") {
|
||||
@@ -1699,6 +2019,26 @@ function bindEvents() {
|
||||
return;
|
||||
}
|
||||
|
||||
// レイアウトタブのコマ/オーバーレイへ(デフォルト経路)。動画(.mp4)はComic Creator側の
|
||||
// 動画ツール専用の受け口(insertVideoFromUrl)へ、それ以外は従来通り画像として送る
|
||||
if (isVideo) {
|
||||
let insertVideoFn = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent.insertVideoFromUrl === "function") {
|
||||
insertVideoFn = window.parent.insertVideoFromUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
insertVideoFn = null;
|
||||
}
|
||||
if (!insertVideoFn) {
|
||||
showToast(t("galleryCCNotAvailable"), "info");
|
||||
return;
|
||||
}
|
||||
insertVideoFn(url, state.selectedImage.filename || "video.mp4");
|
||||
showToast(t("gallerySentCC"), "success");
|
||||
return;
|
||||
}
|
||||
|
||||
let insertFn = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent.insertImageFromUrl === "function") {
|
||||
@@ -1716,12 +2056,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();
|
||||
});
|
||||
@@ -1787,7 +2130,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"); });
|
||||
});
|
||||
|
||||
// ワークフローコピー&キャンバスへ送る
|
||||
@@ -1875,6 +2218,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;
|
||||
@@ -1920,6 +2293,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) {
|
||||
@@ -1959,6 +2342,7 @@ function bindEvents() {
|
||||
if (galleryClearBtn) {
|
||||
galleryClearBtn.textContent = t("clearFilters");
|
||||
galleryClearBtn.addEventListener("click", () => {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
state.favoriteOnly = false;
|
||||
state.tagFilter = "";
|
||||
|
||||
@@ -9,50 +9,9 @@ import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
|
||||
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
function getEagleSettings() {
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/eagle/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
eagleUrl: eagle.url,
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
|
||||
filename: fileInfo?.filename || "",
|
||||
subfolder: fileInfo?.subfolder || "",
|
||||
type: fileInfo?.type || "output",
|
||||
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
|
||||
localPath: fileInfo?.localPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === "success") {
|
||||
console.log("[Eagle] Saved:", name);
|
||||
} else {
|
||||
console.warn("[Eagle] Save failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Eagle] Save error:", err.message);
|
||||
}
|
||||
}
|
||||
import { initLabTab, refreshLabLiveDefaults } from "./lab-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
|
||||
import { isVideoFilename } from "./video-utils.js";
|
||||
|
||||
// ============================================
|
||||
// Gallery Metadata - ワークフロー保存
|
||||
@@ -206,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");
|
||||
@@ -273,7 +243,9 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
comfyUI.currentAnalysis = comfyWorkflow.analyzeWorkflow(apiWorkflow);
|
||||
|
||||
// Render editor tabs
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow);
|
||||
// resetStackMode: 新規ワークフロー読み込み時のみ、LoraManagerノードのloras/text状態を
|
||||
// スナップショットし、Stackグループ使用チェックボックスをOFFへリセットする
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow, { resetStackMode: true });
|
||||
|
||||
// Update raw JSON with highlight
|
||||
const rawTextarea = document.getElementById("wfm-gen-raw-json");
|
||||
@@ -321,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 {
|
||||
@@ -1305,6 +1284,12 @@ async function _loadStyles() {
|
||||
_rebuildStyleList();
|
||||
}
|
||||
|
||||
// Promptタブ「Style」サブタブでスタイルを作成・編集・削除した後に呼ばれる。
|
||||
// GenerateUIのStyleドロップダウン・Batchタブのチェックリストを最新の状態へ再同期する。
|
||||
export async function refreshStylesList() {
|
||||
await _loadStyles();
|
||||
}
|
||||
|
||||
function _renderStyleDropdown() {
|
||||
const sel = document.getElementById("wfm-style-select");
|
||||
if (!sel) return;
|
||||
@@ -1333,6 +1318,21 @@ function _rebuildStyleList() {
|
||||
);
|
||||
}
|
||||
|
||||
// Style Catalogギャラリーの「Select as Style」から呼ばれる。名前が一致する登録済み
|
||||
// Styleを探し、GenerateUIのStyleドロップダウンをそれに切り替えてタブへ移動する。
|
||||
// 画像に埋め込まれたプロンプトを直接適用するのではなく、既存Styleシステムへの
|
||||
// 視覚的なショートカットとして機能する(元のStyle JSONが変更・削除されても影響を受けない)。
|
||||
export function selectStyleByName(name) {
|
||||
const style = _stylesData.find((s) => s.name === name);
|
||||
if (!style) return false;
|
||||
const sel = document.getElementById("wfm-style-select");
|
||||
if (sel) sel.value = name;
|
||||
const enabled = document.getElementById("wfm-style-enabled");
|
||||
if (enabled) enabled.checked = true;
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _applyNamedStyle(workflow, style) {
|
||||
if (!style) return workflow;
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
@@ -1433,7 +1433,7 @@ async function _expandWildcardText(text) {
|
||||
|
||||
const _IMPACT_WC_TYPES = new Set(["ImpactWildcardEncode", "ImpactWildcardProcessor"]);
|
||||
|
||||
async function _expandWildcardsInWorkflow(workflow) {
|
||||
export async function _expandWildcardsInWorkflow(workflow) {
|
||||
let hasWildcard = false;
|
||||
outer: for (const node of Object.values(workflow)) {
|
||||
if (_IMPACT_WC_TYPES.has(node.class_type)) continue;
|
||||
@@ -1462,26 +1462,38 @@ 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)}%`;
|
||||
@@ -1527,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");
|
||||
});
|
||||
@@ -1581,7 +1606,9 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
// ============================================
|
||||
|
||||
// 汎用バッチループ: items を順に applyFn(item) → _coreGenerate() で処理する
|
||||
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
// onResultFn(item, {images, seed}) が渡された場合、各アイテムの生成結果ごとに呼び出す
|
||||
// (Style Catalogのカタログ作成が、生成画像をスタイル名で保存するために使用)
|
||||
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x), onResultFn = null) {
|
||||
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
|
||||
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
|
||||
const batchCount = document.getElementById("wfm-ckpt-batch-count");
|
||||
@@ -1616,7 +1643,8 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
|
||||
try {
|
||||
await applyFn(item);
|
||||
await _coreGenerate(true);
|
||||
const result = await _coreGenerate(true);
|
||||
if (onResultFn) await onResultFn(item, result);
|
||||
completed++;
|
||||
} catch (err) {
|
||||
if (_ckptBatch.aborted) break;
|
||||
@@ -1645,6 +1673,157 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Style Catalog 作成
|
||||
// ============================================
|
||||
// Batchタブ「Style」サブタブで選択済みのスタイル(_batchStyleSelected)を対象に、
|
||||
// 現在ロード中のワークフローでそれぞれ生成し、結果をスタイル名でws_style_catalog
|
||||
// 配下の指定フォルダへ保存する(Style Catalogギャラリーからの視覚的スタイル選択に使う)。
|
||||
|
||||
function _blobToDataUrl(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function _flattenFolderTree(node, acc = [], isRoot = true) {
|
||||
acc.push({ path: isRoot ? "[root]" : node.path, abs_path: node.abs_path });
|
||||
(node.children || []).forEach((c) => _flattenFolderTree(c, acc, false));
|
||||
return acc;
|
||||
}
|
||||
|
||||
async function openCatalogCreateModal() {
|
||||
const list = _stylesData.filter((s) => _batchStyleSelected.has(s.name));
|
||||
if (list.length === 0) {
|
||||
showToast(t("catalogCreateNoStyles"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let catalogRoot = null;
|
||||
let folderTree = null;
|
||||
try {
|
||||
const rootRes = await fetch("/wfm/gallery/style-catalog/root");
|
||||
const rootData = await rootRes.json();
|
||||
catalogRoot = rootData.root;
|
||||
if (!catalogRoot) throw new Error(rootData.error || "Style Catalog root not resolved");
|
||||
const treeRes = await fetch(`/wfm/gallery/folders?root=${encodeURIComponent(catalogRoot)}`);
|
||||
folderTree = await treeRes.json();
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderOptions = folderTree && !folderTree.error ? _flattenFolderTree(folderTree) : [];
|
||||
|
||||
const html = `
|
||||
<p style="margin:0 0 10px;font-size:13px;color:var(--wfm-text-secondary);">${t("catalogCreateSelectedCount", list.length)}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="radio" name="wfm-catalog-dest" value="new" checked>
|
||||
${t("catalogCreateNewFolder")}
|
||||
</label>
|
||||
<input type="text" id="wfm-catalog-new-folder-name" class="wfm-input" placeholder="folder name" style="margin-left:22px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="radio" name="wfm-catalog-dest" value="existing" ${folderOptions.length === 0 ? "disabled" : ""}>
|
||||
${t("catalogCreateExistingFolder")}
|
||||
</label>
|
||||
<select id="wfm-catalog-existing-folder" class="wfm-input" style="margin-left:22px;" ${folderOptions.length === 0 ? "disabled" : ""}>
|
||||
${folderOptions.map((f) => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.path)}</option>`).join("")}
|
||||
</select>
|
||||
<hr class="wfm-group-divider" style="margin:4px 0;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="checkbox" id="wfm-catalog-skip-output">
|
||||
${t("catalogCreateSkipOutput")}
|
||||
</label>
|
||||
</div>
|
||||
<button id="wfm-catalog-run-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:14px;">${t("catalogCreateRun")}</button>
|
||||
`;
|
||||
openModal(t("catalogCreateTitle"), html);
|
||||
|
||||
document.getElementById("wfm-catalog-run-btn")?.addEventListener("click", async () => {
|
||||
const destType = document.querySelector('input[name="wfm-catalog-dest"]:checked')?.value;
|
||||
let destFolder = null;
|
||||
|
||||
if (destType === "new") {
|
||||
const name = document.getElementById("wfm-catalog-new-folder-name")?.value.trim();
|
||||
if (!name) {
|
||||
showToast(t("catalogCreateFolderNameRequired"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent: catalogRoot, name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Failed to create folder");
|
||||
destFolder = data.path;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
destFolder = document.getElementById("wfm-catalog-existing-folder")?.value || null;
|
||||
if (!destFolder) {
|
||||
showToast(t("catalogCreateFolderRequired"), "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const skipOutput = document.getElementById("wfm-catalog-skip-output")?.checked || false;
|
||||
|
||||
closeModal();
|
||||
await _runCatalogCreate(list, destFolder, skipOutput);
|
||||
});
|
||||
}
|
||||
|
||||
async function _runCatalogCreate(list, destFolder, skipOutput) {
|
||||
try {
|
||||
await _runBatchLoop(
|
||||
list,
|
||||
(style) => { _batchStyleOverride = style; },
|
||||
(s) => s.name,
|
||||
async (style, result) => {
|
||||
const outputImages = (result?.images || []).filter((img) => img.type !== "temp");
|
||||
if (outputImages.length === 0) return;
|
||||
const srcImage = outputImages[0];
|
||||
const blob = await comfyUI.getImageBlob(srcImage);
|
||||
const dataUrl = await _blobToDataUrl(blob);
|
||||
const res = await fetch("/wfm/gallery/image/save-to-folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder: destFolder, filename: style.name, imageData: dataUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Failed to save catalog image");
|
||||
|
||||
// Outputフォルダに元画像を残さないオプション: カタログへのコピー保存が
|
||||
// 成功した後にのみ、ComfyUIのOutputフォルダ側の元ファイルを削除する
|
||||
// (コピー失敗時は削除せず、元画像を保持したままにする)
|
||||
if (skipOutput) {
|
||||
try {
|
||||
await fetch("/wfm/gallery/output-image/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: srcImage.filename,
|
||||
subfolder: srcImage.subfolder || "",
|
||||
type: srcImage.type || "output",
|
||||
}),
|
||||
});
|
||||
} catch { /* 削除失敗はカタログ作成自体を止めない */ }
|
||||
}
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
_batchStyleOverride = null;
|
||||
}
|
||||
}
|
||||
|
||||
// アクティブなバッチタイプに応じてバッチを実行するディスパッチャ
|
||||
async function _runBatchGenerate() {
|
||||
switch (_activeBatchType) {
|
||||
@@ -1837,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) {
|
||||
@@ -1868,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";
|
||||
@@ -1933,6 +2114,25 @@ export async function initGenerateTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Style Catalog 作成ボタン
|
||||
const catalogCreateBtn = document.getElementById("wfm-gen-catalog-create-btn");
|
||||
if (catalogCreateBtn) {
|
||||
catalogCreateBtn.textContent = t("catalogCreateBtn");
|
||||
catalogCreateBtn.addEventListener("click", () => {
|
||||
openCatalogCreateModal().catch((e) => showToast(t("errorWithMsg", e.message), "error"));
|
||||
});
|
||||
}
|
||||
|
||||
// Style Catalog を開くボタン(Gallery タブ → Style_Catalog サブタブへ遷移)
|
||||
const catalogOpenBtn = document.getElementById("wfm-gen-catalog-open-btn");
|
||||
if (catalogOpenBtn) {
|
||||
catalogOpenBtn.textContent = t("catalogOpenBtn");
|
||||
catalogOpenBtn.addEventListener("click", () => {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="styleCatalog"]')?.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Generate button
|
||||
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", () => {
|
||||
handleGenerate().catch(() => {});
|
||||
@@ -1961,7 +2161,7 @@ export async function initGenerateTab() {
|
||||
function moveRawJsonToTab(tabKey) {
|
||||
const widget = document.getElementById("wfm-gen-rawjson-widget");
|
||||
if (!widget) return;
|
||||
if (tabKey === "feeder" || tabKey === "batch") {
|
||||
if (tabKey === "feeder" || tabKey === "batch" || tabKey === "lab") {
|
||||
widget.style.display = "none";
|
||||
return;
|
||||
}
|
||||
@@ -1985,6 +2185,17 @@ export async function initGenerateTab() {
|
||||
if (target === "model" && comfyUI.currentAnalysis) {
|
||||
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
|
||||
}
|
||||
// Re-render Lab's column grid so Setting 1's live-reflected cells (checkpoint/
|
||||
// VAE/prompt/KSampler) pick up whatever is currently loaded in GenerateUI, not
|
||||
// whatever was loaded the last time Lab was rendered.
|
||||
if (target === "lab") refreshLabLiveDefaults();
|
||||
// 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" : "";
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1995,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", () => {
|
||||
@@ -2072,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);
|
||||
@@ -2161,6 +2381,9 @@ export async function initGenerateTab() {
|
||||
// Feeder tab
|
||||
await initFeederTab();
|
||||
|
||||
// Lab tab (experimental I2I batch generation)
|
||||
initLabTab();
|
||||
|
||||
// Style dropdown
|
||||
await _loadStyles();
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Image Edit Tab - Background Removal
|
||||
* Two backends: a lightweight in-browser model (@imgly/background-removal)
|
||||
* and BiRefNet via the Mask Editor One server endpoints.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class BgRemove {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(newLayer: object) => void} callbacks.onLayerAdded
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const resp = await fetch("/mask_editor/birefnet/status");
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
this.birefnetAvailable = json.loaded === true || json.model_found === true;
|
||||
} catch {
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const birefnetDisabled = this.birefnetAvailable ? "" : "disabled";
|
||||
const birefnetLabel = this.birefnetAvailable
|
||||
? "BiRefNet (Mask Editor One)"
|
||||
: "BiRefNet (Mask Editor One required)";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<label>Model</label>
|
||||
<select id="ie-bgremove-model" class="ie-opt-select">
|
||||
<option value="imgly">Lightweight (@imgly)</option>
|
||||
<option value="birefnet" ${birefnetDisabled}>${birefnetLabel}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-bgremove-new-layer" checked> New Layer
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-bgremove-btn">Remove BG</button>
|
||||
</div>
|
||||
<span id="ie-bgremove-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;"></span>
|
||||
`;
|
||||
document.getElementById("ie-bgremove-btn")?.addEventListener("click", () => this.apply());
|
||||
}
|
||||
|
||||
async apply() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
|
||||
const model = document.getElementById("ie-bgremove-model")?.value ?? "imgly";
|
||||
const asNew = document.getElementById("ie-bgremove-new-layer")?.checked ?? true;
|
||||
const statusEl = document.getElementById("ie-bgremove-status");
|
||||
const btn = document.getElementById("ie-bgremove-btn");
|
||||
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
setStatus("Starting...");
|
||||
|
||||
try {
|
||||
const dataUrl = layer.canvas.toDataURL("image/png");
|
||||
|
||||
let resultDataUrl;
|
||||
if (model === "imgly") {
|
||||
resultDataUrl = await this._removeImgly(dataUrl, setStatus);
|
||||
} else {
|
||||
resultDataUrl = await this._removeBiRefNet(dataUrl, setStatus);
|
||||
}
|
||||
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("Result image load failed"));
|
||||
i.src = resultDataUrl;
|
||||
});
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (asNew) {
|
||||
const newL = layerMgr.addLayer("image", layer.name + " (no bg)", {
|
||||
contentW: img.width, contentH: img.height,
|
||||
displayW: layer.displayW, displayH: layer.displayH,
|
||||
x: layer.x, y: layer.y,
|
||||
});
|
||||
newL.ctx.drawImage(img, 0, 0);
|
||||
layerMgr.setActive(newL.id);
|
||||
this._cb.onLayerAdded(newL);
|
||||
} else {
|
||||
layer.canvas.width = img.width;
|
||||
layer.canvas.height = img.height;
|
||||
layer.ctx = layer.canvas.getContext("2d");
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
setStatus("Done!");
|
||||
setTimeout(() => setStatus(""), 3000);
|
||||
showToast("Background removed", "success");
|
||||
|
||||
} catch (err) {
|
||||
setStatus("Error: " + err.message);
|
||||
showToast("BG remove failed: " + err.message, "error");
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _removeImgly(dataUrl, onStatus) {
|
||||
if (!window._wfmImglyRemoveBg) {
|
||||
onStatus("Loading model...");
|
||||
const mod = await import("https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022");
|
||||
window._wfmImglyRemoveBg = mod.removeBackground;
|
||||
}
|
||||
onStatus("Processing...");
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const resultBlob = await window._wfmImglyRemoveBg(blob, {
|
||||
publicPath: "https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/",
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(resultBlob);
|
||||
});
|
||||
}
|
||||
|
||||
async _removeBiRefNet(dataUrl, onStatus) {
|
||||
const NODE_ID = "wfs_bgremove";
|
||||
|
||||
// 1. アクティブレイヤー画像を Mask Editor One のキャッシュに登録
|
||||
onStatus("Sending image...");
|
||||
const storeResp = await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
|
||||
});
|
||||
if (!storeResp.ok) throw new Error("Failed to cache image for BiRefNet");
|
||||
|
||||
// 2. BiRefNet 推論実行
|
||||
onStatus("Running BiRefNet...");
|
||||
const resp = await fetch("/mask_editor/birefnet/remove_bg", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID }),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
|
||||
// 3. グレースケールマスク (白=前景) → RGBA PNG に変換して返す
|
||||
onStatus("Compositing...");
|
||||
return await this._applyMaskToImage(dataUrl, json.mask_b64);
|
||||
}
|
||||
|
||||
async _applyMaskToImage(imageB64, maskB64) {
|
||||
const loadImage = src => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed: " + src.slice(0, 40)));
|
||||
img.src = src;
|
||||
});
|
||||
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
|
||||
|
||||
const w = origImg.naturalWidth;
|
||||
const h = origImg.naturalHeight;
|
||||
|
||||
// マスク: グレースケール輝度値 → アルファチャンネル (RGBA 白マスク)
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = w;
|
||||
maskCanvas.height = h;
|
||||
const mc = maskCanvas.getContext("2d");
|
||||
mc.drawImage(maskImg, 0, 0, w, h);
|
||||
const maskData = mc.getImageData(0, 0, w, h);
|
||||
const md = maskData.data;
|
||||
for (let i = 0; i < md.length; i += 4) {
|
||||
md[i + 3] = md[i]; // 輝度 → アルファ
|
||||
md[i] = md[i + 1] = md[i + 2] = 255;
|
||||
}
|
||||
mc.putImageData(maskData, 0, 0);
|
||||
|
||||
// 元画像にマスクを destination-in で適用
|
||||
const out = document.createElement("canvas");
|
||||
out.width = w;
|
||||
out.height = h;
|
||||
const ctx = out.getContext("2d");
|
||||
ctx.drawImage(origImg, 0, 0, w, h);
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
ctx.drawImage(maskCanvas, 0, 0, w, h);
|
||||
|
||||
return out.toDataURL("image/png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Image Edit Tab - Blur / Mosaic Tool
|
||||
* Whole-layer blur/mosaic buttons plus a drag-to-select rectangle mode
|
||||
* that applies blur or mosaic to just the dragged region.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
function _applyMosaicToRegion(ctx, x, y, w, h, size) {
|
||||
if (w <= 0 || h <= 0 || size < 1) return;
|
||||
const imgData = ctx.getImageData(x, y, w, h);
|
||||
const d = imgData.data;
|
||||
for (let py = 0; py < h; py += size) {
|
||||
for (let px = 0; px < w; px += size) {
|
||||
const i = (py * w + px) * 4;
|
||||
const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3];
|
||||
for (let by = py; by < Math.min(py + size, h); by++) {
|
||||
for (let bx = px; bx < Math.min(px + size, w); bx++) {
|
||||
const j = (by * w + bx) * 4;
|
||||
d[j] = r; d[j + 1] = g; d[j + 2] = b; d[j + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, x, y);
|
||||
}
|
||||
|
||||
export class BlurTool {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => number} callbacks.getZoom
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.rectMode = null; // null | 'blur' | 'mosaic'
|
||||
this._dragging = false;
|
||||
this._dragStart = null;
|
||||
this._dragCur = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.rectMode = null;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) {
|
||||
overlay.style.cursor = "";
|
||||
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const blurOn = this.rectMode === "blur";
|
||||
const mosaicOn = this.rectMode === "mosaic";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Whole:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Blur</label>
|
||||
<input type="range" id="ie-whole-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-whole-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-blur-apply">Apply</button>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Mosaic</label>
|
||||
<input type="range" id="ie-whole-mosaic" min="5" max="100" value="20" style="width:70px;">
|
||||
<span id="ie-whole-mosaic-val" style="font-size:11px;min-width:22px;">20</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-mosaic-apply">Apply</button>
|
||||
</div>
|
||||
<div style="width:1px;height:22px;background:var(--wfm-border);margin:0 6px;flex-shrink:0;"></div>
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Rect:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${blurOn ? " ie-opt-active" : ""}" id="ie-rect-blur-toggle"
|
||||
style="${blurOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Blur: ${blurOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-rect-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${mosaicOn ? " ie-opt-active" : ""}" id="ie-rect-mosaic-toggle"
|
||||
style="${mosaicOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Mosaic: ${mosaicOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-mosaic" min="5" max="50" value="15" style="width:70px;">
|
||||
<span id="ie-rect-mosaic-val" style="font-size:11px;min-width:22px;">15</span>px
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("ie-whole-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-blur-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeBlur(parseInt(document.getElementById("ie-whole-blur").value));
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeMosaic(parseInt(document.getElementById("ie-whole-mosaic").value));
|
||||
});
|
||||
document.getElementById("ie-rect-blur-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "blur" ? null : "blur";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "mosaic" ? null : "mosaic";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
}
|
||||
|
||||
onMouseDown(pos) {
|
||||
if (!this.rectMode) return;
|
||||
this._dragging = true;
|
||||
this._dragStart = { x: pos.x, y: pos.y };
|
||||
this._dragCur = { x: pos.x, y: pos.y };
|
||||
}
|
||||
|
||||
onMouseMove(pos) {
|
||||
if (!this._dragging) return;
|
||||
this._dragCur = pos;
|
||||
this._drawPreview();
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._applyRectEffect();
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
applyWholeBlur(amount) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
applyWholeMosaic(size) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
_applyMosaicToRegion(layer.ctx, 0, 0, layer.canvas.width, layer.canvas.height, size);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
// canvas座標 (cx,cy) → layer.canvas 座標への逆変換
|
||||
_canvasToLayerCoords(layer, cx, cy) {
|
||||
const centerX = layer.x + layer.displayW / 2;
|
||||
const centerY = layer.y + layer.displayH / 2;
|
||||
const dx = cx - centerX, dy = cy - centerY;
|
||||
const angle = -(layer.rotation || 0) * Math.PI / 180;
|
||||
const rdx = dx * Math.cos(angle) - dy * Math.sin(angle);
|
||||
const rdy = dx * Math.sin(angle) + dy * Math.cos(angle);
|
||||
const scaleX = layer.displayW / layer.canvas.width;
|
||||
const scaleY = layer.displayH / layer.canvas.height;
|
||||
let lx = rdx / scaleX + layer.canvas.width / 2;
|
||||
let ly = rdy / scaleY + layer.canvas.height / 2;
|
||||
if (layer.flipX) lx = layer.canvas.width - lx;
|
||||
if (layer.flipY) ly = layer.canvas.height - ly;
|
||||
return { x: lx, y: ly };
|
||||
}
|
||||
|
||||
_drawPreview() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (!overlay) return;
|
||||
const ctx = overlay.getContext("2d");
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (!s || !c) return;
|
||||
const x = Math.min(s.x, c.x), y = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
const zoom = this._cb.getZoom();
|
||||
ctx.strokeStyle = this.rectMode === "blur" ? "#4af" : "#fa4";
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
ctx.setLineDash([4 / zoom, 2 / zoom]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
_applyRectEffect() {
|
||||
const layer = this._cb.getLayerManager()?.activeLayer;
|
||||
if (!layer || !this._dragStart || !this._dragCur) return;
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (Math.abs(c.x - s.x) < 3 || Math.abs(c.y - s.y) < 3) return;
|
||||
|
||||
const minX = Math.min(s.x, c.x), minY = Math.min(s.y, c.y);
|
||||
const maxX = Math.max(s.x, c.x), maxY = Math.max(s.y, c.y);
|
||||
const p1 = this._canvasToLayerCoords(layer, minX, minY);
|
||||
const p2 = this._canvasToLayerCoords(layer, maxX, maxY);
|
||||
|
||||
const lx = Math.round(Math.max(0, Math.min(p1.x, p2.x)));
|
||||
const ly = Math.round(Math.max(0, Math.min(p1.y, p2.y)));
|
||||
const lw = Math.round(Math.min(layer.canvas.width - lx, Math.abs(p2.x - p1.x)));
|
||||
const lh = Math.round(Math.min(layer.canvas.height - ly, Math.abs(p2.y - p1.y)));
|
||||
if (lw <= 0 || lh <= 0) return;
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (this.rectMode === "blur") {
|
||||
const amount = parseInt(document.getElementById("ie-rect-blur")?.value ?? "10");
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = layer.canvas.width; tmp.height = layer.canvas.height;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.drawImage(tmp, lx, ly, lw, lh, lx, ly, lw, lh);
|
||||
} else {
|
||||
const size = parseInt(document.getElementById("ie-rect-mosaic")?.value ?? "15");
|
||||
_applyMosaicToRegion(layer.ctx, lx, ly, lw, lh, size);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Image Edit Tab - File Export
|
||||
* Composites the visible layers (respecting mask-apply clipping) into a
|
||||
* flat canvas and offers three destinations: local PNG download, WFS
|
||||
* Gallery, and the selected node's image widget on the ComfyUI canvas
|
||||
* (Send to LI node).
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { Layer } from "./LayerManager.js";
|
||||
|
||||
export class FileExport {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => {w: number, h: number}} callbacks.getCanvasSize
|
||||
* @param {() => string} callbacks.getBaseName
|
||||
* @param {(ctx: CanvasRenderingContext2D, drawCanvas: HTMLCanvasElement, maskLayer: object, targetLayer: object, showOverlay: boolean) => void} callbacks.renderMaskedLayer
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
}
|
||||
|
||||
_buildCompositeCanvas() {
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return canvas;
|
||||
this._compositeForExport(canvas, layerMgr);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// 保存用合成: maskApply=true のクリッピングを適用、マスクオーバーレイは除外
|
||||
_compositeForExport(target, layerMgr) {
|
||||
const ctx = target.getContext("2d");
|
||||
ctx.clearRect(0, 0, target.width, target.height);
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (!layer.visible) continue;
|
||||
if (maskedIndices.has(i)) continue;
|
||||
if (layer.type === "mask") {
|
||||
if (layer.maskApply && i + 1 < layers.length) {
|
||||
this._cb.renderMaskedLayer(ctx, target, layer, layers[i + 1], false);
|
||||
}
|
||||
// maskApply=false のマスクはエクスポートに含めない
|
||||
} else {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = layer.opacity;
|
||||
ctx.globalCompositeOperation = layer.blendMode;
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用レイヤー配列を構築: 各レイヤーを個別のcanvas(ドキュメント全体サイズ)に
|
||||
// 回転・反転・拡縮のみベイクして描画する。opacity/blendModeはピクセルに焼き込まず
|
||||
// PSDレイヤー属性として渡すためメタデータのまま残す。
|
||||
// maskApply=true のクリッピングマスクは対象レイヤーと合成して1枚にベイクする
|
||||
// (PSDの本物のレイヤーマスクとしては書き出さない簡略化)。
|
||||
_buildLayerExportList(layerMgr, w, h) {
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const exportLayers = [];
|
||||
// 背面→前面の順(PSD側で後から追加したレイヤーほど上に重なるため、この順で送る)
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (maskedIndices.has(i)) continue; // マスク側の処理でまとめて扱う
|
||||
|
||||
if (layer.type === "mask") {
|
||||
if (!(layer.maskApply && layer.visible && i + 1 < layers.length)) continue;
|
||||
const target = layers[i + 1];
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
this._cb.renderMaskedLayer(canvas.getContext("2d"), canvas, layer, target, false);
|
||||
exportLayers.push({
|
||||
name: target.name || "Layer",
|
||||
visible: target.visible,
|
||||
opacity: 1,
|
||||
blendMode: "source-over",
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.save();
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
exportLayers.push({
|
||||
name: layer.name || "Layer",
|
||||
visible: layer.visible,
|
||||
opacity: layer.opacity,
|
||||
blendMode: layer.blendMode,
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
}
|
||||
return exportLayers;
|
||||
}
|
||||
|
||||
async saveAsPsd() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const exportLayers = this._buildLayerExportList(layerMgr, w, h);
|
||||
if (exportLayers.length === 0) { showToast("No layers to export", "error"); return; }
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/export-psd-layers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width: w, height: h, layers: exportLayers }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + ".psd";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("PSD saved", "success");
|
||||
} catch (err) {
|
||||
showToast(`PSD save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
savePng() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const a = document.createElement("a");
|
||||
a.href = canvas.toDataURL("image/png");
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + "-output.png";
|
||||
a.click();
|
||||
showToast("PNG saved", "success");
|
||||
}
|
||||
|
||||
async saveToGallery() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// デフォルトファイル名: wfs-image-YYYYMMDDHHmmss
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const defaultName = `wfs-image-${ts}`;
|
||||
|
||||
const filename = window.prompt("Save to Gallery — file name (without extension):", defaultName);
|
||||
if (filename === null) return; // キャンセル
|
||||
const safeName = filename.trim() || defaultName;
|
||||
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const imageData = canvas.toDataURL("image/png");
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: safeName, imageData }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
showToast(`Saved to Gallery: ${safeName}.png`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Gallery save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the composite via /upload/image, then (via window.opener,
|
||||
// since WFS runs in its own window — same cross-window pattern as
|
||||
// wfmReceiveWorkflow/wfmOpenMaskEditorForNode) writes the resulting
|
||||
// filename into the "image" widget of the currently selected ComfyUI
|
||||
// node (falling back to the first node in the graph with one), mirroring
|
||||
// the chat_TE custom node's "Send to workflow" button.
|
||||
async sendToWorkflow() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
|
||||
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
|
||||
return;
|
||||
}
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("overwrite", "true");
|
||||
try {
|
||||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||||
const data = await r.json();
|
||||
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
|
||||
window.opener.wfmSendImageToSelectedNode(filename);
|
||||
showToast(`Sent "${filename}" to LI node`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Send to LI node failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Image Edit Tab - Fill Tool (bucket fill)
|
||||
* Flood-fills a contiguous same-color region on the active layer canvas,
|
||||
* either with a solid color or a linear/radial gradient.
|
||||
* Gradient ramp math (evalGradient / hex<->rgb) is ported from
|
||||
* comfyUI-particle-pixijs's particle_engine.js.
|
||||
*/
|
||||
|
||||
function _hexToRgb01(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || "#000000");
|
||||
if (!m) return { r: 0, g: 0, b: 0 };
|
||||
return { r: parseInt(m[1], 16) / 255, g: parseInt(m[2], 16) / 255, b: parseInt(m[3], 16) / 255 };
|
||||
}
|
||||
|
||||
function _rgb01ToHex({ r, g, b }) {
|
||||
const h = v => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0");
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
function _lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
export class FillTool {
|
||||
constructor() {
|
||||
// fill settings
|
||||
this.tolerance = 32; // 0-255
|
||||
this.fillMode = "solid"; // "solid" | "gradient"
|
||||
this.color = "#ff0000"; // solid fill color
|
||||
this.opacity = 1.0;
|
||||
|
||||
// gradient settings
|
||||
this.gradientShape = "linear"; // "linear" | "radial"
|
||||
this.gradientAngleDeg = 0; // linear direction
|
||||
this.gradientStrength = 1.0; // 0.2-3.0, length/radius multiplier
|
||||
this.gradientStops = [
|
||||
{ pos: 0.0, color: "#ffffff" },
|
||||
{ pos: 1.0, color: "#888888" },
|
||||
];
|
||||
this.selectedStopIdx = 0;
|
||||
|
||||
// internal state
|
||||
this._active = false;
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas ? canvas.getContext("2d") : null;
|
||||
}
|
||||
|
||||
onChange(cb) { this._onChange = cb; }
|
||||
|
||||
activate() { this._active = true; }
|
||||
deactivate() { this._active = false; }
|
||||
|
||||
// ── color ramp evaluation (ported from particle_engine.js evalGradient) ──
|
||||
|
||||
static evalGradient(stops, t) {
|
||||
if (!stops || stops.length === 0) return { r: 0, g: 0, b: 0 };
|
||||
const sorted = [...stops].sort((a, b) => a.pos - b.pos);
|
||||
if (t <= sorted[0].pos) return _hexToRgb01(sorted[0].color);
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (t >= last.pos) return _hexToRgb01(last.color);
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i], b = sorted[i + 1];
|
||||
if (t >= a.pos && t <= b.pos) {
|
||||
const span = b.pos - a.pos;
|
||||
const localT = span > 0 ? (t - a.pos) / span : 0;
|
||||
const ca = _hexToRgb01(a.color), cb = _hexToRgb01(b.color);
|
||||
return {
|
||||
r: _lerp(ca.r, cb.r, localT),
|
||||
g: _lerp(ca.g, cb.g, localT),
|
||||
b: _lerp(ca.b, cb.b, localT),
|
||||
};
|
||||
}
|
||||
}
|
||||
return _hexToRgb01(last.color);
|
||||
}
|
||||
|
||||
/** Preview swatch color (hex) of the ramp at position t. */
|
||||
evalGradientHex(t) {
|
||||
return _rgb01ToHex(FillTool.evalGradient(this.gradientStops, t));
|
||||
}
|
||||
|
||||
// ── stop editing helpers (ported from particle_widget.js addStop/removeStop/selectStop) ──
|
||||
|
||||
addStop() {
|
||||
const sorted = [...this.gradientStops].sort((a, b) => a.pos - b.pos);
|
||||
let bestGapStart = 0, bestGapSize = 0;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].pos - sorted[i].pos;
|
||||
if (gap > bestGapSize) { bestGapSize = gap; bestGapStart = sorted[i].pos; }
|
||||
}
|
||||
const pos = sorted.length < 2 ? 0.5 : bestGapStart + bestGapSize / 2;
|
||||
const color = _rgb01ToHex(FillTool.evalGradient(this.gradientStops, pos));
|
||||
this.gradientStops.push({ pos, color });
|
||||
this.selectedStopIdx = this.gradientStops.length - 1;
|
||||
}
|
||||
|
||||
removeStop(idx = this.selectedStopIdx) {
|
||||
if (this.gradientStops.length <= 1) return;
|
||||
this.gradientStops.splice(idx, 1);
|
||||
this.selectedStopIdx = Math.max(0, Math.min(this.selectedStopIdx, this.gradientStops.length - 1));
|
||||
}
|
||||
|
||||
selectStop(idx) {
|
||||
this.selectedStopIdx = idx;
|
||||
}
|
||||
|
||||
// ── flood fill ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flood-fills the contiguous region matching the pixel at (x, y).
|
||||
* (x, y) are canvas-pixel coordinates (already converted via DrawTool.getCanvasPos).
|
||||
*/
|
||||
onMouseDown(x, y) {
|
||||
if (!this._active || !this._ctx) return;
|
||||
const w = this._canvas.width, h = this._canvas.height;
|
||||
const px = Math.floor(x), py = Math.floor(y);
|
||||
if (px < 0 || py < 0 || px >= w || py >= h) return;
|
||||
|
||||
const imgData = this._ctx.getImageData(0, 0, w, h);
|
||||
const data = imgData.data;
|
||||
|
||||
const mask = this._buildFloodMask(data, w, h, px, py, this.tolerance);
|
||||
if (!mask) return;
|
||||
|
||||
if (this.fillMode === "gradient") {
|
||||
this._fillMaskWithGradient(data, w, h, mask.pixels, mask.bbox);
|
||||
} else {
|
||||
this._fillMaskWithSolid(data, mask.pixels, this.color, this.opacity);
|
||||
}
|
||||
|
||||
this._ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
/** Scanline stack-based flood fill. Returns { pixels: Uint8Array(w*h), bbox } or null. */
|
||||
_buildFloodMask(data, w, h, startX, startY, tolerance) {
|
||||
const idx0 = (startY * w + startX) * 4;
|
||||
const tr = data[idx0], tg = data[idx0 + 1], tb = data[idx0 + 2], ta = data[idx0 + 3];
|
||||
const tol2 = tolerance * tolerance;
|
||||
|
||||
const matches = (i) => {
|
||||
const dr = data[i] - tr, dg = data[i + 1] - tg, db = data[i + 2] - tb, da = data[i + 3] - ta;
|
||||
return (dr * dr + dg * dg + db * db + da * da) <= tol2 * 4;
|
||||
};
|
||||
|
||||
const pixels = new Uint8Array(w * h);
|
||||
const stack = [[startX, startY]];
|
||||
let minX = startX, maxX = startX, minY = startY, maxY = startY;
|
||||
|
||||
while (stack.length) {
|
||||
let [x, y] = stack.pop();
|
||||
while (x >= 0 && matches((y * w + x) * 4)) x--;
|
||||
x++;
|
||||
let spanUp = false, spanDown = false;
|
||||
while (x < w && matches((y * w + x) * 4) && !pixels[y * w + x]) {
|
||||
pixels[y * w + x] = 1;
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
|
||||
if (y > 0) {
|
||||
const above = matches((( y - 1) * w + x) * 4) && !pixels[(y - 1) * w + x];
|
||||
if (above && !spanUp) { stack.push([x, y - 1]); spanUp = true; }
|
||||
else if (!above) spanUp = false;
|
||||
}
|
||||
if (y < h - 1) {
|
||||
const below = matches(((y + 1) * w + x) * 4) && !pixels[(y + 1) * w + x];
|
||||
if (below && !spanDown) { stack.push([x, y + 1]); spanDown = true; }
|
||||
else if (!below) spanDown = false;
|
||||
}
|
||||
x++;
|
||||
}
|
||||
}
|
||||
return { pixels, bbox: { minX, minY, maxX, maxY } };
|
||||
}
|
||||
|
||||
_fillMaskWithSolid(data, pixels, colorHex, opacity) {
|
||||
const { r, g, b } = _hexToRgb01(colorHex);
|
||||
const cr = r * 255, cg = g * 255, cb = b * 255;
|
||||
const alpha = Math.max(0, Math.min(1, opacity));
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
if (!pixels[i]) continue;
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], cr, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], cg, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], cb, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable with any {pixels, bbox} mask (flood fill today; rect/ellipse selections in the future). */
|
||||
_fillMaskWithGradient(data, w, h, pixels, bbox) {
|
||||
const cx = (bbox.minX + bbox.maxX) / 2, cy = (bbox.minY + bbox.maxY) / 2;
|
||||
const diagW = bbox.maxX - bbox.minX, diagH = bbox.maxY - bbox.minY;
|
||||
const diag = Math.max(1, Math.hypot(diagW, diagH));
|
||||
const strength = Math.max(0.2, Math.min(3.0, this.gradientStrength));
|
||||
const alpha = Math.max(0, Math.min(1, this.opacity));
|
||||
|
||||
const angleRad = this.gradientAngleDeg * Math.PI / 180;
|
||||
const dirX = Math.cos(angleRad), dirY = Math.sin(angleRad);
|
||||
const halfLen = (diag / 2) * strength;
|
||||
const radius = (diag / 2) * strength;
|
||||
|
||||
for (let y = bbox.minY; y <= bbox.maxY; y++) {
|
||||
for (let x = bbox.minX; x <= bbox.maxX; x++) {
|
||||
const i = y * w + x;
|
||||
if (!pixels[i]) continue;
|
||||
const dx = x - cx, dy = y - cy;
|
||||
let t;
|
||||
if (this.gradientShape === "radial") {
|
||||
t = Math.hypot(dx, dy) / radius;
|
||||
} else {
|
||||
t = (dx * dirX + dy * dirY) / (halfLen * 2) + 0.5;
|
||||
}
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const c = FillTool.evalGradient(this.gradientStops, t);
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], c.r * 255, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], c.g * 255, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], c.b * 255, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Image Edit Tab - G'MIC Integration
|
||||
* Launches the G'MIC GUI on the server for the active layer, polls job
|
||||
* status, and applies the resulting image back onto the layer.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class GmicIntegration {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.syncActiveLayerFromCanvas
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.state = {
|
||||
lastResultJobId: null,
|
||||
processing: false,
|
||||
aborted: false
|
||||
};
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const openBtnDisabled = this.state.processing ? "disabled" : "";
|
||||
const applyBtnDisabled = (!this.state.lastResultJobId || this.state.processing) ? "disabled" : "";
|
||||
const progressStyle = this.state.processing ? "display:flex" : "display:none";
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-gmic-open-btn" ${openBtnDisabled}>G'MIC GUIで編集</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-apply-btn" ${applyBtnDisabled}>結果を反映</button>
|
||||
</div>
|
||||
<div class="ie-opt-group" id="ie-gmic-progress-area" style="${progressStyle}; align-items:center; gap:6px;">
|
||||
<span id="ie-gmic-progress-lbl" style="font-size:11px; color:var(--wfm-text-secondary);">G'MIC GUIを起動中...</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-abort-btn" style="background:#ea4335;color:#fff;">中断</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("ie-gmic-open-btn")?.addEventListener("click", () => this.openGui());
|
||||
document.getElementById("ie-gmic-apply-btn")?.addEventListener("click", () => this.applyResult());
|
||||
document.getElementById("ie-gmic-abort-btn")?.addEventListener("click", () => this.abort());
|
||||
}
|
||||
|
||||
async openGui() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.syncActiveLayerFromCanvas();
|
||||
|
||||
if (this.state.processing) return;
|
||||
|
||||
const dataUrl = layer.canvas.toDataURL("image/png");
|
||||
|
||||
this.state.processing = true;
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
if (progressArea) progressArea.style.display = "flex";
|
||||
if (progressLbl) progressLbl.textContent = "画像をサーバーへ送信中...";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/gmic/open", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_b64: dataUrl })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
this.state.lastResultJobId = data.job_id;
|
||||
if (progressLbl) progressLbl.textContent = "G'MIC GUIで編集中... フィルターを選択して「OK」を押すと適用可能になります";
|
||||
await this._waitForJob(data.job_id);
|
||||
} catch (e) {
|
||||
if (e.message !== "__aborted__") {
|
||||
showToast("G'MIC Error: " + e.message, "error");
|
||||
}
|
||||
this.state.processing = false;
|
||||
this.state.aborted = false;
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.state.aborted = true;
|
||||
this.state.processing = false;
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
}
|
||||
|
||||
async _waitForJob(jobId) {
|
||||
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
const maxWait = 600, interval = 2000;
|
||||
const start = Date.now();
|
||||
this.state.aborted = false;
|
||||
|
||||
while (true) {
|
||||
if (this.state.aborted) throw new Error("__aborted__");
|
||||
if ((Date.now() - start) / 1000 > maxWait) throw new Error("Timeout");
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
if (this.state.aborted) throw new Error("__aborted__");
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/gmic/status/${jobId}`);
|
||||
if (res.status === 404) throw new Error("__aborted__");
|
||||
if (!res.ok) continue;
|
||||
const status = await res.json();
|
||||
if (status.status === "completed") {
|
||||
this.state.lastResultJobId = jobId;
|
||||
if (applyBtn) applyBtn.disabled = false;
|
||||
if (progressLbl) progressLbl.textContent = "処理完了 → 「結果を反映」で画像に適用";
|
||||
this.state.processing = false;
|
||||
showToast("G'MIC filtering complete. Click Apply to insert result.", "success");
|
||||
return;
|
||||
}
|
||||
if (status.status === "failed") {
|
||||
if (progressLbl) progressLbl.textContent = status.error || "G'MIC GUIがキャンセルされました";
|
||||
throw new Error("__aborted__");
|
||||
}
|
||||
if (progressLbl) progressLbl.textContent = status.message || "G'MIC GUIで編集中...";
|
||||
} catch (e) {
|
||||
if (e.message === "__aborted__" || e.message.includes("Timeout")) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyResult() {
|
||||
if (!this.state.lastResultJobId) {
|
||||
showToast("No G'MIC result to apply", "error");
|
||||
return;
|
||||
}
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`/api/wfm/gmic/status/${this.state.lastResultJobId}`);
|
||||
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
|
||||
const statusData = await statusRes.json();
|
||||
if (!statusData.result_path) throw new Error("No result path found");
|
||||
|
||||
const b64res = await fetch("/api/wfm/gmic/result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result_path: statusData.result_path })
|
||||
});
|
||||
if (!b64res.ok) throw new Error(`HTTP ${b64res.status}`);
|
||||
const { image_b64: dataUrl } = await b64res.json();
|
||||
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("Failed to load result image"));
|
||||
i.src = dataUrl;
|
||||
});
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
const layer = layerMgr?.activeLayer;
|
||||
if (!layer) throw new Error("No active layer");
|
||||
|
||||
layer.canvas.width = img.width;
|
||||
layer.canvas.height = img.height;
|
||||
layer.ctx = layer.canvas.getContext("2d");
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast("G'MIC filter applied successfully", "success");
|
||||
|
||||
// Reset G'MIC status
|
||||
this.state.lastResultJobId = null;
|
||||
this.state.processing = false;
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
this._cb.renderToolOptions("filter");
|
||||
} catch (err) {
|
||||
showToast("Failed to apply G'MIC result: " + err.message, "error");
|
||||
if (applyBtn) applyBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Image Edit Tab - Inpaint / I2I Actions
|
||||
* Composites the active mask layer against the background and runs it
|
||||
* through GenerateUI's currently loaded workflow (or a dedicated one) for
|
||||
* inpainting, plus a mask-less I2I variant. Also exposes the external
|
||||
* entry points used by other same-origin panels (e.g. Comic Creator).
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { comfyUI } from "../comfyui-client.js";
|
||||
import { comfyEditor } from "../comfyui-editor.js";
|
||||
import { comfyWorkflow } from "../comfyui-workflow.js";
|
||||
|
||||
export class InpaintI2IActions {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
|
||||
this.positive = "";
|
||||
this.negative = "";
|
||||
this.growMaskBy = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this.denoise = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this.running = false;
|
||||
this.resultUrl = null;
|
||||
this.i2iExternalRunning = false;
|
||||
|
||||
// Inpaint 専用ワークフロー(OFF時は GenerateUI で読み込み中のワークフローを使用)
|
||||
this.useDedicated = false;
|
||||
this.dedicatedFilename = null;
|
||||
this.workflowList = [];
|
||||
}
|
||||
|
||||
async fetchWorkflowList(onLoaded) {
|
||||
try {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows`);
|
||||
if (!resp.ok) return;
|
||||
const list = await resp.json();
|
||||
this.workflowList = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
onLoaded?.();
|
||||
} catch {
|
||||
this.workflowList = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
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 = "Inpaint";
|
||||
|
||||
// 未初期化ならワークフローの現在値から初期化(VAEEncodeForInpaint.grow_mask_by / KSampler.denoise)
|
||||
if (this.growMaskBy == null) {
|
||||
this.growMaskBy = comfyUI.currentAnalysis?.inpaint_encode_nodes?.[0]?.grow_mask_by ?? 6;
|
||||
}
|
||||
if (this.denoise == null) {
|
||||
const samplerNode = (comfyUI.currentAnalysis?.sampler_nodes || []).find((n) => n.denoise !== undefined);
|
||||
this.denoise = samplerNode?.denoise ?? 1.0;
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-inpaint-use-dedicated" ${this.useDedicated ? "checked" : ""}>
|
||||
Use dedicated workflow
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-props-row" id="ie-inpaint-dedicated-row" style="${this.useDedicated ? "" : "display:none;"}">
|
||||
<select id="ie-inpaint-dedicated-select" style="flex:1;">
|
||||
<option value="">-- select workflow --</option>
|
||||
${this.workflowList.map((f) => `<option value="${f}" ${f === this.dedicatedFilename ? "selected" : ""}>${f}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="ie-inpaint-positive" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.positive}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea id="ie-inpaint-negative" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.negative}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Grow Mask By</label>
|
||||
<input type="number" id="ie-inpaint-grow-mask" min="0" max="200" step="1" value="${this.growMaskBy}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Denoise</label>
|
||||
<input type="number" id="ie-inpaint-denoise" min="0" max="1" step="0.01" value="${this.denoise}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-inpaint-run-btn" style="flex:1;" ${this.running ? "disabled" : ""}>
|
||||
${this.running ? "Running..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
<span id="ie-inpaint-status" style="font-size:11px;color:var(--wfm-text-secondary);"></span>
|
||||
${this.resultUrl ? `
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Result</label>
|
||||
<img src="${this.resultUrl}" style="max-width:100%;border-radius:var(--wfm-radius-sm);border:1px solid var(--wfm-border);">
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
|
||||
document.getElementById("ie-inpaint-use-dedicated")?.addEventListener("change", e => {
|
||||
this.useDedicated = e.target.checked;
|
||||
this.renderPanel();
|
||||
});
|
||||
document.getElementById("ie-inpaint-dedicated-select")?.addEventListener("change", e => {
|
||||
this.dedicatedFilename = e.target.value || null;
|
||||
});
|
||||
document.getElementById("ie-inpaint-positive")?.addEventListener("input", e => { this.positive = e.target.value; });
|
||||
document.getElementById("ie-inpaint-negative")?.addEventListener("input", e => { this.negative = e.target.value; });
|
||||
document.getElementById("ie-inpaint-grow-mask")?.addEventListener("input", e => {
|
||||
this.growMaskBy = parseInt(e.target.value) || 0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-denoise")?.addEventListener("input", e => {
|
||||
this.denoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
|
||||
if (Number.isNaN(this.denoise)) this.denoise = 1.0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-run-btn")?.addEventListener("click", () => this.run());
|
||||
}
|
||||
|
||||
_exportMaskCanvas(maskLayer) {
|
||||
const w = maskLayer.canvas.width, h = maskLayer.canvas.height;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(maskLayer.canvas, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (this.running) return;
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// 非表示のマスクレイヤーはInpaint対象から除外する(目玉アイコンOFF=対象外)
|
||||
const maskLayer = (layerMgr.activeLayer?.type === "mask" && layerMgr.activeLayer.visible)
|
||||
? layerMgr.activeLayer
|
||||
: layerMgr.layers.find(l => l.type === "mask" && l.visible);
|
||||
if (!maskLayer) { showToast("Create or show a mask layer first (Mask tool)", "info"); return; }
|
||||
|
||||
if (this.useDedicated) {
|
||||
if (!this.dedicatedFilename) { showToast("Select a dedicated workflow first", "info"); return; }
|
||||
} else if (!comfyUI.currentAnalysis) {
|
||||
showToast("Load a workflow in GenerateUI first", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const runBtn = document.getElementById("ie-inpaint-run-btn");
|
||||
const statusEl = document.getElementById("ie-inpaint-status");
|
||||
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
this.running = true;
|
||||
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
|
||||
setStatus(this.useDedicated ? "Loading dedicated workflow..." : "Compositing...");
|
||||
|
||||
try {
|
||||
// 専用ワークフロー使用時は GenerateUI の comfyUI.currentWorkflow/currentAnalysis には
|
||||
// 一切触れず、ここでロード・解析したローカルなワークフロー/解析結果だけを使う。
|
||||
let overrideOpts = {};
|
||||
|
||||
if (this.useDedicated) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(this.dedicatedFilename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let dedicatedWorkflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(dedicatedWorkflow, this.dedicatedFilename);
|
||||
if (format === "ui") {
|
||||
dedicatedWorkflow = await comfyWorkflow.convertUiToApi(dedicatedWorkflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
overrideOpts = { workflow: dedicatedWorkflow, analysis: comfyWorkflow.analyzeWorkflow(dedicatedWorkflow) };
|
||||
}
|
||||
|
||||
setStatus("Compositing...");
|
||||
const bgCanvas = this._cb.buildBgCanvas();
|
||||
const imageBlob = await new Promise((resolve) => bgCanvas.toBlob(resolve, "image/png"));
|
||||
const maskCanvas = this._exportMaskCanvas(maskLayer);
|
||||
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
|
||||
|
||||
const resultUrl = await this._runWithImages(imageBlob, maskBlob, {
|
||||
positive: this.positive,
|
||||
negative: this.negative,
|
||||
growMaskBy: this.growMaskBy,
|
||||
denoise: this.denoise,
|
||||
overrideOpts,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
if (resultUrl) this.resultUrl = resultUrl;
|
||||
|
||||
setStatus("Done");
|
||||
showToast("Inpaint generation complete", "success");
|
||||
} catch (err) {
|
||||
setStatus("Error");
|
||||
showToast(`Inpaint failed: ${err.message}`, "error");
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.renderPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像+マスクBlobを対象ノード(LoadImage/MaskEditorOne)へ反映し、
|
||||
// プロンプト/grow_mask_by/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// UI駆動の run() と外部(Comic Creator等)向けの runExternal() の両方から使う。
|
||||
async _runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
// LoadImage(の MASK 出力が使われている)スロットを優先し、無ければ Mask Editor One
|
||||
// ノード(LoadImage を介さず画像/マスクを内包する custom node)にフォールバックする
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
const slotIndex = (analysisForLookup.load_image_nodes || []).findIndex(n => n.mask_used);
|
||||
const meoNode = (analysisForLookup.mask_editor_one_nodes || [])[0];
|
||||
if (slotIndex === -1 && !meoNode) {
|
||||
throw new Error("No inpaint-capable LoadImage or Mask Editor One node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
if (slotIndex !== -1) {
|
||||
await comfyEditor.applyImageAndMaskToSlot(imageBlob, maskBlob, slotIndex, overrideOpts);
|
||||
} else {
|
||||
await comfyEditor.applyImageAndMaskToMaskEditorOneNode(imageBlob, maskBlob, meoNode.id, overrideOpts);
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ growMaskBy, denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント。
|
||||
// 専用ワークフロー選択(useDedicated)は扱わず、常にGenerate UIに現在
|
||||
// ロード中のワークフロー(comfyUI.currentWorkflow/currentAnalysis)を対象にする。
|
||||
async runExternal(imageBlob, maskBlob, { positive = "", negative = "", growMaskBy = 6, denoise = 1.0 } = {}) {
|
||||
if (this.running) throw new Error("Inpaint is already running");
|
||||
this.running = true;
|
||||
try {
|
||||
const resultUrl = await this._runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像Blob(マスクなし)を対象LoadImageノード(slot 0)へ反映し、
|
||||
// プロンプト/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// _runWithImages のマスクなし版(Comic CreatorのSelect I2I連携専用)。
|
||||
async _runI2IWithImage(imageBlob, { positive, negative, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
if (!(analysisForLookup.load_image_nodes?.length > 0)) {
|
||||
throw new Error("No LoadImage node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
const file = new File([imageBlob], `i2i_${Math.random().toString(36).slice(2, 10)}.png`, { type: "image/png" });
|
||||
await comfyEditor.applyImageToSlot(file, 0, overrideOpts);
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント(I2I版)。
|
||||
// runExternal と対になる、マスク不要のシンプルなI2I実行。
|
||||
async runI2IExternal(imageBlob, { positive = "", negative = "", denoise = 1.0 } = {}) {
|
||||
if (this.i2iExternalRunning) throw new Error("I2I is already running");
|
||||
this.i2iExternalRunning = true;
|
||||
try {
|
||||
const resultUrl = await this._runI2IWithImage(imageBlob, { positive, negative, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this.i2iExternalRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Image Edit Tab - Mask Editor One 連携ブリッジ
|
||||
* WFS Image Edit タブの合成画像を、ComfyUIキャンバス上のMaskEditorOneノード(選択中のノードを
|
||||
* 優先、無ければグラフ内で最初に見つかったもの)へ送り、window.opener(ComfyUIタブ)側で実際の
|
||||
* モーダルを開いて編集させ、Apply後の結果マスクを新規マスクレイヤーとしてWFS側に取り込む。
|
||||
* 「Send to Workflow」の image ウィジェット探索と同じ「選択中優先・フォールバック」方式のため、
|
||||
* WFS側のワークフロー読み込み状況とは無関係に動作する。
|
||||
* モーダル自体は ComfyUI ページの LiteGraph ノードに強く依存するため、WFS 側からは
|
||||
* window.opener.wfmOpenMaskEditorForNode()(web/comfyui/node_sets_menu.js 側で定義)を介して操作する。
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class MaskEditorOneBridge {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
get bridgeReady() {
|
||||
return !!(window.opener && typeof window.opener.wfmOpenMaskEditorForNode === "function");
|
||||
}
|
||||
|
||||
async openEditor() {
|
||||
if (this.running) return;
|
||||
if (!this.bridgeReady) {
|
||||
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
|
||||
return;
|
||||
}
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
const dataUrl = this._cb.buildBgCanvas().toDataURL("image/png");
|
||||
// アクティブレイヤーが既存のマスクレイヤーなら、その内容もそのまま Mask Editor One の
|
||||
// 初期マスクとして渡す(画像だけでなく、今描いているマスクも引き継ぐ)
|
||||
const activeLayer = layerMgr.activeLayer;
|
||||
const existingMaskDataUrl = activeLayer?.type === "mask" ? activeLayer.canvas.toDataURL("image/png") : null;
|
||||
window.opener.focus();
|
||||
showToast("Switch to the ComfyUI tab, edit the mask, then click Apply", "info");
|
||||
|
||||
const maskDataUrl = await window.opener.wfmOpenMaskEditorForNode(dataUrl, existingMaskDataUrl);
|
||||
if (!maskDataUrl) {
|
||||
showToast("Mask Editor One: cancelled (no changes)", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
this._cb.saveUndo();
|
||||
const maskLayer = layerMgr.addLayer("mask", "Mask Editor One", {
|
||||
contentW: layerMgr.width, contentH: layerMgr.height,
|
||||
displayW: layerMgr.width, displayH: layerMgr.height,
|
||||
x: 0, y: 0,
|
||||
});
|
||||
layerMgr.setActive(maskLayer.id);
|
||||
await this._applyMask(maskLayer, maskDataUrl);
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast("Mask Editor One: mask imported", "success");
|
||||
} catch (err) {
|
||||
showToast("Mask Editor One error: " + err.message, "error");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// グレースケール輝度 → アルファ白マスクに変換して書き込む(Sam3Segmentation._applyMask と同じ変換)
|
||||
_applyMask(maskLayer, maskDataUrl) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const W = maskLayer.canvas.width;
|
||||
const H = maskLayer.canvas.height;
|
||||
const off = document.createElement("canvas");
|
||||
off.width = W; off.height = H;
|
||||
const mc = off.getContext("2d");
|
||||
mc.drawImage(img, 0, 0, W, H);
|
||||
const imgData = mc.getImageData(0, 0, W, H);
|
||||
const d = imgData.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const lum = d[i];
|
||||
d[i] = d[i + 1] = d[i + 2] = 255;
|
||||
d[i + 3] = lum;
|
||||
}
|
||||
mc.putImageData(imgData, 0, 0);
|
||||
maskLayer.ctx.drawImage(off, 0, 0);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = resolve;
|
||||
img.src = maskDataUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Image Edit Tab - SAM3 Segmentation (Mask Editor One integration)
|
||||
* Text-prompted segmentation via the Mask Editor One SAM3 server endpoints.
|
||||
* Rendered as a subtool of the Mask tool; the toolbar shell itself stays in
|
||||
* ImageEditTab (shared with the other mask subtools), but the prompt/run
|
||||
* controls and the result thumbnail grid are owned here.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class Sam3Segmentation {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
* @param {(sub: string) => void} callbacks.renderMaskProps
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.available = false;
|
||||
this.results = []; // [{mask_b64, score, area}, ...]
|
||||
this.prompt = "";
|
||||
this.maxMasks = 9;
|
||||
this.loading = false;
|
||||
this.mode = "add"; // "add" | "erase"
|
||||
this.selected = new Set(); // 選択中の結果インデックス
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const resp = await fetch("/mask_editor/sam3/status");
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
this.available = json.loaded === true || json.ckpt_found === true;
|
||||
} catch {
|
||||
this.available = false;
|
||||
}
|
||||
}
|
||||
|
||||
// マスクツールバー内、SAM3選択時にのみ表示するprompt/max/runコントロール
|
||||
renderToolbarExtra(activeSub) {
|
||||
if (!this.available || activeSub !== "sam3") return "";
|
||||
return `
|
||||
<div class="ie-opt-group">
|
||||
<input type="text" id="ie-sam3-prompt" class="ie-opt-input"
|
||||
placeholder="e.g. cat, person..."
|
||||
value="${this.prompt}"
|
||||
style="width:160px;font-size:11px;padding:2px 6px;border:1px solid var(--wfm-border);border-radius:3px;background:var(--wfm-surface);color:var(--wfm-text);">
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;color:var(--wfm-text-secondary);">Max</label>
|
||||
<select id="ie-sam3-max" class="ie-opt-select" style="width:44px;">
|
||||
${[3,6,9,12].map(n => `<option value="${n}"${n === this.maxMasks ? " selected" : ""}>${n}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-run-btn" ${this.loading ? "disabled" : ""}>
|
||||
${this.loading ? "Running..." : "Segment"}
|
||||
</button>
|
||||
</div>
|
||||
<span id="ie-sam3-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;">
|
||||
${this.results.length > 0 ? `${this.results.length} masks found` : ""}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
bindToolbarEvents() {
|
||||
document.getElementById("ie-sam3-prompt")?.addEventListener("input", e => {
|
||||
this.prompt = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-sam3-max")?.addEventListener("change", e => {
|
||||
this.maxMasks = parseInt(e.target.value);
|
||||
});
|
||||
document.getElementById("ie-sam3-run-btn")?.addEventListener("click", () => this.runSegment());
|
||||
}
|
||||
|
||||
// props ペイン("sam3" サブツール選択時): モード切替 + 結果サムネイルグリッド
|
||||
renderResultsPanel(body) {
|
||||
const modeAdd = this.mode === "add";
|
||||
const selCount = this.selected.size;
|
||||
const hasResult = this.results.length > 0;
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label>Mode</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-add" style="flex:1;">Add</button>
|
||||
<button class="wfm-btn wfm-btn-sm${!modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-erase" style="flex:1;">Erase</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasResult ? `
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);">Click to select / deselect:</div>
|
||||
<div id="ie-sam3-results" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;">
|
||||
${this.results.map((r, i) => {
|
||||
const sel = this.selected.has(i);
|
||||
const borderColor = sel ? "var(--wfm-primary,#4682e6)" : "var(--wfm-border)";
|
||||
const bg = sel ? "color-mix(in srgb,var(--wfm-primary,#4682e6) 15%,transparent)" : "transparent";
|
||||
return `<div class="ie-sam3-thumb" data-idx="${i}"
|
||||
style="cursor:pointer;border:2px solid ${borderColor};border-radius:4px;overflow:hidden;text-align:center;background:${bg};position:relative;">
|
||||
<img src="${r.mask_b64}" style="width:100%;display:block;background:#000;">
|
||||
<div style="font-size:10px;padding:2px 0;color:var(--wfm-text-secondary);">
|
||||
${Math.round((r.score ?? 0) * 100)}%
|
||||
</div>
|
||||
${sel ? '<div style="position:absolute;top:2px;right:3px;font-size:12px;line-height:1;color:var(--wfm-primary,#4682e6);">✓</div>' : ""}
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-apply-btn"
|
||||
${selCount === 0 ? "disabled" : ""} style="margin-top:4px;">
|
||||
Apply Selected${selCount > 0 ? ` (${selCount})` : ""}
|
||||
</button>
|
||||
</div>` : `
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);padding:4px 0;">
|
||||
${this.loading ? "Segmenting..." : "Enter a prompt and press Segment"}
|
||||
</div>`}
|
||||
`;
|
||||
document.getElementById("ie-sam3-mode-add")?.addEventListener("click", () => {
|
||||
this.mode = "add";
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
document.getElementById("ie-sam3-mode-erase")?.addEventListener("click", () => {
|
||||
this.mode = "erase";
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
body.querySelectorAll(".ie-sam3-thumb").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const idx = parseInt(el.dataset.idx);
|
||||
if (this.selected.has(idx)) this.selected.delete(idx);
|
||||
else this.selected.add(idx);
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
});
|
||||
document.getElementById("ie-sam3-apply-btn")?.addEventListener("click", () => {
|
||||
this.applySelectedMasks();
|
||||
});
|
||||
}
|
||||
|
||||
async runSegment() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr || this.loading) return;
|
||||
const prompt = this.prompt.trim();
|
||||
if (!prompt) { showToast("Please enter a prompt", "warning"); return; }
|
||||
|
||||
// 推論対象: アクティブレイヤー(マスクなら下のイメージレイヤーを探す)
|
||||
let imageLayer = layerMgr.activeLayer;
|
||||
if (!imageLayer || imageLayer.type === "mask") {
|
||||
imageLayer = layerMgr.layers.find(l => l.type !== "mask" && l.visible);
|
||||
}
|
||||
if (!imageLayer) { showToast("No image layer found", "error"); return; }
|
||||
|
||||
const NODE_ID = "wfs_sam3";
|
||||
this.loading = true;
|
||||
this.results = [];
|
||||
this.selected = new Set();
|
||||
this._cb.renderToolOptions("mask");
|
||||
this._cb.renderMaskProps("sam3");
|
||||
|
||||
try {
|
||||
const dataUrl = imageLayer.canvas.toDataURL("image/png");
|
||||
await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
|
||||
});
|
||||
|
||||
const resp = await fetch("/mask_editor/sam3/segment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, prompt, max_masks: this.maxMasks }),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
this.results = json.masks || [];
|
||||
if (this.results.length === 0) showToast("No masks found", "warning");
|
||||
else showToast(`${this.results.length} mask(s) found`, "success");
|
||||
} catch (err) {
|
||||
showToast("SAM3 error: " + err.message, "error");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this._cb.renderToolOptions("mask");
|
||||
this._cb.renderMaskProps("sam3");
|
||||
}
|
||||
}
|
||||
|
||||
async applySelectedMasks() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr || this.selected.size === 0) return;
|
||||
const indices = [...this.selected].sort((a, b) => a - b);
|
||||
const masks = indices.map(i => this.results[i]).filter(Boolean);
|
||||
if (masks.length === 0) return;
|
||||
|
||||
let maskLayer = layerMgr.activeLayer;
|
||||
if (!maskLayer || maskLayer.type !== "mask") {
|
||||
const ref = layerMgr.activeLayer;
|
||||
maskLayer = layerMgr.addLayer("mask", "SAM3 Mask", {
|
||||
contentW: ref?.canvas.width ?? layerMgr.width,
|
||||
contentH: ref?.canvas.height ?? layerMgr.height,
|
||||
displayW: ref?.displayW ?? layerMgr.width,
|
||||
displayH: ref?.displayH ?? layerMgr.height,
|
||||
x: ref?.x ?? 0, y: ref?.y ?? 0,
|
||||
});
|
||||
layerMgr.setActive(maskLayer.id);
|
||||
}
|
||||
|
||||
this._cb.saveUndo();
|
||||
for (const r of masks) {
|
||||
await this._applyMask(maskLayer, r.mask_b64, this.mode);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast(`SAM3: ${masks.length} mask(s) applied (${this.mode})`, "success");
|
||||
}
|
||||
|
||||
_applyMask(maskLayer, maskB64, mode = "add") {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const W = maskLayer.canvas.width;
|
||||
const H = maskLayer.canvas.height;
|
||||
// グレースケール輝度 → アルファ白マスクに変換
|
||||
const off = document.createElement("canvas");
|
||||
off.width = W; off.height = H;
|
||||
const mc = off.getContext("2d");
|
||||
mc.drawImage(img, 0, 0, W, H);
|
||||
const imgData = mc.getImageData(0, 0, W, H);
|
||||
const d = imgData.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const lum = d[i];
|
||||
d[i] = d[i+1] = d[i+2] = 255;
|
||||
d[i+3] = lum;
|
||||
}
|
||||
mc.putImageData(imgData, 0, 0);
|
||||
// モードに合わせてマスクキャンバスに合成
|
||||
maskLayer.ctx.save();
|
||||
maskLayer.ctx.globalCompositeOperation = mode === "erase" ? "destination-out" : "source-over";
|
||||
maskLayer.ctx.drawImage(off, 0, 0);
|
||||
maskLayer.ctx.restore();
|
||||
resolve();
|
||||
};
|
||||
img.onerror = resolve;
|
||||
img.src = maskB64;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* ImagePrompt Gallery — Gallery タブのサブタブ。
|
||||
* ws_image_prompt フォルダ配下の画像+.txtサイドカーを閲覧し、
|
||||
* クリックで選択→「追加」でプロンプトを組み立てて最終的にコピーする。
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
const API = {
|
||||
root: () => `/wfm/gallery/image-prompt/root`,
|
||||
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
|
||||
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
|
||||
imageMeta: (path) => `/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`,
|
||||
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
|
||||
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
|
||||
savePrompt: `/wfm/gallery/image/image-prompt`,
|
||||
};
|
||||
|
||||
const state = {
|
||||
root: null,
|
||||
currentFolder: null,
|
||||
search: "",
|
||||
images: [],
|
||||
selectedImage: null,
|
||||
chips: [], // [{ path, filename, prompt }]
|
||||
};
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 初期化 ────────────────────────────────────────────────────
|
||||
|
||||
export function initImagePromptTab() {
|
||||
document.getElementById("wfm-imageprompt-refresh-btn")?.addEventListener("click", () => {
|
||||
loadFolderTree();
|
||||
loadImages();
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-imageprompt-search", "wfm-imageprompt-search-clear-btn", () => {
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById("wfm-imageprompt-search");
|
||||
searchInput?.addEventListener("input", debounce(() => {
|
||||
state.search = searchInput.value.trim();
|
||||
loadImages();
|
||||
}, 300));
|
||||
|
||||
document.getElementById("wfm-imageprompt-add-btn")?.addEventListener("click", addSelectedToChips);
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn")?.addEventListener("click", saveSelectedPrompt);
|
||||
document.getElementById("wfm-imageprompt-clear-btn")?.addEventListener("click", clearAllChips);
|
||||
document.getElementById("wfm-imageprompt-copy-btn")?.addEventListener("click", copyFinalPrompt);
|
||||
}
|
||||
|
||||
export async function activateImagePromptTab() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
const data = await apiFetch(API.root());
|
||||
state.root = data.root;
|
||||
await loadFolderTree();
|
||||
} catch (e) {
|
||||
const tree = document.getElementById("wfm-imageprompt-tree");
|
||||
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
async function loadFolderTree() {
|
||||
if (!state.root) return;
|
||||
const tree = document.getElementById("wfm-imageprompt-tree");
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
try {
|
||||
const data = await apiFetch(API.folders(state.root));
|
||||
if (data.error) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
|
||||
return;
|
||||
}
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
|
||||
if (firstLabel) firstLabel.click();
|
||||
} catch (e) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTreeNode(node, container, depth, isRoot) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-gallery-tree-item";
|
||||
item.style.paddingLeft = `${depth * 12}px`;
|
||||
item.dataset.path = node.abs_path;
|
||||
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const absPath = node.abs_path;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "wfm-gallery-tree-arrow";
|
||||
if (hasChildren) {
|
||||
arrow.textContent = "▶";
|
||||
arrow.dataset.expanded = "false";
|
||||
} else {
|
||||
arrow.textContent = " ";
|
||||
arrow.style.visibility = "hidden";
|
||||
}
|
||||
item.appendChild(arrow);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
// サブフォルダ内も含めた合計件数(クリックで再帰的に全画像を表示するため)
|
||||
const totalCount = node.image_count_total ?? node.image_count;
|
||||
if (totalCount > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
badge.textContent = totalCount;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
item.appendChild(label);
|
||||
|
||||
label.addEventListener("click", () => {
|
||||
document.querySelectorAll("#wfm-imageprompt-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
|
||||
item.classList.add("selected");
|
||||
state.currentFolder = absPath;
|
||||
loadImages();
|
||||
});
|
||||
|
||||
if (hasChildren) {
|
||||
const childContainer = document.createElement("div");
|
||||
childContainer.className = "wfm-gallery-tree-children";
|
||||
childContainer.style.display = "none";
|
||||
|
||||
arrow.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const expanded = arrow.dataset.expanded === "true";
|
||||
if (!expanded) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
if (childContainer.children.length === 0) {
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
arrow.dataset.expanded = "false";
|
||||
arrow.textContent = "▶";
|
||||
childContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画像一覧 ─────────────────────────────────────────────────
|
||||
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
const grid = document.getElementById("wfm-imageprompt-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
|
||||
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
|
||||
if (state.search) params.search = state.search;
|
||||
|
||||
try {
|
||||
const data = await apiFetch(API.images(params));
|
||||
state.images = data.images || [];
|
||||
document.getElementById("wfm-imageprompt-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const grid = document.getElementById("wfm-imageprompt-grid");
|
||||
if (state.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
|
||||
grid.appendChild(fragment);
|
||||
}
|
||||
|
||||
function createThumbCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-gallery-thumb-card";
|
||||
card.title = img.filename;
|
||||
card.dataset.path = img.path;
|
||||
if (state.selectedImage && state.selectedImage.path === img.path) {
|
||||
card.classList.add("selected");
|
||||
}
|
||||
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "wfm-gallery-thumb-img";
|
||||
imgEl.loading = "lazy";
|
||||
imgEl.src = API.thumb(img.path);
|
||||
imgEl.alt = img.filename;
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "wfm-gallery-thumb-placeholder";
|
||||
placeholder.textContent = "?";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
};
|
||||
card.appendChild(imgEl);
|
||||
|
||||
card.addEventListener("click", () => selectImage(img));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 選択画像 / プロンプト表示 ─────────────────────────────────
|
||||
|
||||
async function selectImage(img) {
|
||||
state.selectedImage = img;
|
||||
document.querySelectorAll("#wfm-imageprompt-grid .wfm-gallery-thumb-card").forEach(el => {
|
||||
el.classList.toggle("selected", el.dataset.path === img.path);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-imageprompt-selected-name").textContent = img.filename;
|
||||
const preview = document.getElementById("wfm-imageprompt-selected-preview");
|
||||
preview.innerHTML = "";
|
||||
const previewImg = document.createElement("img");
|
||||
previewImg.src = API.serveImage(img.path);
|
||||
previewImg.alt = img.filename;
|
||||
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;";
|
||||
preview.appendChild(previewImg);
|
||||
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
promptBox.value = "";
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = true;
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = true;
|
||||
|
||||
try {
|
||||
const meta = await apiFetch(API.imageMeta(img.path));
|
||||
promptBox.value = meta.image_prompt || "";
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = false;
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = !meta.image_prompt;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedPrompt() {
|
||||
if (!state.selectedImage) return;
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
try {
|
||||
const res = await apiFetch(API.savePrompt, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.selectedImage.path, prompt: promptBox.value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(t("imagePromptSaved"), "success");
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = !promptBox.value.trim();
|
||||
} else {
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── プロンプトビルダー (チップ / 最終プロンプト) ─────────────────
|
||||
|
||||
function cleanPromptText(text) {
|
||||
return String(text || "")
|
||||
.replace(/^[,\s]+|[,\s]+$/g, "")
|
||||
.replace(/\s*,\s*/g, ", ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function addSelectedToChips() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
const prompt = cleanPromptText(promptBox.value);
|
||||
if (!prompt) return;
|
||||
if (state.chips.some(c => c.path === img.path)) {
|
||||
showToast(t("imagePromptAlreadyAdded"), "info");
|
||||
return;
|
||||
}
|
||||
state.chips.push({ path: img.path, filename: img.filename, prompt });
|
||||
renderChips();
|
||||
rebuildFinalPrompt();
|
||||
}
|
||||
|
||||
function removeChip(path) {
|
||||
state.chips = state.chips.filter(c => c.path !== path);
|
||||
renderChips();
|
||||
rebuildFinalPrompt();
|
||||
}
|
||||
|
||||
function clearAllChips() {
|
||||
state.chips = [];
|
||||
renderChips();
|
||||
document.getElementById("wfm-imageprompt-final-prompt").value = "";
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const container = document.getElementById("wfm-imageprompt-chips");
|
||||
container.innerHTML = "";
|
||||
state.chips.forEach(chip => {
|
||||
const el = document.createElement("span");
|
||||
el.className = "wfm-imageprompt-chip";
|
||||
el.title = chip.prompt;
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "wfm-imageprompt-chip-text";
|
||||
text.textContent = chip.filename.replace(/\.[^.]+$/, "");
|
||||
el.appendChild(text);
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "wfm-imageprompt-chip-remove";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", () => removeChip(chip.path));
|
||||
el.appendChild(removeBtn);
|
||||
|
||||
container.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildFinalPrompt() {
|
||||
const combined = state.chips.map(c => c.prompt).join(", ");
|
||||
document.getElementById("wfm-imageprompt-final-prompt").value = cleanPromptText(combined);
|
||||
}
|
||||
|
||||
async function copyFinalPrompt() {
|
||||
const text = document.getElementById("wfm-imageprompt-final-prompt").value;
|
||||
if (!text.trim()) {
|
||||
showToast(t("imagePromptNothingToCopy"), "info");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(t("imagePromptCopiedToast"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ function parseITXtChunk(data, latin1, utf8) {
|
||||
pos = findNull(data, pos); if (pos === -1) return null; pos++;
|
||||
return { keyword, text: utf8.decode(data.slice(pos)) };
|
||||
}
|
||||
async function readAllPNGTextChunks(file) {
|
||||
export async function readAllPNGTextChunks(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
@@ -97,6 +97,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)。ここでは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 = [];
|
||||
@@ -188,11 +263,67 @@ function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("
|
||||
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
|
||||
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
|
||||
|
||||
function extractPrompts(wf) {
|
||||
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
|
||||
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
|
||||
function resolveEditPlusText(mode, textEdit, text1, text2) {
|
||||
const t1 = typeof text1 === "string" ? text1 : "";
|
||||
const edit = typeof textEdit === "string" ? textEdit : "";
|
||||
const insert = typeof text2 === "string" && text2 !== "" ? text2 : edit;
|
||||
switch (mode) {
|
||||
case "RAW": return t1;
|
||||
case "EDIT": return edit;
|
||||
case "front": return t1 ? `${insert}, ${t1}` : insert;
|
||||
case "back": return t1 ? `${t1}, ${insert}` : insert;
|
||||
default: return t1 || edit;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPrompts(wf) {
|
||||
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
|
||||
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
|
||||
}
|
||||
|
||||
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
|
||||
function resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const srcNode = nodeMap.get(srcId);
|
||||
if (!srcNode) return null;
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[slot];
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
|
||||
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
|
||||
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
|
||||
for (const name of ["on_false", "on_true"]) {
|
||||
const inp = srcNode.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) continue;
|
||||
const text = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// WFS_PromptText, PrimitiveStringMultiline 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") return v;
|
||||
// さらにリンクされている場合(StringReplace等の中継ノード)は追跡する
|
||||
// "source" = PreviewAny(値タップ中継), "string_a" = StringConcatenate(LoRAトリガーワード
|
||||
// 連結等)— Krea-2のプロンプト強化配線で使われる中継ノード
|
||||
if (Array.isArray(srcNode.inputs)) {
|
||||
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string", "source", "string_a"].includes(i.name));
|
||||
if (nextInput?.link != null) {
|
||||
const originId = linkOrigin.get(nextInput.link);
|
||||
const originSlot = linkSlot.get(nextInput.link) ?? 0;
|
||||
if (originId != null) return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
|
||||
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
|
||||
function extractPromptsFromNodeSet(nodes, links) {
|
||||
@@ -212,7 +343,22 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
}
|
||||
const textMap = new Map();
|
||||
for (const n of nodes) {
|
||||
if (!isTextEncoderNode(n.type ?? "")) continue;
|
||||
const type = n.type ?? "";
|
||||
if (type === "CLIPTextEncodeEditPlus") {
|
||||
const resolveNamedInput = (name) => {
|
||||
if (!Array.isArray(n.inputs)) return null;
|
||||
const inp = n.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) return null;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) return null;
|
||||
return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
};
|
||||
const combined = resolveEditPlusText(n.widgets_values?.[1], n.widgets_values?.[0], resolveNamedInput("text1"), resolveNamedInput("text2"));
|
||||
if (combined) textMap.set(n.id, combined);
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(type)) continue;
|
||||
const text = n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") {
|
||||
textMap.set(n.id, text);
|
||||
@@ -221,18 +367,8 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const srcNode = originId != null ? nodeMap.get(originId) : null;
|
||||
if (srcNode) {
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[originSlot];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
} else {
|
||||
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
}
|
||||
}
|
||||
const text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,24 +377,46 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
for (const n of nodes) {
|
||||
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
|
||||
foundSampler = true;
|
||||
for (const inp of n.inputs) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role slots.
|
||||
let inputsToScan = n.inputs;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
const isPos = name === "positive" || name.startsWith("positive");
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
// negative_prompt directly, so textMap (single text per node) can't represent both
|
||||
// roles.
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit") {
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit" || srcNode?.type === "TextEncodeBooguEdit") {
|
||||
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
|
||||
const text = srcNode.widgets_values?.[widgetIdx];
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(originId);
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
let resolvedOriginId = originId;
|
||||
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
|
||||
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
|
||||
if (innerInput?.link != null) {
|
||||
const innerId = linkOrigin.get(innerInput.link);
|
||||
if (innerId != null) resolvedOriginId = innerId;
|
||||
}
|
||||
}
|
||||
const text = textMap.get(resolvedOriginId);
|
||||
if (!text) continue;
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
@@ -312,7 +470,8 @@ function extractMarkdownNoteModels(wf) {
|
||||
}
|
||||
|
||||
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
|
||||
function resolveLinkedText(wf, srcId, slot) {
|
||||
function resolveLinkedText(wf, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const src = wf[String(srcId)];
|
||||
if (!src || typeof src !== "object") return null;
|
||||
const ct = src.class_type ?? "";
|
||||
@@ -321,13 +480,34 @@ function resolveLinkedText(wf, srcId, slot) {
|
||||
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// 汎用テキストキー
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
|
||||
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
|
||||
// リテラルへ解決できた方を採用する。
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const v = src.inputs?.[key];
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
} else if (typeof v === "string" && v) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 汎用テキストキー("value" は PrimitiveString/PrimitiveStringMultiline、"source" は
|
||||
// PreviewAny の値タップ中継、"string_a" は StringConcatenate の主オペランド — いずれも
|
||||
// Krea-2のプロンプト強化配線で使われる中継ノード)
|
||||
const keys = slot === 0
|
||||
? ["text_positive", "text", "text_g", "prompt"]
|
||||
: ["text_negative", "text_l"];
|
||||
? ["text_positive", "text", "text_g", "prompt", "value", "source", "string_a"]
|
||||
: ["text_negative", "text_l", "source", "string_a"];
|
||||
for (const k of keys) {
|
||||
const v = src.inputs?.[k];
|
||||
if (v && typeof v === "string") return v;
|
||||
if (typeof v === "string" && v) return v;
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -341,8 +521,30 @@ function extractPromptsAPI(wf) {
|
||||
}
|
||||
const textMap = new Map();
|
||||
for (const [id, n] of Object.entries(wf)) {
|
||||
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
|
||||
if (!n) continue;
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
const resolveField = (key) => {
|
||||
const v = n.inputs?.[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (Array.isArray(v)) return resolveLinkedText(wf, v[0], v[1] ?? 0);
|
||||
return null;
|
||||
};
|
||||
const combined = resolveEditPlusText(n.inputs?.mode, n.inputs?.text_edit, resolveField("text1"), resolveField("text2"));
|
||||
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".
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? null;
|
||||
if (raw && typeof raw === "string") {
|
||||
textMap.set(id, raw);
|
||||
} else if (Array.isArray(raw)) {
|
||||
@@ -356,20 +558,35 @@ function extractPromptsAPI(wf) {
|
||||
for (const n of Object.values(wf)) {
|
||||
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const isPos = key === "positive" || key.startsWith("positive");
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
// negative_prompt directly, so textMap (single text per node) can't represent both
|
||||
// roles.
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit") {
|
||||
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(String(val[0]));
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
|
||||
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
|
||||
: String(val[0]);
|
||||
const text = textMap.get(resolvedId);
|
||||
if (!text) continue;
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
@@ -468,10 +685,11 @@ function parseFooocusMetadata(raw) {
|
||||
}
|
||||
|
||||
// ── Master extractAllMetadata ─────────────────────────────────
|
||||
async function extractAllMetadata(file) {
|
||||
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;
|
||||
@@ -509,6 +727,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;
|
||||
@@ -558,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;
|
||||
@@ -588,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);
|
||||
}
|
||||
@@ -655,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,159 @@
|
||||
/**
|
||||
* Models Tab - Badge palette, badge rendering, badge edit modal, badge filter bar
|
||||
*/
|
||||
|
||||
import { openModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml, readJsonStorage } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
|
||||
// Grid re-render is owned by models-tab.js (grid rendering hasn't been split
|
||||
// out yet); registered once from initModelsTab() to avoid a circular import.
|
||||
let _onGridChange = () => {};
|
||||
export function setGridChangeCallback(fn) { _onGridChange = fn; }
|
||||
|
||||
export function getBadgePalette() {
|
||||
return readJsonStorage("wfm_models_badge_palette");
|
||||
}
|
||||
|
||||
export function saveBadgePalette(palette) {
|
||||
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
|
||||
}
|
||||
|
||||
export function badgeHtml(label) {
|
||||
const palette = getBadgePalette();
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
return `<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
export function modelBadgesHtml(modelName, clickable = false) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const badges = meta.badges || [];
|
||||
if (badges.length === 0) return "";
|
||||
return badges.map((label) => {
|
||||
const palette = getBadgePalette();
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
const dataAttr = clickable ? ` data-badge-filter="${escapeHtml(label)}"` : "";
|
||||
return `<span class="wfm-badge wfm-badge-model${clickable ? " wfm-badge-clickable" : ""}"${style}${dataAttr}>${escapeHtml(label)}</span>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export function openBadgeEditModal(onPaletteChange = null) {
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
|
||||
const rowsHtml = labels.map((label) => {
|
||||
const color = palette[label] || "#6366f1";
|
||||
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
|
||||
${badgeHtml(label)}
|
||||
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">×</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const html = `
|
||||
<div style="min-width:320px;">
|
||||
<div id="wfm-badge-list">${rowsHtml || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`}</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);margin-top:12px;padding-top:12px;display:flex;gap:6px;align-items:center;">
|
||||
<input type="text" id="wfm-badge-new-label" class="wfm-input" style="flex:1;" placeholder="${t("badgeNewLabel")}">
|
||||
<input type="color" id="wfm-badge-new-color" value="#6366f1" style="width:36px;height:28px;padding:1px;border-radius:4px;cursor:pointer;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-badge-add-btn">${t("badgeAdd")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(t("badgeManage"), html);
|
||||
bindBadgeModalEvents(onPaletteChange);
|
||||
}
|
||||
|
||||
function bindBadgeModalEvents(onPaletteChange = null) {
|
||||
const afterChange = () => {
|
||||
renderBadgeFilter();
|
||||
_onGridChange();
|
||||
if (onPaletteChange) onPaletteChange();
|
||||
};
|
||||
|
||||
const refreshList = () => {
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
const listEl = document.getElementById("wfm-badge-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = labels.map((label) => {
|
||||
const color = palette[label] || "#6366f1";
|
||||
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
|
||||
${badgeHtml(label)}
|
||||
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">×</button>
|
||||
</div>`;
|
||||
}).join("") || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`;
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
afterChange();
|
||||
};
|
||||
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
|
||||
document.getElementById("wfm-badge-add-btn")?.addEventListener("click", () => {
|
||||
const labelInput = document.getElementById("wfm-badge-new-label");
|
||||
const colorInput = document.getElementById("wfm-badge-new-color");
|
||||
const label = labelInput?.value.trim();
|
||||
if (!label) return;
|
||||
const palette = getBadgePalette();
|
||||
palette[label] = colorInput?.value || "#6366f1";
|
||||
saveBadgePalette(palette);
|
||||
if (labelInput) labelInput.value = "";
|
||||
refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
function bindBadgeRowEvents(refreshList, afterChange = null) {
|
||||
document.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
|
||||
input.addEventListener("input", (e) => {
|
||||
const label = e.target.dataset.badgeLabel;
|
||||
const palette = getBadgePalette();
|
||||
palette[label] = e.target.value;
|
||||
saveBadgePalette(palette);
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".wfm-badge-delete-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const label = btn.dataset.badgeLabel;
|
||||
const palette = getBadgePalette();
|
||||
delete palette[label];
|
||||
saveBadgePalette(palette);
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Badge Filter Bar ──────────────────────────────────────
|
||||
|
||||
export function renderBadgeFilter() {
|
||||
const container = document.getElementById("wfm-models-badge-filter-bar");
|
||||
if (!container) return;
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
|
||||
if (labels.length === 0) {
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = labels.map((label) => {
|
||||
const color = palette[label] || "";
|
||||
const isActive = state.badgeFilter === label;
|
||||
const style = color ? `background:${color};color:#fff;` : "";
|
||||
return `<button class="wfm-badge-filter-btn${isActive ? " active" : ""}" data-badge="${escapeHtml(label)}" style="${style}">${escapeHtml(label)}</button>`;
|
||||
}).join("");
|
||||
|
||||
container.querySelectorAll(".wfm-badge-filter-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const label = btn.dataset.badge;
|
||||
state.badgeFilter = state.badgeFilter === label ? "" : label;
|
||||
state.currentPage = 0;
|
||||
renderBadgeFilter();
|
||||
_onGridChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
/**
|
||||
* Models Tab - Detail modal (double-click) and the side panel's
|
||||
* Info / Group / CivitAI tabs.
|
||||
*
|
||||
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
|
||||
* import triangle — see the note at the top of grid-view.js.
|
||||
*/
|
||||
|
||||
import { showToast, openModal, closeModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state, RESERVED_GROUPS, GENUI_TYPE_MAP } from "./state.js";
|
||||
import { getCurrentModels, renderTagFilter, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette, modelBadgesHtml, openBadgeEditModal } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl } from "./helpers.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import {
|
||||
applyToGenUI, applyEmbeddingToPrompt, saveModelMetadata, toggleGroupEnable,
|
||||
fetchModelMetadata, saveModelGroups,
|
||||
} from "../models-tab.js";
|
||||
|
||||
// ── Detail Modal (double-click, with badges) ──────────────
|
||||
|
||||
export function openDetailModal(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const isFav = !!meta.favorite;
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const selectedBadges = meta.badges || [];
|
||||
const palette = getBadgePalette();
|
||||
const allBadgeLabels = Object.keys(palette).sort();
|
||||
|
||||
const badgeCheckboxes = allBadgeLabels.map((label) => {
|
||||
const checked = selectedBadges.includes(label) ? " checked" : "";
|
||||
const color = palette[label] || "";
|
||||
const style = color ? `background:${color};color:#fff;` : "";
|
||||
return `<label class="wfm-badge-check-label">
|
||||
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
|
||||
<span class="wfm-badge wfm-badge-model" style="${style}">${escapeHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
|
||||
const html = `
|
||||
<div class="wfm-modal-thumb-section">
|
||||
<img class="wfm-modal-thumb-img" style="display:none" />
|
||||
<div class="wfm-modal-thumb-placeholder">${t("modelsNoPreview")}</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:8px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-change-thumb">${t("changeThumbnail")}</button>
|
||||
<input type="file" id="wfm-modal-thumb-file" accept="image/*" style="display:none">
|
||||
</div>
|
||||
<div class="wfm-modal-two-col">
|
||||
<div class="wfm-modal-left">
|
||||
<section>
|
||||
<h4>${t("modelsInfo")}</h4>
|
||||
<div><span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>${dir ? ` <span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-modal-badge-manage" style="margin-left:6px;font-size:10px;">⚙ ${t("badgeManage")}</button></h4>
|
||||
<div id="wfm-modal-badge-checkboxes" class="wfm-badge-checkboxes">
|
||||
${allBadgeLabels.length === 0
|
||||
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
|
||||
: badgeCheckboxes}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsTags")} <span style="font-weight:normal;font-size:11px;">${t("modelsTagsHint")}</span></h4>
|
||||
<input type="text" class="wfm-input" id="wfm-modal-model-tags" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsMemo")}</h4>
|
||||
<textarea class="wfm-textarea" id="wfm-modal-model-memo" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
|
||||
</section>
|
||||
<div class="wfm-modal-actions">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-modal-model-save">${t("modelsSave")}</button>
|
||||
${state.activeModelType === "embedding"
|
||||
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-pp" title="Positive Promptにエンベッディング追加">GenUI PP</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-np" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
|
||||
: (GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-model" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
|
||||
: "")}
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-modal-model-delete" style="margin-left:auto;">${t("modelsDelete")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-model-close">${t("close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(getStem(name), html);
|
||||
|
||||
// Load preview image
|
||||
const modalImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
const modalPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
|
||||
if (modalImg) loadPreviewImage(modalImg, modalPlaceholder, modelName);
|
||||
|
||||
// Add favorite button in header
|
||||
const titleEl = document.getElementById("wfm-modal-title");
|
||||
if (titleEl) {
|
||||
titleEl.parentNode.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
|
||||
const favBtn = document.createElement("button");
|
||||
favBtn.className = isFav ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
favBtn.style.cssText = "position:static;font-size:18px;margin-right:8px;";
|
||||
favBtn.textContent = isFav ? "★" : "☆";
|
||||
favBtn.addEventListener("click", async () => {
|
||||
const newVal = !meta.favorite;
|
||||
await saveModelMetadata(modelName, { favorite: newVal });
|
||||
meta.favorite = newVal;
|
||||
favBtn.textContent = newVal ? "★" : "☆";
|
||||
favBtn.classList.toggle("active", newVal);
|
||||
renderModelGrid();
|
||||
});
|
||||
titleEl.parentNode.insertBefore(favBtn, titleEl);
|
||||
}
|
||||
|
||||
// Badge manage button → open badge edit modal
|
||||
document.getElementById("wfm-modal-badge-manage")?.addEventListener("click", () => {
|
||||
openBadgeEditModal();
|
||||
});
|
||||
|
||||
// Save
|
||||
document.getElementById("wfm-modal-model-save")?.addEventListener("click", async () => {
|
||||
const tagsInput = document.getElementById("wfm-modal-model-tags");
|
||||
const memoInput = document.getElementById("wfm-modal-model-memo");
|
||||
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const memo = memoInput ? memoInput.value : "";
|
||||
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map((cb) => cb.value);
|
||||
await saveModelMetadata(modelName, { tags, memo, badges });
|
||||
showToast(t("modelsSaved"), "success");
|
||||
renderTagFilter();
|
||||
renderDirFilter();
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// GenUI Model / Embedding buttons
|
||||
document.getElementById("wfm-modal-genui-model")?.addEventListener("click", () => {
|
||||
applyToGenUI(modelName, state.activeModelType);
|
||||
});
|
||||
document.getElementById("wfm-modal-genui-pp")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "positive");
|
||||
});
|
||||
document.getElementById("wfm-modal-genui-np")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "negative");
|
||||
});
|
||||
|
||||
// Close button (連続作業時にXボタンまでマウス移動しなくて済むように)
|
||||
document.getElementById("wfm-modal-model-close")?.addEventListener("click", () => {
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// Delete model button
|
||||
document.getElementById("wfm-modal-model-delete")?.addEventListener("click", async () => {
|
||||
const confirmMsg = t("modelBulkDeleteConfirm").replace("{count}", "1");
|
||||
if (!confirm(confirmMsg)) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names: [modelName] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.errors?.length > 0) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${data.errors[0].error}`, "error");
|
||||
return;
|
||||
}
|
||||
showToast(`${getStem(name)} ${t("modelBulkDeleteDone")}`, "success");
|
||||
closeModal();
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(modelName);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
delete state.modelMetadata[modelName];
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds) ds.delete(modelName);
|
||||
state.selectedModels.delete(modelName);
|
||||
if (state.selectedModel === modelName) {
|
||||
state.selectedModel = null;
|
||||
const titleEl2 = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl2) titleEl2.textContent = "";
|
||||
}
|
||||
renderModelGrid();
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Change thumbnail
|
||||
const changeBtn = document.getElementById("wfm-modal-change-thumb");
|
||||
const thumbFile = document.getElementById("wfm-modal-thumb-file");
|
||||
if (changeBtn && thumbFile) {
|
||||
changeBtn.addEventListener("click", () => { thumbFile.value = ""; thumbFile.click(); });
|
||||
thumbFile.addEventListener("change", async () => {
|
||||
const file = thumbFile.files?.[0];
|
||||
if (!file) return;
|
||||
changeBtn.disabled = true;
|
||||
changeBtn.textContent = t("uploading");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("type", state.activeModelType);
|
||||
fd.append("name", modelName);
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/wfm/models/change-preview", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
// Reload preview in modal
|
||||
const newUrl = previewUrl(modelName) + "&t=" + Date.now();
|
||||
const mImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
const mPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
|
||||
if (mImg) { mImg.src = newUrl; mImg.style.display = ""; }
|
||||
if (mPlaceholder) mPlaceholder.style.display = "none";
|
||||
renderModelGrid();
|
||||
if (state.selectedModel === modelName) renderSideInfo(modelName);
|
||||
showToast(t("thumbnailChanged"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("thumbnailError") + ": " + err.message, "error");
|
||||
} finally {
|
||||
changeBtn.disabled = false;
|
||||
changeBtn.textContent = t("changeThumbnail");
|
||||
thumbFile.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Side Panel ────────────────────────────────────────────
|
||||
|
||||
export function showSidePanel(modelName) {
|
||||
state.selectedModel = modelName;
|
||||
|
||||
// Highlight selected
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
el.classList.toggle("wfm-card-selected", el.dataset.modelName === modelName);
|
||||
});
|
||||
|
||||
const { name } = parseModelPath(modelName);
|
||||
document.getElementById("wfm-models-panel-title").textContent = name;
|
||||
|
||||
// Reset to CivitAI tab
|
||||
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((b) => b.classList.remove("active"));
|
||||
document.querySelector('.wfm-models-side-tab-btn[data-side-tab="civitai"]')?.classList.add("active");
|
||||
document.querySelectorAll(".wfm-models-side-content").forEach((c) => (c.style.display = "none"));
|
||||
const civitaiEl = document.getElementById("wfm-models-side-civitai");
|
||||
if (civitaiEl) civitaiEl.style.display = "block";
|
||||
|
||||
const genuiNavBtn = document.getElementById("wfm-side-genui-nav-btn");
|
||||
const genuiNpBtn = document.getElementById("wfm-side-genui-np-btn");
|
||||
if (genuiNavBtn) {
|
||||
if (state.activeModelType === "embedding") {
|
||||
genuiNavBtn.textContent = "GenUI PP";
|
||||
genuiNavBtn.title = "Positive Promptにエンベッディング追加";
|
||||
genuiNavBtn.style.display = "";
|
||||
if (genuiNpBtn) {
|
||||
genuiNpBtn.textContent = "GenUI NP";
|
||||
genuiNpBtn.title = "Negative Promptにエンベッディング追加";
|
||||
genuiNpBtn.style.display = "";
|
||||
}
|
||||
} else if (GENUI_TYPE_MAP[state.activeModelType]) {
|
||||
genuiNavBtn.textContent = t("modelsGenUIBtn");
|
||||
genuiNavBtn.title = t("modelsGenUITitle");
|
||||
genuiNavBtn.style.display = "";
|
||||
if (genuiNpBtn) genuiNpBtn.style.display = "none";
|
||||
} else {
|
||||
genuiNavBtn.style.display = "none";
|
||||
if (genuiNpBtn) genuiNpBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
renderSideInfo(modelName);
|
||||
renderSideGroup(modelName);
|
||||
renderSideCivitai(modelName);
|
||||
}
|
||||
|
||||
export function closeSidePanel() {
|
||||
state.selectedModel = null;
|
||||
const titleEl = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
el.classList.remove("wfm-card-selected");
|
||||
});
|
||||
const npBtn = document.getElementById("wfm-side-genui-np-btn");
|
||||
if (npBtn) npBtn.style.display = "none";
|
||||
const ppBtn = document.getElementById("wfm-side-genui-nav-btn");
|
||||
if (ppBtn) ppBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// ── Side Panel: Info Tab ──────────────────────────────────
|
||||
|
||||
export function renderSideInfo(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-info");
|
||||
if (!el) return;
|
||||
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const userBadgesHtml = modelBadgesHtml(modelName, false);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-side-thumb-container">
|
||||
<div class="wfm-side-thumb-img-wrap">
|
||||
<img style="display:none" />
|
||||
<span class="wfm-side-thumb-placeholder">${t("modelsNoPreview")}</span>
|
||||
</div>
|
||||
<div class="wfm-side-thumb-info">
|
||||
<div class="wfm-side-thumb-name wfm-model-name-copy" title="${t("modelsCopyName")}">${escapeHtml(name)}</div>
|
||||
<div class="wfm-side-thumb-meta">
|
||||
<span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>
|
||||
${dir ? `<span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}
|
||||
${userBadgesHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsFilePath")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<span id="wfm-models-side-filepath" class="wfm-model-filepath" title="${t("modelsCopyPath")}" style="cursor:pointer;word-break:break-all;font-size:0.85em;color:#aaa;">${t("modelsLoading")}...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsTags")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<input type="text" id="wfm-models-side-tags" class="wfm-search-input" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsMemo")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<textarea id="wfm-models-side-memo" class="wfm-textarea" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section" style="display:flex;gap:6px;">
|
||||
<button id="wfm-models-side-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsSave")}</button>
|
||||
${state.activeModelType === "embedding"
|
||||
? `<button id="wfm-models-side-genui-pp-btn" class="wfm-btn wfm-btn-sm" title="Positive Promptにエンベッディング追加">GenUI PP</button>
|
||||
<button id="wfm-models-side-genui-np-btn" class="wfm-btn wfm-btn-sm" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
|
||||
: (GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button id="wfm-models-side-genui-btn" class="wfm-btn wfm-btn-sm" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
|
||||
: "")}
|
||||
</div>`;
|
||||
|
||||
// Load preview image
|
||||
const sideImg = el.querySelector(".wfm-side-thumb-img-wrap img");
|
||||
const sidePlaceholder = el.querySelector(".wfm-side-thumb-placeholder");
|
||||
if (sideImg) loadPreviewImage(sideImg, sidePlaceholder, modelName);
|
||||
|
||||
// Copy model name on click
|
||||
el.querySelector(".wfm-model-name-copy")?.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(modelName).then(() => {
|
||||
showToast(t("modelsCopiedName"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and display file path
|
||||
fetch(`/api/wfm/models/filepath?type=${encodeURIComponent(state.activeModelType)}&name=${encodeURIComponent(modelName)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl && data.path) {
|
||||
fpEl.textContent = data.path;
|
||||
fpEl.title = t("modelsCopyPath");
|
||||
} else if (fpEl) {
|
||||
fpEl.textContent = modelName;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) fpEl.textContent = modelName;
|
||||
});
|
||||
|
||||
// Copy file path on click
|
||||
el.querySelector("#wfm-models-side-filepath")?.addEventListener("click", () => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) {
|
||||
navigator.clipboard.writeText(fpEl.textContent).then(() => {
|
||||
showToast(t("modelsCopiedPath"), "success");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save button
|
||||
el.querySelector("#wfm-models-side-save-btn")?.addEventListener("click", () => {
|
||||
const tagsInput = document.getElementById("wfm-models-side-tags");
|
||||
const memoInput = document.getElementById("wfm-models-side-memo");
|
||||
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const memo = memoInput ? memoInput.value : "";
|
||||
saveModelMetadata(modelName, { tags, memo }).then(() => {
|
||||
showToast(t("modelsSaved"), "success");
|
||||
renderTagFilter();
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// GenUI Model / Embedding buttons
|
||||
el.querySelector("#wfm-models-side-genui-btn")?.addEventListener("click", () => {
|
||||
applyToGenUI(modelName, state.activeModelType);
|
||||
});
|
||||
el.querySelector("#wfm-models-side-genui-pp-btn")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "positive");
|
||||
});
|
||||
el.querySelector("#wfm-models-side-genui-np-btn")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "negative");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Side Panel: Group Tab ─────────────────────────────────
|
||||
|
||||
export function renderSideGroup(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-group");
|
||||
if (!el) return;
|
||||
|
||||
// Find groups this model belongs to
|
||||
const memberOf = [];
|
||||
for (const [gName, members] of Object.entries(state.modelGroups)) {
|
||||
if (members.includes(modelName)) memberOf.push(gName);
|
||||
}
|
||||
|
||||
// All group names for the assign dropdown
|
||||
const allGroups = Object.keys(state.modelGroups).sort();
|
||||
const availableGroups = allGroups.filter((g) => !memberOf.includes(g));
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map((g) => `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:13px;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-group-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-models-group-assign" class="wfm-select" style="flex:1;font-size:12px;">
|
||||
${availableGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: availableGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-assign-btn" ${availableGroups.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-models-group-new" class="wfm-search-input" style="flex:1;font-size:12px;" placeholder="${t("modelsGroupName")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-create-btn">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;border-top:1px solid var(--wfm-border);padding-top:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<select id="wfm-models-group-manage-select" class="wfm-select" style="flex:1;font-size:12px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-rename-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsRename")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-models-group-delete-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsDelete")}</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-enable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupEnableAll")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-disable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupDisableAll")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove from group
|
||||
el.querySelectorAll(".wfm-group-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const g = btn.dataset.group;
|
||||
const members = state.modelGroups[g] || [];
|
||||
state.modelGroups[g] = members.filter((m) => m !== modelName);
|
||||
if (state.modelGroups[g].length === 0) delete state.modelGroups[g];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Assign to group
|
||||
document.getElementById("wfm-models-group-assign-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-assign");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
if (!state.modelGroups[g]) state.modelGroups[g] = [];
|
||||
if (!state.modelGroups[g].includes(modelName)) state.modelGroups[g].push(modelName);
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
// Create new group
|
||||
document.getElementById("wfm-models-group-create-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-models-group-new");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (state.modelGroups[name]) {
|
||||
showToast(t("modelsGroupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.modelGroups[name] = [modelName];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
// Rename group
|
||||
document.getElementById("wfm-models-group-rename-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
if (RESERVED_GROUPS.includes(oldName)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
const newName = prompt(t("modelsRenamePrompt"), oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (state.modelGroups[newName]) {
|
||||
showToast(t("modelsGroupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.modelGroups[newName] = state.modelGroups[oldName];
|
||||
delete state.modelGroups[oldName];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete group
|
||||
document.getElementById("wfm-models-group-delete-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
if (RESERVED_GROUPS.includes(g)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
if (!confirm(t("modelsDeleteGroupConfirm").replace("{name}", g))) return;
|
||||
delete state.modelGroups[g];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Enable all models in selected group
|
||||
document.getElementById("wfm-models-group-enable-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
toggleGroupEnable(g, true).then(() => renderSideGroup(modelName));
|
||||
});
|
||||
|
||||
// Disable all models in selected group
|
||||
document.getElementById("wfm-models-group-disable-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
toggleGroupEnable(g, false).then(() => renderSideGroup(modelName));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Side Panel: CivitAI Tab ───────────────────────────────
|
||||
|
||||
export function renderSideCivitai(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-civitai");
|
||||
if (!el) return;
|
||||
|
||||
// Check if we have cached civitai data via sha256
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const cached = sha256 && state.civitaiCache[sha256];
|
||||
|
||||
if (cached) {
|
||||
renderCivitaiInfo(el, cached, modelName);
|
||||
} else if (sha256) {
|
||||
// SHA256 is known but model was not found on CivitAI
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiNotFoundDesc")}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-fetch-btn">
|
||||
${t("civitaiRefetchBtn")}
|
||||
</button>
|
||||
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
|
||||
</div>`;
|
||||
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiFetchDesc")}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-civitai-fetch-btn">
|
||||
${t("civitaiFetch")}
|
||||
</button>
|
||||
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
|
||||
</div>`;
|
||||
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCivitaiForModel(modelName, el) {
|
||||
const statusEl = document.getElementById("wfm-civitai-status");
|
||||
const fetchBtn = document.getElementById("wfm-civitai-fetch-btn");
|
||||
if (fetchBtn) fetchBtn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = t("civitaiHashing");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: state.activeModelType, name: modelName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "ok" && data.civitai) {
|
||||
// Update caches
|
||||
if (data.sha256) {
|
||||
state.civitaiCache[data.sha256] = data.civitai;
|
||||
// Update metadata with sha256
|
||||
if (!state.modelMetadata[modelName]) state.modelMetadata[modelName] = {};
|
||||
state.modelMetadata[modelName].sha256 = data.sha256;
|
||||
}
|
||||
renderCivitaiInfo(el, data.civitai, modelName);
|
||||
showToast(t("civitaiFound"), "success");
|
||||
// Always refresh preview: local file if saved, otherwise civitai image as fallback
|
||||
const sidePanel = document.getElementById("wfm-models-side-panel");
|
||||
if (sidePanel) {
|
||||
const sideImg = sidePanel.querySelector(".wfm-side-thumb-img-wrap img");
|
||||
const sidePh = sidePanel.querySelector(".wfm-side-thumb-placeholder");
|
||||
if (sideImg) {
|
||||
if (data.preview_saved) {
|
||||
sideImg.src = previewUrl(modelName) + "&t=" + Date.now();
|
||||
} else {
|
||||
const civitaiImg = data.civitai.images && data.civitai.images[0];
|
||||
if (civitaiImg) sideImg.src = civitaiImg;
|
||||
}
|
||||
sideImg.style.display = "";
|
||||
if (sidePh) sidePh.style.display = "none";
|
||||
}
|
||||
}
|
||||
renderModelGrid();
|
||||
} else if (data.status === "not_found") {
|
||||
if (statusEl) statusEl.textContent = t("civitaiNotFound");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
} else {
|
||||
if (statusEl) statusEl.textContent = data.error || t("civitaiError");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("CivitAI fetch error:", err);
|
||||
if (statusEl) statusEl.textContent = t("civitaiError");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCivitaiInfo(el, info, modelName) {
|
||||
// URL: ユーザー設定ホスト(localStorage)を使用、modelId なし時は /model-versions/ にフォールバック
|
||||
const civitaiHost = localStorage.getItem("wfm_civitai_host") || "civitai.com";
|
||||
const modelUrl = info.modelId && info.versionId
|
||||
? `https://${civitaiHost}/models/${info.modelId}?modelVersionId=${info.versionId}`
|
||||
: info.versionId
|
||||
? `https://${civitaiHost}/model-versions/${info.versionId}`
|
||||
: (info.modelUrl || "#");
|
||||
|
||||
// Hash: BLAKE3 優先、なければ SHA256
|
||||
const fileHashes = info.fileHashes || {};
|
||||
const blake3 = fileHashes.BLAKE3 || fileHashes.Blake3 || "";
|
||||
const sha256 = fileHashes.SHA256 || (state.modelMetadata[modelName] || {}).sha256 || "";
|
||||
const hashType = blake3 ? "BLAKE3" : (sha256 ? "SHA256" : "");
|
||||
const hashFull = blake3 || sha256;
|
||||
|
||||
// Detail rows
|
||||
const ROW = "display:flex;align-items:center;font-size:12px;margin-bottom:5px;";
|
||||
const LABEL = "color:var(--wfm-text-secondary);min-width:80px;flex-shrink:0;";
|
||||
|
||||
const typeRow = info.type ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiType")}</span>
|
||||
<span style="font-size:10px;font-weight:700;background:var(--wfm-bg-tertiary,rgba(255,255,255,0.1));padding:2px 7px;border-radius:3px;letter-spacing:0.6px;">${escapeHtml(info.type.toUpperCase())}</span>
|
||||
</div>` : "";
|
||||
|
||||
const baseModelRow = info.baseModel ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiBaseModel")}</span>
|
||||
<span>${escapeHtml(info.baseModel)}</span>
|
||||
</div>` : "";
|
||||
|
||||
const hashRow = hashFull ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiHashLabel")}</span>
|
||||
<code class="wfm-hash-value" data-hash="${escapeHtml(hashFull)}"
|
||||
style="background:var(--wfm-bg-secondary);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 84px);"
|
||||
title="${t("civitaiCopyHash")}">${escapeHtml(hashType)}: ${escapeHtml(hashFull.substring(0, 16).toUpperCase())}…</code>
|
||||
</div>` : "";
|
||||
|
||||
const detailSection = (typeRow || baseModelRow || hashRow)
|
||||
? `<div style="margin-bottom:10px;">${typeRow}${baseModelRow}${hashRow}</div>` : "";
|
||||
|
||||
const tagsHtml = (info.tags || []).map((tag) =>
|
||||
`<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`
|
||||
).join(" ");
|
||||
|
||||
const trainedWordsHtml = (info.trainedWords || []).map((w) =>
|
||||
`<code style="font-size:11px;background:var(--wfm-bg-secondary);padding:1px 4px;border-radius:3px;cursor:pointer;" class="wfm-trained-word" title="${t("civitaiCopyWord")}">${escapeHtml(w)}</code>`
|
||||
).join(" ");
|
||||
|
||||
// Sample images — all go to Sample pane
|
||||
const images = info.images || [];
|
||||
const sampleImagesHtml = images.map((url) =>
|
||||
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${t("civitaiOpenImage")}"><img src="${escapeHtml(url)}" style="width:100%;border-radius:4px;margin-bottom:6px;cursor:pointer;display:block;" loading="lazy" /></a>`
|
||||
).join("");
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="display:flex;border-bottom:1px solid var(--wfm-border);margin-bottom:10px;">
|
||||
<button class="wfm-civitai-subtab-btn" data-pane="info"
|
||||
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid var(--wfm-primary);color:var(--wfm-primary);font-weight:600;margin-bottom:-1px;">
|
||||
${t("civitaiTabInfo")}
|
||||
</button>
|
||||
<button class="wfm-civitai-subtab-btn" data-pane="sample"
|
||||
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;color:var(--wfm-text-secondary);margin-bottom:-1px;">
|
||||
${t("civitaiTabSample")}${images.length ? ` (${images.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="wfm-civitai-pane-info">
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="font-weight:700;font-size:14px;margin-bottom:2px;">
|
||||
<a href="${escapeHtml(modelUrl)}" target="_blank" style="color:var(--wfm-primary);text-decoration:none;">${escapeHtml(info.modelName)}</a>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--wfm-text-secondary);">
|
||||
${escapeHtml(info.versionName)}${info.creator ? ` · by ${escapeHtml(info.creator)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
${detailSection}
|
||||
${tagsHtml ? `<div style="margin-bottom:8px;">${tagsHtml}</div>` : ""}
|
||||
${trainedWordsHtml ? `<div style="margin-bottom:10px;"><div style="font-weight:600;font-size:12px;margin-bottom:4px;">${t("civitaiTriggerWords")}</div>${trainedWordsHtml}</div>` : ""}
|
||||
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${info.description}</div>` : ""}
|
||||
<div style="margin-top:10px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-refresh-btn">${t("civitaiRefresh")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wfm-civitai-pane-sample" style="display:none;">
|
||||
${sampleImagesHtml || `<p style="color:var(--wfm-text-secondary);font-size:13px;text-align:center;margin-top:20px;">${t("civitaiNoImages")}</p>`}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Sub-tab switching
|
||||
const subtabBtns = el.querySelectorAll(".wfm-civitai-subtab-btn");
|
||||
subtabBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const pane = btn.dataset.pane;
|
||||
subtabBtns.forEach((b) => {
|
||||
const isActive = b === btn;
|
||||
b.style.borderBottom = isActive ? "2px solid var(--wfm-primary)" : "2px solid transparent";
|
||||
b.style.color = isActive ? "var(--wfm-primary)" : "var(--wfm-text-secondary)";
|
||||
b.style.fontWeight = isActive ? "600" : "400";
|
||||
});
|
||||
el.querySelector("#wfm-civitai-pane-info").style.display = pane === "info" ? "" : "none";
|
||||
el.querySelector("#wfm-civitai-pane-sample").style.display = pane === "sample" ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// Copy trigger word on click
|
||||
el.querySelectorAll(".wfm-trained-word").forEach((wordEl) => {
|
||||
wordEl.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(wordEl.textContent).then(() => {
|
||||
showToast(t("civitaiWordCopied"), "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy hash on click
|
||||
el.querySelectorAll(".wfm-hash-value").forEach((hashEl) => {
|
||||
hashEl.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(hashEl.dataset.hash).then(() => {
|
||||
showToast(t("civitaiHashCopied"), "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
document.getElementById("wfm-civitai-refresh-btn")?.addEventListener("click", () => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
if (meta.sha256) delete state.civitaiCache[meta.sha256];
|
||||
delete meta.sha256;
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
}
|
||||
|
||||
// ── CivitAI cache / batch fetch ───────────────────────────
|
||||
|
||||
export async function fetchCivitaiCache() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/cache");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export async function batchFetchCivitai() {
|
||||
const models = getCurrentModels();
|
||||
if (models.length === 0) {
|
||||
showToast(t("modelsNoModels"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out models that already have CivitAI data
|
||||
const meta = state.modelMetadata;
|
||||
const uncached = models.filter((m) => {
|
||||
const sha = meta[m]?.sha256;
|
||||
return !sha || !state.civitaiCache[sha];
|
||||
});
|
||||
|
||||
if (uncached.length === 0) {
|
||||
showToast(t("civitaiBatchAllCached"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById("wfm-models-civitai-batch-btn");
|
||||
const progressEl = document.getElementById("wfm-models-civitai-progress");
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: state.activeModelType, models: uncached }),
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let eventType = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
eventType = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (eventType === "progress") {
|
||||
const pct = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
|
||||
const statusText = data.status === "hashing" ? t("civitaiHashing2")
|
||||
: data.status === "fetching" ? t("civitaiFetching")
|
||||
: data.status === "cached" ? "✓"
|
||||
: data.status === "found" ? "✓"
|
||||
: data.status === "not_found" ? "—"
|
||||
: "";
|
||||
if (progressEl) progressEl.textContent = `${pct}% (${data.current}/${data.total}) ${statusText}`;
|
||||
} else if (eventType === "done") {
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
const previewNote = data.preview_saved > 0 ? ` (+${data.preview_saved} preview)` : "";
|
||||
showToast(t("civitaiBatchDone", data.found, data.not_found) + previewNote, "success");
|
||||
// Reload caches
|
||||
const [newMeta, newCache] = await Promise.all([fetchModelMetadata(), fetchCivitaiCache()]);
|
||||
// Apply sha256 hashes from batch result directly in case fetchModelMetadata
|
||||
// returns before the server has flushed updated metadata to disk
|
||||
if (data.hashes) {
|
||||
for (const [modelName, sha256] of Object.entries(data.hashes)) {
|
||||
if (!newMeta[modelName]) newMeta[modelName] = {};
|
||||
if (!newMeta[modelName].sha256) newMeta[modelName].sha256 = sha256;
|
||||
}
|
||||
}
|
||||
state.modelMetadata = newMeta;
|
||||
state.civitaiCache = newCache;
|
||||
renderModelGrid();
|
||||
// Refresh side panel if open
|
||||
if (state.selectedModel) renderSideCivitai(state.selectedModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Batch CivitAI error:", err);
|
||||
showToast(t("civitaiError"), "error");
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Models Tab - Filtering, sorting, and the tag/group/directory filter dropdowns
|
||||
*/
|
||||
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { parseModelPath, getExtension } from "./helpers.js";
|
||||
|
||||
export function getCurrentModels() {
|
||||
return state.modelsByType[state.activeModelType] || [];
|
||||
}
|
||||
|
||||
export function isModelDisabled(modelName) {
|
||||
const s = state.disabledModels[state.activeModelType];
|
||||
return s ? s.has(modelName) : false;
|
||||
}
|
||||
|
||||
export function filterModels() {
|
||||
let models = getCurrentModels();
|
||||
|
||||
if (state.statusFilter === "enabled") {
|
||||
models = models.filter((m) => !isModelDisabled(m));
|
||||
} else if (state.statusFilter === "disabled") {
|
||||
models = models.filter((m) => isModelDisabled(m));
|
||||
}
|
||||
|
||||
if (state.showFavoritesOnly) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.favorite;
|
||||
});
|
||||
}
|
||||
|
||||
if (state.showBatchOnly) {
|
||||
const batchMembers = state.modelGroups["Batch"] || [];
|
||||
models = models.filter((m) => batchMembers.includes(m));
|
||||
}
|
||||
|
||||
if (state.tagFilter) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.tags && meta.tags.includes(state.tagFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.badgeFilter) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.badges && meta.badges.includes(state.badgeFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.dirFilter) {
|
||||
models = models.filter((m) => {
|
||||
const { dir } = parseModelPath(m);
|
||||
return dir === state.dirFilter;
|
||||
});
|
||||
}
|
||||
|
||||
if (state.groupFilter) {
|
||||
const members = state.modelGroups[state.groupFilter] || [];
|
||||
models = models.filter((m) => members.includes(m));
|
||||
}
|
||||
|
||||
if (state.searchText) {
|
||||
const q = state.searchText.toLowerCase();
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
const searchable = [m, ...(meta?.tags || []), meta?.memo || ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return sortModels(models);
|
||||
}
|
||||
|
||||
export function sortKeyOf(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
switch (state.sortColumn) {
|
||||
case "fav":
|
||||
return meta.favorite ? 1 : 0;
|
||||
case "filename":
|
||||
return parseModelPath(modelName).name.toLowerCase();
|
||||
case "subdir":
|
||||
return parseModelPath(modelName).dir.toLowerCase();
|
||||
case "civtype": {
|
||||
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
|
||||
return (civ?.type || "").toLowerCase();
|
||||
}
|
||||
case "basemodel": {
|
||||
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
|
||||
return (civ?.baseModel || "").toLowerCase();
|
||||
}
|
||||
case "ext":
|
||||
return getExtension(parseModelPath(modelName).name).toLowerCase();
|
||||
case "tags":
|
||||
return (meta.tags || []).join(", ").toLowerCase();
|
||||
case "memo":
|
||||
return (meta.memo || "").toLowerCase();
|
||||
case "enabled":
|
||||
return isModelDisabled(modelName) ? 1 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function sortModels(models) {
|
||||
if (!state.sortColumn) return models;
|
||||
// ソートキーを1モデル1回だけ計算(比較ごとのparseModelPath等の再計算を回避)
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
return models
|
||||
.map((m) => [sortKeyOf(m), m])
|
||||
.sort((a, b) => (a[0] < b[0] ? -dir : a[0] > b[0] ? dir : 0))
|
||||
.map((pair) => pair[1]);
|
||||
}
|
||||
|
||||
export function getAllTags() {
|
||||
const set = new Set();
|
||||
const models = getCurrentModels();
|
||||
models.forEach((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
if (meta?.tags) meta.tags.forEach((tag) => set.add(tag));
|
||||
});
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ── Render: Tag Filter ────────────────────────────────────
|
||||
|
||||
export function renderTagFilter() {
|
||||
const select = document.getElementById("wfm-models-tag-filter");
|
||||
if (!select) return;
|
||||
const tags = getAllTags();
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllTags")}</option>` +
|
||||
tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
|
||||
select.value = state.tagFilter;
|
||||
}
|
||||
|
||||
export function renderGroupFilter() {
|
||||
const select = document.getElementById("wfm-models-group-filter");
|
||||
if (!select) return;
|
||||
|
||||
const groups = state.allModelGroups[state.activeModelType] || {};
|
||||
const names = Object.keys(groups).sort();
|
||||
|
||||
// 選択中のグループが削除された場合はフィルターをリセット
|
||||
if (state.groupFilter && !groups[state.groupFilter]) {
|
||||
state.groupFilter = "";
|
||||
}
|
||||
|
||||
const currentValue = state.groupFilter
|
||||
? `${state.activeModelType}::${state.groupFilter}`
|
||||
: "";
|
||||
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllGroups")}</option>` +
|
||||
names.map((name) => {
|
||||
const value = `${state.activeModelType}::${name}`;
|
||||
const isActive = value === currentValue;
|
||||
return `<option value="${escapeHtml(value)}"${isActive ? " selected" : ""}>${escapeHtml(name)}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export function renderDirFilter() {
|
||||
const select = document.getElementById("wfm-models-dir-filter");
|
||||
if (!select) return;
|
||||
const dirs = [...new Set(
|
||||
getCurrentModels()
|
||||
.map((m) => parseModelPath(m).dir)
|
||||
.filter((d) => d !== "")
|
||||
)].sort();
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllDirs")}</option>` +
|
||||
dirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("");
|
||||
select.value = state.dirFilter;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Models Tab - Grid rendering (Thumbnail / Table views) and per-card
|
||||
* Batch/Stack/Favorite toggles.
|
||||
*
|
||||
* This module and selection-bulk.js / detail-panel.js form a circular
|
||||
* import triangle with each other and with models-tab.js: rendering a card
|
||||
* needs to wire up selection, side-panel, and detail-modal click handlers,
|
||||
* and those in turn re-render the grid after they change state. All the
|
||||
* cross-module calls happen inside event handlers (never at module
|
||||
* evaluation time), and every export here is a `function` declaration
|
||||
* (hoisted), so the cycle resolves safely under ES Modules.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { filterModels, getCurrentModels, isModelDisabled } from "./filters.js";
|
||||
import { modelBadgesHtml } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage } from "./helpers.js";
|
||||
import { toggleModelSelection } from "./selection-bulk.js";
|
||||
import { showSidePanel, openDetailModal, renderSideInfo } from "./detail-panel.js";
|
||||
import { saveModelGroups, saveModelMetadata, toggleModelEnable } from "../models-tab.js";
|
||||
|
||||
export function renderModelGrid() {
|
||||
const grid = document.getElementById("wfm-models-grid");
|
||||
if (!grid) return;
|
||||
|
||||
grid.className = `wfm-grid wfm-view-${state.viewMode}`;
|
||||
const filtered = filterModels();
|
||||
|
||||
// Update count
|
||||
const countEl = document.getElementById("wfm-models-count");
|
||||
if (countEl) {
|
||||
const total = getCurrentModels().length;
|
||||
countEl.textContent = `${filtered.length} / ${total}`;
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("modelsNoModels")}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderTableView(grid, filtered);
|
||||
} else {
|
||||
renderThumbView(grid, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thumbnail View (same as Workflow tab) ─────────────────
|
||||
|
||||
export function renderThumbView(grid, models) {
|
||||
grid.innerHTML = "";
|
||||
models.forEach((modelName) => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { name } = parseModelPath(modelName);
|
||||
const disabled = isModelDisabled(modelName);
|
||||
const userBadges = modelBadgesHtml(modelName);
|
||||
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
|
||||
const favStar = meta.favorite ? "★" : "☆";
|
||||
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
|
||||
const showStackBtn = state.activeModelType === "lora";
|
||||
const inBatch = showBatchBtn && isInBatch(modelName);
|
||||
const inStack = showStackBtn && isInStack(modelName);
|
||||
const batchClass = inBatch ? "wfm-batch-btn active" : "wfm-batch-btn";
|
||||
const stackClass = inStack ? "wfm-stack-btn active" : "wfm-stack-btn";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card";
|
||||
if (disabled) card.classList.add("wfm-model-disabled");
|
||||
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
|
||||
card.dataset.modelName = modelName;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-thumb">
|
||||
<img style="display:none" />
|
||||
<span class="wfm-card-thumb-placeholder">${t("modelsNoPreview")}</span>
|
||||
${disabled ? `<span class="wfm-disabled-overlay">${t("modelDisabled")}</span>` : ""}
|
||||
</div>
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(getStem(name))}</div>
|
||||
<div class="wfm-card-meta">${userBadges} ${tagsHtml}</div>
|
||||
</div>
|
||||
${showBatchBtn ? `<button class="${batchClass}" title="${t("modelsBatch")}">B</button>` : ""}
|
||||
${showStackBtn ? `<button class="${stackClass}" title="Stack (Lora multi-apply)">S</button>` : ""}
|
||||
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
|
||||
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
|
||||
|
||||
// Load preview without 404 console spam
|
||||
const img = card.querySelector(".wfm-card-thumb img");
|
||||
const placeholder = card.querySelector(".wfm-card-thumb-placeholder");
|
||||
loadPreviewImage(img, placeholder, modelName);
|
||||
|
||||
card.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleBatch(modelName);
|
||||
if (state.showBatchOnly) {
|
||||
renderModelGrid();
|
||||
} else {
|
||||
btn.classList.toggle("active", isInBatch(modelName));
|
||||
}
|
||||
});
|
||||
card.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleStack(modelName);
|
||||
btn.classList.toggle("active", isInStack(modelName));
|
||||
});
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(modelName);
|
||||
});
|
||||
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelEnable(modelName);
|
||||
});
|
||||
if (state.selectMode) {
|
||||
const isChecked = state.selectedModels.has(modelName);
|
||||
card.classList.toggle("wfm-card-checked", isChecked);
|
||||
const checkEl = document.createElement("div");
|
||||
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
|
||||
card.appendChild(checkEl);
|
||||
card.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-batch-btn, .wfm-stack-btn, .wfm-fav-btn, .wfm-toggle-btn")) return;
|
||||
toggleModelSelection(modelName);
|
||||
});
|
||||
} else {
|
||||
card.addEventListener("click", () => showSidePanel(modelName));
|
||||
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
|
||||
}
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Table View ────────────────────────────────────────────
|
||||
|
||||
export function thSortHtml(label, col, extraClass = "", extraStyle = "") {
|
||||
const isActive = state.sortColumn === col;
|
||||
const arrow = isActive ? (state.sortDir === "asc" ? " ▲" : " ▼") : "";
|
||||
const activeStyle = isActive ? "color:var(--wfm-accent,#6366f1);" : "";
|
||||
const cls = ["wfm-table-th-sortable", extraClass].filter(Boolean).join(" ");
|
||||
return `<th class="${cls}" data-sort-col="${col}" style="${activeStyle}${extraStyle}">${label}${arrow}</th>`;
|
||||
}
|
||||
|
||||
export function renderTableView(grid, models) {
|
||||
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
|
||||
const showStackBtn = state.activeModelType === "lora";
|
||||
const rows = models
|
||||
.map((modelName) => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiType = civitai ? (civitai.type || "") : "";
|
||||
const civitaiBaseModel = civitai ? (civitai.baseModel || "") : "";
|
||||
const disabled = isModelDisabled(modelName);
|
||||
const isChecked = state.selectMode && state.selectedModels.has(modelName);
|
||||
const favIcon = meta.favorite ? "★" : "☆";
|
||||
const batchIcon = !showBatchBtn ? "" : isInBatch(modelName) ? `<button class="wfm-batch-btn active" title="${t("modelsBatch")}">B</button>` : `<button class="wfm-batch-btn" title="${t("modelsBatch")}">B</button>`;
|
||||
const stackIcon = isInStack(modelName) ? `<button class="wfm-stack-btn active" title="Stack (Lora multi-apply)">S</button>` : `<button class="wfm-stack-btn" title="Stack (Lora multi-apply)">S</button>`;
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const memo = meta.memo || "";
|
||||
const toggleLabel = disabled ? t("modelEnable") : t("modelDisable");
|
||||
const toggleIcon = disabled ? "▶" : "⏸";
|
||||
const checkCell = state.selectMode
|
||||
? `<td class="wfm-table-td-check"><div class="wfm-select-check${isChecked ? " checked" : ""}"></div></td>`
|
||||
: "";
|
||||
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}${disabled ? " wfm-model-disabled" : ""}${isChecked ? " wfm-card-checked" : ""}" data-model-name="${escapeHtml(modelName)}">
|
||||
${checkCell}
|
||||
<td class="wfm-models-table-fav" title="Favorite">${favIcon}</td>
|
||||
<td class="wfm-table-td-thumb"><img class="wfm-table-thumb" style="display:none" /></td>
|
||||
<td class="wfm-table-td-filename" title="${escapeHtml(modelName)}">${escapeHtml(name)}</td>
|
||||
<td class="wfm-table-td-subdir">${escapeHtml(dir)}</td>
|
||||
<td class="wfm-table-td-civtype" title="${escapeHtml(civitaiType)}">${escapeHtml(civitaiType)}</td>
|
||||
<td class="wfm-table-td-basemodel" title="${escapeHtml(civitaiBaseModel)}">${escapeHtml(civitaiBaseModel)}</td>
|
||||
<td class="wfm-table-td-ext">${escapeHtml(ext)}</td>
|
||||
<td>${escapeHtml(tagsStr)}</td>
|
||||
<td class="wfm-table-td-memo" title="${escapeHtml(memo)}">${escapeHtml(memo)}</td>
|
||||
<td class="wfm-table-td-toggle"><button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${toggleLabel}">${toggleIcon}</button></td>
|
||||
${showBatchBtn ? `<td class="wfm-table-td-batch">${batchIcon}</td>` : ""}
|
||||
${showStackBtn ? `<td class="wfm-table-td-stack">${stackIcon}</td>` : ""}
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const checkTh = state.selectMode ? `<th style="width:24px;"></th>` : "";
|
||||
grid.innerHTML = `<table class="wfm-models-table"><thead><tr>
|
||||
${checkTh}
|
||||
${thSortHtml("★", "fav", "", "width:30px;text-align:center;")}
|
||||
<th style="width:40px;"></th>
|
||||
${thSortHtml(t("modelsFileName"), "filename", "wfm-table-th-filename")}
|
||||
${thSortHtml(t("modelsSubdir"), "subdir", "wfm-table-th-subdir")}
|
||||
${thSortHtml(t("civitaiType"), "civtype", "wfm-table-th-civtype")}
|
||||
${thSortHtml(t("civitaiBaseModel"), "basemodel", "wfm-table-th-basemodel")}
|
||||
${thSortHtml(t("modelsExt"), "ext", "wfm-table-th-ext")}
|
||||
${thSortHtml(t("modelsTags"), "tags")}
|
||||
${thSortHtml(t("modelsMemo"), "memo")}
|
||||
${thSortHtml("E/D", "enabled", "", "width:50px;text-align:center;")}
|
||||
${showBatchBtn ? `<th style="width:30px;">B</th>` : ""}
|
||||
${showStackBtn ? `<th style="width:30px;">S</th>` : ""}
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
|
||||
grid.querySelectorAll(".wfm-models-table-row").forEach((row) => {
|
||||
const mn = row.dataset.modelName;
|
||||
const img = row.querySelector(".wfm-table-thumb");
|
||||
if (img) loadPreviewImage(img, null, mn);
|
||||
|
||||
if (state.selectMode) {
|
||||
row.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-models-table-fav, .wfm-toggle-btn, .wfm-batch-btn, .wfm-stack-btn")) return;
|
||||
toggleModelSelection(mn);
|
||||
});
|
||||
} else {
|
||||
row.addEventListener("click", () => showSidePanel(mn));
|
||||
row.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(mn); });
|
||||
}
|
||||
row.querySelector(".wfm-models-table-fav").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(mn);
|
||||
});
|
||||
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelEnable(mn);
|
||||
});
|
||||
row.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await toggleBatch(mn);
|
||||
if (state.showBatchOnly) {
|
||||
renderModelGrid();
|
||||
} else {
|
||||
e.currentTarget.classList.toggle("active", isInBatch(mn));
|
||||
}
|
||||
});
|
||||
row.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await toggleStack(mn);
|
||||
e.currentTarget.classList.toggle("active", isInStack(mn));
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll(".wfm-table-th-sortable").forEach((th) => {
|
||||
th.addEventListener("click", () => {
|
||||
const col = th.dataset.sortCol;
|
||||
if (state.sortColumn === col) {
|
||||
if (state.sortDir === "asc") {
|
||||
state.sortDir = "desc";
|
||||
} else {
|
||||
state.sortColumn = null;
|
||||
state.sortDir = "asc";
|
||||
}
|
||||
} else {
|
||||
state.sortColumn = col;
|
||||
state.sortDir = "asc";
|
||||
}
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch group helpers ───────────────────────────────────
|
||||
|
||||
export function isInBatch(modelName) {
|
||||
return (state.modelGroups["Batch"] || []).includes(modelName);
|
||||
}
|
||||
|
||||
export async function toggleBatch(modelName) {
|
||||
const batch = state.modelGroups["Batch"] || [];
|
||||
const idx = batch.indexOf(modelName);
|
||||
if (idx >= 0) {
|
||||
batch.splice(idx, 1);
|
||||
} else {
|
||||
batch.push(modelName);
|
||||
}
|
||||
state.modelGroups["Batch"] = batch;
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
}
|
||||
|
||||
export async function clearBatchGroup() {
|
||||
state.modelGroups["Batch"] = [];
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
if (state.showBatchOnly) {
|
||||
state.showBatchOnly = false;
|
||||
document.getElementById("wfm-models-batch-filter-btn")?.classList.remove("active");
|
||||
}
|
||||
renderModelGrid();
|
||||
showToast(t("modelsBatchClear"), "success");
|
||||
}
|
||||
|
||||
export function isInStack(modelName) {
|
||||
return (state.modelGroups["Stack"] || []).includes(modelName);
|
||||
}
|
||||
|
||||
export async function toggleStack(modelName) {
|
||||
const stack = state.modelGroups["Stack"] || [];
|
||||
const idx = stack.indexOf(modelName);
|
||||
if (idx >= 0) {
|
||||
stack.splice(idx, 1);
|
||||
} else {
|
||||
stack.push(modelName);
|
||||
}
|
||||
state.modelGroups["Stack"] = stack;
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
}
|
||||
|
||||
export async function clearStackGroup() {
|
||||
state.modelGroups["Stack"] = [];
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
renderModelGrid();
|
||||
showToast(t("stackCleared"), "success");
|
||||
}
|
||||
|
||||
// ── Favorite toggle ───────────────────────────────────────
|
||||
|
||||
export async function toggleFavorite(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const newFav = !meta.favorite;
|
||||
await saveModelMetadata(modelName, { favorite: newFav });
|
||||
renderModelGrid();
|
||||
if (state.selectedModel === modelName) renderSideInfo(modelName);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Models Tab - Small path/preview helpers shared across the models/* modules
|
||||
*/
|
||||
|
||||
import { state } from "./state.js";
|
||||
|
||||
export function parseModelPath(fullName) {
|
||||
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
|
||||
if (lastSlash === -1) return { dir: "", name: fullName };
|
||||
return { dir: fullName.substring(0, lastSlash), name: fullName.substring(lastSlash + 1) };
|
||||
}
|
||||
|
||||
export function getExtension(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.substring(dot) : "";
|
||||
}
|
||||
|
||||
export function getStem(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
|
||||
export function previewUrl(modelName, modelType) {
|
||||
const type = modelType || state.activeModelType;
|
||||
return `/api/wfm/models/preview?type=${encodeURIComponent(type)}&name=${encodeURIComponent(modelName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preview image. Uses img onload/onerror instead of HEAD request
|
||||
* (aiohttp add_get does not auto-handle HEAD method).
|
||||
* Falls back to CivitAI cached image if no local preview exists.
|
||||
*/
|
||||
export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
const url = previewUrl(modelName, modelType);
|
||||
imgEl.onload = () => {
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
// Fallback: use CivitAI cached image if available
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiImg = civitai && civitai.images && civitai.images[0];
|
||||
if (civitaiImg) {
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
};
|
||||
imgEl.src = civitaiImg;
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
} else {
|
||||
imgEl.style.display = "none";
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
}
|
||||
};
|
||||
imgEl.src = url;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Models Tab - Multi-select mode and bulk group/badge/favorite/move/delete
|
||||
* operations on the current selection.
|
||||
*
|
||||
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
|
||||
* import triangle — see the note at the top of grid-view.js.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state, RESERVED_GROUPS } from "./state.js";
|
||||
import { filterModels, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette } from "./badges.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import { saveModelGroups, saveModelMetadata } from "../models-tab.js";
|
||||
|
||||
export function toggleSelectMode() {
|
||||
state.selectMode = !state.selectMode;
|
||||
if (!state.selectMode) state.selectedModels.clear();
|
||||
const btn = document.getElementById("wfm-models-select-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", state.selectMode);
|
||||
btn.textContent = state.selectMode ? t("modelSelectExit") : t("modelSelectMode");
|
||||
}
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function toggleModelSelection(modelName) {
|
||||
if (state.selectedModels.has(modelName)) {
|
||||
state.selectedModels.delete(modelName);
|
||||
} else {
|
||||
state.selectedModels.add(modelName);
|
||||
}
|
||||
// Update DOM directly (avoid full re-render)
|
||||
document.querySelectorAll("[data-model-name]").forEach((el) => {
|
||||
if (el.dataset.modelName !== modelName) return;
|
||||
const checked = state.selectedModels.has(modelName);
|
||||
el.classList.toggle("wfm-card-checked", checked);
|
||||
const c = el.querySelector(".wfm-select-check");
|
||||
if (c) c.classList.toggle("checked", checked);
|
||||
});
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function clearSelection() {
|
||||
state.selectedModels.clear();
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function selectAll() {
|
||||
filterModels().forEach(m => state.selectedModels.add(m));
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function renderBulkActionBar() {
|
||||
const bar = document.getElementById("wfm-models-bulk-bar");
|
||||
if (!bar) return;
|
||||
if (!state.selectMode || state.selectedModels.size === 0) {
|
||||
bar.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const count = state.selectedModels.size;
|
||||
const groupNames = Object.keys(state.modelGroups).sort();
|
||||
const noGroups = groupNames.length === 0;
|
||||
const groupOptions = noGroups
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: groupNames.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
|
||||
|
||||
const palette = getBadgePalette();
|
||||
const badgeLabels = Object.keys(palette).sort();
|
||||
const noBadges = badgeLabels.length === 0;
|
||||
const badgeOptions = noBadges
|
||||
? `<option value="">${t("modelBulkNoBadge")}</option>`
|
||||
: badgeLabels.map((b) => `<option value="${escapeHtml(b)}">${escapeHtml(b)}</option>`).join("");
|
||||
|
||||
bar.style.display = "flex";
|
||||
bar.innerHTML = `
|
||||
<div class="wfm-bulk-header">
|
||||
<span class="wfm-bulk-count">${count} ${t("modelSelected")}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-deselect-all-btn">${t("modelBulkDeselectAll")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-select-all-btn">${t("modelBulkSelectAll")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-add-btn">${t("modelBulkFavAdd")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-remove-btn">${t("modelBulkFavRemove")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-rows">
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">Group:</span>
|
||||
<select id="wfm-bulk-group-select" class="wfm-select wfm-bulk-select">${groupOptions}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-add-btn"${noGroups ? " disabled" : ""}>${t("modelBulkAddGroup")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-remove-btn"${noGroups ? " disabled" : ""}>${t("modelBulkRemoveGroup")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<input type="text" id="wfm-bulk-new-group-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelsGroupName")}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-create-add-btn">${t("modelBulkCreateAdd")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">Badge:</span>
|
||||
<select id="wfm-bulk-badge-select" class="wfm-select wfm-bulk-select">${badgeOptions}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-badge-apply-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-badge-remove-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeRemove")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">File:</span>
|
||||
<select id="wfm-bulk-move-select" class="wfm-select wfm-bulk-select">
|
||||
<option value="">${t("modelBulkMoveRoot")}</option>
|
||||
${state.subdirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-move-btn">${t("modelBulkMoveBtn")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<input type="text" id="wfm-bulk-new-dir-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelBulkMoveNewFolder")}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-mkdir-move-btn">${t("modelBulkMoveCreateMove")}</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-delete-btn">${t("modelBulkDelete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-bulk-deselect-all-btn")?.addEventListener("click", clearSelection);
|
||||
document.getElementById("wfm-bulk-select-all-btn")?.addEventListener("click", selectAll);
|
||||
document.getElementById("wfm-bulk-fav-add-btn")?.addEventListener("click", () => bulkSetFavorite(true));
|
||||
document.getElementById("wfm-bulk-fav-remove-btn")?.addEventListener("click", () => bulkSetFavorite(false));
|
||||
document.getElementById("wfm-bulk-add-btn")?.addEventListener("click", () => {
|
||||
const g = document.getElementById("wfm-bulk-group-select")?.value;
|
||||
if (g) bulkAddToGroup(g);
|
||||
});
|
||||
document.getElementById("wfm-bulk-remove-btn")?.addEventListener("click", () => {
|
||||
const g = document.getElementById("wfm-bulk-group-select")?.value;
|
||||
if (g) bulkRemoveFromGroup(g);
|
||||
});
|
||||
document.getElementById("wfm-bulk-create-add-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-bulk-new-group-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (state.modelGroups[name]) { showToast(t("modelsGroupExists"), "warning"); return; }
|
||||
bulkAddToGroup(name);
|
||||
});
|
||||
document.getElementById("wfm-bulk-badge-apply-btn")?.addEventListener("click", () => {
|
||||
const label = document.getElementById("wfm-bulk-badge-select")?.value;
|
||||
if (label) bulkApplyBadge(label, true);
|
||||
});
|
||||
document.getElementById("wfm-bulk-badge-remove-btn")?.addEventListener("click", () => {
|
||||
const label = document.getElementById("wfm-bulk-badge-select")?.value;
|
||||
if (label) bulkApplyBadge(label, false);
|
||||
});
|
||||
document.getElementById("wfm-bulk-move-btn")?.addEventListener("click", () => {
|
||||
const dest = document.getElementById("wfm-bulk-move-select")?.value || "";
|
||||
bulkMoveModels(dest);
|
||||
});
|
||||
document.getElementById("wfm-bulk-mkdir-move-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-bulk-new-dir-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
bulkMoveModels(name);
|
||||
});
|
||||
document.getElementById("wfm-bulk-delete-btn")?.addEventListener("click", bulkDeleteModels);
|
||||
}
|
||||
|
||||
export async function bulkAddToGroup(groupName) {
|
||||
const groups = { ...state.modelGroups };
|
||||
if (!groups[groupName]) groups[groupName] = [];
|
||||
const toAdd = [...state.selectedModels].filter((m) => !groups[groupName].includes(m));
|
||||
groups[groupName] = [...groups[groupName], ...toAdd];
|
||||
await saveModelGroups(groups);
|
||||
showToast(`${toAdd.length} ${t("modelBulkAddDone")}`, "success");
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export async function bulkRemoveFromGroup(groupName) {
|
||||
const groups = { ...state.modelGroups };
|
||||
if (!groups[groupName]) return;
|
||||
const before = groups[groupName].length;
|
||||
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
|
||||
const removed = before - groups[groupName].length;
|
||||
// RESERVED_GROUPS (Batch / Stack) は空になってもキーを保持する
|
||||
if (groups[groupName].length === 0 && !RESERVED_GROUPS.includes(groupName)) delete groups[groupName];
|
||||
await saveModelGroups(groups);
|
||||
showToast(`${removed} ${t("modelBulkRemoveDone")}`, "success");
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export async function bulkSetFavorite(isFav) {
|
||||
const models = [...state.selectedModels];
|
||||
let count = 0;
|
||||
for (const mn of models) {
|
||||
const meta = state.modelMetadata[mn] || {};
|
||||
if (meta.favorite === isFav) continue;
|
||||
await saveModelMetadata(mn, { favorite: isFav });
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
showToast(`${count} ${isFav ? t("modelBulkFavDone") : t("modelBulkUnfavDone")}`, "success");
|
||||
renderModelGrid();
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkApplyBadge(badgeLabel, add) {
|
||||
const models = [...state.selectedModels];
|
||||
let count = 0;
|
||||
for (const mn of models) {
|
||||
const meta = state.modelMetadata[mn] || {};
|
||||
const badges = [...(meta.badges || [])];
|
||||
if (add) {
|
||||
if (badges.includes(badgeLabel)) continue;
|
||||
badges.push(badgeLabel);
|
||||
} else {
|
||||
const idx = badges.indexOf(badgeLabel);
|
||||
if (idx === -1) continue;
|
||||
badges.splice(idx, 1);
|
||||
}
|
||||
await saveModelMetadata(mn, { badges });
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
showToast(`${count} ${add ? t("modelBulkBadgeApplyDone") : t("modelBulkBadgeRemoveDone")}`, "success");
|
||||
renderModelGrid();
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkDeleteModels() {
|
||||
const count = state.selectedModels.size;
|
||||
if (count === 0) return;
|
||||
const msg = t("modelBulkDeleteConfirm").replace("{count}", count);
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
const model_names = [...state.selectedModels];
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const okCount = (data.ok || []).length;
|
||||
const errCount = (data.errors || []).length;
|
||||
if (errCount > 0) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${errCount} errors`, "error");
|
||||
}
|
||||
if (okCount > 0) {
|
||||
showToast(`${okCount} ${t("modelBulkDeleteDone")}`, "success");
|
||||
// Remove deleted models from local state
|
||||
model_names.forEach((mn) => {
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(mn);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
delete state.modelMetadata[mn];
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds) ds.delete(mn);
|
||||
state.selectedModels.delete(mn);
|
||||
});
|
||||
state.loaded[state.activeModelType] = false;
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSubdirs() {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/models/subdirs?type=${encodeURIComponent(state.activeModelType)}`);
|
||||
state.subdirs = res.ok ? await res.json() : [];
|
||||
} catch { state.subdirs = []; }
|
||||
}
|
||||
|
||||
export async function bulkMoveModels(destSubdir) {
|
||||
const model_names = [...state.selectedModels];
|
||||
if (model_names.length === 0) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names, dest: destSubdir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const okCount = data.moved?.length || 0;
|
||||
const errCount = data.errors?.length || 0;
|
||||
|
||||
if (okCount > 0) {
|
||||
data.moved.forEach(({ from, to }) => {
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(from);
|
||||
if (idx !== -1) list[idx] = to;
|
||||
if (state.modelMetadata[from]) {
|
||||
state.modelMetadata[to] = state.modelMetadata[from];
|
||||
delete state.modelMetadata[from];
|
||||
}
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds && ds.has(from)) { ds.delete(from); ds.add(to); }
|
||||
state.selectedModels.delete(from);
|
||||
for (const members of Object.values(state.modelGroups)) {
|
||||
const gi = members.indexOf(from);
|
||||
if (gi !== -1) members[gi] = to;
|
||||
}
|
||||
});
|
||||
if (state.selectedModel && data.moved.some((m) => m.from === state.selectedModel)) {
|
||||
state.selectedModel = null;
|
||||
const titleEl = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
}
|
||||
showToast(`${okCount} ${t("modelBulkMoveDone")}`, "success");
|
||||
await fetchSubdirs();
|
||||
renderDirFilter();
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
if (errCount > 0) {
|
||||
showToast(`${t("modelBulkMoveError")}: ${data.errors[0].error}`, "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkMoveError")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Models Tab - Shared state and static lookup tables
|
||||
* `state` is a `const` object never reassigned — modules read/write its
|
||||
* properties directly, which stays within ESM's read-only binding rule
|
||||
* (only the binding itself, not the object it points to, is immutable).
|
||||
*/
|
||||
|
||||
import { comfyUI } from "../comfyui-client.js";
|
||||
|
||||
export const RESERVED_GROUPS = ["Batch", "Stack"];
|
||||
export const BATCH_MODEL_TYPES = ["checkpoint", "lora"];
|
||||
export const STACK_MODEL_TYPES = ["lora"];
|
||||
|
||||
export const state = {
|
||||
modelsByType: {
|
||||
checkpoint: [],
|
||||
lora: [],
|
||||
vae: [],
|
||||
controlnet: [],
|
||||
unet: [],
|
||||
textencoder: [],
|
||||
hypernetwork: [],
|
||||
embedding: [],
|
||||
},
|
||||
modelMetadata: {},
|
||||
modelGroups: {},
|
||||
allModelGroups: {}, // { type: { groupName: [models] } } — all types
|
||||
civitaiCache: {},
|
||||
disabledModels: {}, // { type: Set<modelName> }
|
||||
subdirs: [],
|
||||
selectMode: false,
|
||||
selectedModels: new Set(),
|
||||
searchText: "",
|
||||
tagFilter: "",
|
||||
badgeFilter: "",
|
||||
dirFilter: "",
|
||||
groupFilter: "",
|
||||
statusFilter: "all", // "all" | "enabled" | "disabled"
|
||||
showFavoritesOnly: false,
|
||||
showBatchOnly: false,
|
||||
viewMode: localStorage.getItem("wfm_models_view") === "table" ? "table" : "thumb",
|
||||
activeModelType: "checkpoint",
|
||||
selectedModel: null,
|
||||
loaded: {},
|
||||
currentPage: 0,
|
||||
sortColumn: null, // "fav" | "filename" | "subdir" | "civtype" | "basemodel" | "ext" | "tags" | "memo" | "enabled"
|
||||
sortDir: "asc", // "asc" | "desc"
|
||||
};
|
||||
|
||||
|
||||
export const FETCH_MAP = {
|
||||
checkpoint: () => comfyUI.fetchCheckpoints(),
|
||||
lora: () => comfyUI.fetchLoras(),
|
||||
vae: () => comfyUI.fetchVaes(),
|
||||
controlnet: () => comfyUI.fetchControlNets(),
|
||||
unet: () => comfyUI.fetchDiffusionModels(),
|
||||
textencoder: () => comfyUI.fetchTextEncoders(),
|
||||
hypernetwork: () => comfyUI.fetchHypernetworks(),
|
||||
embedding: () => comfyUI.fetchEmbeddings(),
|
||||
};
|
||||
|
||||
export const TYPE_LABELS = {
|
||||
checkpoint: "Checkpoint",
|
||||
lora: "LoRA",
|
||||
vae: "VAE",
|
||||
controlnet: "ControlNet",
|
||||
unet: "UNET",
|
||||
textencoder: "TextEncoder",
|
||||
hypernetwork: "Hypernetwork",
|
||||
embedding: "Embedding",
|
||||
};
|
||||
|
||||
// Mapping from models-tab type → comfyui-editor key + inputKey
|
||||
export const GENUI_TYPE_MAP = {
|
||||
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
|
||||
lora: { key: "loras", inputKey: "lora_name" },
|
||||
vae: { key: "vaes", inputKey: "vae_name" },
|
||||
controlnet: { key: "controlNets", inputKey: "control_net_name" },
|
||||
unet: { key: "diffusionModels",inputKey: "unet_name" },
|
||||
textencoder: { key: "textEncoders", inputKey: "clip_name1" },
|
||||
hypernetwork: { key: "hypernetworks", inputKey: "hypernetwork_name" },
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -366,6 +394,21 @@ export function applyJsonColors(colors) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// GenerateUI Model tab: highlight color for section labels that match the loaded workflow
|
||||
const MODEL_ACTIVE_COLOR_STYLE_ID = "wfm-model-active-color-style";
|
||||
const MODEL_ACTIVE_COLOR_DEFAULT = "#4ade80";
|
||||
|
||||
export function applyModelTabActiveColor(color) {
|
||||
const val = color || MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
let el = document.getElementById(MODEL_ACTIVE_COLOR_STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
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,\n.wfm-video-plan-content label.wfm-model-label-active { color: ${val}; }`;
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
if (themeId) {
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
@@ -560,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>
|
||||
@@ -577,6 +638,21 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- GenerateUI Model Tab Highlight Color -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("modelTabColorSection") || "GenerateUI Modelタブ ハイライト色"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("modelTabColorLabel") || "ワークフローに含まれる項目の色"}</label>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="color" id="wfm-model-active-color" value="${settings.modelTabActiveColor || MODEL_ACTIVE_COLOR_DEFAULT}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-model-active-color-reset">${t("jsonColorReset") || "デフォルトに戻す"}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("modelTabColorHint") || "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Workflow Data Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
|
||||
@@ -661,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>
|
||||
@@ -794,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>
|
||||
|
||||
@@ -1109,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;
|
||||
@@ -1312,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;
|
||||
@@ -1333,6 +1314,22 @@ export async function initSettingsTab() {
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// --- GenerateUI Model tab highlight color ---
|
||||
document.getElementById("wfm-model-active-color")?.addEventListener("input", (e) => {
|
||||
saveLocalSettings({ modelTabActiveColor: e.target.value });
|
||||
applyModelTabActiveColor(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-model-active-color-reset")?.addEventListener("click", () => {
|
||||
const cur = loadLocalSettings();
|
||||
delete cur.modelTabActiveColor;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
|
||||
applyModelTabActiveColor(undefined);
|
||||
const inp = document.getElementById("wfm-model-active-color");
|
||||
if (inp) inp.value = MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// Save (local settings)
|
||||
document.getElementById("wfm-settings-save")?.addEventListener("click", () => {
|
||||
const taFontSize = parseInt(document.getElementById("wfm-ta-font-size")?.value) || TEXTAREA_FONT_SIZE_DEFAULT;
|
||||
@@ -1486,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
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Style Catalog Gallery — Gallery タブのサブタブ。
|
||||
* ws_style_catalog フォルダ配下の画像(GenerateUIの「カタログ作成」で生成した、
|
||||
* ファイル名=スタイル名のプレビュー画像)を閲覧し、埋め込みPositive/Negative
|
||||
* プロンプトを表示・コピーする。「Select as Style」でGenerateUIの登録済み
|
||||
* Styleドロップダウンをファイル名と同名のStyleに切り替える。
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { extractAllMetadata, loadFileIntoMetadataTab } from "./metadata-tab.js";
|
||||
import { selectStyleByName, loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
|
||||
const API = {
|
||||
root: () => `/wfm/gallery/style-catalog/root`,
|
||||
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
|
||||
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
|
||||
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
|
||||
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
|
||||
workflow: (path) => `/wfm/gallery/image/workflow?path=${encodeURIComponent(path)}`,
|
||||
};
|
||||
|
||||
const state = {
|
||||
root: null,
|
||||
currentFolder: null,
|
||||
search: "",
|
||||
images: [],
|
||||
selectedImage: null,
|
||||
};
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 初期化 ────────────────────────────────────────────────────
|
||||
|
||||
export function initStyleCatalogTab() {
|
||||
document.getElementById("wfm-stylecatalog-refresh-btn")?.addEventListener("click", () => {
|
||||
loadFolderTree();
|
||||
loadImages();
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-stylecatalog-search", "wfm-stylecatalog-search-clear-btn", () => {
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById("wfm-stylecatalog-search");
|
||||
searchInput?.addEventListener("input", debounce(() => {
|
||||
state.search = searchInput.value.trim();
|
||||
loadImages();
|
||||
}, 300));
|
||||
|
||||
document.getElementById("wfm-stylecatalog-copy-positive-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-positive-text"));
|
||||
document.getElementById("wfm-stylecatalog-copy-negative-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-negative-text"));
|
||||
document.getElementById("wfm-stylecatalog-select-style-btn")?.addEventListener("click", onSelectStyleClick);
|
||||
document.getElementById("wfm-stylecatalog-load-genui-btn")?.addEventListener("click", onLoadGenUIClick);
|
||||
document.getElementById("wfm-stylecatalog-open-metadata-btn")?.addEventListener("click", onOpenMetadataClick);
|
||||
}
|
||||
|
||||
export async function activateStyleCatalogTab() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
const data = await apiFetch(API.root());
|
||||
state.root = data.root;
|
||||
await loadFolderTree();
|
||||
} catch (e) {
|
||||
const tree = document.getElementById("wfm-stylecatalog-tree");
|
||||
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
async function loadFolderTree() {
|
||||
if (!state.root) return;
|
||||
const tree = document.getElementById("wfm-stylecatalog-tree");
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
try {
|
||||
const data = await apiFetch(API.folders(state.root));
|
||||
if (data.error) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
|
||||
return;
|
||||
}
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
|
||||
if (firstLabel) firstLabel.click();
|
||||
} catch (e) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTreeNode(node, container, depth, isRoot) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-gallery-tree-item";
|
||||
item.style.paddingLeft = `${depth * 12}px`;
|
||||
item.dataset.path = node.abs_path;
|
||||
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const absPath = node.abs_path;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "wfm-gallery-tree-arrow";
|
||||
if (hasChildren) {
|
||||
arrow.textContent = "▶";
|
||||
arrow.dataset.expanded = "false";
|
||||
} else {
|
||||
arrow.textContent = " ";
|
||||
arrow.style.visibility = "hidden";
|
||||
}
|
||||
item.appendChild(arrow);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
const totalCount = node.image_count_total ?? node.image_count;
|
||||
if (totalCount > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
badge.textContent = totalCount;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
item.appendChild(label);
|
||||
|
||||
label.addEventListener("click", () => {
|
||||
document.querySelectorAll("#wfm-stylecatalog-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
|
||||
item.classList.add("selected");
|
||||
state.currentFolder = absPath;
|
||||
loadImages();
|
||||
});
|
||||
|
||||
if (hasChildren) {
|
||||
const childContainer = document.createElement("div");
|
||||
childContainer.className = "wfm-gallery-tree-children";
|
||||
childContainer.style.display = "none";
|
||||
|
||||
arrow.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const expanded = arrow.dataset.expanded === "true";
|
||||
if (!expanded) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
if (childContainer.children.length === 0) {
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
arrow.dataset.expanded = "false";
|
||||
arrow.textContent = "▶";
|
||||
childContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画像一覧 ─────────────────────────────────────────────────
|
||||
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
const grid = document.getElementById("wfm-stylecatalog-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
|
||||
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
|
||||
if (state.search) params.search = state.search;
|
||||
|
||||
try {
|
||||
const data = await apiFetch(API.images(params));
|
||||
state.images = data.images || [];
|
||||
document.getElementById("wfm-stylecatalog-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const grid = document.getElementById("wfm-stylecatalog-grid");
|
||||
if (state.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
|
||||
grid.appendChild(fragment);
|
||||
}
|
||||
|
||||
function createThumbCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-gallery-thumb-card";
|
||||
card.title = img.filename;
|
||||
card.dataset.path = img.path;
|
||||
if (state.selectedImage && state.selectedImage.path === img.path) {
|
||||
card.classList.add("selected");
|
||||
}
|
||||
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "wfm-gallery-thumb-img";
|
||||
imgEl.loading = "lazy";
|
||||
imgEl.src = API.thumb(img.path);
|
||||
imgEl.alt = img.filename;
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "wfm-gallery-thumb-placeholder";
|
||||
placeholder.textContent = "?";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
};
|
||||
card.appendChild(imgEl);
|
||||
|
||||
card.addEventListener("click", () => selectImage(img));
|
||||
card.addEventListener("dblclick", () => openLightbox(img));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 選択画像 / Positive・Negative表示 ─────────────────────────
|
||||
|
||||
function styleNameFromFilename(filename) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
async function selectImage(img) {
|
||||
state.selectedImage = img;
|
||||
document.querySelectorAll("#wfm-stylecatalog-grid .wfm-gallery-thumb-card").forEach(el => {
|
||||
el.classList.toggle("selected", el.dataset.path === img.path);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-stylecatalog-selected-name").textContent = img.filename;
|
||||
const preview = document.getElementById("wfm-stylecatalog-selected-preview");
|
||||
preview.innerHTML = "";
|
||||
const previewImg = document.createElement("img");
|
||||
previewImg.src = API.serveImage(img.path);
|
||||
previewImg.alt = img.filename;
|
||||
previewImg.title = "Double-click to enlarge";
|
||||
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;cursor:zoom-in;";
|
||||
previewImg.addEventListener("dblclick", () => openLightbox(img));
|
||||
preview.appendChild(previewImg);
|
||||
|
||||
const positiveText = document.getElementById("wfm-stylecatalog-positive-text");
|
||||
const negativeText = document.getElementById("wfm-stylecatalog-negative-text");
|
||||
const copyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
|
||||
const copyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
|
||||
const selectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
|
||||
const loadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
|
||||
const openMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
|
||||
|
||||
positiveText.textContent = "";
|
||||
negativeText.textContent = "";
|
||||
copyPositiveBtn.disabled = true;
|
||||
copyNegativeBtn.disabled = true;
|
||||
selectStyleBtn.disabled = false; // ファイル名からのStyle選択はメタデータ抽出に依存しない
|
||||
loadGenuiBtn.disabled = false;
|
||||
openMetadataBtn.disabled = false;
|
||||
|
||||
try {
|
||||
const res = await fetch(API.serveImage(img.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
const meta = await extractAllMetadata(file);
|
||||
|
||||
const positive = meta?.positives?.[0] || "";
|
||||
const negative = meta?.negatives?.[0] || "";
|
||||
|
||||
if (!positive && !negative) {
|
||||
positiveText.textContent = t("stylecatalogNoPromptFound");
|
||||
} else {
|
||||
positiveText.textContent = positive;
|
||||
negativeText.textContent = negative;
|
||||
copyPositiveBtn.disabled = !positive;
|
||||
copyNegativeBtn.disabled = !negative;
|
||||
}
|
||||
} catch (e) {
|
||||
positiveText.textContent = t("stylecatalogNoPromptFound");
|
||||
}
|
||||
}
|
||||
|
||||
// gallery-tab.jsのライトボックスと同じCSSクラスを使う(ESCで閉じる処理もgallery-tab.js側の
|
||||
// グローバルkeydownリスナーが共通で担う)。
|
||||
function openLightbox(img) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "wfm-gallery-lightbox";
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-gallery-lightbox-inner">
|
||||
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
|
||||
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
|
||||
<button class="wfm-gallery-lightbox-close">×</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay || e.target.classList.contains("wfm-gallery-lightbox-close")) {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function copyText(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
const text = el?.textContent || "";
|
||||
if (!text.trim()) return;
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => showToast(t("imagePromptCopiedToast"), "success"))
|
||||
.catch((e) => showToast(t("errorWithMsg", e.message), "error"));
|
||||
}
|
||||
|
||||
function onSelectStyleClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
const name = styleNameFromFilename(img.filename);
|
||||
const ok = selectStyleByName(name);
|
||||
if (ok) {
|
||||
showToast(t("stylecatalogStyleSelected", name), "success");
|
||||
} else {
|
||||
showToast(t("stylecatalogNoMatch"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// カタログ画像は生成元の出力画像と同一バイト列で保存されているため、埋め込みワークフローも
|
||||
// そのまま残っている。Outputフォルダ内の元画像を探す必要なく、この画像から直接読み込める。
|
||||
async function onLoadGenUIClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
try {
|
||||
const data = await apiFetch(API.workflow(img.path));
|
||||
if (!data.has_workflow) {
|
||||
showToast(t("galleryNoEmbeddedWorkflow"), "warning");
|
||||
return;
|
||||
}
|
||||
const loaded = await loadWorkflowIntoEditor(data.workflow, img.filename);
|
||||
if (loaded !== false) {
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function onOpenMetadataClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
try {
|
||||
const res = await fetch(API.serveImage(img.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
await loadFileIntoMetadataTab(file);
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,76 @@
|
||||
* Shared utilities
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// PNG metadata embedding (iTXt chunk)
|
||||
// ============================================
|
||||
// Embeds an arbitrary key/UTF-8-text pair into a canvas-generated PNG data URL as
|
||||
// an iTXt chunk, inserted right after IHDR — same insertion point/CRC approach as
|
||||
// ComfyUI's own workflow-embedding (web/comfyui/top_menu_extension.js's
|
||||
// embedWorkflowInPng), but iTXt instead of tEXt so non-ASCII text (Japanese notes,
|
||||
// prompts, etc.) round-trips correctly through this codebase's PNG chunk reader
|
||||
// (metadata-tab.js's readAllPNGTextChunks already decodes iTXt text as UTF-8;
|
||||
// tEXt text there is decoded as Latin-1, which would mangle non-ASCII payloads).
|
||||
|
||||
function _n2b(n) {
|
||||
return new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
function _joinBytes(...arrs) {
|
||||
const result = new Uint8Array(arrs.reduce((total, a) => total + a.byteLength, 0));
|
||||
arrs.reduce((offset, a) => { result.set(a, offset); return offset + a.byteLength; }, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
let _pngCrcTable = null;
|
||||
function _pngCrc32(data) {
|
||||
if (!_pngCrcTable) {
|
||||
_pngCrcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
_pngCrcTable[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < data.byteLength; i++) crc = (crc >>> 8) ^ _pngCrcTable[(crc ^ data[i]) & 0xff];
|
||||
return (crc ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
function _dataUrlToBytes(dataUrl) {
|
||||
const binary = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function _bytesToDataUrl(bytes, mime) {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
return `data:${mime};base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
export function embedPngTextChunk(dataUrl, keyword, text) {
|
||||
const bytes = _dataUrlToBytes(dataUrl);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
const data = _joinBytes(
|
||||
new TextEncoder().encode(`iTXt${keyword}`),
|
||||
new Uint8Array([0, 0, 0, 0, 0]), // keyword-NUL, compression flag(0), compression method(0), empty lang tag+NUL, empty translated keyword+NUL
|
||||
new TextEncoder().encode(text),
|
||||
);
|
||||
const chunk = _joinBytes(_n2b(data.byteLength - 4), data, _n2b(_pngCrc32(data)));
|
||||
|
||||
// Insert after IHDR (8-byte signature + 4-byte length + 4-byte type + IHDR data + 4-byte CRC)
|
||||
const ihdrDataLen = view.getUint32(8);
|
||||
const insertAt = ihdrDataLen + 20;
|
||||
const result = _joinBytes(bytes.subarray(0, insertAt), chunk, bytes.subarray(insertAt));
|
||||
return _bytesToDataUrl(result, "image/png");
|
||||
}
|
||||
|
||||
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
@@ -32,12 +102,102 @@ export const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
unsloth: "http://localhost:8888",
|
||||
};
|
||||
|
||||
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
|
||||
// ============================================
|
||||
|
||||
export function getEagleSettings() {
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/eagle/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
eagleUrl: eagle.url,
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
|
||||
filename: fileInfo?.filename || "",
|
||||
subfolder: fileInfo?.subfolder || "",
|
||||
type: fileInfo?.type || "output",
|
||||
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
|
||||
localPath: fileInfo?.localPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === "success") {
|
||||
console.log("[Eagle] Saved:", name);
|
||||
} else {
|
||||
console.warn("[Eagle] Save failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Eagle] Save error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索inputにオーバーレイXボタンを設定する。
|
||||
* @param {string} inputId - 検索inputのID
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Video Tab - Asset subtab: browses the videos a Plan run has generated.
|
||||
*
|
||||
* Deliberately thin — it reuses Gallery's existing backend (groups, tags/memo
|
||||
* sidecar, thumbnailing) exactly like feeder-tab.js's own Gallery mode does,
|
||||
* rather than re-implementing folder browsing, bulk selection, etc. Anything
|
||||
* beyond basic browsing/tagging (move, export, delete, multi-select) is left
|
||||
* to the real Gallery tab via the "Open in Gallery" button.
|
||||
*/
|
||||
|
||||
import { VIDEO_GROUP, VTEMP_GROUP, ensureVideoGroup, isVideoFile } from "./gallery-tab.js";
|
||||
import { setSourcePreview } from "./video-preview.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { addClipFromFile } from "./video-edit-tab.js";
|
||||
import { setBlockImageFromFile } from "./video-plan-tab.js";
|
||||
|
||||
// Sentinel for the "All Video Assets" option — not a real backend group, since
|
||||
// Gallery's group filter only ever matches one group at a time. Selecting it
|
||||
// fetches VIDEO_GROUP and VTEMP_GROUP separately and merges them (see _loadImages).
|
||||
const ALL_GROUPS_VALUE = "__video_asset_all__";
|
||||
|
||||
// Display names are now identical to the underlying group names (minus the
|
||||
// "__...__" wrapping) — no more per-plan "VideoPlan:<name>" sub-groups, so the
|
||||
// dropdown is always exactly these three fixed entries.
|
||||
const _GROUPS = [ALL_GROUPS_VALUE, VIDEO_GROUP, VTEMP_GROUP];
|
||||
|
||||
const _s = {
|
||||
outputDir: "",
|
||||
group: ALL_GROUPS_VALUE,
|
||||
images: [],
|
||||
selectedPath: null,
|
||||
loaded: false, // becomes true once the Asset subtab has been shown at least once
|
||||
searchQuery: "",
|
||||
viewMode: "grid", // "grid" | "table"
|
||||
};
|
||||
|
||||
function _formatDate(mtime) {
|
||||
return new Date(mtime * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function _filteredImages() {
|
||||
const q = _s.searchQuery.trim().toLowerCase();
|
||||
if (!q) return _s.images;
|
||||
return _s.images.filter((img) => img.filename.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
async function _fetchOutputDir() {
|
||||
if (_s.outputDir) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_s.outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
function _groupLabel(g) {
|
||||
if (g === ALL_GROUPS_VALUE) return "All Video Assets";
|
||||
if (g === VIDEO_GROUP) return "Video Assets";
|
||||
if (g === VTEMP_GROUP) return "Video Temp";
|
||||
return g;
|
||||
}
|
||||
|
||||
function _renderGroupSelect() {
|
||||
const sel = document.getElementById("wfm-video-asset-group");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = _GROUPS
|
||||
.map((g) => `<option value="${g}"${g === _s.group ? " selected" : ""}>${_groupLabel(g)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function _fetchGroupImages(group) {
|
||||
const params = new URLSearchParams({ folder: _s.outputDir, group, recursive: "true", sort: "date_desc" });
|
||||
const res = await fetch(`/wfm/gallery/images?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
return data.images || [];
|
||||
}
|
||||
|
||||
async function _loadImages() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const statusEl = document.getElementById("wfm-video-asset-status");
|
||||
if (!grid) return;
|
||||
await _fetchOutputDir();
|
||||
if (!_s.outputDir) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No output folder configured</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = `<div class="wfm-placeholder">Loading...</div>`;
|
||||
try {
|
||||
let images;
|
||||
if (_s.group === ALL_GROUPS_VALUE) {
|
||||
// "All Video Assets" = the union of the curated __Video Assets__
|
||||
// group and __Video Temp__ (auto-populated by every plan run,
|
||||
// organized into __Video Assets__ by the user on their own
|
||||
// schedule) — not a single backend group filter, so fetch both
|
||||
// and merge.
|
||||
const [curated, temp] = await Promise.all([
|
||||
_fetchGroupImages(VIDEO_GROUP),
|
||||
_fetchGroupImages(VTEMP_GROUP),
|
||||
]);
|
||||
const byPath = new Map();
|
||||
for (const img of [...curated, ...temp]) byPath.set(img.path, img);
|
||||
images = Array.from(byPath.values()).sort((a, b) => b.mtime - a.mtime);
|
||||
} else {
|
||||
images = await _fetchGroupImages(_s.group);
|
||||
}
|
||||
_s.images = images;
|
||||
_renderList();
|
||||
if (statusEl) statusEl.textContent = `${_s.images.length} video(s)`;
|
||||
} catch (err) {
|
||||
_s.images = [];
|
||||
grid.innerHTML = `<div class="wfm-placeholder" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Renders whichever of grid/table is active for the current viewMode — called
|
||||
// on load, on search input, and after switching view modes.
|
||||
function _renderList() {
|
||||
if (_s.viewMode === "table") _renderTable();
|
||||
else _renderGrid();
|
||||
}
|
||||
|
||||
function _renderGrid() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
if (!grid) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No videos yet</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
for (const img of images) grid.appendChild(_makeCard(img));
|
||||
}
|
||||
|
||||
function _renderTable() {
|
||||
const body = document.getElementById("wfm-video-asset-table-body");
|
||||
if (!body) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
body.innerHTML = `<tr><td colspan="3" class="wfm-placeholder">No videos yet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
for (const img of images) body.appendChild(_makeRow(img));
|
||||
}
|
||||
|
||||
function _makeRow(img) {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "wfm-video-asset-row" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
row.dataset.path = img.path;
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.textContent = img.filename;
|
||||
nameCell.title = img.filename;
|
||||
|
||||
const dateCell = document.createElement("td");
|
||||
dateCell.textContent = _formatDate(img.mtime);
|
||||
|
||||
const tagsCell = document.createElement("td");
|
||||
tagsCell.textContent = (img.tags || []).join(", ");
|
||||
|
||||
row.append(nameCell, dateCell, tagsCell);
|
||||
row.addEventListener("click", () => _selectImage(img));
|
||||
return row;
|
||||
}
|
||||
|
||||
function _makeCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-video-asset-card" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
card.dataset.path = img.path;
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "wfm-video-asset-card-img";
|
||||
thumb.loading = "lazy";
|
||||
thumb.src = `/wfm/gallery/image/thumb?path=${encodeURIComponent(img.path)}&w=200`;
|
||||
thumb.onerror = () => { thumb.style.display = "none"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-video-asset-card-name";
|
||||
name.textContent = img.filename;
|
||||
name.title = img.filename;
|
||||
|
||||
card.append(thumb, name);
|
||||
card.addEventListener("click", () => _selectImage(img));
|
||||
return card;
|
||||
}
|
||||
|
||||
function _selectImage(img) {
|
||||
_s.selectedPath = img.path;
|
||||
document.querySelectorAll(".wfm-video-asset-card, .wfm-video-asset-row").forEach((c) => {
|
||||
c.classList.toggle("selected", c.dataset.path === img.path);
|
||||
});
|
||||
_renderDetail(img);
|
||||
_loadIntoSourcePreview(img);
|
||||
}
|
||||
|
||||
// Feeds the selected asset's media into the center panel's Asset/Source preview
|
||||
// pane (see video-preview.js) so it's visible without switching tabs, and so the
|
||||
// Frame/GIF property tools can operate on it just like a Video Source drop.
|
||||
// Fetched as a Blob and wrapped as a "local" source rather than trying to map
|
||||
// Gallery's arbitrary absolute path onto ComfyUI's filename/subfolder/type
|
||||
// triple — that keeps the GIF tool's existing upload-on-first-use path working
|
||||
// unchanged for both cases. Still/animated images (this group isn't exclusively
|
||||
// videos — anything tagged into it shows up here) preview via the pane's <img>
|
||||
// companion element instead of trying to play them as a <video>.
|
||||
async function _loadIntoSourcePreview(img) {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const isVideo = isVideoFile(img);
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideo ? "video/mp4" : "image/png") });
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file }, isVideo ? "video" : "image");
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] failed to load preview:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDetail(img) {
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (!panel) return;
|
||||
// Filename/memo are untrusted (filesystem names, user-entered text) — the
|
||||
// markup skeleton is static, and those two values are assigned afterward
|
||||
// via textContent/.value, never interpolated into the HTML string itself.
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-video-asset-name" id="wfm-video-asset-name"></div>
|
||||
<label>Tags</label>
|
||||
<div class="wfm-video-asset-tags" id="wfm-video-asset-tags"></div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<input type="text" id="wfm-video-asset-tag-input" class="wfm-input" placeholder="Add tag" style="flex:1;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-tag-add">Add</button>
|
||||
</div>
|
||||
<label style="margin-top:10px;">Memo</label>
|
||||
<textarea id="wfm-video-asset-memo" class="wfm-textarea" rows="3"></textarea>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-memo-save" style="margin-top:6px;">Save Memo</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-send-to-edit" style="width:100%;margin-top:12px;">${t("videoEditSendToEdit")}</button>
|
||||
<div id="wfm-video-asset-set-plan-image-row" style="display:${isVideoFile(img) ? "none" : "flex"};gap:6px;margin-top:6px;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-first" style="flex:1;">${t("videoAssetSetAsFirst")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-last" style="flex:1;">${t("videoAssetSetAsLast")}</button>
|
||||
</div>
|
||||
<button type="button" class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-video-asset-open-gallery" style="width:100%;margin-top:6px;">Open in Gallery</button>
|
||||
`;
|
||||
|
||||
const nameEl = document.getElementById("wfm-video-asset-name");
|
||||
if (nameEl) { nameEl.textContent = img.filename; nameEl.title = img.filename; }
|
||||
const memoEl = document.getElementById("wfm-video-asset-memo");
|
||||
if (memoEl) memoEl.value = img.memo || "";
|
||||
|
||||
_renderTags(img);
|
||||
|
||||
panel.querySelector("#wfm-video-asset-tag-add")?.addEventListener("click", async () => {
|
||||
const input = panel.querySelector("#wfm-video-asset-tag-input");
|
||||
const tag = input?.value.trim();
|
||||
if (!tag) return;
|
||||
const tags = Array.from(new Set([...(img.tags || []), tag]));
|
||||
await _saveMeta(img.path, { tags });
|
||||
img.tags = tags;
|
||||
input.value = "";
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-memo-save")?.addEventListener("click", async () => {
|
||||
const memo = panel.querySelector("#wfm-video-asset-memo")?.value || "";
|
||||
await _saveMeta(img.path, { memo });
|
||||
img.memo = memo;
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-send-to-edit")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideoFile(img) ? "video/mp4" : "image/png") });
|
||||
addClipFromFile(file, img.filename);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoEditClipSent", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const setAsPlanImage = async (which) => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
await setBlockImageFromFile(which, file);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="plan"]')?.click();
|
||||
showToast(t(which === "first" ? "videoAssetSetAsFirstDone" : "videoAssetSetAsLastDone", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
};
|
||||
panel.querySelector("#wfm-video-asset-set-first")?.addEventListener("click", () => setAsPlanImage("first"));
|
||||
panel.querySelector("#wfm-video-asset-set-last")?.addEventListener("click", () => setAsPlanImage("last"));
|
||||
|
||||
panel.querySelector("#wfm-video-asset-open-gallery")?.addEventListener("click", () => {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
const filterSel = document.getElementById("wfm-gallery-group-filter");
|
||||
if (filterSel) {
|
||||
// ALL_GROUPS_VALUE is a virtual union with no Gallery-side equivalent
|
||||
// (Gallery's own filter only ever matches one real group) — fall back
|
||||
// to the curated group, the closer of the two to "everything worth keeping".
|
||||
filterSel.value = _s.group === ALL_GROUPS_VALUE ? VIDEO_GROUP : _s.group;
|
||||
filterSel.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _renderTags(img) {
|
||||
const container = document.getElementById("wfm-video-asset-tags");
|
||||
if (!container) return;
|
||||
const tags = img.tags || [];
|
||||
container.innerHTML = "";
|
||||
if (tags.length === 0) {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "wfm-placeholder";
|
||||
placeholder.textContent = "No tags";
|
||||
container.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
// Built via DOM APIs (not innerHTML) since tag text is user-entered —
|
||||
// textContent keeps it inert regardless of what characters it contains.
|
||||
for (const tag of tags) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "wfm-video-asset-tag";
|
||||
pill.append(document.createTextNode(`${tag} `));
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", async () => {
|
||||
const newTags = tags.filter((t2) => t2 !== tag);
|
||||
await _saveMeta(img.path, { tags: newTags });
|
||||
img.tags = newTags;
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
pill.appendChild(removeBtn);
|
||||
container.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveMeta(path, data) {
|
||||
try {
|
||||
await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, ...data }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] saveMeta failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Called whenever the Asset subtab becomes active (see video-tab.js's subtab
|
||||
// toggle) — always refetches rather than caching, so a Plan run's newly
|
||||
// generated videos show up as soon as the user switches over to look.
|
||||
export async function refreshVideoAssetTab() {
|
||||
if (!_s.loaded) {
|
||||
_renderGroupSelect();
|
||||
_s.loaded = true;
|
||||
}
|
||||
await _loadImages();
|
||||
}
|
||||
|
||||
function _setViewMode(mode) {
|
||||
_s.viewMode = mode;
|
||||
document.getElementById("wfm-video-asset-view-grid")?.classList.toggle("active", mode === "grid");
|
||||
document.getElementById("wfm-video-asset-view-table")?.classList.toggle("active", mode === "table");
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const tableWrap = document.getElementById("wfm-video-asset-table-wrap");
|
||||
if (grid) grid.style.display = mode === "grid" ? "" : "none";
|
||||
if (tableWrap) tableWrap.style.display = mode === "table" ? "" : "none";
|
||||
_renderList();
|
||||
}
|
||||
|
||||
export function initVideoAssetTab() {
|
||||
document.getElementById("wfm-video-asset-group")?.addEventListener("change", (e) => {
|
||||
_s.group = e.target.value;
|
||||
_s.selectedPath = null;
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (panel) panel.innerHTML = `<span class="wfm-placeholder">Select a video</span>`;
|
||||
_loadImages();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-refresh")?.addEventListener("click", () => _loadImages());
|
||||
document.getElementById("wfm-video-asset-search")?.addEventListener("input", (e) => {
|
||||
_s.searchQuery = e.target.value;
|
||||
_renderList();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-view-grid")?.addEventListener("click", () => _setViewMode("grid"));
|
||||
document.getElementById("wfm-video-asset-view-table")?.addEventListener("click", () => _setViewMode("table"));
|
||||
|
||||
// Neither reserved group is ever auto-created by a batch run itself (see
|
||||
// video-plan-tab.js's _ensureVideoAssetGroups, which only ensures
|
||||
// __Video Temp__ right before a run starts), so a fresh install or a group
|
||||
// deleted via Gallery's Manage Groups would otherwise never come back on
|
||||
// its own — add_to_group() happily records membership on individual images
|
||||
// without ever re-registering the group itself. ensureVideoGroup() ensures
|
||||
// both __Video Assets__ and __Video Temp__ every time the Asset subtab inits.
|
||||
ensureVideoGroup();
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
/**
|
||||
* Video Tab - Edit subtab: a minimal non-linear-editor-style clip timeline
|
||||
* (MVP scope: add clips, reorder, trim, concatenate, export).
|
||||
*
|
||||
* Design (see VIDEO_EDIT_TAB_PLAN.md at the repo root for the full writeup):
|
||||
* - Every clip is uploaded to ComfyUI's `input` area on add (same
|
||||
* upload-on-first-use pattern as video-tab.js's GIF tool), then probed
|
||||
* via /api/wfm/video/edit/probe (PyAV, no ffmpeg) for duration/width/height.
|
||||
* - Export builds a ComfyUI API-format prompt directly (LoadVideo -> VideoTrim
|
||||
* per clip -> ConcatenateVideo (if >1 clip) -> SaveVideo) and runs it through
|
||||
* the existing comfyUI.queuePrompt()/trackProgress() infra — no new backend
|
||||
* "build workflow" endpoint needed.
|
||||
* - Trim/scrub preview reuses the existing Source preview pane (video-preview.js)
|
||||
* instead of a second <video> element — selecting a clip loads it there,
|
||||
* exactly like video-asset-tab.js already does for asset selection.
|
||||
*
|
||||
* Confirmed against a live ComfyUI 0.36.0 instance (see plan doc "Phase 0"):
|
||||
* - The class_type for the simple trim node is `"Video Slice"` (WITH A SPACE —
|
||||
* `/object_info/VideoSlice` looks empty because that's not its real id; its
|
||||
* node_id is literally "Video Slice"). It takes plain flat inputs
|
||||
* (video/start_time/duration/strict_duration), no VIDEO_EDIT nesting, and
|
||||
* is not experimental — this is what's used here, not `VideoTrim`.
|
||||
* `VideoTrim`'s `trim` input, if ever needed, must be double-nested —
|
||||
* `{"trim": {"start_time":.., "duration":..}}` — because its execute()
|
||||
* does `(trim or {}).get("trim")` before reading start_time/duration; a
|
||||
* flat `{"start_time":..}` value is silently accepted and silently
|
||||
* no-ops (verified: it does NOT raise, it just doesn't trim anything).
|
||||
* - `ConcatenateVideo`'s Autogrow `videos` input is addressed via flat,
|
||||
* dot-joined keys at the top level of `inputs`: "videos.video0",
|
||||
* "videos.video1", ... (0-indexed) — NOT a nested object and NOT plain
|
||||
* "video0"/"video1" without the "videos." prefix.
|
||||
* - ConcatenateVideo hard-errors at execution time if clip frame dimensions
|
||||
* differ, so mismatched clips are blocked client-side before export —
|
||||
* but only between VIDEO clips; an image clip is auto-fit (ImageScale,
|
||||
* center-crop) to match, since it isn't fixed-resolution source footage.
|
||||
*
|
||||
* Image clips: a still or animated image dropped in has no inherent video
|
||||
* duration, so it gets a user-editable "hold" length instead of in/out trim
|
||||
* points (clip.trimStart stays 0, clip.trimEnd IS the hold length — this
|
||||
* keeps it a drop-in replacement everywhere the code already reads
|
||||
* trimEnd-trimStart as "this clip's length in the timeline", e.g. the block
|
||||
* width calc and the total-duration readout). Export turns it into a real
|
||||
* VIDEO segment via LoadImage -> (ImageScale, only if resolution needs to
|
||||
* match) -> RepeatImageBatch(amount = holdSeconds * fps) -> CreateVideo —
|
||||
* confirmed on a live instance to produce an exact-duration, correctly
|
||||
* concatenation-compatible clip (see VIDEO_EDIT_TAB_PLAN.md "Image clips").
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import {
|
||||
setSourcePreview, setResultPreview, getActivePreviewVideoElement, getResultPreviewVideoElement,
|
||||
} from "./video-preview.js";
|
||||
import { VTEMP_GROUP, ensureVideoGroup } from "./gallery-tab.js";
|
||||
|
||||
const _s = {
|
||||
clips: [], // { id, name, file, kind:"video"|"image", serverRef:{filename,subfolder,type}|null, duration, width, height, fps, trimStart, trimEnd, probing, error }
|
||||
selectedId: null,
|
||||
exporting: false,
|
||||
nextId: 1,
|
||||
outputDir: "",
|
||||
};
|
||||
|
||||
// Pixel-per-second scale for the timeline track — clips are laid out at their
|
||||
// real (trimmed) duration and left-aligned, NOT stretched to fill the track
|
||||
// width like the Plan tab's blocks (whose widths are relative shares of a
|
||||
// fixed-length plan) — Edit's timeline represents actual seconds.
|
||||
const _PX_PER_SEC = 20;
|
||||
const _MIN_BLOCK_PX = 56;
|
||||
|
||||
// Default hold length for a freshly-added image clip; user-adjustable per
|
||||
// clip afterward in the trim panel.
|
||||
const _DEFAULT_IMAGE_DURATION = 3.0;
|
||||
// fps used when turning a held image into a video segment for export/preview
|
||||
// concatenation — arbitrary but consistent (doesn't need to match other
|
||||
// clips' native fps; ConcatenateVideo works at the container/frame level).
|
||||
const _IMAGE_EXPORT_FPS = 24;
|
||||
|
||||
let _dragClipId = null; // clip being dragged for timeline reordering
|
||||
|
||||
// ============================================
|
||||
// Output-dir lookup (same small helper video-asset-tab.js / video-plan-tab.js
|
||||
// each keep their own copy of — needed to build the absolute path Gallery's
|
||||
// group-tagging API expects).
|
||||
// ============================================
|
||||
|
||||
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 */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Clip list state
|
||||
// ============================================
|
||||
|
||||
function _selectedClip() {
|
||||
return _s.clips.find((c) => c.id === _s.selectedId) || null;
|
||||
}
|
||||
|
||||
// Exported so video-asset-tab.js's "Send to Edit" button can add an asset
|
||||
// (already fetched as a Blob/File the same way it feeds the Source preview)
|
||||
// without either module importing the other's internals.
|
||||
export function addClipFromFile(file, displayName) {
|
||||
const kind = file.type.startsWith("video/") ? "video" : "image";
|
||||
const clip = {
|
||||
id: _s.nextId++,
|
||||
name: displayName || file.name,
|
||||
file,
|
||||
kind,
|
||||
serverRef: null,
|
||||
duration: kind === "image" ? _DEFAULT_IMAGE_DURATION : 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: kind === "image" ? _DEFAULT_IMAGE_DURATION : 0,
|
||||
probing: true,
|
||||
error: null,
|
||||
};
|
||||
_s.clips.push(clip);
|
||||
_s.selectedId = clip.id;
|
||||
_renderTimeline();
|
||||
_renderTrimPanel();
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file }, kind);
|
||||
if (kind === "image") _probeImageClip(clip);
|
||||
else _probeClip(clip);
|
||||
}
|
||||
|
||||
async function _probeClip(clip) {
|
||||
try {
|
||||
const uploaded = await comfyUI.uploadImage(clip.file, clip.file.name);
|
||||
clip.serverRef = { filename: uploaded.name, subfolder: uploaded.subfolder || "", type: "input" };
|
||||
|
||||
const params = new URLSearchParams({
|
||||
filename: clip.serverRef.filename,
|
||||
subfolder: clip.serverRef.subfolder,
|
||||
type: clip.serverRef.type,
|
||||
});
|
||||
const res = await fetch(`/api/wfm/video/edit/probe?${params}`);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
|
||||
clip.duration = json.duration || 0;
|
||||
clip.width = json.width || 0;
|
||||
clip.height = json.height || 0;
|
||||
clip.fps = json.fps || 0;
|
||||
clip.trimStart = 0;
|
||||
clip.trimEnd = clip.duration;
|
||||
} catch (err) {
|
||||
clip.error = err.message;
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
} finally {
|
||||
clip.probing = false;
|
||||
_renderTimeline();
|
||||
if (_s.selectedId === clip.id) _renderTrimPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// Image dimensions are read client-side (no server round-trip needed — PyAV
|
||||
// isn't reliable for opening arbitrary still-image formats as "video" the
|
||||
// way it is for real video containers) via a throwaway <img>, revoking its
|
||||
// blob URL once read either way.
|
||||
function _readImageDimensions(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => { URL.revokeObjectURL(url); resolve({ width: img.naturalWidth, height: img.naturalHeight }); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Failed to read image dimensions")); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function _probeImageClip(clip) {
|
||||
try {
|
||||
const uploaded = await comfyUI.uploadImage(clip.file, clip.file.name);
|
||||
clip.serverRef = { filename: uploaded.name, subfolder: uploaded.subfolder || "", type: "input" };
|
||||
const dims = await _readImageDimensions(clip.file);
|
||||
clip.width = dims.width;
|
||||
clip.height = dims.height;
|
||||
// duration/trimEnd already hold the default (or a previously-edited)
|
||||
// hold length — probing an image only needs to fill in serverRef/size.
|
||||
} catch (err) {
|
||||
clip.error = err.message;
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
} finally {
|
||||
clip.probing = false;
|
||||
_renderTimeline();
|
||||
if (_s.selectedId === clip.id) _renderTrimPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function _moveClip(id, delta) {
|
||||
const idx = _s.clips.findIndex((c) => c.id === id);
|
||||
const target = idx + delta;
|
||||
if (idx < 0 || target < 0 || target >= _s.clips.length) return;
|
||||
[_s.clips[idx], _s.clips[target]] = [_s.clips[target], _s.clips[idx]];
|
||||
_renderTimeline();
|
||||
}
|
||||
|
||||
function _duplicateClip(id) {
|
||||
const idx = _s.clips.findIndex((c) => c.id === id);
|
||||
if (idx < 0) return;
|
||||
const clone = { ..._s.clips[idx], id: _s.nextId++ };
|
||||
_s.clips.splice(idx + 1, 0, clone);
|
||||
_s.selectedId = clone.id;
|
||||
_renderTimeline();
|
||||
_renderTrimPanel();
|
||||
}
|
||||
|
||||
function _deleteClip(id) {
|
||||
_stopPreview();
|
||||
_s.clips = _s.clips.filter((c) => c.id !== id);
|
||||
if (_s.selectedId === id) {
|
||||
_s.selectedId = _s.clips.length ? _s.clips[0].id : null;
|
||||
if (_s.selectedId) _selectClip(_s.selectedId);
|
||||
else setSourcePreview(null, null);
|
||||
}
|
||||
_renderTimeline();
|
||||
_renderTrimPanel();
|
||||
}
|
||||
|
||||
function _clearTimeline() {
|
||||
if (_s.clips.length === 0) return;
|
||||
if (!confirm(t("videoEditConfirmClear"))) return;
|
||||
_stopPreview();
|
||||
_s.clips = [];
|
||||
_s.selectedId = null;
|
||||
setSourcePreview(null, null);
|
||||
_renderTimeline();
|
||||
_renderTrimPanel();
|
||||
}
|
||||
|
||||
function _selectClip(id) {
|
||||
_s.selectedId = id;
|
||||
const clip = _selectedClip();
|
||||
_renderTimeline();
|
||||
_renderTrimPanel();
|
||||
if (clip) setSourcePreview(URL.createObjectURL(clip.file), { kind: "local", file: clip.file }, clip.kind);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Resolution-mismatch guard — ConcatenateVideo hard-errors at execution time
|
||||
// on mismatched frame dimensions (confirmed on a live 0.36.0 instance), so
|
||||
// this is checked client-side before ever building the export graph. Only
|
||||
// VIDEO clips are compared: an image clip's resolution is auto-fit at export
|
||||
// time (see _buildExportWorkflow), so it can never be the thing blocking export.
|
||||
// ============================================
|
||||
|
||||
function _findResolutionMismatch(clips) {
|
||||
const sized = clips.filter((c) => c.kind === "video" && c.width && c.height);
|
||||
if (sized.length < 2) return null;
|
||||
const first = sized[0];
|
||||
const mismatched = sized.find((c) => c.width !== first.width || c.height !== first.height);
|
||||
if (!mismatched) return null;
|
||||
return t("videoEditResolutionMismatch", `${first.name} (${first.width}x${first.height})`, `${mismatched.name} (${mismatched.width}x${mismatched.height})`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rendering
|
||||
// ============================================
|
||||
|
||||
function _fmtTime(s) {
|
||||
if (!s && s !== 0) return "--";
|
||||
return `${s.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
// M:SS.ds timecode for the trim scrubber's ruler ticks and badges (mirrors
|
||||
// the "0:01.70" style Clipchamp-like editors use) — distinct from _fmtTime's
|
||||
// coarser "1.7s" used elsewhere (timeline block meta, total-duration readout).
|
||||
function _fmtTimecode(s) {
|
||||
const clamped = Math.max(0, s || 0);
|
||||
const m = Math.floor(clamped / 60);
|
||||
const sec = clamped - m * 60;
|
||||
return `${m}:${sec.toFixed(2).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
// Horizontal timeline track (see VIDEO_EDIT_TAB_PLAN.md's UI redesign note):
|
||||
// clips are laid out left-to-right at their real (trimmed) duration on a
|
||||
// fixed px/sec scale and left-aligned — an empty track stays empty on the
|
||||
// right rather than stretching clips to fill it. Reordering is done either
|
||||
// by dragging a block onto another (native HTML5 DnD) or via the shared
|
||||
// toolbar buttons below the track, which act on whichever clip is selected
|
||||
// (mirrors the Plan subtab's "+Split/+Block/Delete" toolbar pattern instead
|
||||
// of giving every row its own set of buttons).
|
||||
function _renderTimeline() {
|
||||
const track = document.getElementById("wfm-video-edit-timeline-track");
|
||||
if (!track) return;
|
||||
track.innerHTML = "";
|
||||
|
||||
if (_s.clips.length === 0) {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "wfm-placeholder wfm-video-edit-timeline-placeholder";
|
||||
placeholder.textContent = t("videoEditNoClipsHint");
|
||||
track.appendChild(placeholder);
|
||||
_updateToolbarState();
|
||||
return;
|
||||
}
|
||||
|
||||
_s.clips.forEach((clip) => {
|
||||
const block = document.createElement("div");
|
||||
block.className = "wfm-video-edit-timeline-block" + (clip.id === _s.selectedId ? " selected" : "");
|
||||
const seconds = clip.probing || clip.error ? 0 : Math.max(0.1, clip.trimEnd - clip.trimStart);
|
||||
block.style.width = `${Math.max(_MIN_BLOCK_PX, Math.round(seconds * _PX_PER_SEC))}px`;
|
||||
block.title = clip.name;
|
||||
block.draggable = true;
|
||||
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "wfm-video-edit-timeline-block-name";
|
||||
nameEl.textContent = `${clip.kind === "image" ? "🖼" : "🎬"} ${clip.name}`;
|
||||
const metaEl = document.createElement("div");
|
||||
metaEl.className = "wfm-video-edit-timeline-block-meta";
|
||||
if (clip.probing) metaEl.textContent = t("videoEditProbing");
|
||||
else if (clip.error) metaEl.textContent = "✗";
|
||||
else metaEl.textContent = _fmtTime(clip.trimEnd - clip.trimStart);
|
||||
block.append(nameEl, metaEl);
|
||||
|
||||
block.addEventListener("click", () => _selectClip(clip.id));
|
||||
|
||||
block.addEventListener("dragstart", (e) => {
|
||||
_dragClipId = clip.id;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
block.addEventListener("dragover", (e) => e.preventDefault());
|
||||
block.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_reorderByDrop(clip.id);
|
||||
});
|
||||
|
||||
track.appendChild(block);
|
||||
});
|
||||
|
||||
_updateToolbarState();
|
||||
}
|
||||
|
||||
function _reorderByDrop(targetId) {
|
||||
if (_dragClipId == null || _dragClipId === targetId) { _dragClipId = null; return; }
|
||||
const fromIdx = _s.clips.findIndex((c) => c.id === _dragClipId);
|
||||
_dragClipId = null;
|
||||
if (fromIdx < 0) return;
|
||||
// Remove first, then look up the target's index in the now-shortened array —
|
||||
// sidesteps the off-by-one from a naive "look up both indices, then splice
|
||||
// twice" approach when fromIdx < toIdx. Dropping onto a block inserts the
|
||||
// dragged clip right before it; dropping past the last block (targetId
|
||||
// null, from the track's own drop handler) appends to the end.
|
||||
const [moved] = _s.clips.splice(fromIdx, 1);
|
||||
if (targetId == null) {
|
||||
_s.clips.push(moved);
|
||||
} else {
|
||||
const toIdx = _s.clips.findIndex((c) => c.id === targetId);
|
||||
_s.clips.splice(toIdx < 0 ? _s.clips.length : toIdx, 0, moved);
|
||||
}
|
||||
_renderTimeline();
|
||||
}
|
||||
|
||||
function _updateToolbarState() {
|
||||
const idx = _s.clips.findIndex((c) => c.id === _s.selectedId);
|
||||
const hasSelection = idx >= 0;
|
||||
const setDisabled = (id, disabled) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.disabled = disabled;
|
||||
};
|
||||
setDisabled("wfm-video-edit-move-left-btn", !hasSelection || idx === 0);
|
||||
setDisabled("wfm-video-edit-move-right-btn", !hasSelection || idx === _s.clips.length - 1);
|
||||
setDisabled("wfm-video-edit-duplicate-btn", !hasSelection);
|
||||
setDisabled("wfm-video-edit-delete-btn", !hasSelection);
|
||||
setDisabled("wfm-video-edit-clear-btn", _s.clips.length === 0);
|
||||
setDisabled("wfm-video-edit-preview-btn", _s.clips.length === 0);
|
||||
_updateTotalDuration();
|
||||
}
|
||||
|
||||
function _updateTotalDuration() {
|
||||
const el = document.getElementById("wfm-video-edit-total-duration");
|
||||
if (!el) return;
|
||||
const total = _s.clips.reduce((sum, c) => {
|
||||
if (c.probing || c.error) return sum;
|
||||
return sum + Math.max(0, c.trimEnd - c.trimStart);
|
||||
}, 0);
|
||||
el.textContent = _s.clips.length === 0 ? "" : t("videoEditTotalDuration", _fmtTime(total));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Trim scrubber — Clipchamp-style visual timeline for the selected video
|
||||
// clip's trim panel: a time ruler, a draggable in/out range, and a playhead
|
||||
// synced to the Source preview's <video> element. All positions are computed
|
||||
// as percentages of clip.duration (not px/sec) so no separate zoom/scale
|
||||
// bookkeeping is needed — the track just fills whatever width the panel gives it.
|
||||
// ============================================
|
||||
|
||||
// Tracks the (element, listener) pair currently wired to a <video>'s
|
||||
// "timeupdate" so it can be torn down before the next clip selection reuses
|
||||
// the same persistent preview element (see video-preview.js) — otherwise
|
||||
// listeners would pile up across every clip switch.
|
||||
let _scrubVideoEl = null;
|
||||
let _scrubVideoListener = null;
|
||||
|
||||
function _teardownTrimScrubber() {
|
||||
if (_scrubVideoEl && _scrubVideoListener) {
|
||||
_scrubVideoEl.removeEventListener("timeupdate", _scrubVideoListener);
|
||||
}
|
||||
_scrubVideoEl = null;
|
||||
_scrubVideoListener = null;
|
||||
}
|
||||
|
||||
// "Nice" tick spacing so the ruler shows roughly 5-8 labeled ticks regardless
|
||||
// of clip length (a 3s clip gets 0.5s ticks, a 5min clip gets 60s ticks).
|
||||
function _niceRulerStep(duration) {
|
||||
const rough = duration / 6;
|
||||
const candidates = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||
return candidates.find((c) => c >= rough) || candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
function _renderRuler(ruler, duration) {
|
||||
ruler.innerHTML = "";
|
||||
if (!duration) return;
|
||||
const step = _niceRulerStep(duration);
|
||||
for (let time = 0; time <= duration + 0.001; time += step) {
|
||||
const tick = document.createElement("span");
|
||||
tick.className = "wfm-video-trim-ruler-tick";
|
||||
tick.style.left = `${Math.min(100, (time / duration) * 100)}%`;
|
||||
tick.textContent = _fmtTimecode(time).replace(/\.\d+$/, "");
|
||||
ruler.appendChild(tick);
|
||||
}
|
||||
}
|
||||
|
||||
// Wires the scrubber DOM (already inserted into the trim panel by
|
||||
// _renderTrimPanel) for one video clip. `commit` is the trim-panel's own
|
||||
// start/end-input commit function — the scrubber drives it via the same
|
||||
// inputs rather than writing clip.trimStart/trimEnd directly, so both paths
|
||||
// (typed numbers, dragged handles) stay in sync through one code path.
|
||||
function _wireTrimScrubber(clip, startInput, endInput, commit) {
|
||||
const track = document.getElementById("wfm-video-trim-track");
|
||||
const ruler = document.getElementById("wfm-video-trim-ruler");
|
||||
const rangeEl = document.getElementById("wfm-video-trim-range");
|
||||
const handleStart = document.getElementById("wfm-video-trim-handle-start");
|
||||
const handleEnd = document.getElementById("wfm-video-trim-handle-end");
|
||||
const playhead = document.getElementById("wfm-video-trim-playhead");
|
||||
const badgeStart = document.getElementById("wfm-video-trim-badge-start");
|
||||
const badgeEnd = document.getElementById("wfm-video-trim-badge-end");
|
||||
const badgePlayhead = document.getElementById("wfm-video-trim-badge-playhead");
|
||||
if (!track || !clip.duration) return;
|
||||
|
||||
_renderRuler(ruler, clip.duration);
|
||||
|
||||
const pct = (t) => `${Math.min(100, Math.max(0, (t / clip.duration) * 100))}%`;
|
||||
|
||||
function updateRange() {
|
||||
rangeEl.style.left = pct(clip.trimStart);
|
||||
rangeEl.style.width = `${Math.max(0, ((Math.min(clip.trimEnd, clip.duration) - clip.trimStart) / clip.duration) * 100)}%`;
|
||||
handleStart.style.left = pct(clip.trimStart);
|
||||
handleEnd.style.left = pct(clip.trimEnd);
|
||||
badgeStart.textContent = _fmtTimecode(clip.trimStart);
|
||||
badgeEnd.textContent = _fmtTimecode(clip.trimEnd);
|
||||
}
|
||||
updateRange();
|
||||
|
||||
function updatePlayhead(t) {
|
||||
playhead.style.left = pct(t);
|
||||
badgePlayhead.textContent = _fmtTimecode(t);
|
||||
}
|
||||
|
||||
const video = getActivePreviewVideoElement();
|
||||
if (video) {
|
||||
_scrubVideoEl = video;
|
||||
_scrubVideoListener = () => updatePlayhead(video.currentTime);
|
||||
video.addEventListener("timeupdate", _scrubVideoListener);
|
||||
updatePlayhead(video.currentTime);
|
||||
} else {
|
||||
updatePlayhead(0);
|
||||
}
|
||||
|
||||
function seekTo(clientX) {
|
||||
const rect = track.getBoundingClientRect();
|
||||
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||
const t = ratio * clip.duration;
|
||||
const v = getActivePreviewVideoElement();
|
||||
if (v) v.currentTime = t;
|
||||
updatePlayhead(t);
|
||||
}
|
||||
|
||||
// Drags a trim handle: while pointer is down, only the scrubber's own
|
||||
// visuals (range/handle position, badge text) update — clip.trimStart/End
|
||||
// and the timeline block widths commit once on pointerup, so dragging
|
||||
// doesn't thrash _renderTimeline() on every mousemove.
|
||||
function wireHandleDrag(handleEl, isStart) {
|
||||
handleEl.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEl.setPointerCapture(e.pointerId);
|
||||
const onMove = (ev) => {
|
||||
const rect = track.getBoundingClientRect();
|
||||
const ratio = Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width));
|
||||
const t = ratio * clip.duration;
|
||||
if (isStart) startInput.value = Math.min(t, clip.trimEnd - 0.05).toFixed(2);
|
||||
else endInput.value = Math.max(t, clip.trimStart + 0.05).toFixed(2);
|
||||
commit({ skipTimelineRerender: true });
|
||||
updateRange();
|
||||
};
|
||||
const onUp = () => {
|
||||
handleEl.removeEventListener("pointermove", onMove);
|
||||
handleEl.removeEventListener("pointerup", onUp);
|
||||
_renderTimeline();
|
||||
};
|
||||
handleEl.addEventListener("pointermove", onMove);
|
||||
handleEl.addEventListener("pointerup", onUp);
|
||||
});
|
||||
}
|
||||
wireHandleDrag(handleStart, true);
|
||||
wireHandleDrag(handleEnd, false);
|
||||
|
||||
track.addEventListener("pointerdown", (e) => {
|
||||
if (e.target === handleStart || e.target === handleEnd || e.target === playhead) return;
|
||||
seekTo(e.clientX);
|
||||
});
|
||||
|
||||
playhead.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
playhead.setPointerCapture(e.pointerId);
|
||||
const onMove = (ev) => seekTo(ev.clientX);
|
||||
const onUp = () => {
|
||||
playhead.removeEventListener("pointermove", onMove);
|
||||
playhead.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
playhead.addEventListener("pointermove", onMove);
|
||||
playhead.addEventListener("pointerup", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
function _renderTrimPanel() {
|
||||
const panel = document.getElementById("wfm-video-edit-trim-panel");
|
||||
if (!panel) return;
|
||||
_teardownTrimScrubber();
|
||||
const clip = _selectedClip();
|
||||
if (!clip) {
|
||||
panel.innerHTML = `<span class="wfm-placeholder">${t("videoEditSelectClipHint")}</span>`;
|
||||
return;
|
||||
}
|
||||
if (clip.probing) {
|
||||
panel.innerHTML = `<span class="wfm-placeholder">${t("videoEditProbing")}</span>`;
|
||||
return;
|
||||
}
|
||||
if (clip.error) {
|
||||
panel.innerHTML = `<span class="wfm-placeholder" style="color:var(--wfm-danger)">✗ ${clip.error}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// clip.name is a user-controlled filename — kept out of the innerHTML string
|
||||
// and assigned via textContent afterward (same pattern as video-asset-tab.js's
|
||||
// _renderDetail()), so it can never be interpreted as markup.
|
||||
if (clip.kind === "image") {
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-video-edit-clip-name" id="wfm-video-edit-trim-clip-name" style="margin-bottom:6px;"></div>
|
||||
<div class="wfm-video-edit-trim-field" style="max-width:160px;">
|
||||
<label>${t("videoEditImageDuration")}</label>
|
||||
<input type="number" id="wfm-video-edit-image-duration" class="wfm-input" step="0.1" min="0.1" value="${clip.trimEnd.toFixed(2)}">
|
||||
</div>
|
||||
`;
|
||||
const nameEl = document.getElementById("wfm-video-edit-trim-clip-name");
|
||||
if (nameEl) { nameEl.textContent = clip.name; nameEl.title = clip.name; }
|
||||
|
||||
const durInput = document.getElementById("wfm-video-edit-image-duration");
|
||||
durInput.addEventListener("change", () => {
|
||||
const val = Math.max(0.1, Number(durInput.value) || _DEFAULT_IMAGE_DURATION);
|
||||
clip.trimEnd = val;
|
||||
clip.duration = val;
|
||||
durInput.value = val.toFixed(2);
|
||||
_renderTimeline();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-video-edit-clip-name" id="wfm-video-edit-trim-clip-name" style="margin-bottom:6px;"></div>
|
||||
<div class="wfm-video-trim-scrubber" id="wfm-video-trim-scrubber">
|
||||
<div class="wfm-video-trim-ruler" id="wfm-video-trim-ruler"></div>
|
||||
<div class="wfm-video-trim-track" id="wfm-video-trim-track">
|
||||
<div class="wfm-video-trim-range" id="wfm-video-trim-range"></div>
|
||||
<div class="wfm-video-trim-handle" id="wfm-video-trim-handle-start">
|
||||
<span class="wfm-video-trim-badge" id="wfm-video-trim-badge-start"></span>
|
||||
</div>
|
||||
<div class="wfm-video-trim-handle" id="wfm-video-trim-handle-end">
|
||||
<span class="wfm-video-trim-badge" id="wfm-video-trim-badge-end"></span>
|
||||
</div>
|
||||
<div class="wfm-video-trim-playhead" id="wfm-video-trim-playhead">
|
||||
<span class="wfm-video-trim-badge wfm-video-trim-badge-playhead" id="wfm-video-trim-badge-playhead"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-edit-trim-row" style="margin-top:10px;">
|
||||
<div class="wfm-video-edit-trim-field">
|
||||
<label>${t("videoEditTrimStart")}</label>
|
||||
<input type="number" id="wfm-video-edit-trim-start" class="wfm-input" step="0.1" min="0" max="${clip.duration}" value="${clip.trimStart.toFixed(2)}">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-video-edit-playhead-btn" id="wfm-video-edit-trim-start-set">${t("videoEditSetFromPlayhead")}</button>
|
||||
</div>
|
||||
<div class="wfm-video-edit-trim-field">
|
||||
<label>${t("videoEditTrimEnd")}</label>
|
||||
<input type="number" id="wfm-video-edit-trim-end" class="wfm-input" step="0.1" min="0" max="${clip.duration}" value="${clip.trimEnd.toFixed(2)}">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-video-edit-playhead-btn" id="wfm-video-edit-trim-end-set">${t("videoEditSetFromPlayhead")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const nameEl = document.getElementById("wfm-video-edit-trim-clip-name");
|
||||
if (nameEl) { nameEl.textContent = clip.name; nameEl.title = clip.name; }
|
||||
|
||||
const startInput = document.getElementById("wfm-video-edit-trim-start");
|
||||
const endInput = document.getElementById("wfm-video-edit-trim-end");
|
||||
|
||||
// skipTimelineRerender: the scrubber's handle-drag calls this on every
|
||||
// pointermove (see _wireTrimScrubber) — re-running _renderTimeline() that
|
||||
// often would rebuild every timeline block per mousemove for no visible
|
||||
// benefit, since the scrubber already redraws its own range live.
|
||||
const commit = ({ skipTimelineRerender } = {}) => {
|
||||
let start = Math.max(0, Math.min(Number(startInput.value) || 0, clip.duration));
|
||||
let end = Math.max(0, Math.min(Number(endInput.value) || 0, clip.duration));
|
||||
if (end <= start) end = Math.min(clip.duration, start + 0.1);
|
||||
clip.trimStart = start;
|
||||
clip.trimEnd = end;
|
||||
startInput.value = start.toFixed(2);
|
||||
endInput.value = end.toFixed(2);
|
||||
if (!skipTimelineRerender) _renderTimeline();
|
||||
};
|
||||
startInput.addEventListener("change", commit);
|
||||
endInput.addEventListener("change", commit);
|
||||
|
||||
document.getElementById("wfm-video-edit-trim-start-set")?.addEventListener("click", () => {
|
||||
const video = getActivePreviewVideoElement();
|
||||
if (video) { startInput.value = video.currentTime.toFixed(2); commit(); }
|
||||
});
|
||||
document.getElementById("wfm-video-edit-trim-end-set")?.addEventListener("click", () => {
|
||||
const video = getActivePreviewVideoElement();
|
||||
if (video) { endInput.value = video.currentTime.toFixed(2); commit(); }
|
||||
});
|
||||
|
||||
_wireTrimScrubber(clip, startInput, endInput, commit);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sequential timeline preview — plays every ready clip back-to-back (each
|
||||
// trimmed to its in/out point) in the shared "Result" pane (the same one
|
||||
// Export writes its finished output into), so what plays there is always
|
||||
// exactly what Export would currently produce. Deliberately reuses that pane
|
||||
// rather than a third <video> element: the user asked for it to be the same
|
||||
// slot the exported video lands in, not a separate preview area.
|
||||
// ============================================
|
||||
|
||||
let _previewPlaying = false;
|
||||
let _previewClips = [];
|
||||
let _previewIndex = 0;
|
||||
let _previewImageTimer = null; // holds an image clip on screen for its duration (setTimeout, no video events to hook)
|
||||
|
||||
function _onPreviewTimeUpdate() {
|
||||
const video = getResultPreviewVideoElement();
|
||||
const clip = _previewClips[_previewIndex];
|
||||
if (!video || !clip || clip.kind !== "video") return;
|
||||
if (video.currentTime >= clip.trimEnd) _advancePreview();
|
||||
}
|
||||
|
||||
function _advancePreview() {
|
||||
_previewIndex += 1;
|
||||
if (_previewIndex >= _previewClips.length) { _stopPreview(); return; }
|
||||
_playPreviewClip();
|
||||
}
|
||||
|
||||
function _playPreviewClip() {
|
||||
const clip = _previewClips[_previewIndex];
|
||||
if (!clip) { _stopPreview(); return; }
|
||||
|
||||
if (clip.kind === "image") {
|
||||
const video = getResultPreviewVideoElement();
|
||||
video?.pause();
|
||||
setResultPreview(URL.createObjectURL(clip.file), { kind: "local", file: clip.file }, "image");
|
||||
const holdMs = Math.max(50, (clip.trimEnd - clip.trimStart) * 1000);
|
||||
_previewImageTimer = setTimeout(_advancePreview, holdMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const video = getResultPreviewVideoElement();
|
||||
if (!video) { _stopPreview(); return; }
|
||||
setResultPreview(URL.createObjectURL(clip.file), { kind: "local", file: clip.file }, "video");
|
||||
// currentTime only reliably applies once the new source has metadata —
|
||||
// setting it immediately after swapping src is flaky across browsers.
|
||||
const onReady = () => {
|
||||
video.removeEventListener("loadedmetadata", onReady);
|
||||
video.currentTime = clip.trimStart;
|
||||
video.play().catch(() => {});
|
||||
};
|
||||
video.addEventListener("loadedmetadata", onReady);
|
||||
}
|
||||
|
||||
function _startPreview() {
|
||||
_previewClips = _s.clips.filter((c) => !c.probing && !c.error);
|
||||
if (_previewClips.length === 0) {
|
||||
showToast(t("videoEditNoClips"), "error");
|
||||
return;
|
||||
}
|
||||
_previewIndex = 0;
|
||||
_previewPlaying = true;
|
||||
const video = getResultPreviewVideoElement();
|
||||
video?.addEventListener("timeupdate", _onPreviewTimeUpdate);
|
||||
video?.addEventListener("ended", _advancePreview);
|
||||
_playPreviewClip();
|
||||
_updatePreviewBtn();
|
||||
}
|
||||
|
||||
function _stopPreview() {
|
||||
_previewPlaying = false;
|
||||
if (_previewImageTimer) { clearTimeout(_previewImageTimer); _previewImageTimer = null; }
|
||||
const video = getResultPreviewVideoElement();
|
||||
video?.removeEventListener("timeupdate", _onPreviewTimeUpdate);
|
||||
video?.removeEventListener("ended", _advancePreview);
|
||||
video?.pause();
|
||||
_updatePreviewBtn();
|
||||
}
|
||||
|
||||
function _togglePreview() {
|
||||
if (_previewPlaying) _stopPreview();
|
||||
else _startPreview();
|
||||
}
|
||||
|
||||
function _updatePreviewBtn() {
|
||||
const btn = document.getElementById("wfm-video-edit-preview-btn");
|
||||
if (btn) btn.textContent = _previewPlaying ? t("videoEditPreviewStop") : t("videoEditPreviewPlay");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Export — builds a ComfyUI API-format prompt directly (see module header for
|
||||
// the confirmed input formats) and runs it through the existing execution
|
||||
// infrastructure (comfyui-client.js), exactly like video-plan-tab.js does.
|
||||
// ============================================
|
||||
|
||||
function _buildExportWorkflow(clips) {
|
||||
const prompt = {};
|
||||
let nextId = 1;
|
||||
const alloc = () => String(nextId++);
|
||||
const trimOutputs = [];
|
||||
|
||||
// Auto-fit target for image clips: the first VIDEO clip's resolution, so
|
||||
// a still dropped alongside real footage doesn't need to be pre-sized by
|
||||
// hand (see _findResolutionMismatch, which only ever compares VIDEO
|
||||
// clips against each other — images are exempt because of this fit step).
|
||||
const videoClip = clips.find((c) => c.kind === "video");
|
||||
const targetW = videoClip ? videoClip.width : clips[0]?.width;
|
||||
const targetH = videoClip ? videoClip.height : clips[0]?.height;
|
||||
|
||||
// Only strip audio when clips will actually be concatenated — a single
|
||||
// video clip never goes through ConcatenateVideo, so there's no mixed-
|
||||
// audio compatibility problem to avoid and its audio can be kept intact.
|
||||
const needsUniformSilence = clips.length > 1;
|
||||
|
||||
for (const clip of clips) {
|
||||
const file = clip.serverRef.subfolder
|
||||
? `${clip.serverRef.subfolder}/${clip.serverRef.filename}`
|
||||
: clip.serverRef.filename;
|
||||
|
||||
if (clip.kind === "image") {
|
||||
const loadId = alloc();
|
||||
prompt[loadId] = { class_type: "LoadImage", inputs: { image: file } };
|
||||
let imageOut = [loadId, 0];
|
||||
|
||||
if (targetW && targetH && (clip.width !== targetW || clip.height !== targetH)) {
|
||||
const scaleId = alloc();
|
||||
prompt[scaleId] = {
|
||||
class_type: "ImageScale",
|
||||
inputs: { image: imageOut, upscale_method: "lanczos", width: targetW, height: targetH, crop: "center" },
|
||||
};
|
||||
imageOut = [scaleId, 0];
|
||||
}
|
||||
|
||||
const repeatId = alloc();
|
||||
const amount = Math.max(1, Math.round((clip.trimEnd - clip.trimStart) * _IMAGE_EXPORT_FPS));
|
||||
prompt[repeatId] = { class_type: "RepeatImageBatch", inputs: { image: imageOut, amount } };
|
||||
|
||||
const createId = alloc();
|
||||
prompt[createId] = {
|
||||
class_type: "CreateVideo",
|
||||
inputs: { images: [repeatId, 0], fps: _IMAGE_EXPORT_FPS, codec: "auto" },
|
||||
};
|
||||
trimOutputs.push(createId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const loadId = alloc();
|
||||
prompt[loadId] = { class_type: "LoadVideo", inputs: { file } };
|
||||
|
||||
const trimId = alloc();
|
||||
prompt[trimId] = {
|
||||
class_type: "Video Slice",
|
||||
inputs: {
|
||||
video: [loadId, 0],
|
||||
start_time: clip.trimStart,
|
||||
duration: Math.max(0.05, clip.trimEnd - clip.trimStart),
|
||||
strict_duration: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (!needsUniformSilence) {
|
||||
trimOutputs.push(trimId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip audio by decomposing/recomposing through GetVideoComponents ->
|
||||
// CreateVideo (audio input left unset). Verified on a live instance:
|
||||
// ConcatenateVideo hard-errors mixing a clip with stereo/32kHz audio
|
||||
// next to a silent image-derived clip ("audio layout: expected None,
|
||||
// got 'stereo'"), so every clip is made silent whenever more than one
|
||||
// clip is being concatenated. This means a multi-clip export never
|
||||
// carries audio — a known MVP limitation, not handled per-clip
|
||||
// because ConcatenateVideo needs uniform audio across all inputs. A
|
||||
// lone clip (see needsUniformSilence above) keeps its original audio.
|
||||
const componentsId = alloc();
|
||||
prompt[componentsId] = { class_type: "GetVideoComponents", inputs: { video: [trimId, 0] } };
|
||||
const silentId = alloc();
|
||||
prompt[silentId] = {
|
||||
class_type: "CreateVideo",
|
||||
inputs: { images: [componentsId, 0], fps: [componentsId, 2], codec: "auto" },
|
||||
};
|
||||
trimOutputs.push(silentId);
|
||||
}
|
||||
|
||||
let finalOutput;
|
||||
if (trimOutputs.length === 1) {
|
||||
finalOutput = [trimOutputs[0], 0];
|
||||
} else {
|
||||
const concatId = alloc();
|
||||
const inputs = { codec: "auto" };
|
||||
// Autogrow's flat "videos.videoN" key format — see module header.
|
||||
trimOutputs.forEach((tid, i) => { inputs[`videos.video${i}`] = [tid, 0]; });
|
||||
prompt[concatId] = { class_type: "ConcatenateVideo", inputs };
|
||||
finalOutput = [concatId, 0];
|
||||
}
|
||||
|
||||
const saveId = alloc();
|
||||
prompt[saveId] = {
|
||||
class_type: "SaveVideo",
|
||||
inputs: { video: finalOutput, filename_prefix: "video/wfm_edit", format: "auto" },
|
||||
};
|
||||
return { prompt, saveId };
|
||||
}
|
||||
|
||||
function _setExportUi(running, pct) {
|
||||
const btn = document.getElementById("wfm-video-edit-export-btn");
|
||||
const bar = document.getElementById("wfm-video-edit-progress-bar");
|
||||
const text = document.getElementById("wfm-video-edit-progress-text");
|
||||
if (btn) btn.disabled = running;
|
||||
if (bar) bar.style.width = `${Math.round((pct || 0) * 100)}%`;
|
||||
if (text) text.textContent = running ? t("videoEditExporting") : "Ready";
|
||||
}
|
||||
|
||||
async function _addOutputToVideoTemp(filename, subfolder) {
|
||||
await _fetchOutputDir();
|
||||
if (!_s.outputDir) return;
|
||||
const parts = [_s.outputDir];
|
||||
if (subfolder) parts.push(subfolder);
|
||||
parts.push(filename);
|
||||
const path = parts.join("/");
|
||||
try {
|
||||
await ensureVideoGroup();
|
||||
await fetch(`/wfm/gallery/groups/${encodeURIComponent(VTEMP_GROUP)}/add`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[VideoEdit] failed to tag export into video group:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function _exportTimeline() {
|
||||
if (_s.exporting) return;
|
||||
_stopPreview();
|
||||
|
||||
const readyClips = _s.clips.filter((c) => c.serverRef && !c.error);
|
||||
if (readyClips.length === 0) {
|
||||
showToast(t("videoEditNoClips"), "error");
|
||||
return;
|
||||
}
|
||||
if (_s.clips.some((c) => c.probing)) {
|
||||
showToast(t("videoEditStillProbing"), "error");
|
||||
return;
|
||||
}
|
||||
const mismatch = _findResolutionMismatch(readyClips);
|
||||
if (mismatch) {
|
||||
showToast(mismatch, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
_s.exporting = true;
|
||||
_setExportUi(true, 0);
|
||||
try {
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) throw new Error("Failed to connect WebSocket");
|
||||
|
||||
const { prompt, saveId } = _buildExportWorkflow(readyClips);
|
||||
const result = await comfyUI.queuePrompt(prompt);
|
||||
await comfyUI.trackProgress(result.prompt_id, (pct) => _setExportUi(true, pct));
|
||||
|
||||
const history = await comfyUI.getHistory(result.prompt_id);
|
||||
const output = history?.outputs?.[saveId]?.images?.[0];
|
||||
if (!output) throw new Error("No output produced");
|
||||
|
||||
const params = new URLSearchParams({ filename: output.filename, subfolder: output.subfolder || "", type: "output" });
|
||||
setResultPreview(`${comfyUI.baseUrl}/view?${params}`, { kind: "output", filename: output.filename, subfolder: output.subfolder || "", type: "output" });
|
||||
showToast(t("videoEditExportDone", output.filename), "success");
|
||||
await _addOutputToVideoTemp(output.filename, output.subfolder);
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
} finally {
|
||||
_s.exporting = false;
|
||||
_setExportUi(false, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Init
|
||||
// ============================================
|
||||
|
||||
function _wireToolbar() {
|
||||
document.getElementById("wfm-video-edit-move-left-btn")?.addEventListener("click", () => {
|
||||
if (_s.selectedId != null) _moveClip(_s.selectedId, -1);
|
||||
});
|
||||
document.getElementById("wfm-video-edit-move-right-btn")?.addEventListener("click", () => {
|
||||
if (_s.selectedId != null) _moveClip(_s.selectedId, 1);
|
||||
});
|
||||
document.getElementById("wfm-video-edit-duplicate-btn")?.addEventListener("click", () => {
|
||||
if (_s.selectedId != null) _duplicateClip(_s.selectedId);
|
||||
});
|
||||
document.getElementById("wfm-video-edit-delete-btn")?.addEventListener("click", () => {
|
||||
if (_s.selectedId != null) _deleteClip(_s.selectedId);
|
||||
});
|
||||
document.getElementById("wfm-video-edit-preview-btn")?.addEventListener("click", _togglePreview);
|
||||
document.getElementById("wfm-video-edit-clear-btn")?.addEventListener("click", _clearTimeline);
|
||||
|
||||
// Dropping a dragged block past the last one (onto empty track space)
|
||||
// moves it to the end — block-level drop handlers stopPropagation() so
|
||||
// this only fires for drops that miss every block.
|
||||
const track = document.getElementById("wfm-video-edit-timeline-track");
|
||||
track?.addEventListener("dragover", (e) => e.preventDefault());
|
||||
track?.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
_reorderByDrop(null);
|
||||
});
|
||||
}
|
||||
|
||||
export function initVideoEditTab() {
|
||||
_wireToolbar();
|
||||
document.getElementById("wfm-video-edit-export-btn")?.addEventListener("click", _exportTimeline);
|
||||
_renderTimeline();
|
||||
_renderTrimPanel();
|
||||
_updatePreviewBtn();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Each pane also has a companion <img> element (wfm-video-*-preview-img) so a
|
||||
* still/animated image (from Asset or a Video Source drop) can be previewed
|
||||
* without forcing it through a <video> tag, which can't render image formats
|
||||
* at all. mediaType controls which element is shown; Frame/GIF's
|
||||
* getActivePreviewVideoElement() returns null while a pane is showing an
|
||||
* image, since those tools only make sense against real video.
|
||||
*/
|
||||
|
||||
const _PANES = {
|
||||
source: { video: "wfm-video-source-preview-video", img: "wfm-video-source-preview-img", placeholder: "wfm-video-source-preview-placeholder" },
|
||||
result: { video: "wfm-video-preview-video", img: "wfm-video-preview-img", placeholder: "wfm-video-preview-placeholder" },
|
||||
};
|
||||
|
||||
const _objectUrls = { source: null, result: null };
|
||||
|
||||
// Whichever pane most recently received media 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 (or the Edit tab's export/preview) activates "result".
|
||||
let _activePane = null; // "source" | "result" | null
|
||||
let _activeSource = null;
|
||||
let _activeMediaType = null; // "video" | "image" | null — mirrors _activePane's current content
|
||||
|
||||
function _setPreview(pane, url, source, mediaType = "video") {
|
||||
const ids = _PANES[pane];
|
||||
const video = document.getElementById(ids.video);
|
||||
const img = document.getElementById(ids.img);
|
||||
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) {
|
||||
if (mediaType === "image") {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.style.display = "none";
|
||||
if (img) { img.src = url; img.style.display = "block"; }
|
||||
} else {
|
||||
if (img) { img.removeAttribute("src"); img.style.display = "none"; }
|
||||
video.src = url;
|
||||
video.style.display = "block";
|
||||
}
|
||||
placeholder.style.display = "none";
|
||||
_activePane = pane;
|
||||
_activeSource = source;
|
||||
_activeMediaType = mediaType;
|
||||
} else {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.style.display = "none";
|
||||
if (img) { img.removeAttribute("src"); img.style.display = "none"; }
|
||||
placeholder.style.display = "";
|
||||
if (_activePane === pane) { _activePane = null; _activeSource = null; _activeMediaType = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// Sets (or clears, with url=null) the Asset-selection/Video-Source-drop pane.
|
||||
// mediaType: "video" (default) or "image".
|
||||
export function setSourcePreview(url, source, mediaType = "video") { _setPreview("source", url, source, mediaType); }
|
||||
|
||||
// Sets (or clears) the generated-result pane — called once per block as a
|
||||
// batch run's video output comes in, and by the Edit tab's timeline preview
|
||||
// (which may show either a video clip or a held still image).
|
||||
export function setResultPreview(url, source, mediaType = "video") { _setPreview("result", url, source, mediaType); }
|
||||
|
||||
// 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 media.
|
||||
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 (including when the active pane is
|
||||
// showing a still image instead).
|
||||
export function getActivePreviewVideoElement() {
|
||||
if (!_activePane || _activeMediaType === "image") 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);
|
||||
}
|
||||
|
||||
// Direct access to the "result" pane's <video> element — used by
|
||||
// video-edit-tab.js's timeline preview, which needs to drive playback
|
||||
// (seek to a clip's trim-in, listen for the trim-out point, swap to the
|
||||
// next clip) rather than just set a src via setResultPreview().
|
||||
export function getResultPreviewVideoElement() {
|
||||
return document.getElementById(_PANES.result.video);
|
||||
}
|
||||
@@ -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,417 @@
|
||||
/**
|
||||
* 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 { initVideoEditTab, addClipFromFile } from "./video-edit-tab.js";
|
||||
import { VIDEO_GROUP, ensureVideoGroup } from "./gallery-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 Project button collapses its
|
||||
// panel 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/Asset trio is a "always exactly one visible" tab group. 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";
|
||||
});
|
||||
if (target === "asset") refreshVideoAssetTab();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar Project button: clicking it shows its panel; clicking it again while
|
||||
// active collapses it, letting the video preview reclaim the sidebar's width
|
||||
// when it isn't 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 === "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);
|
||||
};
|
||||
const setTitle = (id, key) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = t(key);
|
||||
};
|
||||
setText("wfm-video-edit-export-btn", "videoEditExportBtn");
|
||||
setTitle("wfm-video-edit-move-left-btn", "videoEditMoveLeft");
|
||||
setTitle("wfm-video-edit-move-right-btn", "videoEditMoveRight");
|
||||
setText("wfm-video-edit-duplicate-btn", "videoEditDuplicate");
|
||||
setText("wfm-video-edit-delete-btn", "videoEditDelete");
|
||||
setText("wfm-video-edit-clear-btn", "videoEditClearBtn");
|
||||
setText("wfm-video-source-add-to-edit", "videoSourceAddToEdit");
|
||||
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) => {
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
const isImage = file.type.startsWith("image/");
|
||||
if (!isVideo && !isImage) return;
|
||||
const statusEl = document.getElementById("wfm-video-source-status");
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file }, isVideo ? "video" : "image");
|
||||
if (statusEl) { statusEl.textContent = file.name; statusEl.style.color = ""; }
|
||||
});
|
||||
|
||||
// "Add to Edit" — explicit, user-triggered handoff from whatever is loaded
|
||||
// in the Source pane (a plain drop, an Asset selection, a Frame/GIF-tool
|
||||
// source) into the Edit subtab's clip timeline. Deliberately NOT automatic
|
||||
// on every Source drop — Frame/GIF is a single-video tool the user reaches
|
||||
// for far more often than multi-clip editing, so auto-adding would clutter
|
||||
// the Edit timeline with videos the user never meant to combine.
|
||||
document.getElementById("wfm-video-source-add-to-edit")?.addEventListener("click", async () => {
|
||||
const ref = getActivePreviewSource();
|
||||
if (!ref) { showToast(t("videoNoSourceLoaded"), "error"); return; }
|
||||
try {
|
||||
let file;
|
||||
if (ref.kind === "local") {
|
||||
file = ref.file;
|
||||
} else {
|
||||
const blob = await comfyUI.getImageBlob(ref);
|
||||
const isVideoExt = /\.(mp4|webm|mkv|mov)$/i.test(ref.filename || "");
|
||||
file = new File([blob], ref.filename, { type: blob.type || (isVideoExt ? "video/mp4" : "image/png") });
|
||||
}
|
||||
addClipFromFile(file);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoEditClipSent", file.name), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
let _frameOutputDir = "";
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Same small output-dir lookup each of video-asset-tab.js/video-plan-tab.js/
|
||||
// video-edit-tab.js keeps its own copy of — needed to build the absolute path
|
||||
// Gallery's group-tagging API expects.
|
||||
async function _fetchFrameOutputDir() {
|
||||
if (_frameOutputDir) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_frameOutputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
// Opt-in tagging of a saved frame into Gallery's curated __Video Assets__
|
||||
// group (see the "Add to Video Assets group" checkbox) — mirrors
|
||||
// video-edit-tab.js's _addOutputToVideoTemp, but targets the curated group
|
||||
// directly since a manually-captured frame is a deliberate keep, not the
|
||||
// every-run scratch output __Video Temp__ exists for.
|
||||
async function _addFrameToVideoAssets(filename, subfolder) {
|
||||
await _fetchFrameOutputDir();
|
||||
if (!_frameOutputDir) return;
|
||||
const parts = [_frameOutputDir];
|
||||
if (subfolder) parts.push(subfolder);
|
||||
parts.push(filename);
|
||||
const path = parts.join("/");
|
||||
try {
|
||||
await ensureVideoGroup();
|
||||
await fetch(`/wfm/gallery/groups/${encodeURIComponent(VIDEO_GROUP)}/add`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[VideoTab] failed to tag frame into video group:", err);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
if (document.getElementById("wfm-video-frame-add-to-assets")?.checked) {
|
||||
await _addFrameToVideoAssets(json.filename, json.subfolder);
|
||||
}
|
||||
} 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();
|
||||
initVideoEditTab();
|
||||
|
||||
_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;
|
||||
});
|
||||
}
|
||||