Compare commits
@@ -3,9 +3,19 @@
|
||||
# Unsloth Desktop requires an API key even for local access
|
||||
# (Settings -> API in the Unsloth app, shown only once when issued).
|
||||
#
|
||||
# Alternatively leave the key empty and turn on Keyless API access ->
|
||||
# "Chat and inference" in Unsloth Desktop's Settings -> API; requests are then
|
||||
# sent without a key. That setting also serves your private LAN, and keep
|
||||
# "Allow tools" OFF.
|
||||
#
|
||||
# 1. Copy this file to ".env" (same directory as this example file)
|
||||
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
|
||||
# line becomes a comment and the key will not be loaded)
|
||||
# 3. Restart ComfyUI
|
||||
|
||||
UNSLOTH_API_KEY=
|
||||
|
||||
# ComfyUI-Workflow-Studio - Gallery Semantic Search (Gemini Embedding 2)
|
||||
# Get a key at https://aistudio.google.com (free tier available).
|
||||
# Same steps as above: copy to ".env", paste the key, restart ComfyUI.
|
||||
GEMINI_API_KEY=
|
||||
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9 # 1.0.1
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
@@ -6,11 +6,9 @@ __pycache__/
|
||||
*.egg-info/
|
||||
data/
|
||||
|
||||
# graphify: keep the knowledge graph itself under version control,
|
||||
# but exclude the run-by-run cost/token log, the local extraction
|
||||
# cache (both churn every rebuild and have no value to other clones),
|
||||
# and the machine-local interpreter/scan-root pointers.
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/.graphify_python
|
||||
graphify-out/.graphify_root
|
||||
# graphify: local-only knowledge graph output, not shared with other clones
|
||||
graphify-out/
|
||||
|
||||
# Working-notes plan docs — local scratch, not meant to ship with the repo
|
||||
VIDEO_EDIT_TAB_PLAN.md
|
||||
QWEN_CHAT_INTEGRATION_PLAN.md
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
Condensed version-by-version index of DEVLOG.md (each line: version + one-line summary of what changed and why). Full rationale, code details, and "How to apply" lessons live in the excluded DEVLOG.md itself; this index exists so graphify can extract a queryable semantic node per release without feeding the full log to the local Ollama model. Regenerate with `python tools/generate_doc_index.py devlog` whenever DEVLOG.md changes.
|
||||
|
||||
- **v0.8.2(2026-10-12)** — Gallery: セマンティック検索(Gemini Embedding 2 / EmbeddingGemma 2)
|
||||
- **v0.8.1(2026-10-11)** — バグ修正
|
||||
- **v0.8.0(2026-10-11)** — Gallery: 音声ファイル対応(wav / mp3 / ogg / m4a / flac / opus)
|
||||
- **v0.7.14(2026-10-09)** — Comic Creator連携: nanobananaタブの生成履歴をGallery/Metadataタブに表示
|
||||
- **v0.7.13(2026-10-06)** — VRAM調整(Ollama)— Comic Creatorから移植
|
||||
- **v0.7.12(2026-10-04)** — セキュリティ: バックエンド接続先をブラウザから任意指定できた問題(SSRF)を修正
|
||||
- **v0.7.11(2026-10-02)** — セキュリティ: CivitAI情報の説明文のHTML無害化(保存型XSS対策)
|
||||
- **v0.7.10(2026-10-02)** — Windows: ComfyUIが固まる問題の原因特定と対策(Selectorイベントループ)
|
||||
- **v0.7.9(2026-10-02)** — Models: CivitAIベースモデルからの自動バッジ作成
|
||||
- **v0.7.8(2026-10-01)** — Video Edit: マルチトラック化(ビデオ/オーバーレイ/テキスト/オーディオ)
|
||||
- **v0.7.7(2026-09-30)** — 意思決定モデル: Ollama 0.35+ バックエンド追加
|
||||
- **v0.7.6(2026-09-30)** — Settings: 意思決定モデル (Laya) の専用設定
|
||||
- **v0.7.5(2026-09-30)** — Ming Image ワークフロー対応(GenerateUI / Gallery・Metadata / サイドパネルIタブ)
|
||||
- **v0.7.4(2026-09-29)** — Video Edit: BGM/音声トラック合成(Phase 4)— 複数クリップ結合時の音声消失も解消
|
||||
- **v0.7.3(2026-09-24)** — GenerateUIタブにQwen Image 2.1(TextEncodeQwenImage21)対応を追加
|
||||
- **v0.7.2(2026-09-18)** — Workflowタブにソート機能追加(Date/Name 昇順降順)
|
||||
- **v0.7.1(2026-09-17)** — セキュリティ修正: Tagger VLM の Unsloth 経路でAPIキーがSSRFにより外部送信され得る不備を修正
|
||||
- **v0.7.0(2026-09-17)** — プレビュー枠の高さ固定・AssetサブタブをPlan/Editと並ぶ中央下部へ移設・トリムパネルにClipchamp風スクラバー追加・Frame保存のVideo Assets登録とAsset→Plan画像連携
|
||||
- **v0.6.3** — Send CCボタンでMP4動画をComic Creatorの新設「動画ツール」へ送れるように対応
|
||||
- **v0.6.2** — Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
|
||||
- **v0.6.1** — Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
|
||||
- **v0.6.0** — Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
|
||||
- **v0.5.8** — Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
|
||||
- **v0.5.7** — 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
|
||||
- **v0.5.6** — バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
|
||||
- **v0.5.5** — Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
|
||||
- **v0.5.4** — 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
|
||||
- **v0.5.3** — バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
|
||||
- **v0.5.2** — 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
|
||||
- **v0.5.1** — 機能追加: mp4動画の埋め込みメタデータ対応(Gallery Prompt/JSON・Metadataサブタブ・サイドパネルIタブ)+ Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
|
||||
- **v0.5.0** — 機能追加: mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
|
||||
|
||||
@@ -2,6 +2,803 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.8.2(2026-10-12)
|
||||
|
||||
### Gallery: セマンティック検索(Gemini Embedding 2 / EmbeddingGemma 2)
|
||||
|
||||
画像を文章の意味で探す検索と、選択画像に類似した画像の一覧表示を追加した。
|
||||
|
||||
**UI**
|
||||
- 検索ボックス横に「S」ボタン。ONで検索ボックスが意味検索になる(Enterで実行、スコア順、サムネイルにスコアバッジ)。OFFは従来のファイル名/タグ/プロンプト検索。
|
||||
- Sを入れるとツールバーに2行目が現れ、`Similar`(トグル)・`↻`(更新)・`⚙`(設定)・検索結果件数を左詰めで表示する。Similarは、類似一覧の中で別の画像を選んでも一覧はそのままで、↻でその画像の類似を再表示、Similar再押下で通常表示に戻る。フォルダ切替・フィルタクリア・S OFFでも解除。Similar/↻は当初詳細パネルに置いていたが2行目へ移設(Taggerの紫と区別するためティール)。
|
||||
- ⚙のモーダルを3タブ化: インデックス(対象フォルダ・サブフォルダ・新規のみ/全件再作成・進捗・停止・全削除)/ モデル設定(モデルのラジオボタン・次元・ローカルのデバイス/CPUスレッド/画像精度・接続テスト)/ 検索設定(類似画像と文章検索の最小スコア、最大表示件数)。「?」でヘルプの該当項目へジャンプ。
|
||||
- 最小スコア(例: 0.6以上)はモデルごとに保存(スコアの水準がモデルで違うため。少数画像の実測では、類似画像はEmbeddingGemma 2で約0.6〜0.8、Geminiで約0.6、文章検索はそれぞれ約0.6〜0.7・約0.3)。
|
||||
- フォルダツリーの枚数バッジの右に緑のドット = そのフォルダ直下の画像が全件インデックス済み(`GET /wfm/gallery/semantic/folders`)。インデックスは全フォルダ共通の1つで、フォルダ単位で少しずつ作れる。
|
||||
- ヘルプページの幅を760→1076px(上部タブバーのAI TOOLタブの右端に揃える)。
|
||||
|
||||
**バックエンド**
|
||||
- `py/services/semantic_service.py`: ベクトルを `semantic_index.npz` に保存(パス→正規化ベクトル、サイズ/mtimeで鮮度判定、モデル/次元/画像精度をヘッダに保持)。インデックス作成はバックグラウンドスレッド(進捗ポーリング・停止可)、検索はコサイン類似度(`mat @ q`)、未インデックスの基準画像はその場で1枚だけ埋め込む。動画/PSDはサムネイルから、音声は対象外。
|
||||
- Gemini: `gemini-embedding-2`(`x-goog-api-key`ヘッダ、接続先固定、429/5xxは指数バックオフで再試行)。APIキーは `.env` の `GEMINI_API_KEY`(UNSLOTHと同じく settings.json・フロントには出さない)。
|
||||
- EmbeddingGemma 2: `py/services/semantic_local.py`。onnx-community/embeddinggemma-2-ONNX を `onnxruntime` + `tokenizers` + `huggingface_hub` だけで実行(`transformers` の対応を待たない)。Transformers.jsの `Gemma4ImageProcessor` / `EmbeddingGemma2Processor` を移植(アスペクト比維持リサイズ → 16pxパッチ化 → vision_encoder → `<|image|>` 位置へ `image_features` を差し込み → `sentence_embedding`)。クエリは `task: search result | query: ` プレフィックス。MRL(128/256/512/768)で切り詰め+再正規化。
|
||||
- 実行デバイス: 自動(CUDAがあればGPU)/ CPU / GPU(CUDA)。CPUスレッド数の既定はコアの半分。設定は `semantic_settings.json` に保存。画像精度はソフトトークン数 280/140/70(高精度/標準/軽量、CPUで約2.5倍/約5倍速)でインデックスに記録し、変えたら全件再作成が必要。インデックス作成後はセッションを解放しVRAM/RAMを画像生成に譲る。
|
||||
|
||||
**実測(RTX 4070 / 32コア)**
|
||||
- 768×576の画像1枚あたり: CUDA 0.13秒(標準 0.07秒)、CPU 4スレッド 5.8秒(標準 2.3秒、軽量 1.1秒)。CPUとCUDAのベクトルは一致(cos 1.0)。実機で122枚を約20秒で作成。
|
||||
- DirectMLはvision_encoderのReshapeノードで実行時エラーとなり非対応(テキストモデルのみ動作)。WindowsMLは未検証。
|
||||
- 環境メモ: ComfyUI_5のvenvには `onnxruntime` と `onnxruntime-gpu`(ともに1.30.0)が同居し、CPU版が勝っていたため、両方を削除して `onnxruntime-gpu==1.30.0` のみを入れ直した(`pip check` は `onnxruntime` 名での依存を警告するが動作に影響なし)。
|
||||
|
||||
**安全性(セキュリティレビュー指摘への対応)**
|
||||
- 数値パラメータ(dim / soft / threads / limit / min_score)を共通関数で範囲内に丸め、数値でない値は500ではなく400(`bad_param`)で返す。スレッド数はコア数の2倍まで。
|
||||
- ローカルモデルへの入力を制限: テキスト1024トークン、検索文2000文字、画像は長辺2048pxを超えたら先に縮小。
|
||||
|
||||
**その他**
|
||||
- `.env.example` に `GEMINI_API_KEY`、`requirements.txt` にGPU用の手順コメントを追加。ヘルプ(3言語)・READMEを更新。
|
||||
- Gemini Embedding 2には無料枠があるが、無料枠で送った内容はGoogleの製品改善に使われる場合がある(2026年10月時点の公式料金: 画像1枚あたり約$0.00012)。
|
||||
|
||||
## v0.8.1(2026-10-11)
|
||||
|
||||
v0.8.0 のコードレビュー指摘(#1〜#10)への対応。
|
||||
|
||||
### バグ修正
|
||||
- Nodes: 検索結果が0件のとき `renderToken` を進めておらず、描画途中のカードが「No nodes found」の下に追加され続けていた。
|
||||
- Metadata: `convertUiToApi()` に `{ readOnly: true }` を追加。表示用の変換ではCOMBO値の無警告差し替え(サンプラー名の誤表示)を行わず、Workflowタブの互換性チェックが参照する `_last*` 記録も更新しない。
|
||||
- Metadata: 画像を連続で開いたとき、先に始めた処理の結果が後から描画されて混ざる問題を、リクエストID(`_loadSeq`)で防止。
|
||||
- Metadata: サンプラー設定の抽出を改善。Flux/SD3系(RandomNoise / BasicScheduler / KSamplerSelect / CFGGuider)の設定を統合し、複数KSamplerでは設定が最も多いノード(同数なら後ろ)を採用。
|
||||
- Gallery: `history.jsonl` に dict でない行・`filenames` が list でない行があっても、フォルダ内の全画像でメタ取得が失敗しないようにした。
|
||||
- Gallery: 同じファイルを同時に変換すると出力名が衝突して上書きされる問題を、出力名の決定を `_convert_lock` 内に移して解消。
|
||||
- Gallery: PNGチャンクの読み取りを先勝ちに統一(`png_extractor` と同じ)。ワークフロー読み込みとの食い違いを防止。
|
||||
|
||||
### パフォーマンス・保守性
|
||||
- Gallery: 一括選択の変換対象判定を O(選択数×画像数) から O(選択数) に(パス→画像Mapのキャッシュ + `some` で打ち切り)。12,780枚の全選択で約33ms。
|
||||
- Gallery: `history.jsonl` を `(mtime_ns, size)` キーの索引としてキャッシュ(`OrderedDict`、上限16件)。
|
||||
- 音声・動画の拡張子判定を `isAudioFile()` / `isVideoFile()` に統一し、波形サムネイルの版数を `WAVE_THUMB_VERSION` 定数に集約(JS/Python)。
|
||||
|
||||
## v0.8.0(2026-10-11)
|
||||
|
||||
### Gallery: 音声ファイル対応(wav / mp3 / ogg / m4a / flac / opus)
|
||||
|
||||
ComfyUIの音楽生成の増加に合わせ、Galleryで音声ファイルを画像・動画と同じ一覧で扱えるようにした。
|
||||
|
||||
- `gallery_service.py`: `AUDIO_EXTENSIONS` を追加し `IMAGE_EXTENSIONS` に合流。再生時間は`_read_media_dimensions()`(PyAV)、埋め込みタグは`_read_audio_metadata()`で取得(ストリーム側タグも統合し、`prompt`/`workflow`は小文字キーでも引ける)。`_extract_embedded_workflow()`も音声に対応し、Prompt/JSONタブが動く。
|
||||
- サムネイルは`_render_audio_waveform()`でPyAVデコード→8kHzモノラル→列ごとの最大振幅を描画してJPEGキャッシュ。拡張子ごとに波形色を変える(`AUDIO_WAVE_COLORS`)。キャッシュキーに版数(`wave2`)、サムネイルURLにも`&v=wave2`を付けブラウザの24hキャッシュを回避。
|
||||
- `gallery_routes.py`: Windowsで音声MIMEが返らない/不統一なため`mimetypes.add_type`で明示登録。`FileResponse`のRange対応でシークも可能。
|
||||
- `gallery-tab.js`: `isAudioFile()`、♪バッジ、詳細パネル/ライトボックス/Compareで「波形+`<audio controls>`」を表示(音量は動画と共通設定)。詳細パネルは音声のとき固定高さをやめ、再生メニューが見切れないようにした。PSD書き出し・Edit as Layersでは音声を除外。
|
||||
|
||||
### Video: Assetサブタブで音声を素材として利用
|
||||
|
||||
- 先に音声を一覧から除外していたのを改め、種別フィルタに「Audio Only」を追加。音声はプレイヤー付きで、「BGMに設定」「サウンドとして追加(Audioトラックの選択クリップ先頭に配置)」ボタンが使える。`video-edit-tab.js`に`addSoundFromFile()`を追加。Editに送る/オーバーレイ/First・Last Imageと、Frame/GIFツールへのSourceプレビュー投入は音声では出さない。
|
||||
|
||||
### Gallery: ファイル変換(音声変換 / 動画からの音声抽出)
|
||||
|
||||
- 新規`py/services/convert_service.py`(PyAVのみ、システムffmpeg不要)。出力はFLAC / MP3 / Opus / M4A / WAV(Vorbisエンコーダが無いため`.ogg`は入力のみ)。ビットレート・サンプルレート・チャンネル指定可。
|
||||
- `GalleryService.convert_file()` + `POST /wfm/gallery/convert`(1ファイルずつ呼ぶ。`run_in_executor`+同時1本のロック)。同じフォルダに`name.ext`(既存なら`name_converted.ext`、以降連番)で保存し上書きしない。
|
||||
- メタデータ: `prompt`/`workflow`と曲情報タグを、タグを持てる形式(FLAC/MP3/Opus/M4A)へ引き継ぐ。WAVは対象外(通知)。お気に入り/タグ/メモ/グループの引き継ぎ、変換成功後の元ファイル削除(確認ダイアログ付き)を選択可。
|
||||
- UI: 一括バーに「変換...」ボタン(音声/動画を含む選択時のみ表示)とダイアログ。i18n(en/ja/zh)対応。
|
||||
|
||||
### Gallery: メタデータ読み取りの修正(メタデータ抽出 比較レポートA1〜A4)
|
||||
|
||||
- A1: PNGの読み取りがIENDで止まっていたため、ComfyUI-Custom-ScriptsがIENDの後ろに追記する`workflow`チャンクを読めなかった(`_read_png_metadata()` / `extract_png_workflow()`)。
|
||||
- A2: WebPのEXIFから`workflow:`/`prompt:`を取り出す`_read_webp_metadata()`を追加。
|
||||
- A3: ワークフローが無い画像(A1111/Forge/Fooocus)は`parameters`からPromptタブを表示(`extractPromptsFromParameters()`)。
|
||||
- A4: API形式の`ImageMetadataLoRALoader`(`lora_N`/`strength_model_N`/`strength_clip_N`)をMetadataタブとサイドパネルIタブで検出。
|
||||
|
||||
### Library/Nodes: 表示速度の改善とAlt+クリックお気に入り
|
||||
|
||||
- LibraryのNタブ: Alt+クリックでお気に入りをON/OFF。`/object_info`(数MB)の取得を待たずに一覧表示し、Category/Packageは`LiteGraph.registered_node_types[..].nodeData`から取得(無い環境のみ裏で取得)。
|
||||
- SPAのNodesタブ: 数千件を一括でDOM生成していたのを分割描画に変更し、検索をデバウンス+検索文字列の事前計算で軽量化。さらに`/api/wfm/nodes/light`(`INPUT_TYPES`を呼ばず約6ms)で一覧を先に表示し、入力定義を含む`/object_info`(実測で約14秒)は裏で取得して補完する方式にした。
|
||||
|
||||
### Gallery: Move Toダイアログにフォルダ絞り込み
|
||||
|
||||
- フォルダ名/パスの部分一致(空白区切りAND)で絞り込み、Enterで先頭候補へ移動。音声/動画も移動できるため、文言を「N件移動しました」に変更(`movedNImages`、`moveToFolderTitle`)。
|
||||
|
||||
### その他
|
||||
|
||||
- ヘルプ(en/ja/zh)とREADMEを更新。READMEのバージョンバッジが0.7.13のままだったのを修正。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.14(2026-10-09)
|
||||
|
||||
### Comic Creator連携: nanobananaタブの生成履歴をGallery/Metadataタブに表示
|
||||
|
||||
Comic Creator(`eagle_comic_creator_spa`)のnanobananaタブが`cc_nanobanana`フォルダに書く生成履歴`history.jsonl`(1行=1生成リクエスト、`filenames`配列で画像ファイルと対応、prompt/negative_prompt/engine/model/image_size/seed/timestampを記録)を、WFS側のGallery/Metadataタブから閲覧できるようにした。
|
||||
|
||||
- `gallery_service.py`に`_read_nanobanana_entry()`を追加。画像の親フォルダ名が`cc_nanobanana`の場合のみ同フォルダの`history.jsonl`を新しい行から探索し、自分のファイル名を含むエントリを返す(既存の`get_image_metadata()`レスポンスに`nanobanana`フィールドとして追加、新規エンドポイントは不要)。
|
||||
- Gallery詳細パネルに「Nanobanana」タブを新設。`cc_nanobanana`フォルダの画像のみタブボタンを表示し、engine/model/size/seed/timestamp/prompt/negative promptを表示。
|
||||
- Metadataタブ右ペインにも同内容の「Nanobanana」セクションを追加。Galleryの「Metadata」ボタンから開いた場合は画像パスも渡されるようになり(`loadFileIntoMetadataTab(file, path)`)、サーバーの`/wfm/gallery/image/meta`から画像サイズとnanobanana履歴を補完する。nanobanana画像はComfyUIワークフローを埋め込んでいないため、従来は「メタデータなし」エラーになっていたが、nanobanana履歴があればエラーにせずNanobananaセクションのみ表示するようにした。
|
||||
- `cc_nanobanana`フォルダの画像はComfyUIワークフローを埋め込んでおらずPromptタブに表示する情報が無いため、Gallery詳細パネルのPromptタブ自体を非表示化(フォルダ名で判定、history.jsonlに対応エントリが無い古い画像でも同様に隠れる)。
|
||||
|
||||
### Metadataタブ: レイアウト再編(LoRA/Promptを中央へ、右ペインをSettings+Nanobananaに)
|
||||
|
||||
- 中央ペイン(Col2): Checkpoint / VAE / Diffusion Model / Text Encoder に加えて LoRA / Prompt もここへ移動。
|
||||
- 右ペイン(Col3): 「Settings」(画像サイズ、Seed、Steps、CFG、Sampler、Scheduler、Denoise)と、上記「Nanobanana」セクション。
|
||||
- KSampler系ノードの設定値を抽出する`extractSamplerSettings()`を新規実装。API形式ワークフローの名前付き`inputs`(`seed`/`noise_seed`/`steps`/`cfg`/`sampler_name`/`scheduler`/`denoise`)から読む。UI形式ワークフローは`convertUiToApi()`を経由して同じ抽出を通す(サブグラフの有無に関わらず常に変換を試みるよう変更。既存のCheckpoint/LoRA/Prompt抽出ロジックには影響なし、変換結果はSettings抽出にのみ使う)。
|
||||
- 画像サイズは、Galleryから開いた場合はサーバー側の実測値(`/wfm/gallery/image/meta`のwidth/height)、ドラッグ&ドロップされた単体ファイルの場合はプレビュー画像の`naturalWidth`/`naturalHeight`を使う。
|
||||
- i18n(en/ja/zh)・ヘルプカードを更新。
|
||||
|
||||
### バグ修正: LiveChatStreamなど複数出力テキストノードがAPI形式プロンプト抽出で解決できない不具合
|
||||
|
||||
ComfyUI-LiveChatStreamの`response`(LLM応答全文)出力をCLIPTextEncodeに直結した場合にGallery/Metadataタブでプロンプトが表示されない、という報告を調査。
|
||||
|
||||
- 原因: PNGの`prompt`(API形式)埋め込みからのプロンプト抽出は`prompt`チャンクを優先して使うが(Metadataタブ・Gallery Promptタブとも)、API形式の`resolveLinkedText()`は接続元ノードの値を決め打ちのキー名リスト(`text_positive`/`text`/`prompt`/`value`等)でしか探しておらず、LiveChatStreamの実際のウィジェット名(`prompt_text`/`negative_text`/`response_text`/`chat_p_text`/`chat_n_text`/`thinking_text`、`nodes.py`の`INPUT_TYPES`で確認)がどれとも一致しないため、**responseだけでなくpositive/negativeも含め、API形式経由では本来解決できていなかった**(UI形式の`workflow`埋め込みは`widgets_values`を出力スロット順でそのまま読むため、たまたま正しく解決できていた=どちらの埋め込みがPNGにあるかで体感が変わっていたと推測)。
|
||||
- `metadata-tab.js`の`resolveLinkedText()`、およびサイドパネルIタブ(`node_sets_menu.js`)の複製ロジック`_resolveLinkedText()`の両方に、`class_type === "LiveChatStream"`の個別対応を追加(`LIVE_CHAT_STREAM_OUTPUT_KEYS`で出力スロット→ウィジェット名を対応付け)。
|
||||
- 実機(稼働中のComfyUI_5)の`/wfm_static/js/metadata-tab.js`に対し、約19,000〜40,000文字の長文応答を模したAPI形式ワークフローで検証し、全文正しく(0.4ms程度で)抽出されることを確認。文字数そのものは無関係だった。
|
||||
|
||||
### Gallery: Send to LV node ボタン(ネイティブLoad Videoへの送信)
|
||||
|
||||
既存の「Send to LI node」(選択画像をComfyUIキャンバス上の選択中ノードの`image`ウィジェットへ送る)の動画版。
|
||||
|
||||
- `node_sets_menu.js`に`_wfmFindVideoWidget()`/`window.wfmSendVideoToSelectedNode()`を追加。ネイティブの`LoadVideo`ノード(`comfy_extras/nodes_video.py`、`file`ウィジェット)に絞って探す(`image`と違い`file`という名前は他ノードとも衝突しうるため`node.type === "LoadVideo"`で限定)。
|
||||
- Galleryツールバーに「Send to LV node」ボタンを追加。動画以外が選択されている場合はエラートースト。アップロードは既存の`/upload/image`エンドポイント(拡張子を問わず任意バイナリをinputフォルダへ保存できる汎用エンドポイント)をそのまま流用。
|
||||
- 実機でLoadVideoノードを作成→選択→送信し、`file`ウィジェットの値が正しく書き換わることを確認(`ComfyUI_00013_.mp4` → `test_video_12345.webm`)。
|
||||
|
||||
### Gallery: WebM対応
|
||||
|
||||
- `gallery_service.py`の`.mp4`限定だった5箇所(埋め込みメタデータ読み込み・寸法/再生時間取得・サムネイル抽出・PSDレイヤー除外判定・一覧フィルタ)を`VIDEO_EXTENSIONS = {".mp4", ".webm"}`ベースに統一。いずれもPyAV(`av`)経由でコンテナ形式を問わず同じコードパスのため、webm固有の実装追加は不要だった。
|
||||
- `gallery-tab.js`の`isVideoFile()`をwebm対応に拡張。一覧表示・プレビュー・ライトボックス・Comic Creator連携・一括レイヤー編集の動画除外フィルタなど、これを参照する全箇所が自動的にwebm対応。
|
||||
- ヘルプカード(Gallery/3言語)を更新。
|
||||
|
||||
### 意思決定モデルクライアントにvision対応を追加(移植、消費する機能はまだ無い)
|
||||
|
||||
Comic Creatorの`decision-client.js`が2026-10に追加したvision対応(Ollamaの`clef`等、画像入力可能な意思決定モデル向け)を、WFS本家の`static/js/decision-client.js`へ移植した。
|
||||
|
||||
- `decide(state, questions, opts, images)`に第4引数`images`(base64配列、`data:`プレフィックス無し)を追加。
|
||||
- `imageToBase64(src)`(Blob/data URL/画像URL→base64)、`supportsDecisionVision(opts)`(設定中のモデルが画像入力に対応しているかをOllamaの`/api/show`で判定。`capabilities`に`vision`が無いビルドでは`projector_info`の有無でも判定するフォールバック込み)を新規追加。
|
||||
- `DECISION_BACKENDS.ollama.models`に`"clef"`を追加(27B・vision対応、Ollama 0.35.1以降が必要。`clef-flash`は2026-10時点でOllama側の不安定挙動があるため含めていない)。
|
||||
- 実機(稼働中のOllama、`clef`モデル)で`supportsDecisionVision`→`true`、`imageToBase64`→`decide(images=[...])`の一連の流れを実画像(canvas生成の赤い正方形)で検証し、`red: 0.99` / `blue: 0.009`と正しく判定されることを確認。
|
||||
- 既存の呼び出し箇所(`settings-tab.js`、`vram-prepare.js`)は`images`引数を使わないため後方互換。消費する機能(Gallery自動分類等)はまだWFS側に無く、クライアントライブラリの同期のみ。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.13(2026-10-06)
|
||||
|
||||
### VRAM調整(Ollama)— Comic Creatorから移植
|
||||
|
||||
画像生成の直前にOllamaでロード中のモデルをアンロードし、ComfyUIの空きVRAMを確保する機能をComfyUI Comic Creator(`/api/ccc/vram/prepare`)から移植。
|
||||
|
||||
- 設定タブに「VRAM調整(Ollama)」セクションを新設。生成前の動作は「何もしない」(既定)/「空きが目標未満のときだけ必要な分をアンロード」/「毎回すべてアンロード」、目標の空き(GB)、「今すぐすべてアンロード」ボタン。設定はブラウザの `wfm_vram_settings`。
|
||||
- フックは `comfyUI.queuePrompt()` の直前(`static/js/vram-prepare.js` の `prepareVramForGeneration()`)。GenerateUI・Lab・バッチ・Video・AI TOOLのChat生成など、Workflow Studio経由の生成すべてに効く。失敗しても生成は止めず、アンロード時・目標未達・失敗時のみトーストで通知(連続生成で同じ目標未達の警告は繰り返さない)。ComfyUIのキャンバスから直接実行する生成は対象外。
|
||||
- サーバー: 新規 `py/routes/vram_routes.py`(`POST /api/wfm/vram/prepare`、Content-Typeは `application/json` のみ=他サイトからの単純リクエストでモデルを外されないため)。空きVRAMは **nvidia-smiの実測+ComfyUI自身のtorch未使用キャッシュ**(ComfyUIの `get_free_memory` はOllamaのロード/アンロードに反応しないことがあるため)。取れなければComfyUIの値。autoは `size_vram` が実際に空く量より小さいため、大きいモデルから1台ずつアンロードしてそのつど実測で止める。アンロードは `keep_alive:0` の `/api/generate`、受け付けない意思決定モデルは `/api/chat` で再試行し、最後に `/api/ps` で確認。同時実行はロックで409。
|
||||
- 対象のOllama: 既定の `127.0.0.1:11434`(常に追加)+OllamaのときのAI TOOL・Prompt・TaggerのAI設定と意思決定モデル。接続先は v0.7.12 の `url_guard`(loopback+`WFS_ALLOWED_BACKEND_HOSTS`)を通し、許可外のURLはリクエスト全体を拒否せずそのURLだけ飛ばす。
|
||||
- 「モデルをアンロード」ボタン(AI TOOLタブ、Prompt/Taggerの⚙設定モーダル)は、Ollamaのとき意思決定モデルを含む全モデルをアンロードするよう変更(同じサーバー処理)。サイドパネルAタブの Settings にも「VRAM (Ollama) → Unload all now」を追加(サイドパネルからはSPAの `vram-prepare.js` を import できないため、同じlocalStorageキーから接続先を集める小関数を `node_sets_menu.js` に内蔵)。
|
||||
- 実機(ComfyUI_5、ポート8189、RTX 12GB)で確認: qwen3.5:9b+tev1:0.8bをロードし空き1.5GBの状態から、auto(目標4GB)はqwen3.5:9bのみアンロードして空き1.7→9.1GB・tev1は残る、allはtev1も外す(各約1.1秒)。415/400の入力検証、許可外URLのスキップ、設定タブUI、`queuePrompt()` 経由のフック(`/prompt` 送信のみ差し止めて確認)、各アンロードボタンを確認。
|
||||
|
||||
### Workflowタブ: GenerateUIチェック(変換・操作性バッジ)
|
||||
|
||||
ワークフローをGenerateUIに読み込む前に、動くかどうかを確認できるようにした。
|
||||
|
||||
- 詳細モーダル(ダブルクリック)のタイトル下に、形式バッジ(UI/API/App形式)・「GenerateUIチェック」ボタン・✅⚠❌ の結果バッジ2つ。サイドの詳細ペインにも名前の下に形式バッジと結果バッジ(選択時に自動チェック、結果のみ)。バッジにマウスを乗せると理由をCSSチップで表示(詳細ペインはペイン幅いっぱいに表示)。
|
||||
- **変換**: GenerateUIと同じ `convertUiToApi()` で変換。未対応形式・変換エラー・このComfyUIに無いノード型は ❌、この環境に無いCOMBO値(モデル・LoRA・画像など)は ⚠。UI形式は変換時に先頭の選択肢へ無警告で差し替わる(全COMBOの差し替えを記録する `getLastComboSubstitutions()` を追加。GenerateUIの挙動は不変)。API形式は変換しないため値を直接照合し、「そのまま送るとComfyUIの検証エラー(Value not in list)」と案内。
|
||||
- **操作性**: 設定を持つノードのうち、`analyzeWorkflow()` でGenerateUIに表示されるノードの割合(✅80%以上/⚠50%以上/❌それ未満)。チップにプロンプト欄・サンプラー数と、ノードグラフで変更が必要なノードを列挙。
|
||||
- 当初は意思決定モデル(Unsloth laya)のscoreで操作性を評価する設計だったが、実測で区別できなかった(全ノード非表示の状態2.54 > 全表示2.35、状態を単純化しても2.24対1.87、yes確率0.41対0.44、choiceはどちらも同じ選択肢)ためルール判定に変更。
|
||||
- 結果は保存せずセッション中のメモリのみ。チェックは約20ms。`/object_info` の初回取得(約10秒)を避けるため、モーダルを開いた時点で先読み。
|
||||
|
||||
### ヘルプ
|
||||
|
||||
設定タブ(VRAM調整)、Workflowタブ(サイドパネル・GenerateUIチェック)、Prompt/Taggerタブ(アンロード)、AI TOOLタブ、サイドパネルAタブの記述を3言語で更新。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.12(2026-10-04)
|
||||
|
||||
### セキュリティ: バックエンド接続先をブラウザから任意指定できた問題(SSRF)を修正
|
||||
|
||||
ComfyUI-Manager登録PR(LiveChatStream、Comfy-Org/ComfyUI-Manager#3340)のレビューで指摘された「リクエストから接続先URLを受け取り、サーバーが接続する」問題が、WFSにも同じ構造で存在していたため修正。
|
||||
|
||||
- 対象: `POST /api/wfm/ollama/chat`(bodyの `url`。従来は**検証なし**で、任意ホスト・クラウドメタデータ・`file://` に接続できた)、Tagger VLM(`/wfm/tagger/vlm/models`・`vlm/predict`・`batch/start` の `api_url`。Unslothのみ制限されていた)、Eagle(`/api/wfm/eagle/add`・`/test` の `eagleUrl`)、Unslothプロキシ(`baseUrl`)。
|
||||
- 新規 `py/services/url_guard.py` に共通化。許可は **loopback(localhost / 127.0.0.1 / ::1、ポート自由)** と、運用者が環境変数 `WFS_ALLOWED_BACKEND_HOSTS`(`host` または `host:port` のカンマ区切り)で指定したホストのみ。`http(s)` 以外(`file://` 等)、URL内の認証情報、`localhost.evil.com` のような偽装ホストは拒否。**リダイレクトは追従しない**(302は接続せずエラー)。
|
||||
- **挙動変更**: LAN上の別マシンのOllama/Eagle等を使っている場合は `WFS_ALLOWED_BACKEND_HOSTS` の設定が必要(拒否時のエラーメッセージに案内を表示)。
|
||||
- 実機(ComfyUI_5、ポート8189)で、メタデータIP・`file://`・公開IPの拒否、loopbackのOllama chat/Tagger一覧/Eagle test/Unsloth Decision APIの正常動作を確認。
|
||||
- 注意: ComfyUIを `--listen` で公開すると、到達できる相手は引き続きこれらのルートを使える(接続先を変えられないだけ)。信頼できるネットワークでのみ使うこと。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.11(2026-10-02)
|
||||
|
||||
### セキュリティ: CivitAI情報の説明文のHTML無害化(保存型XSS対策)
|
||||
|
||||
- Models詳細パネルのCivitAIタブは、説明文(`description`)をエスケープせずHTMLとして挿入していた。CivitAI APIの応答は従来から同じ扱いだったが、v0.7.10で追加した `.cm-info.json`(Stability Matrix)取り込みにより、**ローカルのサイドカーファイルからも同じ経路で入る**ようになった。細工されたファイルがモデルと一緒に配布された場合、詳細パネルを開いただけでスクリプトが実行され得る(ComfyUIと同一オリジンのため、ComfyUIのAPIを操作できる)。
|
||||
- `static/js/util.js` に許可リスト方式の `sanitizeHtml()` と `safeHttpUrl()` を追加し、説明文・モデルURL・サンプル画像URLに適用。`script/style/iframe/svg/form` 等の要素、`on*` 属性、`style` 属性、`javascript:`/`data:` URL、**相対URL**(ComfyUI自身のURLへリクエストを送らせる経路)を除去する。許可するのは段落・見出し・リスト・強調・表・`code`・`a`/`img`(絶対http(s) URLのみ)など。
|
||||
- Playwright(実ブラウザ)で攻撃文字列16種を検証し、スクリプトが一度も実行されないことを確認。
|
||||
|
||||
### セキュリティ: サイドカー経由のSSRF/ローカルファイル読み取りと、Hostヘッダ依存の接続先
|
||||
|
||||
取り込み経路の再点検で見つかった2件を修正。
|
||||
|
||||
- **プレビュー画像のダウンロード(`download_image`)のURLが無検証だった**。URLは `.metadata.json`(Lora Manager形式のローカルサイドカー)の `civitai.images` から来るため、細工されたサイドカーがあると、サーバーが任意のURLへ接続する(SSRF)。`file://` を指定すればローカルファイルを読んでプレビュー画像として保存でき、プレビューAPI経由で取り出せた。**https かつ CivitAI系ホスト(`civitai.com`/`civitai.red`/`civitai.green` とそのサブドメイン)のみ許可**し、リダイレクト先も同じ条件で検証、サイズ上限50MBを設定。正規のCivitAI画像の取得は実環境で確認済み。
|
||||
- **Lora Manager連携(`lora_manager_bridge`)の接続先を、リクエストのHostヘッダから組み立てていた**。Hostの偽装(DNSリバインディング等)で任意のホストへ接続させられるため、**ループバック(`127.0.0.1`)+実際に待ち受けているポート**に固定。
|
||||
- サンプル画像・フォールバック画像のURLにも `safeHttpUrl()`(絶対http(s)のみ)を適用。
|
||||
|
||||
### `.cm-info.json` / `.metadata.json` 取り込みの堅牢化
|
||||
|
||||
- `ModelId`/`VersionId` は正の整数のみ受け付ける(URLに文字列を埋め込まない)。不正なら情報は採用せず、sha256のみ利用。
|
||||
- タグ・トリガーワードは文字列/数値のみ・件数上限あり。JSONのトップレベルがオブジェクトでない場合は無視。
|
||||
- サイドカーJSONのサイズ上限を2MBに設定(巨大ファイルでメモリを使い切らない)。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.10(2026-10-02)
|
||||
|
||||
### Windows: ComfyUIが固まる問題の原因特定と対策(Selectorイベントループ)
|
||||
|
||||
v0.7.9で「原因未特定」としていたComfyUIの無応答は、ほぼ素の環境(ComfyUI 0.34 / Workflow Studio 0.7.2〜0.7.9、ComfyUI Desktop・Stability Matrix両方)でも再現し、`/system_stats` すら応答しなかった。py-spy(`dump --native`)でメインスレッド(イベントループ)が `asyncio proactor _call_connection_lost → socket.close → closesocket → NtClose` で停止していることを確認。ハッシュ計算・モデルファイル・CivitAI取得・Windows更新(26H2を戻しても再現)・ComfyUI/WSL/Windows Firewall Controlのいずれも原因ではなかった。
|
||||
|
||||
- きっかけは「プレビュー画像の多い一覧を開き、続けて他の一覧へ切り替える」操作。ブラウザが読み込み中の画像リクエストを一斉に中止し、短時間に多数の接続が開閉する。
|
||||
- **対策**: `prestartup_script.py` でWindowsのasyncioループをProactorからSelectorへ切り替えるスイッチを追加(既定オフ)。設定画面の「Windowsの安定性(イベントループ)」のチェック、または環境変数 `WFS_SELECTOR_LOOP=1` で有効化(ComfyUI再起動後に反映)。有効化すると同じ操作で再現しなくなった。
|
||||
- トレードオフ: asyncioのサブプロセス機能が使えない/同時接続数の上限が約512。ComfyUI-Managerの閲覧・CivitAI取得は問題なし。
|
||||
- 固まったプロセスは `taskkill` しても終了せずポートを握り続けるため、再起動が必要になる。固まったときは `py-spy dump --native --pid <PID>` でメインスレッドの位置を確認する。
|
||||
|
||||
### Models: プレビュー画像の404削減
|
||||
|
||||
- プレビューが無いモデルにも画像リクエストを出しており、一覧の切り替えごとに大量の404と接続の開閉が発生していた。
|
||||
- 新API `GET /api/wfm/models/preview-keys?type=` がフォルダを1回走査して「プレビューを持つモデル」の一覧を返し、画面側は無いモデルへ画像リクエストを出さない(取得失敗時は従来動作)。サムネ変更・CivitAI取得でのプレビュー保存・一括取得後は一覧を更新。
|
||||
- **無効化(`.disabled`)されたモデルのプレビューが表示されなかった問題を修正**(`find_preview_image` が `<名前>.disabled` を見ていなかった)。
|
||||
|
||||
### Models: モデルフォルダ設定(Stability Matrix対応)
|
||||
|
||||
- 設定に「モデルフォルダ」を追加。追加のモデルルート(例: Stability Matrixの `Models`、`;` 区切りで複数可)直下の `StableDiffusion/Lora/VAE/ControlNet/TextEncoders/DiffusionModels/Embeddings/HyperNetworks`(またはComfyUI名 `checkpoints/loras` …)をModelsタブの一覧とフォルダ解決に追加する。
|
||||
- Modelsタブの一覧はComfyUIの `/object_info` 由来で追加フォルダを含まないため、`/api/wfm/models/files?extra_only=1` の結果を合流させている。
|
||||
|
||||
### CivitAI取得: ハッシュ計算を行わない運用へ
|
||||
|
||||
- 一括取得は**ハッシュ計算をしない**。保存済みsha256、`.metadata.json`(Lora Manager)、`.cm-info.json`(Stability Matrix)、Lora Managerの `/api/lm/{loras,checkpoints,embeddings}/list` のsha256から解決できる分だけを取得し、未確定のモデルは「ハッシュ未計算」として件数を表示する(大きなモデルを多数ハッシュするとディスクが飽和するため)。
|
||||
- **`.cm-info.json` 取り込み**: `Hashes.SHA256` とCivitAI情報を読む。ファイルサイズが無いため `ImportedAt` より後にモデルが更新されていたら不採用。画像一覧を持たないので `source: "cm-info"` を付け、個別の取得/更新でAPIの完全な情報に置き換える。
|
||||
- 詳細パネルのCivitAIタブで、ハッシュ未計算のモデルは赤字「未計算」を表示。取得/更新ボタンでハッシュ計算して取得する(ファイルサイズ/更新日時も保存)。
|
||||
- クライアント切断(再読み込み・タブ遷移)で一括取得を中断し、計算済みのハッシュだけ保存して終了。ハッシュ計算を3→2並列、読み込み1MB→512KB、プレビュー保存4→2並列に抑制。
|
||||
- 詳細パネルの「ファイルパス」表示を削除。
|
||||
|
||||
### ヘルプ・i18n
|
||||
|
||||
- 設定のヘルプに「モデルフォルダ」「Windowsの安定性」を追加(英日中)。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.9(2026-10-02)
|
||||
|
||||
### Models: CivitAIベースモデルからの自動バッジ作成
|
||||
|
||||
バッジ管理モーダルに「CivitAIから自動バッジ作成」を追加(`static/js/models/auto-badge.js`)。現在のタブのモデルのCivitAI `baseModel` からバッジを作る。意思決定モデルは使わず、ルール表で決定的に決める("SDXL 1.0"/"SDXL Turbo"等はSDXLにまとめ、Pony/Illustrious/NoobAI/Flux.1/Flux.2/Qwen/Wan/Z-Image/HiDream等は別バッジ。表に無い値は文字列そのまま、"Other"と空は対象外)。
|
||||
|
||||
- モデル側は既存バッジを残したまま追加(同名が既にあれば何もしない)。パレットは同名バッジの色を上書き可(チェックで切替)。「すでにバッジが付いているモデルはスキップ」オプションあり(スキップ分のバッジ名はパレットにも作らない)。
|
||||
- プレビューで件数・新規/既存を確認してから適用。
|
||||
|
||||
### CivitAI一括取得の高速化と安定化
|
||||
|
||||
参考にしたcivitai-updater/tiny-model-managerはどちらも単発GETの逐次実行で、当プラグインの一括POST(100件/回)のほうが進んでいた。遅さの原因は取得ではなくハッシュ計算側だった。
|
||||
|
||||
- **保存済みsha256の再利用**: 一括取得が保存済みハッシュを無視して毎回全件再計算していた。`sha256Size`/`sha256Mtime`を記録してファイル差し替えを検知(記録の無い旧データは信頼)。
|
||||
- **サイドカー取り込み**: `<名前>.metadata.json` のsha256とCivitAI情報を、サイズ一致かつ`files[].hashes.SHA256`一致の場合だけ採用(ハッシュ計算・通信不要)。実データでサイドカー87件中74件が利用可能、58件にCivitAI情報あり。
|
||||
- **並列ハッシュ(3並列)+ 1MB読み**、確定ごとに5秒間隔でメタデータへ保存(途中で止めても計算済み分が残る。以前は最後にまとめて保存していた)。
|
||||
- **プレビュー保存**を4並列・別スレッド化(以前はasyncハンドラ内で逐次ダウンロードしイベントループを塞いでいた)。進捗に「プレビュー保存中」を追加。
|
||||
- **未登録スキップ**: CivitAIに存在しなかったモデルに `civitaiNotFound` を記録し、ツールバーの「未登録はスキップ」で一括取得の対象外にできる(既定オフ)。通信エラーで失敗した分は記録しない。見つかれば記録は自動解除。
|
||||
|
||||
### フォルダのシンボリックリンク/ジャンクション対応
|
||||
|
||||
- モデル走査を `rglob` から自前の `_iter_files` に変更。リンクは辿りつつ、祖先フォルダへ戻る循環だけを検出して入らない(警告ログ)。
|
||||
- 一括移動の移動先チェックを `resolve()` 比較から論理パス比較に変更し、リンク先フォルダへの移動が「Destination is outside model root」で拒否される問題を修正(`..`・絶対パスでの脱出は従来通り拒否)。
|
||||
- 注意: リンク配下のモデルを削除すると、リンク先の実ファイルが消える。
|
||||
|
||||
### 実機で分かったこと
|
||||
|
||||
- TextEncoderタブでの一括取得中にComfyUIが応答しなくなる事象があった。py-spyのダンプでは、イベントループがWindowsの`closesocket`内で停止しており(作業スレッドは全て待機中でプラグインのコードはスタックに無い)、本変更が直接原因とは断定できなかった。PC再起動と動作に影響しうる他のカスタムノードの削除後は正常動作を確認。
|
||||
- この環境(管理者権限なし)ではシンボリックリンクを作れず、ジャンクションのみでテストした。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.8(2026-10-01)
|
||||
|
||||
### Video Edit: マルチトラック化(ビデオ/オーバーレイ/テキスト/オーディオ)
|
||||
|
||||
タイムライン上部のボタンでトラック表示を切り替える(ビデオ=青 `#4a9eff`・オーバーレイ=青・テキスト=緑 `#3fb950`・オーディオ=赤 `#f85149`)。下のツールメニューも連動して切り替わる(ビデオ=トリム+クロップ、オーバーレイ=オーバーレイクリップ、テキスト=テキストオーバーレイ、オーディオ=オーディオ設定)。Save/Load/書き出しは右に固定。
|
||||
|
||||
- **テキストをタイムライン直付けに変更**: 時間をクリップ相対でなくタイムライン絶対秒で持つ(`_s.texts`)。テキスト帯のドラッグ移動(クリップをまたげる)と端ドラッグでのリサイズに対応。旧形式プロジェクト(クリップ内`texts`)は読込時に絶対秒へ自動変換する(変換処理は実機未検証)。クリップ複製ではテキストは複製されなくなった。
|
||||
- **追加サウンド**: オーディオトラックに「+ サウンド...」で効果音・ナレーションを何個でも配置(開始/長さ/音量dB、帯のドラッグで移動・端ドラッグでトリム)。書き出しは`LoadAudio→TrimAudioDuration→AudioAdjustVolume→AudioConcat(無音パディング)→AudioMerge`でBGMの上にミックス。実際のComfyUIで実行成功を確認(音の位置自体は耳では未確認)。
|
||||
- **オーバーレイ(PinP)**: 動画/画像をメイン動画の上に重ねる。時間・位置(9方向プリセット/X/Y)・サイズ・不透明度を個別指定。追加位置は4隅→中央の順にずらす。プレビューには点線枠+サムネイルを表示。Assetタブの詳細パネルに「オーバーレイに追加(PinP)」ボタンを追加。
|
||||
- **合成方式**: ComfyUIグラフには手を入れず、既存のテキスト焼き込みと同じPyAV 1パス(`/api/wfm/video/edit/overlay-text`に`pips`を追加)で、オーバーレイ→テキストの順に描画。動画オーバーレイは終端フレームを保持。オーバーレイ自身の音声はミックスしない。実際の書き出し出力のフレームを目視確認。
|
||||
- Undo/Redo・プロジェクト保存/読込・ヘルプ(3言語)に対応。
|
||||
|
||||
### フルバックアップ: 追加サウンド/オーバーレイの素材漏れを修正
|
||||
|
||||
「Video Editプロジェクトの素材を含める」が、クリップとBGMしか集めていなかったため、追加サウンドとオーバーレイクリップの素材がZIPに入らなかった(`_iter_video_edit_media_refs`が`clips`とBGMだけを走査)。`sounds`/`pips`も走査するよう修正。単体テストと実APIでの出力ZIPの中身確認済み(パストラバーサル・出力フォルダ参照・旧形式・壊れたJSONの扱いも確認)。復元側は無変更。
|
||||
|
||||
### 実機で分かったこと
|
||||
|
||||
- **ファイル選択ダイアログ**: Overlay追加ボタン(label+非表示input、BGMと同方式)が、ユーザーの通常ブラウザタブではダイアログを出さず、Shift+クリックの別ウィンドウでは出た。コードの欠陥ではなく通常タブ環境側の可能性が高い(未特定)。念のためOverlay/Soundボタンは透明な本物の`<input type=file>`をボタンに重ねる方式に変更。Playwrightはダイアログを横取りするため、この種の不具合は自動テストでは検出できない。
|
||||
- 検証用ブラウザに待機中のファイル選択が溜まると、ページが古い状態のままになりうる(サーバーは`Cache-Control: no-store`)。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.7(2026-09-30)
|
||||
|
||||
### 意思決定モデル: Ollama 0.35+ バックエンド追加
|
||||
|
||||
Ollama v0.35.0(2026-09-28)が`POST /v1/systemone`で意思決定モデルに対応した(質問・回答の形式はUnsloth/ollayaと同じ)。モデルは`tev1`(Together AI、4B・4.5GB)/ `tev1:0.8b`(812MB)/ `nimble`(Bespoke Labs、9B・9.5GB)で、`ollama pull`で取得する。
|
||||
|
||||
- `decision-client.js`: バックエンド`ollama`を追加。APIキー不要でCORSも許可されているため、Unslothのようなサーバー中継は通さずブラウザから直接呼ぶ(既存のOllama LLMバックエンドと同じ)。Ollamaのエラー`{"error": ...}`(例: `model "tev1" not found, try pulling it first`)はそのまま表示。
|
||||
- `listDecisionModels()`: `/api/tags`の`capabilities`に`"decision"`を持つインストール済みモデルだけを返す(通常のLLMは出ない)。
|
||||
- Settings「意思決定モデル」: バックエンド切替でURL(11434)とモデル一覧を更新、↻ボタン、意思決定モデルが無い場合は`ollama pull tev1:0.8b`等を案内。Laya専用でなくなったためセクション名を「意思決定モデル」に変更。保存済みモデル名が別バックエンドのもの(Ollamaなのに`laya`等)なら既定モデルへ戻す。
|
||||
- **実測**: Layaが外した画像プロンプト(「masterpiece, 1girl, silver hair, school uniform…」)の判定を、`tev1:0.8b`は主題person 0.98〜0.99・NSFW「none」0.97〜0.98と全問正解。2回目以降0.2〜0.6秒。ユーザー実機のテストボタンで「接続成功 ✓ 280 ms、yes=0.914」。
|
||||
- **作業中の不具合**: Pythonスクリプト経由で書いた正規表現の`\b`がバックスペース制御文字(0x08)に化け、モデル名補正が効かなかった(構文エラーにならず単体テストで発覚)。修正後、リポジトリ全体に制御文字が無いことを確認。
|
||||
|
||||
### Unsloth: APIキー未設定時はキー無しで送信(Keyless API access対応)
|
||||
|
||||
Unsloth Desktopの Settings → API → Keyless API access →「Chat and inference」をオンにするとキー無しで使えるため、`UNSLOTH_API_KEY`未設定を許容した。
|
||||
|
||||
- 対象は中継(`py/routes/unsloth_routes.py`: AI TOOL・サイドパネル・意思決定API)とTaggerのUnsloth VLM経路(`py/services/tagger_service.py`)の2か所。キー未設定なら事前エラーにせず`Authorization`無しで送る。localhost限定(SSRF対策)は維持。
|
||||
- キー無しでUnslothが401を返した場合は「.envにキーを設定するか、Keyless API access →『Chat and inference』をオンに」という案内を返す。
|
||||
- 偽Unslothサーバー(キー必須/不要)で、キー無し成功・案内メッセージ・キー付与・非localhost拒否を検証(5回連続合格)、ユーザー実機でも確認。
|
||||
- ヘルプ・README・`.env.example`に、この設定は同じLAN内にもAPIを開放すること、「Allow tools」(キー無しの接続元にPython/ターミナル実行を許す)はオフのままにすることを明記。
|
||||
- **実機で発生した不具合**: 英語ヘルプ文に`"Chat and inference"`をエスケープせずに入れ、`i18n.js`が構文エラーになりSPA全体が「Loading workflows...」で停止した。この環境では`node --check x.js`がESモジュールの構文エラーを見逃す(終了コード0)ことが原因で、以後は`.mjs`にコピーして`node --check`する方式で確認している。
|
||||
|
||||
### ヘルプ(英日中)の点検と修正
|
||||
|
||||
Settings・Video(Edit)ページを3言語で点検。言語間の内容差は無かったが、3言語共通の記載漏れ・不正確な点を修正した。
|
||||
|
||||
- Video Edit: Saveで保存される内容に、クロップ・テキストオーバーレイ・音声/BGM設定を追記(従来は順序・トリム・参照ファイルのみ記載)。本文に出ていた内部キー名「(see helpVideo9)」を「Projectタブの項目を参照」に。素材はinput内ファイルへの参照のみであること、フルバックアップの素材オプションで保存できることを追記。
|
||||
- Settings: 未記載だった5セクション(Video Volume、Gallery出力フォルダ、デフォルトCheckpoint、CivitAI APIキー、G'MIC-Qtパス)をhelpSettings18〜22として追加。
|
||||
- フォント数「Google Fonts 16種」→「15種+システム既定」に訂正。
|
||||
- テンプレート(index.html)の英語初期表示のうち古くなっていた項目を、Settings・Video全項目についてi18nの英語と同期。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.6(2026-09-30)
|
||||
|
||||
### Settings: 意思決定モデル (Laya) の専用設定
|
||||
|
||||
v0.7.5で入れた`decision-client.js`は接続先を「AI TOOLのバックエンドがUnslothならそのURL、それ以外は8888」とAI TOOL設定から借りていたため、LLM/VLMと併用するとURL変更が連動する・モデルやしきい値を選べない問題があった。Settingsタブに独立したセクションを新設し、`wfm_decision_settings`(backend / baseUrl / model / threshold)としてAI TOOL・Tagger設定とは別に保存する。
|
||||
|
||||
- backendは現状Unslothのみだが、同じTypeSafe互換APIを話すollaya(:11435、キー不要)等を後から足せるようフィールドとして持つ。
|
||||
- モデル: `laya`(Unsloth側で選択中)/ `laya-multilingual` / `laya-english` / `laya-typed-decisions`。
|
||||
- 既定しきい値(0.5〜0.99、既定0.8): `isYes`/`pickChoice`/`pickScore`でしきい値省略時に使う。
|
||||
- テストボタン: 保存前のフォーム値でnoul1問を送り、応答時間と回答を表示(`testDecisionConnection()`)。
|
||||
- **実機確認**: 初回はUnsloth側で Settings → API → Decision API → Serve requests がオフのため HTTP 404 `The Decision API is off`(中継・APIキーは正常に通っている)。オン後は「接続成功 ✓ 10430 ms、テスト回答 yes=0.945」(初回はモデル読み込み込み)。
|
||||
|
||||
### Settings: フルバックアップのVideo Edit対応
|
||||
|
||||
Video Editのプロジェクト(`video_edit_project/*.json`)はデータフォルダ内なので元からZIPに入っていたが、中身はComfyUIの`input/`にアップロードしたクリップ・BGMへの参照(`serverRef`)だけで、素材そのものは含まれていなかった。別環境への復元や`input/`整理後はプロジェクトを開いてもクリップが読み込めない。説明文にもVideo Editの記載がなかった。
|
||||
|
||||
- 3つ目のオプション「Video Editプロジェクトの素材を含める」(`include_video_media=1`): 全プロジェクトの`clips[].serverRef`と`audio.bgm.serverRef`(type=input)を`_video_edit_media/<subfolder>/<filename>`に同梱。重複・欠損は除外し、`input/`の外を指す参照(`..`等)は含めない。
|
||||
- 復元時、`_video_edit_media/`はデータフォルダではなく`input/`へ戻す。同名ファイルが既にあれば上書きしない(`input/`は他のワークフローと共有のため)。結果に`media_restored`/`media_existing`を返し「Video Edit素材: ○件復元、○件は既存」と表示。エクスポート専用の`_external/`とは扱いが異なる。
|
||||
- 説明文(Settings注記・ヘルプ・README)のバックアップ対象にVideo Editプロジェクトを追加。
|
||||
|
||||
**実機で判明した既存問題(Request Entity Too Large)**: aiohttp 3.14の`BodyPartReader.read()`はComfyUIの`--max-upload-size`(既定100MB)を強制する。workflows/wildcardオプション込みのバックアップは約125MB(`_external/`だけで118MB)で超過し復元できなかった(今回の追加以前から起こり得た)。`handle_import_full`を`read_chunk()`で1MBずつ一時ファイルへ流し込む方式に変更し、上限に関係なく復元できるようにした(ZIP全体をメモリに載せることもなくなった)。
|
||||
|
||||
**検証**: 一時フォルダでの単体テスト(素材収集・重複/欠損除外・パストラバーサル遮断・既存非上書き)、上限1MBのaiohttpテストサーバー(ComfyUIと同じ3.14.3)への5MBアップロード、ユーザー実機で「インポート完了: 45件 / Video Edit素材: 1件復元、3件は既存」を確認。
|
||||
|
||||
### ヘルプ
|
||||
|
||||
Settingsページの「Connections」カードに意思決定モデルの項目(helpSettings17、英日中)を追加。フルバックアップの項目(helpSettings15/16)にVideo Editプロジェクトと素材オプションを追記し、テンプレート側の英語初期表示もi18nと同期(15はVideo Plansが抜けたままになっていた)。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.5(2026-09-30)
|
||||
|
||||
### Ming Image ワークフロー対応(GenerateUI / Gallery・Metadata / サイドパネルIタブ)
|
||||
|
||||
ユーザー提供の`ming_image_test1〜5.json`(T2I・参照画像Edit・レイヤー分解×2・標準KSampler T2I)に対応。
|
||||
|
||||
- `comfyui-workflow.js`: `TextEncodeMingImageEdit`(`prompt`単一ウィジェット+`images.image_N`最大8枚のAutogrow参照画像)を`TextEncodeQwenImageEdit`と同じ扱いで検出。ロールはsampler/guider配線から決まるので、レイヤー分解ワークフローでKSamplerのnegativeに繋がる空プロンプトの2つ目のインスタンスはNegativeとして出る。linked promptへのロール伝播も追加。`EmptyQwenImageLayeredLatentImage`をLatentパネル対象に追加。
|
||||
- SamplerCustomAdvancedのSAMPLER入力が`sampler_name`を持たないノード(`SamplerLCM`)の場合は`samplerNodeId=null`とし、SettingsパネルのSampler欄をN/A無効化(従来は無関係なsampler名が選択表示され、Applyで存在しない入力に書き込んでいた)。
|
||||
- `metadata-tab.js` / `node_sets_menu.js`: `BasicGuider`の`conditioning`入力をpositive扱い(従来は判別不能テキスト扱い)。UI形式でCLIPTextEncodeの`text`がリンクされている場合、`widgets_values[0]`に残る古い入力値よりリンク先(`PrimitiveStringMultiline`等)を優先。
|
||||
|
||||
**汎用バグ修正(top-levelノードのwidgets_valuesズレ)**: ウィジェットをリンク入力化しても現行フロントエンドは値を`widgets_values`に残す(legacy full形式)。サブグラフ内部ノードは`_stripLegacyLinkedWidgetValues`で正規化していたが、top-levelノードは未対応で、リンクされたウィジェットより後ろの値が1つずつズレていた(`EmptyLatentImage`のwidth/heightを`GetImageSize`/`PrimitiveInt`に繋ぐと`batch_size=1024`、`EmptyQwenImageLayeredLatentImage`の`layers=1024`等)。判定ロジックを`_normalizeLinkedWidgetValues`に切り出し、`convertUiToApi`の本ループ前にも適用。
|
||||
|
||||
**検証**: Node上で稼働中ComfyUIの`/object_info`を使い3系統(analyzeWorkflow / metadata-tab / node_sets_menu、UI形式・API形式両方)を5ワークフローで確認。回帰確認として`user/default/workflows`配下366件を修正前後の`convertUiToApi`で比較し、差分35件はすべてズレていた値が正しくなったもの(KSampler `steps=397027830130951`→10、`cfg=25`→8等)。変換エラー1件は修正前から発生する無関係なもの。ユーザー実機で生成まで確認済み。
|
||||
|
||||
### Unsloth Decision API(Laya決定モデル)クライアントの土台
|
||||
|
||||
Unsloth DesktopがTypeSafe互換の意思決定API(`POST /v1/systemone`、Laya)を提供するようになったため、活用機能の試行に先立って呼び出しの土台のみ実装(UIからの利用は未実装)。
|
||||
|
||||
- `py/routes/unsloth_routes.py`: 既存プロキシ(`.env`のAPIキー付与+localhost限定)の`_ALLOWED_PATHS`に`/v1/systemone`を追加。HTTPエラー時はUnsloth側エラー本文の先頭300字をmessageに含める(質問スキーマ誤りをフロントから診断できるように)。
|
||||
- `static/js/decision-client.js`(新規、SPA用): `decide(state, questions, {model, baseUrl})`・`decideMany`(同時実行数制限、個別失敗は`{error}`)、ビルダー`noul`/`choice`/`score`(Unslothの`instructions`+`criteria`形式)、上限チェック(64問/255選択肢/10段階)、`isYes`/`pickChoice`/`pickScore`。ドキュメントの推奨どおり`confidence`ではなく`probabilities`でしきい値判定し、しきい値未満は`confident:false`(自動適用せず提案表示する用途向け)。接続先はAI TOOL設定がUnslothならそのURL、それ以外は`http://localhost:8888`。
|
||||
- Layaはテキスト専用(画像入力なし)。画像の判定にはTaggerタグやVLMキャプションを経由させる必要がある。
|
||||
- 検証はプロキシをモックしたNode単体テストのみ(作業時にUnslothのDecision APIが待ち受けておらず実APIは未検証)。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.4(2026-09-29)
|
||||
|
||||
### Video Edit: BGM/音声トラック合成(Phase 4)— 複数クリップ結合時の音声消失も解消
|
||||
|
||||
[VIDEO_EDIT_TAB_PLAN.md](VIDEO_EDIT_TAB_PLAN.md)のPhase 4。着手前に既存の動画編集系ノードを調査し、ComfyUI Core 0.37.0の音声ノード群(`LoadAudio`/`TrimAudioDuration`/`AudioConcat`/`AudioMerge`/`AudioAdjustVolume`/`EmptyAudio`)と`ConcatenateVideo`の`complete_audio`入力(結合動画全体の音声を上書き)だけで、書き出しグラフ内で完結できると判明したため、計画にあった独自`mix-audio` APIは作らなかった。音声トラックの組み方はH3Studioのreel書き出し(クリップ音声を尺にconform→連結→musicを重ねる)を参考にした。
|
||||
|
||||
**グラフ構成**: クリップごとに`EmptyAudio(クリップ尺)`+`AudioMerge(自クリップの音声)`で尺ぴったりのセグメントを作り(`AudioMerge`はaudio2をaudio1の長さにpad/trimし、音声なしクリップ=Noneならaudio1をそのまま返す)、`AudioConcat`で連結→(元音量≠0dBなら`AudioAdjustVolume`)→BGM(`LoadAudio→TrimAudioDuration(開始位置, 総尺)→AudioAdjustVolume`)を`AudioMerge`で重ねて`complete_audio`へ渡す。単一の動画クリップで元音声そのまま・BGMなしの場合のみ従来通り再エンコードなしでパススルー。
|
||||
|
||||
**UI**: 書き出しパネルにオーディオ欄(元の音声を残す+音量dB、BGMファイル、BGM音量、BGM開始位置)。BGMは「BGM選択...」ボタン、オーディオ欄への音声/動画ファイルのドラッグ&ドロップ、AssetサブタブのVideoに追加した「BGMに設定(音声のみ)」(動画の音声トラックを使う。Galleryは音声ファイルを扱わないため)で指定できる。▶プレビュー中はBGMも`<audio>`で同時再生。クリアボタンでオーディオ欄も初期状態に戻る(BGMのみ設定されている状態でもクリア可能)。
|
||||
|
||||
**実機で判明した注意点**: 生の`Video Slice`出力と画像由来の`CreateVideo`クリップを`ConcatenateVideo`へ直接混ぜると`Video chunk N could not be encoded compatibly`(extradata/color space不一致)で失敗する——complete_audioの有無に関係なく。既存の「複数クリップ時は全クリップを`GetVideoComponents→CreateVideo`で再構成する」処理は音声対策だけでなくこのためにも必要だったので維持した。
|
||||
|
||||
**検証**: 動画2本(片方トリム)+静止画+BGM(-6dB)をUIから書き出し、尺6.625秒・音声同尺、区間ごとのRMSで元音声+BGM/BGMのみを確認。音声なし動画(audio=None)+BGM、mp4をBGMにした書き出しも確認。
|
||||
|
||||
### Video Edit: テキストオーバーレイ(Phase 3)
|
||||
|
||||
クリップ単位のテキスト(文字・クリップ内の開始/終了秒・サイズ(フレーム高さ%)・色・9方向アンカー・縁取り・背景)。クリップ相対時刻で保持するので並べ替えに追従し、書き出し時に絶対時刻へ変換する。
|
||||
|
||||
Core `TextOverlay`は上/下固定・区間指定なし・既定フォントがCJK非対応、既存のテキスト系カスタムノード(advanced-textoverlay等)も静止画向けか独立パイプラインで、かつ全フレームをIMAGEテンソル化するとメモリが厳しい(720p×720フレームで約8GB)ため、新設の`POST /api/wfm/video/edit/overlay-text`(`VideoService.overlay_text_on_video()`、`asyncio.to_thread`)でPyAVストリーム処理にした: 1フレームずつデコード→該当区間のテキストレイヤー(事前に1回だけ描画)をPillowで合成→libx264でエンコード、音声パケットは再エンコードせずリマックス。書き出しはグラフ出力を`wfm_edit_pre`中間ファイルに保存→焼き込み→中間ファイル削除(`wfm_edit_pre`接頭辞かつoutput内のみ削除対象)の2段構成。フォントはmeiryo→YuGothM→NotoSansJP→msgothic→arialの順に探索。プレビューはSource/Result枠上にCSSで近似表示(`video-preview.js`に`getPreviewPaneElements()`を追加)。
|
||||
|
||||
**検証**: ComfyUI再起動後、動画+画像クリップに英日テキスト2件を付けて書き出し、各時刻のフレームで表示/非表示・位置・日本語描画・背景、音声長の一致、中間ファイル削除を確認。
|
||||
|
||||
### Video Edit: クロップ(Phase 2)
|
||||
|
||||
動画クリップごとに正規化座標(0..1)のクロップ矩形を持ち(ComfyUI-LoadVideoCropと同じ方式)、Sourceプレビュー上で移動+四隅リサイズ(アスペクト比 自由/16:9/9:16/1:1/4:3/3:4固定可、外側は暗転)。「完了」で確定、「キャンセル」で編集前のクロップに戻す。Plan/Assetサブタブ・他タブへの移動、別クリップ選択時は「完了」扱いで編集モードを自動終了。書き出しはCore `VideoCrop`(0.37.0でもexperimental)を`Video Slice`の直後に挿入——`crop`入力は計画どおり二重ネスト`{"crop":{"x","y","width","height"}}`で、偶数ピクセルに丸める(H.264 4:2:0制約)。出力サイズは先頭動画クリップのクロップ後サイズで、サイズの異なる他クリップは`ImageScale(crop=center)`でフィット。解像度不一致ガードはクロップなしの動画クリップ同士だけ比較。`ImageCrop`は0.37でdeprecatedのため不採用。
|
||||
|
||||
**検証**: 608x352→300x200のクロップ出力が元フレームの同領域と一致(平均画素差3.1=コーデック誤差程度)。UIで1:1クロップしたクリップを先頭に書き出し、280x280・字幕がクロップ後の枠内・2本目が中央フィットされることを確認。
|
||||
|
||||
### Video Edit: Undo/Redo(Phase 5残り)
|
||||
|
||||
編集状態(クリップ順・トリム・保持時間・クロップ・テキスト・オーディオ設定)のJSONスナップショット履歴(上限100)。各編集経路に個別に仕込むのではなく、全編集が必ず通る既存の再描画3関数(`_renderTimeline`/`_refreshTextPreview`/`_syncAudioPanel`)から300msデバウンスで記録し、状態が変わったときだけpushする(選択・プレビュー・リサイズでは積まれず、連続入力やドラッグは1ステップにまとまる)。削除クリップはFile参照を持つ`_clipRegistry`から再アップロードなしで復元。↶/↷ボタンとCtrl+Z / Ctrl+Y / Ctrl+Shift+Z(入力欄内はブラウザ標準のundoを優先)。プロジェクト読込時は履歴リセット。
|
||||
|
||||
### その他
|
||||
|
||||
- **プロジェクト保存**にクロップ・テキスト・オーディオ設定を追加(旧プロジェクトは既定値で読込)。
|
||||
- **BGM選択・Loadボタンが実ブラウザでダイアログを開かない不具合を修正**: hidden `<input type=file>`をボタンのclickハンドラから`input.click()`で開く方式が、ユーザー環境ではエラーも出さずに無反応だった(同タブのVideo Sourceは`<label>`で包む方式で正常動作)。両ボタンを`<label>`方式に変更。Playwrightはファイル選択を横取りするため自動テストでは再現できなかった。
|
||||
- **トリム値入力直後に「+ テキスト追加」のクリックが効かない不具合を修正**(テキスト欄実装時に混入): blur時の`change`でテキスト欄全体を再構築し、押そうとしたボタンが置き換わっていた。上限値だけ更新する方式に変更。
|
||||
|
||||
**How to apply**: ComfyUI Coreの新しい動画/音声ノードは、`execution_success`で判断せず出力をprobe・画素比較まで実測すること(`VideoTrim`/`VideoCrop`の二重ネストのように、形式を誤ってもエラーにならず無視されるノードがある)。ファイル選択UIは`<label>`で包むネイティブ方式で作り、動作確認はユーザー実機で行うこと。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.3(2026-09-24)
|
||||
|
||||
### GenerateUIタブにQwen Image 2.1(TextEncodeQwenImage21)対応を追加
|
||||
|
||||
Comfy-Orgのnightly workflowテンプレート`qwen_image2.1_t2i.json`系(`TextEncodeQwenImage21`ノードでprompt/negative_prompt/resolutionとimage_1〜image_10参照画像を一手に扱う、T2IとEdit兼用のsubgraph構成)をGenerateUIタブで使えるようにした。
|
||||
|
||||
**変更内容**: `comfyui-workflow.js`の`analyzeWorkflow()`に`TextEncodeQwenImage21`の分岐を追加。`TextEncodeBooguEdit`と同じ「1ノードにprompt/negative_prompt両方を持ち、ロールは固定(KSampler側の配線をたどらない)」パターンで`prompt_nodes`へ登録し、Input タブのPositive/Negative Prompt欄に反映されるようにした。あわせて`_getWidgetMapping()`に`TextEncodeQwenImage21: ["prompt", "negative_prompt", "resolution"]`を追加し、Settings欄からの書き戻しにも対応。
|
||||
|
||||
**調査で判明した既存アーキテクチャ**: GenerateUIのModel/Diffusion Model/Text Encoder/VAE/KSampler/EmptyLatentImage検出は元々ノードタイプ非依存の汎用ロジックなので、UNETLoader+CLIPLoader+VAELoaderの3分割ロード構成(Flux/Qwen Image系で共通)は無改修でそのまま動作した。また画像アップロードUI(Input タブのImageサブパネル)は`generate-tab.js`ではなく`comfyui-editor.js`が担当しており、`analysis.load_image_nodes`に載る全LoadImageノード(1個に限らず)に対して個別カードを描画する汎用実装だったため、Qwen Image 2.1の複数参照画像編集(image_1・image_2をそれぞれ別のLoadImageで接続したワークフロー)もコード変更なしでそのまま動作した——ユーザー提供のI2Iテストワークフローで実機確認済み。
|
||||
|
||||
**How to apply**: 新しい「1ノードでprompt+negative_prompt+複数画像入力」型の text-encoder ノードをGenerateUIに対応させる際は、まず`analyzeWorkflow()`のprompt_nodes検出だけ確認すればよい場合が多い——モデルロード・サンプラー・LoadImageカードは大抵ノードタイプに依存しない汎用ロジックが既にカバーしている。画像アップロードUIを探すときは`generate-tab.js`ではなく`comfyui-editor.js`を見ること(両者は別ファイル)。
|
||||
|
||||
関連: [[project_v071_unsloth_ssrf_fix_and_outer_gitignore]]
|
||||
|
||||
---
|
||||
|
||||
## v0.7.2(2026-09-18)
|
||||
|
||||
### Workflowタブにソート機能追加(Date/Name 昇順降順)
|
||||
|
||||
Workflowタブのグループフィルター右隣に、Galleryタブと同じパターンのソートドロップダウン(Date Newest/Oldest、Name A-Z/Z-A)を追加した。`wf.mtime`(バックエンドが既に返していたファイル更新時刻)を使ったクライアント側ソートで、選択状態は`localStorage`(`wfm_workflow_sort`)に永続化する。
|
||||
|
||||
**変更ファイル**: `templates/index.html`(`#wfm-sort` select追加)、`static/js/workflow-tab.js`(`sortWorkflows()`追加、`renderGrid()`で`filterWorkflows()`の結果に適用)
|
||||
|
||||
### Videoタブ左ペイン幅調整・Editトリムパネルのバグ修正とUI調整
|
||||
|
||||
**左ペイン幅**: PlanサブタブのフォームパネルをGenerateUI寄りの380px固定から253px(約2/3)に縮小し、中央プレビュー/タイムラインを広げた(`.wfm-video-form-panel`の`width`のみの変更、`flex:1`の中央パネルが自動で広がる)。
|
||||
|
||||
**「現在位置を使用」ボタンでトリムスクラバーの見た目が更新されないバグを修正**: トリムのStart/End数値入力とスクラバー(ドラッグハンドル・ハイライト範囲)は同じ`commit()`関数を経由する設計だが、`commit()`はスクラバー自身の`updateRange()`(ハンドル位置・ハイライト帯の再描画)を呼んでいなかった。ハンドルをドラッグした場合のみ呼び出し元が直接`updateRange()`を叩いていたため気付かれておらず、数値入力や「現在位置を使用」ボタンではclipのtrimStart/trimEndは正しく更新されるのにスクラバーの表示だけ古いままになっていた。`_wireTrimScrubber()`が`updateRange`関数自体を返すようにし、`_renderTrimPanel()`側で`commit()`から必ずそれを呼べるようにして解消。
|
||||
|
||||
**トリムスクラバーのバッジ重なりを解消**: 開始/終了/再生位置の時刻バッジ(ハンドルから浮き上がるラベル)がクリップ名やルーラー目盛りと重なる、また再生位置がトリム開始/終了に近いとStart/EndバッジとPlayheadバッジ同士が重なって読めなくなる不具合をユーザーが発見。クリップ名とスクラバーの間隔を拡大、スクラバー上部にバッジ専用の余白(`padding-top`)を確保、トラック高さを36px→52pxに拡大した上で、Playheadバッジのみ`top`をStart/Endバッジより高い位置(2段目)にずらして重なりを回避した。
|
||||
|
||||
**変更ファイル**: `static/css/video-tab.css`(`.wfm-video-form-panel`, `.wfm-video-trim-scrubber/-track/-badge/-badge-playhead`)、`static/js/video-edit-tab.js`(`_wireTrimScrubber()`が`updateRange`を返すよう変更、`_renderTrimPanel()`の`commit()`から呼び出し、クリップ名/トリム行の余白調整)
|
||||
|
||||
**How to apply**: 同じ状態変更を複数の入力経路(数値入力・ボタン・ドラッグ)から行える場合、各経路が同じ「見た目を再描画する関数」を実際に呼んでいるかを確認すること——1経路(ドラッグ)だけがたまたま直接呼んでいたために、他の経路のバグが長く気付かれずに残っていた。
|
||||
|
||||
### Video Assetタブに動画/画像フィルタドロップダウン追加
|
||||
|
||||
Asset切替ドロップダウン(`__Video Assets__`/`__Video Temp__`/All Video Assets)の右隣に、幅を約半分にした動画/画像タイプフィルタ(All Types / Video Only / Image Only)を追加。既存の`isVideoFile()`判定を再利用してクライアント側でリストを絞り込む。
|
||||
|
||||
**変更ファイル**: `templates/index.html`(`#wfm-video-asset-kind` select追加)、`static/js/video-asset-tab.js`(`_s.kindFilter`追加、`_filteredImages()`で適用)
|
||||
|
||||
### Video Edit プロジェクト永続化(Phase 5)とサイドバーProjectのPlan/Editモード切替
|
||||
|
||||
[VIDEO_EDIT_TAB_PLAN.md](VIDEO_EDIT_TAB_PLAN.md)のPhase 5(永続化)を実装。他の未着手フェーズ(クロップ/テキストオーバーレイ/BGM合成)と比べ、`video_plan_service.py`と同型のCRUDパターンをほぼそのまま流用できるため最軽量(見積もり1〜1.5日相当)と判断し、最初に着手した。
|
||||
|
||||
**バックエンド**: `video_plan_service.py`と同じ形(パス安全性チェック付きlist/get/save/delete、`updated_at`自動付与)の`VideoEditProjectService`を新設し、`video_edit_project/`ディレクトリに`ws_videoeditproj_*.json`として保存する。`video_plan_service.py`との共通base化は行わず独立クラスのまま(両者のスキーマは無関係なので、無理に共通化しても行数が減るだけで得るものがないという既存の`VideoPlanService`のドキュメント方針を踏襲)。
|
||||
|
||||
**保存対象**: タイムラインのクリップ順序・トリム区間・各クリップが参照するアップロード済みサーバーファイル(`serverRef`)のみ。アップロード未完了のクリップは保存対象から除外する。
|
||||
|
||||
**読込方式**: 保存されたクリップをComfyUIの`/view`エンドポイントからBlobとして再取得し、`File`化した上で既存の「クリップ追加」経路(`addClipFromFile()`→アップロード→probe)にそのまま通してから、保存済みのトリム値を上書き適用する方式にした。`serverRef`とメタデータを信頼してプレビュー生成ロジックを別に書く(再アップロードを省く)選択肢もあったが、既存の十分テスト済みの1経路だけを通ることを優先し、実装量とリスクを抑えた。
|
||||
|
||||
**サイドバーProjectタブの切替**: 中央のPlan/Editどちらのサブタブがアクティブかを`video-tab.js`が`_centerSubtabMode`として記憶し、サイドバーのProjectパネルを開く/切り替えるたびにそのモードを渡すようにした。`video-project-tab.js`は元々Video Plan専用だったリスト表示・削除ロジックを、モードごとの設定テーブル(取得/削除エンドポイント、開く関数、件数ラベル等)に切り出して汎用化。Editモードでのアイテムクリックは新設した`video-edit-tab.js`の`openSavedVideoEditProject()`(`video-plan-tab.js`の`openSavedVideoPlan()`と同型)に委譲する。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`py_compile`で構文確認、開発元リポジトリと実行時`custom_nodes`フォルダ間でファイルのdiffが空になることを確認して同期。Python側(新規サービス・ルート追加)はComfyUI完全再起動が必須(インポートは起動時一度きりのため)である旨をユーザーに案内済み。
|
||||
|
||||
**変更ファイル**: `py/services/video_edit_project_service.py`(新規)、`py/config.py`(`VIDEO_EDIT_PROJECT_DIR`)、`py/routes/video_routes.py`(`/api/wfm/video/edit/projects`系4エンドポイント)、`static/js/video-edit-tab.js`(`_buildProjectData()`/`_saveProject()`/`_restoreClipFromSaved()`/`_loadProjectData()`/`openSavedVideoEditProject()`、Save/Save As/Loadボタン配線)、`static/js/video-project-tab.js`(`_MODES`設定テーブルによるPlan/Edit汎用化)、`static/js/video-tab.js`(`_centerSubtabMode`追跡)、`templates/index.html`(Save/Save As/Load UI)、`static/js/i18n.js`(新規メッセージキー・ヘルプ本文更新、英/日/中3言語)
|
||||
|
||||
**How to apply**: 新機能の永続化を設計する際、対象データを直接シリアライズしてサーバー参照込みで復元しようとすると(プレビュー生成・probe結果の整合性など)別経路のロジックを新たに書く必要が出やすい。既存の「新規追加」経路がテスト済みで十分軽ければ、保存データを最小限(サーバー参照+差分パラメータ)に絞り、読込時はその経路をそのまま再利用する方が実装・保守コストが低い。
|
||||
|
||||
関連: [[project_video_edit_tab_feature_branch]]
|
||||
|
||||
---
|
||||
|
||||
## v0.7.1(2026-09-17)
|
||||
|
||||
### セキュリティ修正: Tagger VLM の Unsloth 経路でAPIキーがSSRFにより外部送信され得る不備を修正
|
||||
|
||||
セキュリティレビューで、Unsloth Desktop連携の2つの経路のうちAI TOOLタブ用プロキシ(`unsloth_routes.py`)にはリクエスト先ホストを`localhost`/`127.0.0.1`/`::1`に制限するallowlistが入っていたが、Taggerタブ VLM経路(`tagger_service.py`の`vlm_models`/`vlm_predict`、`backend=="unsloth"`時)には同等の検証が無く、クライアントがリクエストごとに指定する`api_url`へそのまま`Authorization: Bearer <UNSLOTH_API_KEY>`を送信してしまう実装になっていた。ComfyUIを`--listen 0.0.0.0`等で外部公開している環境では、設定変更なしにこの経路経由でAPIキーが任意の外部ホストへ流出し得る。`unsloth_routes.py`と同じallowlistロジックを`tagger_service.py`側にも追加し、loopback以外の`api_url`はキーを付与せずエラーを返すようにした。
|
||||
|
||||
副次的に、G'MIC-Qt起動(`gmic_routes.py`)の`gmic_qt_path`検証も、存在チェックのみだったのを絶対パス・`.exe`拡張子・実ファイルであることの検証に強化した(`shell=True`は使用しておらずコマンドインジェクションではないが、外部公開環境で設定変更と組み合わさった際のローカル実行リスクを下げる防御的対応)。
|
||||
|
||||
**How to apply**: 同一サービス(Unslothなど認証キーを要する外部API)へ複数の呼び出し経路がある場合、ホスト制限などのセキュリティ検証はルート/サービス単位ではなく呼び出し経路ごとに漏れなく入れる必要がある。片方だけ実装して安心しないこと。
|
||||
|
||||
## v0.7.0(2026-09-17)
|
||||
|
||||
### プレビュー枠の高さ固定・AssetサブタブをPlan/Editと並ぶ中央下部へ移設・トリムパネルにClipchamp風スクラバー追加・Frame保存のVideo Assets登録とAsset→Plan画像連携
|
||||
|
||||
Edit サブタブMVP実装後、実際にユーザーが使う中で見つかった調整要望をまとめて対応した。
|
||||
|
||||
**プレビュー枠の高さ固定**: 中央ペインのSource/Resultプレビュー枠が`min-height`指定のみだったため、動画ソースのネイティブサイズ(特に縦長)によって枠が伸縮し、下のPlan/Editサブタブの表示位置がソースを選ぶたびに動いてしまっていた。枠を`height:260px`固定にし、動画/画像側は`width/height:auto; max-width/max-height:100%`(ネイティブサイズが枠より大きい場合のみ縮小、小さい場合は拡大しない)に変更して解消。
|
||||
|
||||
**AssetサブタブをPlan/Editと並ぶ中央下部の第3サブタブへ移設**: 左サイドバーにあったAsset一覧・詳細パネルを中央ペイン下部(Plan/Editと同じ場所)へ移設し、左=一覧(サムネイル/テーブル切替可)・右=選択ファイルの詳細という2カラムレイアウトに再構成。一覧上部にグループ切替・ファイル名検索・表示切替(▦グリッド/☰テーブル)ボタンを追加した。左サイドバーはProjectのみのシンプルなパネルになった。
|
||||
|
||||
**トリムパネルにClipchamp風ビジュアルスクラバーを追加**: 動画クリップのトリム編集がテキスト入力(開始/終了秒)のみだったのを、時間目盛り(ルーラー)・Sourceプレビューの`currentTime`と同期するドラッグ可能な再生ヘッド・時刻バッジ付きのドラッグ可能なイン/アウトハンドルを備えたビジュアルなスクラバーに拡張した。全位置を`clip.duration`基準の%指定にすることでpx/秒換算の管理を持たずに済ませている。既存のStart/End数値入力・「現在位置を使用」ボタンとは同じ`commit`関数を通して連動する。ハンドルドラッグ中は`_renderTimeline()`の再描画を避け、pointerup時のみ確定してパフォーマンスに配慮。playheadとトリムハンドルが同じ位置に重なってもハンドル操作を優先できるようz-indexを調整した。
|
||||
|
||||
**Frame保存のVideo Assets登録とAsset→Plan画像連携**: 右ペインFrameタブの「Save to Output」に「Add to Video Assets group」チェックボックスを追加し、チェック時は保存したフレームをGalleryの`__Video Assets__`グループへ自動登録できるようにした。またAssetタブの画像アセット詳細に「Set as First Image」「Set as Last Image」ボタンを追加し、PlanタブのFirst/Last Imageへ直接画像をアップロードできるようにした(動画クリップでは非表示、選択中のPlanブロックへ反映後Planサブタブへ自動切り替え)。
|
||||
|
||||
**検証**: いずれもKaptureで実機ComfyUI_5(ポート8189)に接続し、正方形/縦長動画でのプレビュー枠固定確認、Asset新レイアウトでのグリッド/テーブル切替・検索・選択、トリムスクラバーのplayhead同期・トラッククリックシーク・ハンドルドラッグ(playhead重なり時含む)・複数クリップ切替時のリスナー管理、Frame保存後のGalleryグループ登録をAPI経由で確認、Asset画像からFirst/Last Imageへの反映まで一通り実機確認済み。
|
||||
|
||||
**How to apply**: プレビュー枠のようにコンテンツ依存でサイズが変わる要素が、隣接するUIの表示位置に影響する場合は、コンテンツ側ではなく枠側を固定サイズにしてコンテンツを内側で縮小表示する方が安全([[project_video_edit_tab_feature_branch]])。ドラッグ可能なUI要素同士が同じ座標に重なる可能性がある場合は、操作頻度の高い方(今回はトリムハンドル)のz-indexを優先し、もう一方の当たり判定に埋もれないようにする。
|
||||
|
||||
関連: [[project_video_edit_tab_feature_branch]]
|
||||
|
||||
### Video タブ Edit サブタブ: 簡易動画編集機能(クリップタイムライン・トリム・結合・書き出し)を新規実装
|
||||
|
||||
これまで「Editing tools coming soon」のプレースホルダーのみだったVideoタブのEditサブタブに、Adobe Premiereライクな簡易動画編集機能を実装した。詳細設計・調査結果は[VIDEO_EDIT_TAB_PLAN.md](VIDEO_EDIT_TAB_PLAN.md)にまとめてある。
|
||||
|
||||
**MVP機能**: クリップのタイムライン配置(左詰め、実秒数×固定px/秒スケール)、トリム(開始/終了秒)、複数クリップの結合書き出し、タイムライン全体の疑似連続プレビュー、静止画/アニメーション画像クリップ(表示時間指定)、Clear/複製/削除/並べ替え(◀▶ボタンおよびネイティブドラッグ&ドロップ)。新規ファイル`static/js/video-edit-tab.js`が中心実装。
|
||||
|
||||
**Phase 0実機検証での重要な発見(当初の設計を訂正)**:
|
||||
- ComfyUI Core 0.35〜0.36には`VideoSlice`という名前のノードは存在せず、正しいclass_typeは`"Video Slice"`(スペース込み)。`VideoTrim`(experimental)の`trim`入力はフラット値`{"start_time":..}`を渡すと**エラーにならず黙って何もトリムしない**(`.get("trim")`で一段余計にネストを剥がす実装のため)。実際に出力duration をprobeして初めて発覚し、「`execution_success`だけでは検証不十分」という教訓を得た。
|
||||
- `ConcatenateVideo`のAutogrow入力`videos`は、ネストしたJSONオブジェクトではなく`"videos.video0"`のようなドット結合フラットキーがinputsのトップレベルに必要(ComfyUI Core `Autogrow.TemplatePrefix`のドット結合ロジックに由来)。
|
||||
- `ConcatenateVideo`は解像度不一致で実行時ハードエラーになるため、クライアント側で事前チェックし、動画クリップ同士の不一致は書き出しをブロック、画像クリップは`ImageScale`(crop=center)で自動フィットして回避。
|
||||
- 動画クリップ(音声あり)と画像由来クリップ(無音)を結合すると`ConcatenateVideo`が音声レイアウト不一致でエラーになる。複数クリップ結合時のみ、全クリップを`GetVideoComponents`→`CreateVideo`で無音統一する対策を実装(単一クリップ書き出しは音声を保持。**複数クリップ結合時は音声が失われるのが既知の限界**)。
|
||||
|
||||
**静止画/アニメーション画像クリップ対応**: 既存のAsset一覧(`__Video Assets__`/`__Video Temp__`グループ)には元々ビデオ専用フィルタが無く画像も一覧されていたが、プレビューが常に`<video>`要素前提で画像では何も表示されなかった。`video-preview.js`のSource/Resultプレビュー枠に`<img>`コンパニオン要素を追加し、`mediaType`引数で切り替えるよう拡張。画像クリップは`LoadImage`→(必要時)`ImageScale`→`RepeatImageBatch(amount=表示秒数×24fps)`→`CreateVideo`で正確な長さの動画セグメントに変換して書き出しグラフに組み込む。
|
||||
|
||||
**UI設計はPlanサブタブに合わせて再設計**: 当初は縦リスト+各行ボタンだったが、ユーザー指示によりPlanタブの横トラック+共有ツールバー方式(◀▶/複製/削除/クリアが選択中クリップに対して働く)に変更。タイムライン自体はPlanのブロックのようにflex-growで幅一杯に引き伸ばさず、実秒数の固定幅で左詰め(空きトラックはそのまま空く)。この過程で、Edit実装以前の「Editing tools coming soon」プレースホルダー時代の重複CSSルール(`align-items:center`等)が残存しており、実装後もタイムライン全体が中央に縮んで表示される不具合を発見・修正した。
|
||||
|
||||
**タイムライン連続プレビュー**: 新規`<video>`要素を追加せず、Export結果が最終的に書き出される「生成された動画」(Result)プレビュー枠を共有し、全クリップ(トリム区間反映)を順番に連続再生する機能を追加(「▶ プレビュー / ■ 停止」トグル)。ユーザー要望「このプレビューの動画=書き出しされる動画」に対応。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/構文チェックで確認。開発元リポジトリと実行時`custom_nodes`は別実体のため、変更のたびに両フォルダへ同期しmd5ハッシュ比較で完全一致を確認。Kapture(ブラウザDevTools連携)で実機ComfyUI_5(ポート8189)に接続し、クリップ追加・トリム・並べ替え(ドラッグ含む)・複製・削除・クリア・連続プレビュー・単一/複数クリップ書き出し・静止画クリップの書き出し・動画+画像混在結合を一通り実機確認済み。
|
||||
|
||||
**How to apply**: ComfyUI Coreの新しいノード(特にexperimental扱いのもの)をAPIプロンプト形式で直接呼び出す際は、`/object_info`のクラス名・入力スキーマを鵜呑みにせず、実際に`/prompt`で実行して**出力を実測**するまで検証を終えたとみなさない——`execution_success`は型検証を通っただけで、パラメータが意図通り効いたかは別問題(今回`VideoTrim`のトリムが黙って無視される形で顕在化)。Autogrow(`COMFY_AUTOGROW_V3`)型の動的入力は、フラットなドット結合キー(`"prefix{name}"`を`.`区切りでinputsトップレベルに並べる)で渡す。
|
||||
|
||||
関連: [[project_dev_deploy_sync]]
|
||||
|
||||
## v0.6.3
|
||||
|
||||
### Send CCボタンでMP4動画をComic Creatorの新設「動画ツール」へ送れるように対応
|
||||
|
||||
Comic Creator(`eagle_comic_creator_spa/comfyui-comic-creator`、別リポジトリ)側にレイアウトタブの新機能「動画ツール」(MP4をコマに配置して再生・停止・音量/ミュート調整・指定フレームでの静止画キャプチャができる実験的機能)が追加されたのに伴い、Galleryタブの「Send CC」ボタンをこれに対応させた。
|
||||
|
||||
**問題**: 既存の`sendCcBtn`クリックハンドラ(`gallery-tab.js`)は、選択中ファイルのURLを`window.parent.insertImageFromUrl()`(レイアウトタブ宛て)または`window.parent._ccImageTab.loadFromUrl()`(Imageタブ宛て)へ渡すだけで、`.mp4`を判別する分岐が無かった。Comic Creator側の受け口は画像専用ロジックしか持たないため、動画を選んでSend CCを押しても正しく処理されなかった。
|
||||
|
||||
**対応**: `gallery-tab.js`に既にあった`isVideoFile()`ヘルパー(拡張子判定、Video Assetsグループのバッジ表示等で使用中)で選択中ファイルが動画かどうかを判定し、レイアウトタブ宛て(デフォルト経路)で動画の場合はComic Creator側が今回新設した受け口`window.parent.insertVideoFromUrl(url, filename)`を呼ぶよう分岐した。Imageタブ宛て(`_ccI2ITargetMode === 'image'`)で動画を選んだ場合は、Comic Creator側が今回Imageタブの動画編集には対応していないため、トースト(新規i18nキー`galleryVideoNotSupportedInImageTab`、英/日/中3言語)で案内して送信を中断するようにした。
|
||||
|
||||
**検証**: 双方の変更点を構文チェック(`node --check`)のみで確認。ComfyUI実機でのSend CC往復動作(Gallery側でmp4選択→送信→Comic Creator側でのオーバーレイ表示・再生確認)はユーザー側での実機確認待ち。
|
||||
|
||||
**How to apply**: Comic Creator側とWorkflow Studio側は別リポジトリだが、Send CC・Send to LI nodeのような`window.parent`/`window.opener`経由のクロスウィンドウ連携APIは、どちらか一方だけを変更すると壊れる。片方(今回はComic Creator側)に新しい受け口(`insertVideoFromUrl`)を追加したら、呼び出し元であるこちらのGalleryタブ側も同時に更新する必要がある。
|
||||
|
||||
## v0.6.2
|
||||
|
||||
### Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
|
||||
|
||||
ユーザーから「Comic CreatorのImageタブ同様にこちらのImage Editタブをペンタブレット対応させたい」という依頼を起点に、Draw/Maskツールの筆圧対応、Fillツール(バケツ塗りつぶし)の移植、そして実機フィードバックを受けた追加のペンタブレット不具合修正まで一連の作業を行った。
|
||||
|
||||
**Draw/Maskツールの筆圧対応**: `image-edit-tab.js`のキャンバスイベントを`mousedown/mousemove/mouseup/mouseleave`から`pointerdown/pointermove/pointerup/pointerleave`に変更し、両canvasに`touch-action: none`を設定。`PointerEvent.pressure`(ペン=実際の筆圧、マウス=ボタン押下中は常に0.5)を`DrawTool`/`MaskTool`の`onMouseDown`/`onMouseMove`に伝播させ、両ツールに`pressureEnabled`(既定ON)を追加——筆圧に応じてブラシサイズ(0.3〜1.7倍)とDraw側は不透明度(0.4〜1.6倍)を可変化する。pressure=0.5(マウス)では従来と同じ1.0倍になるため、マウス操作の挙動は変わらない。実装はComic Creator(`eagle_comic_creator_spa`)のImageタブで先行実装済みだった筆圧対応パターンをそのまま移植した。
|
||||
|
||||
**Fillツール(バケツ塗りつぶし)を新規追加**: Comic CreatorのImageタブにあった`FillTool.js`(スキャンライン方式のフラッドフィル、Solid/Gradient(線形・放射状)両対応、particle_engine.js由来のグラデーション評価ロジック)をそのまま移植し、`image-edit-tab.js`にツール切り替え・アクティブ化・クリック処理・プロパティパネル(Mode/Color/Ramp/Direction dial/Tolerance/Opacity)を追加、ショートカット`G`を割り当てた。ツールバーのボタンは`templates/index.html`に静的HTMLとして定義されていたため個別追加が必要だった(`TOOL_DEFS`配列だけでは表示されない)。
|
||||
|
||||
**追加のペンタブレット不具合修正(ユーザー実機フィードバック)**: Fillツールのグラデーション設定UI——色ストップを並べた「Ramp」(ドラッグでストップ位置を変更)と方向・強度を指定する「Direction」ミニダイヤル——がペンタブレットでスライド操作できないという報告を受けた。原因はこの2つのミニウィジェットのドラッグ処理だけが`mousedown/mousemove/mouseup`のまま残っていたこと(`touch-action`未指定でブラウザのパン/スクロールジェスチャに奪われやすく、`document`に張った`mousemove`はポインタキャプチャが無いため速いペンの動きを取りこぼしやすい)。`_setupFillGradientRamp()`/`_setupFillGradientDir()`を`pointerdown/pointermove/pointerup/pointercancel`に変更し、`canvas.style.touchAction = "none"`と`canvas.setPointerCapture(e.pointerId)`を追加、ドラッグ中のリスナーも`document`ではなく`canvas`自身に張るよう変更した。
|
||||
|
||||
**Comic Creator側も同じ不具合を横展開で修正**: Fillツールの移植元であるComic Creator(`image-tab.js`)にも同一パターンの`_setupFillGradientRamp()`/`_setupFillGradientDir()`に加え、Shapeツールの塗りグラデーションランプ`_setupShapeGradRamp()`(同一コードパターンの複製)が存在し、同じ理由でペンタブレット操作ができない状態だった。3箇所とも同じPointer Events化を適用して修正した。
|
||||
|
||||
**検証**: Kaptureで実ブラウザ(ComfyUI_5上のWorkflow Studio `/wfm`、Comic Creator `/ccc`)を操作し、Draw/Mask/Fillそれぞれで新規キャンバス作成→クリック描画/塗りつぶし→コンソールエラーなし・レイヤーサムネイル即時更新を確認。Ramp/Directionダイヤルもクリックでのストップ選択・ハイライト表示が正しく動作することを確認した(ペン特有の筆圧・ドラッグ挙動そのものは自動化ツールでは検証できないため、筆圧の効き具合とドラッグ操作は実機ユーザー確認による)。Comic Creator側は開発フォルダがComfyUI custom_nodes配下にシンボリックリンクされているため、コピー不要で編集がそのまま反映される構成だった(Workflow Studio側は開発リポジトリと実行時custom_nodesが別実体のため、変更のたびに手動コピーが必要)。
|
||||
|
||||
**How to apply**: キャンバスベースの描画・ドラッグ操作を実装する際は、最初から`mousedown/mousemove/mouseup`ではなく`pointerdown/pointermove/pointerup/pointercancel`(Pointer Events)を使い、`touch-action: none`を設定する——マウス専用に見えるミニウィジェット(カラーランプ、方向ダイヤル等)であっても、後からペンタブレットで使われた際に同じ不具合が起きる。ドラッグ中の`move`/`up`リスナーは`document`ではなく対象の`canvas`自身に張り、`setPointerCapture()`を使うと要素境界を越えた高速な動きでも取りこぼさない。同一パターンのコードが複数箇所(ファイル間・同一ファイル内)に複製されている場合、1箇所の不具合報告を機に全箇所を横展開で確認する。
|
||||
|
||||
関連: [[project_v062_fill_tool_pentablet]], [[feedback_realtime_debug_logs_and_kapture]], [[project_comic_creator_deploy_symlink]]
|
||||
|
||||
## v0.6.1
|
||||
|
||||
### Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
|
||||
|
||||
ユーザーから「ギャラリータブで検索を行うと読み込み中のままでクリアを行っても中断できない。50枚程度のフォルダで検索をかけても時間がかかっている」という報告を受け、原因調査から修正まで対応した。
|
||||
|
||||
**フロントエンドの2つの不具合**(`gallery-tab.js`): 検索欄はキー入力のたびに即座に(debounceなしで)`loadImages()`を呼んでおり、しかも直前のfetchをキャンセルする仕組み(AbortController)が無かった。そのため「クリア」を押した直後でも、その前に打っていた検索文字列の(まだ処理中の)リクエストが後から返ってきて、クリア後の空検索の結果を上書きしてしまい、「読み込み中のまま/検索結果のまま」に見えていた。検索入力に300msのdebounceを追加し、`loadImages()`にAbortController管理を追加(新しいリクエスト発行時に前回の未完了リクエストを中断)、「クリア」「Clear filters」ボタンはdebounceタイマーもクリアして即座に反映するようにした。
|
||||
|
||||
**バックエンドのロック競合**(`gallery_metadata.py`, `gallery_service.py`): フォルダを開くたびに起動するバックグラウンドインデックス処理が、`gallery_metadata.json`(画像数が多い環境では1MBを超える)全体を`threading.RLock`保持したままJSONシリアライズ+ディスク書き込みしており、その間ずっと検索リクエストの`get()`呼び出しがロック待ちでブロックされていた。さらに10枚処理するごとに1件ずつ`save()`を呼んでいたため、実質10回分の全体書き込みが発生していた。`_save_to_disk()`をロック内ではデータのdeepcopyのみに留め、実際のファイルI/Oは別の書き込み専用ロック(`_write_lock`)でロック外に出すよう変更し、バックグラウンドインデックス側もまとめて1回で保存する`save_batch()`を新設して使うようにした。また検索時は(該当時に全メタデータのJSON全体書き込みを伴う)`cleanup_stale_images()`をスキップするようにした(通常の検索なしロード時に実行されれば十分なため)。
|
||||
|
||||
**根本原因: ponyxlWildcardsVaultフォールバック探索でのVaultフォルダ誤検出**(`gallery_service.py`): 上記2件を直しても実機で53枚のフォルダへの検索が約3秒かかっており、一時的な計測ログを実機に仕込んで原因を特定した。出力ルート直下にComfyUI/StabilityMatrixが作る空の`thumbnails`フォルダがたまたま存在しており、これが検索対象画像の`.txt`サイドカーが無い場合のフォールバックである`_find_vault_category_root()`(ponyxlWildcardsVault形式——「直下にthumbnails(_option2)フォルダを持つ祖先ディレクトリ」というゆるい条件で判定)によって誤ってVaultのcategory_rootと認識されていた。誤検出されたcategory_root(1万枚超のファイルを含む出力ルート)に対し、`_load_vault_leaves()`がキャッシュの妥当性確認のため画像1枚ごとに`glob("*.yaml")`+`glob("*.yml")`を実行しており、1回あたり約53msかかるglobを53回(検索対象の画像数分)実行して合計2.8秒——実測ベンチマークで再現・確認した数値と実機ログの数値がほぼ一致した。`_load_vault_leaves()`をTTL(60秒)ベースのキャッシュに変更し、TTL内はglobを省略するようにして解消した。
|
||||
|
||||
**検証**: Kaptureで実ブラウザ(ComfyUI_5上のWorkflow Studio)を操作し、ネットワークリクエストのタイミングを実測しながら段階的に検証した。Python変更後の再起動が確実に反映されているかはプロセス起動時刻とファイル更新時刻を比較して都度確認した。最終的に53枚のフォルダへの検索は3327ms→74msまで短縮し、検索・クリアとも実機で即座に反映されることを確認した。デバッグ用の計測ログは検証完了後に削除済み。
|
||||
|
||||
**How to apply**: サブフォルダを持つ画像出力ルートで「Vault風フォルダ名(thumbnails等)の存在だけで判定するフォールバック検出」を書くと、無関係な巨大フォルダを誤検出してglob/scandir系の操作が本来の対象より何桁も遅くなることがある——検出ロジック自体だけでなく、誤検出時に何度も高コストな操作(特に大量ファイルを含むディレクトリへのglob)が繰り返し呼ばれないか、呼び出し頻度とキャッシュの妥当性も含めて確認する。原因不明のパフォーマンス低下は仮説で止めず、実機に一時的な計測ログ(`perf_counter`で区間ごとの時間を`logger.warning`に出力)を仕込んで実測すると、コードレビューだけでは気づけない誤検出のような盲点が見つかることがある。
|
||||
|
||||
関連: [[feedback_asyncio_blocking_in_aiohttp_routes]], [[feedback_realtime_debug_logs_and_kapture]], [[feedback_verify_restart_via_process_creation_time]]
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
|
||||
|
||||
ユーザーから「ギャラリーで複数画像を選択してPSDファイルを作成したい」という依頼を起点に、段階的に機能を積み上げてPSD対応を一式実装した。
|
||||
|
||||
**ライブラリ選定の紆余曲折**: 当初はPythonでPSDを書き込めるライブラリとして`pytoshop`を候補に選定したが、実際にインストールを試すとWindows用のプリビルドwheelが存在せず、`packbits`というC拡張のビルドに`cl.exe`(Visual Studio Build Tools)を要求することが発覚した。StabilityMatrix/ComfyUI Portable環境のユーザーは通常Cコンパイラを持たないため、この依存はほぼ確実にインストール失敗を招く。代わりに`psd-tools>=1.10`を採用した——1.10以降は読み取り専用ではなく`PSDImage.frompil()`/`PixelLayer.frompil()`による書き込みにも対応しており、依存(`attrs`, `numpy`, `Pillow`, `typing-extensions`)は全てプリビルドwheelを持ちCコンパイラ不要。実機と同等の条件(venv構築→pip install→実際にPSD作成→再読み込みで往復検証)でレイヤー構成・重なり順・opacity・blend_mode・visibleが正しく機能することを確認してから実装に着手した。
|
||||
|
||||
**Gallery: 複数選択→PSD一括書き出し** — Galleryの一括操作バーに「PSD作成」ボタンを追加。選択した複数画像を、既存のZIPエクスポート機能と同じ選択・送信パターンを踏襲して1つのPSDファイル(各画像=1レイヤー、レイヤー名=ファイル名)にまとめてダウンロードする。画像サイズが異なる場合は最大サイズのキャンバスに左上基準で配置する仕様とした(ユーザー選定)。バックエンドは`GalleryService.build_psd_from_images()`、ルートは`POST /wfm/gallery/images/export-psd`。
|
||||
|
||||
**Image Edit: Save PSD / Open PSD** — Image Editタブ自体にも「Save PSD」(アクションバー)を追加。`LayerManager.layers`を背面→前面順に走査し、各レイヤーをキャンバス全体サイズのoffscreen canvasに`Layer.applyTransform()`(回転・反転・拡縮)のみベイクして書き出す——PSDのピクセルレイヤーは矩形しか持てず回転角度を保持できないため、位置・変形は必ずピクセルに焼き込む一方、opacity・blend_mode・visibleは実際のPSDレイヤー属性としてそのまま渡し、Photoshop側で編集可能な状態を保つ。`maskApply=true`のクリッピングマスクは対象レイヤーと合成して1枚にベイクする簡略化とし、PSD本来のレイヤーマスク機構は使わない(「ライブラリで素直に対応できない部分は無視してよい」というユーザーの了承のもとでの意図的な妥協)。続けて逆方向の「Open PSD」も実装——`psd_tools.PSDImage.open()`→`psd.descendants()`で全レイヤーを再帰的に走査し、`layer.topil()`でレイヤーのbbox範囲にクロップ済みの実サイズ・実位置の画像を復元、`layer.bbox`/`opacity`/`blend_mode`/`visible`を`Layer`オブジェクトに変換する。グループ自体(フォルダ)はスキップしつつ中身は再帰的に取り込み、PSD側のブレンドモード(4バイトタグ)とCanvas2Dの`globalCompositeOperation`文字列の相互変換テーブルを新設した(Canvas2Dに対応物が無いモードはNormalにフォールバック)。
|
||||
|
||||
**Gallery → Image Edit: 複数画像を一括レイヤー転送** — Galleryの一括操作バーに「レイヤーとして編集」ボタンを追加。選択した複数画像(mp4は自動除外)でImage Editタブに切り替え、各画像を独立レイヤーとして展開する。既存の単一画像版「Image Edit」送信ボタンと同じ`window._wfmImageEditTab`グローバル参照の仕組みを再利用し、PSDファイルを経由しない直接URL読み込み方式(`loadLayersFromUrls()`新設)とした——各URLを`new Image()`で並列ロードし、最大幅・高さをキャンバスサイズとして採用、`fitToCanvas()`(既存ヘルパー)で各画像をアスペクト比を保ったまま中央配置する。
|
||||
|
||||
**Gallery: PSDファイルの一覧・サムネイル・プレビュー表示** — `.psd`を`IMAGE_EXTENSIONS`に追加し、Galleryのフォルダ一覧・検索・グループ機能に自動的に乗るようにした。ブラウザは`<img>`でPSDを直接レンダリングできないため、`serve_image`/`serve_thumb`ルートで`.psd`の場合は`psd_tools`で全レイヤーを合成(非表示レイヤーは除外)した上でPNG/JPEGに変換し、mtimeベースのキャッシュキーでディスクキャッシュして配信する方式にした。情報パネルの幅・高さもPSDヘッダーから取得するようにした。
|
||||
|
||||
**実機テストで発覚した2件のバグとその修正**:
|
||||
|
||||
1. **サムネイル表示が遅く、他の無関係なPNGサムネイルまで真っ黒になる**——原因は`serve_image`/`serve_thumb`ルートがPSD合成という重いCPU処理を`async def`ハンドラ内で直接同期実行しており、aiohttpの単一スレッドイベントループ全体をブロックしていたこと。PSD1枚の合成中は他の並行リクエスト(別画像のサムネイル配信など)も完全に止まり、結果的に無関係な画像まで表示が固まって見えた。`serve_image`・`serve_thumb`・`import_psd_layers`・新設`get_psd_layers`の4ルートを`loop.run_in_executor(None, ...)`でデフォルトのThreadPoolExecutorに逃がすことで解消した。この教訓は今後の重い処理全般に適用すべき一般原則として別途メモリに記録した。
|
||||
2. **単一画像「Image Edit」送信ボタンでPSDを開くと1レイヤーしかない**(「Open PSD」ボタンでは正しく全レイヤー開ける)——原因はこのボタンが常に`loadFromUrl()`(1枚のフラット画像として読み込む経路)を使っており、PSD専用のレイヤー分解経路を通っていなかったこと。サーバー上のファイルを再アップロードせず直接レイヤー分解できる新規`GET /wfm/gallery/image/psd-layers`(`GalleryService.get_psd_layers_from_path()`)を追加し、既存の`import_psd_layers()`とロジックを`_extract_psd_layers()`に共通化した上で、PSDファイルの場合はこの新エンドポイントを使うよう分岐させた。
|
||||
|
||||
**Image Editタブのアクションバー・レイヤーパネル改善**: 「Upload」を「File Open」に改名し「Open PSD」をその右隣に移動;新規「Close」ボタン(確認ダイアログ後、現在のドキュメントを破棄してプレースホルダー表示に戻す)を追加;レイヤーパネルヘッダーを2段構成(1段目=+/⧉(複製)/−/↑/↓、2段目=「Mask」ボタン単独、旧「M」から改名・移動)に変更;新規「複製レイヤー」ボタン(⧉)を追加し、`Layer.toJSON()`→`Layer.fromJSON()`のシリアライズ往復でアクティブレイヤーを複製(id再採番)して元レイヤーの直前に挿入する方式とした。実装にあたり、レイヤーリストクリック時の「選択」ロジック(種別・現在ツールに応じたキャンバス同期)を`_activateLayerById()`という共通メソッドに抽出し、複製直後の自動選択でも再利用することで、レイヤー種別(画像・描画・マスク・テキスト)を問わず正しく動作するようにした。
|
||||
|
||||
**ヘルプドキュメント更新**: 上記全機能についてヘルプタブを更新(`templates/index.html` + `i18n.js`英日中3言語 + `app.js`のhelpIdMapの3点セット)。更新作業の検証中に、今回の変更とは無関係の既存バグを2件発見し合わせて修正した——Image Editの「タイプアイコン」説明が英語版で重複定義されておりマスクレイヤーの表記が欠けていた点(片方は死んだ定義だったため削除)、日本語版でStyle Catalogの「Load in GenerateUI」項目が丸ごと未翻訳で英語表示にフォールバックしていた点(翻訳を追加)。
|
||||
|
||||
**検証**: PSD書き込み/読み込みロジックは全て、実際の`GalleryService`コード(相対importを差し替えたコピーをテスト用venvで直接実行)に対してテストスクリプトで検証した——複数レイヤー・サイズ不一致・非表示レイヤー・opacity/blend_mode・グループ内レイヤー・クリッピングマスクを含むPSDを実際に構築し、書き出し→再読み込みで往復一致することを確認。Gallery一括PSD書き出し・Image Edit Save/Open PSD・Gallery表示・Gallery→Image Editレイヤー転送・アクションバー/レイヤーパネル改善は全てユーザーが実機(実ComfyUI環境)で動作確認済み。
|
||||
|
||||
**How to apply**: Cコンパイラを要求するPythonライブラリはWindows向けComfyUIカスタムノードでは避け、`pip install --only-binary=:all: <lib>`でプリビルドwheelの有無を先に確認してから採用する。aiohttpの`async def`ルート内でCPU負荷の高い同期処理(画像デコード・合成・リサイズ等)を直接呼ばず、`loop.run_in_executor()`でスレッドプールに逃がす——特にサムネイル/プレビューのように頻繁に並行して呼ばれるエンドポイントは、1回の遅延が他の全リクエストに波及するため重要。ヘルプ更新は`index.html`だけでなく`i18n.js`(3言語)と`app.js`の`helpIdMap`の3点セットで行う。
|
||||
|
||||
関連: [[project_gallery_psd_export]], [[project_gallery_psd_display]], [[project_gallery_to_image_edit_layers]], [[project_v04x_image_edit_ui_polish]], [[feedback_psd_export_library_choice]], [[feedback_asyncio_blocking_in_aiohttp_routes]]
|
||||
|
||||
## v0.5.8
|
||||
|
||||
### Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
|
||||
|
||||
GitHub上の外部コントリビューター(`censor-ed`氏、fork経由)からPR #2「Tagger DBの複数選択削除を追加」が届いたため、内容確認・実機検証・マージまで一貫して対応した。PRの変更内容はバックエンド(`tagger_routes.py`に`DELETE /wfm/tagger/db`エンドポイントを新設、`tagger_db_service.py`に`delete_bulk()`をプレースホルダ経由のSQLite `DELETE ... WHERE id IN (...)`で実装)とフロントエンド(DBテーブルの各行にチェックボックス、ヘッダーに全選択チェックボックス、既存の単一削除`_dbDelete`と同じ`confirm()`パターンを踏襲した確認ダイアログ、英日中3言語のi18n)。
|
||||
|
||||
コードレビューでは危険な処理(外部送信・eval・shell実行等)は無く、SQLインジェクション対策も適切と判断。ただし2点の懸念を指摘した:(1) 一括削除の`count`がAPIレスポンスで返る削除件数の妥当性、(2) 全選択チェックボックス`_dbToggleSelection`が単純トグル(クリックのたびに全チェックボックスの状態を反転)方式になっており、一部だけ選択済みの状態でクリックすると直感に反する(選択済み行が外れ未選択行が入る)挙動になる懸念。
|
||||
|
||||
実機検証はKapture経由でComfyUI_5(ポート8189)に実際に接続して実施。データベースには実データ(既存のタグ付け済み画像)しか存在しないため、削除操作自体は実行せず安全に検証する方針を取った:一括削除APIは`curl`で存在しないID(999999)を渡して直接叩き、`ids`未指定時の400エラー・不正な値混入時の500エラーハンドリングを確認。UI側はKaptureで実際にチェックボックスを操作し、行クリックとチェックボックスクリックが正しく分離されていること、未選択で削除ボタンを押すと警告トーストが出ること、削除ボタンで`confirm()`ダイアログ(日本語i18n表示)が開くことを確認した上で`mcp__kapture__dialog`の`accept:false`でキャンセルし、実データには一切触れずに検証を完了した。この過程で指摘(2)の懸念が実機で再現することを確認——ID66のみ選択した状態で全選択チェックボックスをクリックすると、ID66のチェックが外れ他の全行がチェックされるという実際の誤動作を確認した。またAPIレベルの検証で指摘(1)も実バグと確定:存在しないID 1件を渡しても`{"ok": true, "count": 1}`が返っていた。原因は`handle_db_delete_bulk`が`delete_bulk()`の戻り値(実際の削除件数=`cur.rowcount`)を使わず、渡された`ids`の件数(`len(ids)`)をそのまま返していたため。
|
||||
|
||||
この検証プロセス中に、ComfyUIの「再起動」がユーザー申告だけでは信頼できないケースに遭遇した。Pythonファイル(`tagger_routes.py`)をコピーしてComfyUI再起動を依頼したところ、依頼直後は新エンドポイントへの`DELETE`が405 Method Not Allowed(`Allow: GET,HEAD`のみ)を返し続けた。原因調査でComfyUI起動ログ(`user/comfyui_8189.log`)や`app.router`のルート登録順序(ComfyUIコア自身のSPAフォールバック用static routeとの競合の可能性)を疑ったが、最終的にはPowerShellの`Get-CimInstance Win32_Process`でプロセスの実際の`CreationDate`を確認し、動いているComfyUIプロセスの起動時刻がコードをコピーした時刻より前であることを突き止めた——つまりユーザーが「再起動しました」と報告した操作は、ブラウザページのリロード等では反映されず、プロセス自体は起動されたままだった。ユーザーに再度プロセスの完全停止・起動を依頼し、新しいプロセス起動時刻(コピー時刻より後)を確認してから再テストしたところ405エラーは解消した。
|
||||
|
||||
2点のバグ修正(`handle_db_delete_bulk`が`delete_bulk()`の戻り値を`count`として返すよう変更、`_dbToggleSelection`をクリックされたヘッダーチェックボックス自身の新しい状態(`e.target.checked`)に全行を揃える一般的な「全選択」挙動に変更)をコミットし、`maintainerCanModify`権限を使ってPR元のフォークブランチ(`censor-ed/ComfyUI-Workflow-Studio`の`feature/tagger-bulk-delete`)に直接pushしてPRへ反映、`gh pr merge`でGitHub上のPR #2を正式にマージした(マージコミット`e067f34`)。ローカル`main`をfast-forward更新し、実行時`custom_nodes`フォルダへ最終版を再同期して`diff --strip-trailing-cr`で完全一致を確認した上で、最後にもう一度ComfyUI再起動→APIレスポンス確認まで実施した。
|
||||
|
||||
**検証**: バックエンドAPI(`curl`直叩き、正常系・異常系)とフロントエンドUI(Kaptureブラウザ操作、チェックボックス・確認ダイアログ・トースト)の両方を実機で確認。修正コミット後も同様に再検証し、`count`が正しい値(0)を返すこと・全選択チェックボックスが選択済み行を含めて正しく全ON/全OFFされることを確認した。実データは一度も削除していない。
|
||||
|
||||
**How to apply**: 外部コントリビューターのPRを取り込む際は、まず実行時`custom_nodes`フォルダと開発リポジトリの現在のブランチが完全一致していること(改行コード差分のみ)を確認してから安全にファイルを上書きする([[project_dev_deploy_sync]])。DBやファイルシステムなど実データを持つ機能のUIテストでは、可能な限り「削除・変更操作自体は実行せず、確認ダイアログの表示とキャンセル」「存在しないIDでのAPI直接テスト」など非破壊的な手段で検証を組み立てる。Pythonファイル変更後のComfyUI再起動は、ユーザーの「再起動しました」という申告だけでなく、`Get-CimInstance Win32_Process`等でプロセスの実際の起動時刻を確認し、コード変更時刻より後であることを検証してから動作確認に進む——ブラウザのページリロードやタブの再接続だけでは、バックエンドのPythonプロセス自体は再起動されていない場合がある。APIレスポンスの件数系フィールド(`count`等)は、渡された引数の長さではなく実際の処理結果(DBの`rowcount`等)を返すべきというありがちなバグパターンに注意する。「全選択」チェックボックスは常時トグルではなく、クリックされた自身の新しい状態を全体に適用する実装が直感に合う。
|
||||
|
||||
関連: [[project_dev_deploy_sync]], [[feedback_comfyui_running_check_before_launch]], [[feedback_realtime_debug_logs_and_kapture]]
|
||||
|
||||
## v0.5.7
|
||||
|
||||
### 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
|
||||
|
||||
「Videoプランの追加で設定のエクスポート、バックアップ機能の更新が必要では」との確認依頼を受けて調査。v0.5.5で追加された`video_plan/`ディレクトリ(Plan JSON+インデックスPNG、`lab_plan/`と同構造)が、フルバックアップZIP機能の説明文言から漏れていないか確認した。実装(`_build_full_backup_zip`)自体は`DATA_DIR`配下のトップレベルエントリを汎用的に走査する設計(除外リスト`_FULL_BACKUP_EXCLUDE_NAMES`は`gmic_temp`/`thumb_cache`/`wildcard`の3つのみ)のため、`video_plan/`は既存コードの変更なしに自動的にバックアップ対象へ含まれていた——つまり機能的な欠陥は無かった。一方、ユーザー向け説明文(`fullBackupHint`/`helpSettings15`のi18n.js3言語、README.mdのFull Backup説明、`settings_routes.py`のコード内コメント)は「AI Skills、Lab Plans、Style presets」という列挙のままVideo Plan機能追加時に更新されておらず、実際にバックアップされるデータの説明として不正確になっていた。該当6箇所+READMEを「Video Plans」を含む形に修正。
|
||||
|
||||
### Video Plan (Planサブタブ) 中央パネルの設定項目レイアウト刷新 + MiniMax H3 Turbo/LTX-2.5 Prompt Enhance/Wan2.2 Turbo対応
|
||||
|
||||
「Duration・Noise SeedをMultipleの右隣に移動し、その下に新しい行でWidth/Height/MiniMaxのturbo_mode/strength/steps・LTX-2.5のprompt_enhance・Wan2.2のenable_turbo_mode(MiniMaxのturbo_modeと兼用)を追加したい」との要望。着手前にComfyUI_5環境の直近保存ワークフロー(`minimax_turbo.json`、LTX-2.5の`ltx_25_test`/`test2`/`test3`、Wan2.2の`wan22_test`/`test2`/`test3_i`)を実際にPythonでパースしてサブグラフの公開入力を調査し、対応可否を事前検証してから実装した。
|
||||
|
||||
調査で判明した構造: MiniMax H3は`turbo_mode`(BOOLEAN)/`turbo_model_strength`(FLOAT、LoRA強度)/`turbo_steps`(INT)をサブグラフの直接ウィジェットとして公開しており、これらがユーザーの言う「strength」「steps」に対応する。LTX-2.5は3バリアントとも`prompt_enhance`(BOOLEAN)を公開。Wan2.2は`enable_turbo_mode`(BOOLEAN)を公開しており、MiniMaxの`turbo_mode`と意味的に同一のためUI上は1つの「Turbo Mode」欄として兼用する設計にした。width/heightはMiniMax・LTX-2.5のText/Image版ではResolutionSelector経由(サブグラフ入力がリンク接続されている)だが、LTX-2.5のFirst & Last Frame版とWan2.2は全バリアントでResolutionSelectorを持たずサブグラフの直接ウィジェット値のまま——既存コードのコメント通りの構造を実データで確認できた。noise_seedは全ファミリーで`name="noise_seed"`に統一されている(LTX-2.5のText to Videoのみ`label="seed"`を持つため、ラベルではなくname直接一致で検索するヘルパーを新設)が、**Wan2.2のText to Videoだけサブグラフの公開入力に`noise_seed`が存在しない**(Image to Videoにはある)という非対称性を発見した。
|
||||
|
||||
実装は`video-workflow.js`の`locateVideoModelNodes()`を拡張し、上記7項目(width/height直接指定用インデックス、noise_seed、turbo_mode、turbo_strength、turbo_steps、prompt_enhance)のインデックスを検出して返すようにした(対応する入力が無ければ-1を返し、呼び出し側で無効化・非ハイライトの判断に使う)。既存のAspect Ratio/Megapixels/Multipleと全く同じ「ワークフローが対応する場合のみ入力可能+ラベルハイライト」の仕組みで新規7項目も統一し、新しい選択UIを作らず既存の`wfm-model-label-active`パターンを再利用した。noise_seedについては値の書き込みロジックをあえて追加しなかった——`comfyUI.generate()`がAPI変換後のワークフロー全体から`noise_seed`/`seed`キーを持つ全ノードを一括で上書きする既存の`applySeedToWorkflow()`が必ず後から実行されるため、`applyBlockToWorkflow()`側で書いても意味がなく、Plan全体のシード管理は引き続きそちらに一任する設計とした。
|
||||
|
||||
UI面ではDuration(元々ブロックごとの独立値)とNoise Seed(Plan全体で共有)を、機能的な性質は変えずにDOM位置だけtopbarのbasesettings行へ移動した。Durationはブロックエディタの`innerHTML`再生成に巻き込まれない固定要素にする必要があったため、change時のリスナー登録をブロック切替のたびに再アタッチする方式から、初期化時に一度だけ登録し値の同期のみブロック切替時に行う方式(`_initDurationControl()`新設)に変更した。新規7項目は`_plan.base_settings`に追加しPlan保存/読込(`_buildPlanData`/`_applyPlanData`)にも対応させた——Aspect Ratio等と同様Plan全体で共有する値とし、ブロックごとには持たせない設計とした。
|
||||
|
||||
実機検証はComfyUI_5(ユーザーが起動中のポート8189、8188に誤って重複起動してしまった分は作業後に停止)上でKaptureブラウザ操作により実施。MiniMax H3ワークフローで`Turbo Mode`/`Strength`/`Steps`が正しくハイライト・値反映され`Width`/`Height`は無効化されること、LTX-2.5 First & Last Frame版で`Width`/`Height`が有効化・ハイライトされること、Wan2.2 Text to Videoで`Noise Seed`のみ非ハイライト(disabled)になり`Turbo Mode`はハイライトされること、ブロック追加後にDurationがブロックごとに独立して保持される(1つ目=5秒のまま、2つ目=7秒に変更後も1つ目に戻ると5秒を維持)ことを、DOM要素のクラス・disabled状態を直接読み取って確認した。
|
||||
|
||||
ヘルプ文言は`helpVideo1`/`helpVideo2`/`helpVideo10`をindex.html・i18n.js(英日中3言語)・app.jsの`helpIdMap`の3点セットで更新——`helpIdMap`自体は既存のキーマッピングをそのまま流用でき変更不要だったが、`applyI18nToHtml`が起動時にi18n.jsの内容でDOM要素を上書きする一方でindex.html側の静的テキストが更新漏れになっていたため、ソース整合性のため合わせて修正した。README.mdのVideo Tabセクション(Plan/Edit subtabsの基本設定説明、Field highlight colorの対象フィールド一覧)も新規7項目を反映して更新した。
|
||||
|
||||
**検証**: `node --check`で変更した全JSファイルの構文確認。実機(ComfyUI_5、ポート8189)でMiniMax H3/LTX-2.5(First & Last Frame版)/Wan2.2(Text to Video版)の3ワークフローをKapture経由で実際にロードし、ハイライト・disabled状態・値反映・ブロックごとのDuration独立性をDOM評価で確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイル(`video-workflow.js`/`video-plan-tab.js`/`i18n.js`/`index.html`/`video-tab.css`)を都度両フォルダへ同期し`diff`で完全一致を確認。
|
||||
|
||||
**How to apply**: UIに新しい項目を追加する際、「ワークフローによって対応する場合のみ有効化・ハイライトする」という既存パターンが確立している画面では、新規に選択UIやトグルを作らず既存の仕組み(本プロジェクトでは`wfm-model-label-active`クラス+ラベルID登録)にそのまま乗せるのが一貫性を保ちやすい([[feedback_reuse_existing_ui_before_building_new]])。同系統に見えるモデルファミリー間でも、実際のワークフローJSONをパースして公開入力の有無を確認しないと「あるはず」の項目が無い非対称性(今回のWan2.2 Text to VideoのNoise Seed欠如)を見落とす——README/ヘルプ文言に「ワークフローXにはYが無い」という例外を書く前に、必ず実データで裏取りする。UI上は1つのフィールドとして統一表示しつつ、内部的には複数のワークフローファミリーそれぞれの異なる入力名(`turbo_mode`と`enable_turbo_mode`)にマッピングする「兼用フィールド」パターンは、意味が同じであることをコメントで明記した上でフォールバック演算子(`||`)で検出すればよく、UI側の分岐は不要になる。生成時に上流のAPI変換・送信処理(今回の`applySeedToWorkflow`)が特定のキーを一括で上書きする既存の仕組みがある場合、新規追加するフィールドの値が同じキーに書き込まれるなら、二重に値を設定するコードを書かず上流の処理に委ねる方が正確(後から上書きされる無駄なコードパスを残さない)。
|
||||
|
||||
関連: [[project_video_plan_asset_tab]], [[project_dev_deploy_sync]], [[feedback_reuse_existing_ui_before_building_new]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.6
|
||||
|
||||
### バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
|
||||
|
||||
ユーザー(censor-ed氏)からのIssue報告: `wd-swinv2-tagger-v3`でタグ付けすると画像内容と一致しない不自然なタグが出力される。調査依頼者自身が原因を特定しており、`tagger_service.py`のONNX推論前処理がWD Tagger系モデルの学習時前処理と異なっていた(単純リサイズ+RGBのまま0.0〜1.0正規化)のに対し、WD Tagger系標準の前処理は「透過は白背景で合成→アスペクト比を維持したまま白背景で正方形にパディング→BICUBICリサイズ→RGB→BGR→0〜255のfloat32のまま(正規化なし)」であるとの具体的な指摘だった。`_predict_onnx()`をこの標準前処理に修正(本プラグインがサポートするONNXタガーはREADME記載の通りWD Tagger系のみのためパス全体を統一。DeepDanbooru用`_predict_dd()`は元々正しい前処理のため変更なし)。実機で`wd-eva02-large-tagger-v3`を使い、くまの着ぐるみパペットを持つ緑髪キャラクターのアニメ調画像でタグ付けを検証したところ、修正前は前処理が公式実装と異なる状態だったのに対し、修正後は`green_hair, bear_ears, bear_costume, paw_gloves, hoodie, animal_hood, animal_costume`など画像内容と高精度に一致する自然なタグが生成されることを確認した。
|
||||
|
||||
---
|
||||
|
||||
## v0.5.5
|
||||
|
||||
### Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
|
||||
|
||||
「Videoタブにバッチ動画生成機能を追加したい。既存の生成UIタブのLab機能を動画生成用に整えたイメージ」との要望を起点に着手。従来のVideoタブ(単発生成フォーム)をPlan/Asset/Editの3タブ構成に再設計し、複数セッションにわたるユーザー実機確認・レイアウト調整・追加要望への対応、および調査の過程で発見した重大バグの根本修正までを1リリースにまとめた。
|
||||
|
||||
**1. Planタブ: タイムラインブロックによるバッチ動画生成**: 「基本設定→タイムラインをブロック分割→各ブロックにFirst/Last画像とプロンプトを設定→実行で順次バッチ生成」という要望を実装。LabタブがCheckpoint/LoRA/VAE/Prompt/Ksamplerそれぞれ独立したキーフレーム配列を持つ設計なのに対し、Video Planは「各ブロックが完結した1本の生成ジョブ」という単位でデータを持つ点が本質的に異なるため、Lab方式(`_buildWorkflowForIteration`によるキーフレーム解決)ではなく新規のデータモデル(`{id, color, duration, prompt, first_image_mode, first_image_filename, last_image_filename}`の配列)を設計した。First ImageはNone/画像を指定/前ブロックの最終フレームを使用の3択(`first_image_mode`)を持ち、chain_previous選択時は前ブロックに明示的なLast Imageがあればそれを直接使い(フレーム抽出不要)、無ければ前ブロックの生成動画から実際の最終フレームを`extractLastFrameBlob()`(Labのchain image機能で実績のある関数を再利用)でクライアント側抽出してアップロードする、という優先順位をユーザーと合意の上で実装した。Run/Pause/Cancelと進捗バーはLabタブの`_runLabBatch`と同じPromiseベースの一時停止機構を踏襲。ワークフロー操作(モデルファミリー自動判定・ノードへの値注入)はVideoタブの既存ロジックから`video-workflow.js`として抽出し、Plan/旧単発フォームの双方から呼べる純粋関数群にした。
|
||||
|
||||
**2. Plan永続化**: Labの`ws_labplan_`プレフィックス+PNGコンタクトシートへのiTXt埋め込み方式をそのまま踏襲し、`ws_videoplan_`プレフィックスでPlan JSON+インデックス画像を保存する仕組みを新規実装(`video_plan_service.py`はLabのサービスを複製、共通化はしない方針)。ブロックが1個のPlan=旧来の単発生成と等価になるよう設計し、「Load in Video」ボタンは常にブロック1個の新規Planを生成するようにした。
|
||||
|
||||
**3. Assetタブ: Galleryのグループ機能を再利用した素材管理**: 独自のフォルダブラウザを新規実装する代わりに、`feeder-tab.js`の「Galleryモード」(Gallery既存バックエンドをグループスコープで叩く軽量グリッド)と同じパターンを踏襲。当初は生成された動画を全て予約グループ`__VideoAssets__`へ自動追加する設計だったが、ユーザーからのフィードバックで用途を再定義:`__VideoAssets__`はユーザーがGalleryで手動キュレーションする「素材」グループとし、バッチ実行の自動追加先は新設の予約グループ`__vtemp__`(Planを保存していない生成物の一時置き場)に変更。Plan保存済みの場合は`VideoPlan:<name>`サブグループへ、未保存の場合は`__vtemp__`へ、という条件分岐にした。Assetタブのグループ選択には「All Video Assets」(`__VideoAssets__`+`__vtemp__`の合算、バックエンドに単一グループでの合算フィルタが無いため2回フェッチしてマージ)に加え、両グループの個別選択・Plan別サブグループ選択も追加。Galleryタブ側には`__vtemp__`用の🔒予約表示(`__Feeder__`と同様リネーム・削除不可)と、詳細パネルに`__vtemp__`をワンクリックでクリアする「VtC」ボタン(既存の`__Feeder__`用「FC」ボタンと同じ`groups/{name}/clear`APIを利用)を追加した。
|
||||
|
||||
**4. UIレイアウトの複数回反復**: 初回実装は左サイドバー(380px固定幅)にPlan/Asset/Editの3サブタブを縦積みする構成にしたが、実機確認で「Timeline+First/Last画像+Promptを横並びにしたい」という要望とレイアウト崩れの報告を受け、Planタブの中身を中央の広いプレビュー欄の下部に移動(ブロックエディタをFirst Image/Last Image/Prompt/Duration+colorスウォッチの横並び4カラムに再設計、実行系(Run/Pause/Cancel/進捗/Seed)とプラン管理系(Note/Save/Load)を「Run」「Plan」の2タブに整理)。続けて「これをPlanタブ化してEditタブを追加、左ペインのPlan/Editは削除」との指示で、中央パネルにPlan/Edit専用のタブナビを新設し、左サイドバーにはAssetのみを残した。この際、両タブナビが同じ`.wfm-video-subtab-btn`/`.wfm-video-subtab-panel`クラスを共有する実装だったため「Assetを選ぶと中央のPlan/Editも連動して消える」という不具合が発生 — 中央タブの切替処理を`.wfm-video-center-panel`スコープに限定したquerySelectorAllに変更し、サイドバーのAsset単独ボタンは独立したshow/hideトグルとして分離することで解消した。中央・右ペイン(プレビュー、Frame抽出/GIF変換/Video Source)はサブタブ切替と独立した常時表示パネルとして、レイアウト変更を通じて無改修のまま維持している。
|
||||
|
||||
**5. バグ修正: Galleryのグループ追加がジャンクションフォルダ環境で一覧に反映されない**: 「生成完了後に`__vtemp__`グループには入らない。Galleryで手動追加したコンテンツは表示される」との報告を受けた調査で発見。ユーザー環境の`ComfyUI_5\output`はStabilityMatrixの共有出力フォルダ機能によるWindowsジャンクション(実体は`StabilityMatrix\Images\Text2Img`)だった。`save_image_meta`(タグ/メモ保存)には既に「`get_image_metadata()`側はresolve()済みパスをキーにするため保存側も統一する」という同種バグの修正コメントがあったが、`add_to_group`/`remove_from_group`/`toggle_favorite`/`bulk_set_favorite`の4箇所には同じ修正が未適用のまま残っていた。そのためグループ追加自体はサーバー上で成功する(ジャンクション形式の未解決パスをキーに保存)が、一覧取得(`list_images`)はフォルダを`.resolve()`した実パスでスキャンするためキーが一致せず、「追加はされるが一覧に出てこない」というサイレント不具合になっていた。4箇所とも`save_image_meta`と同じresolve()統一パターンに修正。Video機能に限らずGallery全体(お気に入り・グループ機能)に影響していた既存バグで、今回の調査で偶然発見・修正できた。
|
||||
|
||||
**6. バグ修正: Galleryのグループフィルタがサブフォルダ内のファイルを見つけられない**: 5の修正後も「rootで`__VideoAssets__`フィルタ時は画像のみ、動画は表示されない」との報告。動画は`video/`サブフォルダに保存されるが、Galleryタブの`loadImages()`はグループフィルタの有無に関わらず常に非再帰スキャン(現在のフォルダ直下のみ)だったため、グループメンバーがサブフォルダにあると机上から漏れていた。グループフィルタが有効な時のみ`recursive=true`を付与するよう修正。
|
||||
|
||||
**7. バグ修正: チェイン画像が無関係な既存ファイルにすり替わる**: 「2ブロックのPlanで2つ目のFirst Imageを前ブロックの最終フレームに設定したが、生成された2つ目の動画は無関係な画像が使われている」との報告。調査の結果、`convertUiToApi()`(UI形式→API形式変換の共通処理)が持つ「COMBO項目の値がキャッシュ済み`/object_info`の選択肢に無い場合、無警告で選択肢の先頭に差し替える」というフォールバック機構(チェックポイント名の互換性対応が主目的で元々存在)が原因と判明。LoadImageノードの「image」もCOMBO型のため対象になり、バッチ実行中にアップロードした抽出フレーム画像のファイル名はその時点でキャッシュ済みの`/object_info`にまだ存在せず、無関係な既存ファイル(選択肢の先頭)に差し替えられていた。LabタブのchainImage機能がこの問題に遭遇しないのは、Labが既にAPI形式のワークフローを直接操作し`convertUiToApi()`を経由しないため。`comfyui-workflow.js`に`invalidateObjectInfoCache()`を新設し、チェイン用フレーム・ユーザーの手動画像アップロードの直後に呼んで次回変換時に最新の`/object_info`を再取得させるよう修正した。
|
||||
|
||||
**8. バグ修正: `__VideoAssets__`グループが再起動後に一覧・詳細パネルから消える**: 「再起動してギャラリーを確認したがグループに`__VideoAssets__`、`__vtemp__`が表示されなくなった」との報告。`gallery_metadata.json`を実データで直接確認したところ、`__vtemp__`はレジストリ・10件のメンバーシップとも正常だった一方、`__VideoAssets__`はグループレジストリ配列からも全画像のメンバーシップからも完全に消えていた(`delete_group()`はレジストリと全画像の`groups`配列を同時に消す実装で、症状と完全に一致)。原因は`__vtemp__`/`VideoPlan:<name>`と異なり、`__VideoAssets__`はバッチ実行側から`ensure_group`を呼ぶコードが一切無い設計だったこと — 3番で予約グループ保護(🔒表示・削除ボタン無効化)を追加する**前**の時点でManage Groupsから削除操作が可能だったため、その時点で削除され、以後`add_to_group`は個別画像への書き込みは成功してもレジストリを再登録しないため二度と一覧に出てこなくなっていた。`gallery-tab.js`に`ensureFeederGroup()`と同型の`ensureVideoGroup()`を新設し、VideoタブのAssetタブ初期化のたびに`__VideoAssets__`を確実に再登録するよう修正。削除時に失われた過去のメンバーシップ自体(開発中のテスト動画)はバックアップが無く復元不可能である旨をユーザーに報告した(このensure漏れが後に13番の伏線になる — `__VideoAssets__`側だけ直し`__vtemp__`側のensureが漏れていた)。
|
||||
|
||||
**9. Assetタブに保存済みPlan一覧を表示する「Project」サブタブを追加**: 「AssetタブにVideo Planを表示したい」との要望を起点に着手。何を指すか確認したところ「右ペインにプロジェクトタブを追加し、保存済み一覧、各動画のplan名表示としたい」との回答。調査の結果、バックエンドには`GET /api/wfm/video/plans`一覧APIと`/wfm_data/video_plan/{name}.png`サムネイル配信が2番のPlan永続化実装時から既に存在していたが、フロントエンドからは一度も呼ばれておらず丸ごと未使用のまま埋まっていたことが判明した。新規`video-project-tab.js`で保存済みPlan一覧(サムネイル・名前・ブロック数・更新日時)表示、クリックでのPlanエディタ読み込み(`video-plan-tab.js`に`openSavedVideoPlan()`をexport)、削除機能を実装し、Assetカードには生成元のPlan名をバッジ表示する機能(`img.groups`から`VideoPlan:<name>`グループを逆引き)も合わせて追加した。当初の指示通り右ペイン(Frame/GIFタブの隣)に実装したが、直後のフィードバックで「左ペインに変更したい」との指示を受け、左サイドバー(Assetタブの隣)へ丸ごと再配置した。この移動に伴い、左サイドバーのトグルロジックを「Assetボタン単体のshow/hide」から「クリックした方を開き他方を閉じる、同じボタンの再クリックで両方とも折りたたむ」という排他的トグルに拡張した。
|
||||
|
||||
**10. 中央下部のワークフロー名表示行を1行に統合**: 「Open Workflowボタン行を上段の現在読み込んでいるワークフロー名行と1行にしたい」との要望。`wfm-video-wf-name`(現在ロード中のワークフロー名)と`wfm-video-plan-workflow-row`(Planに記録されたワークフロー名+Open Workflowボタン)が縦に2行表示されていたのを、`.wfm-video-wf-row`という横並びflexコンテナでまとめ1行に統合した。
|
||||
|
||||
**11. ワークフロー設定可能項目ハイライト表示の復活**: 「以前の単発生成フォーム時の設定項目のハイライト表示を行いたい」との要望を受けて調査したところ、v0.5.4で一度実装されていた機能(`wfm-model-label-active`クラス、GenerateUI Modelタブと共通の設定色)が、4番のPlan/Asset/Edit再設計で単発フォームのコードごと失われ、ヘルプ文言だけが取り残されていたことが判明した。Planタブのブロックエディタ向けに`_updateFieldHighlights()`を再実装:Prompt/Duration/First Image/Last Image/Aspect Ratio/Megapixels/Multipleの各ラベルにIDを付与し、読み込んだワークフローが実際にそのフィールドをサポートしている場合のみハイライトする(Wan2.2のようにResolutionSelectorが無いワークフローではAspect Ratio/Megapixels/Multipleは非ハイライトのまま)。ワークフローロード時・Open Workflowボタン使用時・ブロック切り替え時(`_renderBlockEditor()`はブロック選択のたびにDOMを作り直すため都度再適用が必要)のいずれでも正しく再適用されるようにし、ハイライト色を注入するCSSセレクタ(`settings-tab.js`の`applyModelTabActiveColor()`)も旧レイアウトの`.wfm-video-form-panel`から新レイアウトの`.wfm-video-plan-content`に更新した(4番のレイアウト変更で参照先の無いセレクタになっていた)。この過程で、ヘルプ本文(`helpVideo8`)がHTML側とi18n.js側で別内容になっている既存の不整合(HTML側は旧ハイライト説明、i18n.js側はチェイン機能の説明で後者が実際に表示される)を発見し、ハイライト説明を新規`helpVideo10`として追加し直した。
|
||||
|
||||
**12. Video Assets/Video Tempグループの命名統一・自動登録の運用整理**: 「__vtempグループが消えてしまっている」という報告と、ユーザー自身による修正方針の提示(表示名をグループ名で統一、VideoPlan:自動登録を廃止し常にVideo Tempへ、VtCボタン名を「Clear __Video Temp__」に変更)を受けて実施。3番/8番で導入した`__VideoAssets__`/`__vtemp__`を`__Video Assets__`/`__Video Temp__`に改名し、Assetタブの表示名もグループ名からそのまま導出する形に統一(「Temporary (unsaved runs)」という独自表示名は廃止)。3番の「Plan保存済みなら`VideoPlan:<name>`サブグループへ」という自動振り分けは廃止し、Plan保存の有無に関わらず常に`__Video Temp__`へ登録するよう変更 — `__Video Assets__`への整理は完全にユーザー任意のタイミングに委ねる設計に転換した。対応してAssetタブの「VideoPlan:」動的取得・フィルタ表示ロジックと、9番で追加したAssetカードへのPlan名バッジ表示も、対応するグループ自体が無くなったため削除した。バックエンド予約グループリスト(`py/routes/gallery_routes.py`の`_RESERVED_GROUPS`)も新グループ名に追従させている。既存の`VideoPlan:`グループ自体はユーザー側で削除する前提で進めた。
|
||||
|
||||
**13. 重大バグ修正: `gallery_metadata.json`の非アトミック書き込みによるデータ消失(根本原因)**: 「再起動するとグループに登録した内容が消えます」との報告を受けた調査。まず8番の`ensureVideoGroup()`が実は`__VideoAssets__`(現`__Video Assets__`)しかensureしておらず`__vtemp__`(現`__Video Temp__`)はensureしていなかったという見落としを修正したが、実データ(`user/default/Workflow-Studio/gallery_metadata.json`、画像9,470件)を直接調べたところ、より深刻な問題が見つかった。`GalleryMetadataStore._save_to_disk()`が`open(path, "w")`による直接上書きの非アトミック書き込みで、バックグラウンドインデックス処理(`_bg_index_folder`、未キャッシュ画像10枚ごとに保存)が大量画像フォルダに対して極めて高頻度にこのファイルへ書き込み続けていた(実測でファイルサイズが0.5秒間隔で数百KB単位で変動し、外部プロセスから読むとタイミングによってJSONパースが失敗する状態だった)。この書き込みの最中にComfyUIプロセスが終了すると、ファイルが不完全なJSONのまま残り、次回起動時の`_load()`がパースに失敗して警告ログを出すだけで中身を空の`{"images": {}, "groups": []}`にリセットしてしまう — 以降1回でも保存が走ると、それまでの全グループ登録・タグ・メモが跡形もなく上書きされて消える、という構造だった。実際に確認したところ、グループの登録リスト自体(`__Feeder__`/`__Video Assets__`/`__Video Temp__`等)は残っていたが、動画がどのグループに属しているかという個々のメンバーシップ情報は失われていた。`_save_to_disk()`を一時ファイル書き込み+`Path.replace()`によるアトミックな置き換えに変更し、`_load()`がJSON破損を検出した場合は単に空データにするのではなく`.corrupt`ファイルとしてバックアップしてから起動するよう変更した(今後同様の事態が起きても手動復旧の余地を残す)。単体テスト(アトミック書き込み・破損時バックアップ・復旧後の再保存)で動作確認。今回失われた登録内容自体はバックアップが無く復元不可能である旨をユーザーに報告した。
|
||||
|
||||
**14. 中央プレビューの2ペイン化**: 手描きの構成図での要望「動画ソース・ファイルドロップエリアを左端に移動、プレビューを2つにして左はアセットの選択動画/ドロップされた動画のプレビュー兼用、右は生成された動画のプレビュー用」を実装。従来は単一のプレビュー(Video Sourceドロップの表示専用で、Assetタブの選択動画やPlan実行結果を自動表示する仕組み自体がそもそも存在しなかった)だったのを、3カラム構成(左: Video Sourceフォーム、中央: Asset選択/Sourceドロップ兼用プレビュー、右: 生成動画専用プレビュー、いずれも新規実装)に再構成した。`video-tab.js`・`video-asset-tab.js`(Asset選択)・`video-plan-tab.js`(バッチ実行結果)の3ファイルがプレビュー状態を共有する必要があったため、新規`video-preview.js`という共有モジュールに切り出した(video-tab.jsが既に他の2ファイルをimportしているため、逆方向のimportを追加すると循環参照になることを回避するため)。Frame/GIFツールは「直近にアクティブになった方のプレビュー」を対象に動作する設計にした。Asset選択時はGalleryの絶対パスをComfyUIのfilename/subfolder/type形式に変換する手間を避けるため、動画をBlobとしてfetchし「ローカルファイル」として扱うことで、GIF変換の既存アップロードロジックをそのまま流用できるようにした。Assetサイドバーの詳細パネルにあった動画プレビュー(`<video>`要素)は、中央プレビューに統合されたため削除した。
|
||||
|
||||
**15. ヘルプの整合性チェック**: リリース前の最終確認としてVideo Tabヘルプ全11項目(Title/Desc/1〜10)をHTML・i18n.js(3言語)・app.jsのhelpIdMapで突き合わせ、11番で発見した`helpVideo8`の不整合以外にも、`helpVideoDesc`(旧「Plan / Asset / Edit」の一括り記述が9番以降のサイドバー分離構造と食い違っていた)と`helpVideo1`/`helpVideo2`/`helpVideo3`(HTML側が旧単発フォーム時代の文言のまま放置され、実際に表示されるi18n.js側の内容と長年食い違っていた)を発見・修正した。README.mdのVideo Tabセクションも同様に、4番のPlan/Asset/Edit再設計自体が一切反映されないまま単発フォーム時代の説明で放置されていたことが判明し、9〜14番までの内容を反映して全面的に書き直した。
|
||||
|
||||
**16. `pyproject.toml`のlicense記述をComfy Registry仕様準拠の形式に修正**: リリース準備の一環で、`license = "MIT"`という単純文字列形式が、公式仕様(`license = {file = "LICENSE"}`または`{text = "..."}`のいずれか)に該当しない非推奨形式であり、`comfy env`実行時にcomfy-cliが警告を出していたことを確認した([[project_registry_flagged_versions]]で追跡しているv0.5.0〜v0.5.3の原因不明のFlagged/Pending状態の調査依頼を受けて着手)。合わせて、参照先の`LICENSE`ファイル自体がリポジトリに存在しなかった(README.mdの「MIT License」という一文のみ)ため新規作成し、`license = { file = "LICENSE" }`に修正した。調査の結果、Comfy Registryのセキュリティスキャンは主にAI+静的解析(Ruff)によるPythonコード内の危険なパターン検出(`eval`/`exec`/`subprocess`/`pickle.loads`/カスタムpip wheel等)が対象で、`pyproject.toml`のメタデータ自体(license記述含む)がスキャン対象になるという情報は見つからなかった — つまり今回の修正はComfy Registryの公式仕様に沿った改善ではあるが、Flagged/Banned問題の直接的な解決策である確証はない。一方で、他の開発者(GitHub issue経由)でも同様の原因不明なFlaggedステータスが複数報告されており、pyproject.tomlの記述とは無関係にRegistry側の自動スキャンが誤検知(false positive)を起こしている可能性が高いことが分かった。具体例として、外部バイナリをsubprocessで呼び出す正当なコード(GraphicsMagick連携)が誤検知された報告があり、本プロジェクトにも同種のパターン(`gmic_routes.py`の`subprocess.Popen`+Windows固有の`CREATE_NEW_CONSOLE`フラグ、`wildcard_service.py`の`["cmd", "/c", "mklink", ...]`)が存在するため、これらが誤検知の一因になっている可能性はあるが確証はない(いずれも正当な機能で、変更は行っていない)。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`python -m py_compile`で構文確認、`templates/index.html`はPythonの`html.parser`でタグ対応チェックとJinja2実レンダリングで検証。ジャンクションバグの調査・検証は、ユーザーが起動中のComfyUI_5(ポート8189で稼働)に対し`curl`でAPIを直接叩いて実施 — `os.path.realpath()`で実際にジャンクションであることを確認し、`groups/{name}/images`(生成成功)と`list_images`(グループフィルタで空)の食い違いを再現、修正版デプロイ後の再検証はユーザー自身の実機確認で完了。8番の`__VideoAssets__`消失バグは、実行中サーバーの`gallery_metadata.json`を直接読み込んで原因を特定した(読み取り中にサーバー側の`_save_to_disk()`と競合しJSONDecodeErrorになることがあったため、パース成功するまで数回リトライして実データを確認)。13番の根本原因調査では、同じファイルへの読み取りを連続実行してもファイルサイズと成否が毎回変わることを確認し、実データが継続的に高頻度書き込みされている実態そのものを証拠として特定に用いた。13番の修正自体は`tempfile`を使った独立テストスクリプトで正常保存・再ロード後のデータ保持・破損ファイル検出時のバックアップ・復旧後の再保存を単体検証(ComfyUI起動不要)。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更・新規ファイルを都度両フォルダへ同期しCRLF/LF差異を除いた`diff`で完全一致を確認、Python変更(13番・12番の`gallery_routes.py`予約グループリスト)を含む回では実行時側の`__pycache__`もクリアした。各段階(レイアウト・Projectサブタブ追加・ハイライト復活・グループ運用整理・データ消失バグ・プレビュー2ペイン化・ヘルプ整合性)ともユーザー自身の実機確認・スクリーンショットや手描き構成図の提供を受けて次の修正に進む反復的な進め方で実施した。16番のRegistry仕様調査はWeb検索でComfy公式ドキュメント(`docs.comfy.org/registry/specifications`)とGitHub issue(Comfy-Org/ComfyUI-Manager #2927, #2997)を確認し、`pyproject.toml`の`license = { file = "LICENSE" }`変更後は`toml`パッケージでパース可能なことを確認した。
|
||||
|
||||
**How to apply**: 「◯◯機能では動くのに△△機能では動かない」という報告があった場合、両者の実行経路が本当に同じか(今回はLabがAPI形式を直接操作するのに対しVideoはUI形式から`convertUiToApi()`を経由する、という違い)を疑うと真因に早く到達できる。ComfyUIフロントエンドの`convertUiToApi()`のようなCOMBO値検証は「無警告でのフォールバック」を行う設計になっている場合があるため、実行時に動的アップロードした値をCOMBO項目に書き込む機能(画像チェイン等)を追加する際は、そのCOMBOの選択肢キャッシュが最新かどうかを必ず確認する必要がある。StabilityMatrixのような複数ComfyUIインスタンスでモデル/出力フォルダを共有するツールはジャンクション/シンボリックリンクを使うため、`Path.resolve()`をパスキーの一部に使うコードとファイルシステムスキャンで得たパスとの間に「解決する/しない」の不一致が生まれやすい — 同じ画像を指すパス文字列の保存/検索が全経路で一貫した規約(このプロジェクトでは「常にresolve()後の文字列で統一」)に従っているか、新しい書き込み経路を追加するたびに確認する価値がある。複数のタブ切替グループが同じCSSクラス/data属性を共有する実装は、後から片方だけ物理的に別の場所へ移動すると意図せず連動してしまうため、独立させたいグループは早い段階でクラス名やquerySelectorAllのスコープを分けておくとよい。予約グループ(`__Feeder__`のような「削除されると誰も自動で作り直さない」特別なグループ)を新設する際は、保護(削除禁止)の実装と同時に「常に存在することを保証する`ensure`呼び出し」もセットで用意しないと、保護を追加する前の空白期間や手違いで一度消えた場合に二度と復活しない — この2つは別々の対策であり、片方だけでは不十分。実行中サーバーが継続的に書き込んでいるJSONファイルを外部から直接読んで調査する際は、書き込みと衝突して不完全な内容を読んでしまうことがあるため、パースが安定して成功するまでリトライするのが安全。JSON等の設定/メタデータファイルへの永続化処理を新規実装・レビューする際は、単純な`open(path, "w")`による直接上書きになっていないか必ず確認する — 書き込み頻度が低いうちは問題が表面化しなくても、対象データ量が増えて書き込み回数が増えるほど「書き込み中のプロセス終了でファイルが壊れる」リスクが顕在化しやすく、かつ壊れたファイルのロード失敗を「空データにフォールバック」で処理していると、その後の1回の保存で過去の全データが消えるという最悪の組み合わせになる(一時ファイル+`Path.replace()`のアトミック置き換えと、破損検出時のバックアップ退避はセットで対策する)。UI再設計でコードごと削除した機能(今回のハイライト表示)は、対応するヘルプ文言だけが取り残されて「存在しない機能の説明」として残り続けることがあるため、大きめのUI再設計を行った際はヘルプ文言側の棚卸しも忘れずに行う。ユーザー向けドキュメント(README等)は実装が大きく変わった直後に更新を忘れると、次に触るまで長期間ズレたまま放置されやすい — 大規模なUI再設計コミットを作る際は、その場でREADMEの該当セクションも一緒に更新するのが安全(今回はPlan/Asset/Edit再設計そのものが数バージョン分READMEに未反映のまま残っていた)。
|
||||
|
||||
関連: [[project_v050_video_tab]], [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.4
|
||||
|
||||
### 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
|
||||
|
||||
複数セッションにまたがり実施された作業をまとめてリリース。Videoタブが従来MiniMax H3専用だった検出/生成ロジックをLTX-2.5・Wan2.2にも対応させ、続けて生成UIタブ側の解析ロジックにも同じ2ファミリーへの対応を追加した。並行してAI Assistant設定(Prompt/Tagger)をUnslothバックエンド対応・タブ別モーダル化・モデルアンロード機能へ刷新した。
|
||||
|
||||
**1. Videoタブ: LTX-2.5 / Wan2.2対応(ノード検出の汎用化)**: 従来MiniMax H3専用だったサブグラフ検出/フォーム反映ロジックを、サブグラフが公開する入力のlabel(無ければname)ベースで意味的にスロットを特定する方式に一般化した。MiniMax H3/LTX-2.5系は内部ノード型のプレフィックス(`LTXV*`等)で、Wan2.2は該当する固有ノード型を持たないためサブグラフ名自体("...(Wan2.2)"表記)で家系を判定する。これによりVideoタブの「Load in Video」からLTX-2.5のText/Image/First&Last-Frame to VideoワークフローとWan2.2のText to Videoワークフローも読み込み・生成できるようになった。同梱していない`minimax_test.json`の自動読み込みも廃止。
|
||||
|
||||
**2. バグ修正: convertUiToApi()のMultiTypeウィジェット脱落**: object_infoが複合型(例: `LTXVEmptyLatentAudio.frame_rate: "FLOAT,INT"`)を宣言している入力が、単純な完全一致判定(`upper === "INT" || ...`)に引っかからずウィジェット名/型リストから丸ごと脱落し、後続ウィジェットの値が1つずつずれる問題を発見・修正した。実際に`LTXVEmptyLatentAudio.batch_size`にframe_rateの値が誤って書き込まれ、動画生成が"Expected size 1 but got size 25"で失敗する原因になっていた。`_resolveWidgetType()`を新設し、カンマ区切りの型宣言から実際にウィジェットスロットを占める単一のスカラー型を解決するよう修正。
|
||||
|
||||
**3. 動画再生ボリュームの保存設定**: 動画を切り替えるたびに音量が最大へ戻ってしまう問題を解消するため、設定タブに音量設定を追加しVideo/Galleryタブの全プレーヤー間で保存・共有されるようにした。
|
||||
|
||||
**4. GenerateUIタブ: LTX-2.5 / Wan2.2対応(プロンプト検出・Latent Image・タイムアウト)**: Videoタブとは独立した解析経路(`analyzeWorkflow()`、フラット化済みAPI形式ワークフローを解析)側でも同じ2ファミリーの通常ワークフロー読み込みに対応させる作業。実機確認したところ、Model タブ(Checkpoint/VAE/Diffusion Model/Text Encoder/LoRA)は既存の`UNETLoader`/`CLIPLoader`/`VAELoader`ベースの汎用検出でWan2.2/LTX-2.5とも既に問題なく動作していたが、次の2点に不備を発見した。
|
||||
|
||||
- **Positive/Negative Prompt検出(LTX-2.5)**: LTX-2.5は`LTXVDualCFGGuider`(positive/negativeが同一の`LTXVConditioning`ノードの別出力スロットを指す)+ `LTXVConditioning`(自身のpositive/negative入力を介して実際のプロンプトソースへ分岐)という専用ノード構成を使うため、既存の`CFGGuider`/`InstructPixToPixConditioning`向けロール伝播ロジックでは拾えず、Positive/Negative Promptが空欄のままSaveVideoノードへのフォールバック表示になっていた。`LTXVDualCFGGuider`をロール伝播の起点に、`LTXVConditioning`を`InstructPixToPixConditioning`と同型の分岐伝播ノードとして追加し解決した。
|
||||
- **Latent Image検出(Wan2.2・LTX-2.5共通)**: SettingsタブのLatent Imageセクションが`EmptyLatentImage`/`EmptySD3LatentImage`/`EmptyFlux2LatentImage`のみ対応しており、Wan2.2の`EmptyHunyuanLatentVideo`とLTX-2.5の`EmptyLTXVLatentVideo`が未対応で「No EmptyLatentImage node found」表示になっていた。両ノード型を追加し、動画特有の`length`(フレーム数)入力を編集するフィールドも新設した(Duration×FPSの数式ノードにリンクされ非リテラルなことが多いため、Width/Height同様「linked」表示にフォールバックする)。
|
||||
- あわせて`isVideoWorkflow()`(生成タイムアウトを30分に延長する判定)を、`SaveVideo`/`CreateVideo`ノードや動画用latentノードの存在でも判定できるよう拡張した。従来はMiniMax H3のようなオールインワン`*ToVideo`ノードの有無でしか判定できず、LTX-2.5/Wan2.2は通常の静止画と同じ短いタイムアウトで打ち切られる恐れがあった。
|
||||
|
||||
**5. Videoタブ左パネルのハイライト表示**: GenerateUIのModelタブが持つ「ワークフローに実際に該当する項目のラベルをハイライト」する仕組み(`wfm-model-label-active`クラス、設定タブでカスタマイズ可能な共通色)をVideoタブの左パネルにも適用した。Prompt/Durationは常時、First Frame/Last Frameはサブグラフがそのスロットを公開している場合のみ、Aspect Ratio/Megapixels/MultipleはResolutionSelectorノードが存在する場合のみハイライトする — Wan2.2のように該当ノードがなく操作不可(disabled)な項目は、既存の無効化表示と矛盾しないよう非ハイライトのままにした。
|
||||
|
||||
**6. バグ修正: 生成UIタブRAW JSON検索がヒット箇所にカーソルを当てて不意に編集させてしまう**: 検索でヒットした際に`editor.focus()` + `setSelectionRange()`でテキストエリアへフォーカス・選択範囲を作っていたため、検索直後にキー入力すると選択範囲が上書きされ意図せずJSONが書き換わってしまう問題があった。ハイライト表示(`<mark>`によるオーバーレイ)とスクロール追従は維持しつつ、フォーカス移動と選択状態の作成のみを取りやめ、編集はユーザーが明示的にエディタをクリックした場合のみに限定した。
|
||||
|
||||
**7. AI Assistant設定: Unsloth対応・Prompt/Tagger独立モーダル化・モデルアンロード機能**: Prompt AI(Prompt タブ)とTagger VLM(Taggerタブ)にUnslothバックエンドを追加した(サーバー側プロキシでAPIキーを付与)。Taggerタブは従来Ollama直呼び出しだった実装を、Ollama/LM Studio/Lemonade/Unslothの4バックエンド共通の`vlm_models`/`vlm_predict`エンドポイントに統一し、API URL直接入力欄を廃止した。中央Settingsタブにあった単一の「AI Assistant Settings」パネルを廃止し、Prompt/Tagger各タブに⚙設定ボタン+専用モーダル(`ai-settings-modal.js`、Backend/Connection/Model/Generation/Unload/Saveの共通UIコンポーネント)を新設、それぞれ独立した`localStorage`キーで設定を持つように変更した(PromptとTaggerで異なるバックエンド/モデルを使い分けられる)。Thinking mode / Max tokensの設定をPrompt/Tagger双方の生成呼び出しに反映(Tagger側はPython側の`vlm_predict`にも反映)。Ollama(`keep_alive:0`)/LM Studio(`/api/v1/models/unload`)/Lemonade(`/api/v1/unload`)向けのモデルアンロード機能を追加し、AI TOOLタブの設定にもUnloadボタンを追加した(Unslothは対応する公式APIが無いため非対応として明示)。
|
||||
|
||||
**8. CI: publish-node-actionのバージョンをSHA固定**: `Comfy-Org/publish-node-action@main`という可変ブランチ参照は、アクション側が更新された際に挙動が変わりうるサプライチェーンリスクのため、リリースタグ1.0.1のコミットSHAに固定するよう`.github/workflows/publish.yml`を修正した([[project_registry_flagged_versions]]で追跡している過去のRegistry Banned/Flagged原因調査の一環)。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUIタブ(LTX-2.5/Wan2.2対応・RAW JSON検索の挙動)とVideoタブ(ハイライト色機能)のヘルプ本文を英語・日本語・中国語の3言語で追加・更新した(Prompt/Settings/AI TOOL/Taggerタブのヘルプは7番の対応セッション内で既に更新済み)。README.mdはWorkflowタブ(Load in Video対象の拡大)、GenerateUIタブ(MultiTypeバグ修正・LTX-2.5/Wan2.2対応)、Videoタブ(対応ファミリー拡大・ハイライト色・音量設定・見出しバージョン範囲更新)、Settingsタブ(AI Assistant Settings廃止の案内・音量設定・ハイライト色の共有先追記)、Prompt/Taggerタブ(AI Assistant設定のモーダル化・マルチバックエンド化)、AI TOOLタブ(モデルアンロード機能)を更新した。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`ast.parse()`で構文確認。GenerateUIタブのLTX-2.5/Wan2.2対応は、実機ブラウザ(Kapture)でwan22_test.json / ltx_25_test.jsonの2ワークフローを実際に生成UIタブへ読み込み、Positive/Negative Promptの実文表示・Latent Image欄(Width/Height/Batch Size/Length)の表示・ハイライト色の点灯状態をスクリーンショットで確認した。RAW JSON検索のフォーカス修正は、検索実行後にキー入力してもtextareaの値が変化しないことを実機で確認した。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルを都度両フォルダへ同期しCRLF/LF差異を除いた`diff`で完全一致を確認した。
|
||||
|
||||
**How to apply**: `analyzeWorkflow()`のような中央解析関数はワークフロー「ファミリー」が増えるたびに、既存パターン(KSampler系のロール伝播、EmptyLatentImage系のlatentノード検出)と同型の分岐を追加していく設計で拡張できる — 逆に言えば、新しいノード構成(今回のLTXVDualCFGGuider/LTXVConditioningのような「複数出力を持つ1ノードがpositive/negativeを兼ねる」パターン)は`InstructPixToPixConditioning`のような既存の同型ケースを探して倣うと実装が速い。同じワークフローファミリーへの対応が複数タブ(Video/GenerateUI)にまたがる場合、検出ロジック自体は各タブの解析経路(Video: サブグラフを直接歩く、GenerateUI: フラット化済みAPI形式を解析)が別実装なので、片方を直しても他方は直らない — 「Videoタブでは動くのにGenerateUIでは動かない」という状況は、まさにこの二重実装のズレが原因だった。テキストエリアへの自動フォーカス/selectionRangeは、検索・ハイライトのような「見せるだけ」の操作と「編集を許可する」操作を暗黙に混同させるため、ハイライト表示に徹する機能ではフォーカス移動自体を避けるのが安全。
|
||||
|
||||
関連: [[project_v050_video_tab]], [[project_dev_deploy_sync]], [[project_v0336_conventions]], [[project_registry_flagged_versions]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.3
|
||||
|
||||
### バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
|
||||
|
||||
ユーザーからの複数のバグ報告・改善要望を1セッションで順に対応した作業。
|
||||
|
||||
**1. バグ修正: Labタブで起動時デフォルトワークフロー読み込み後に別ワークフローを読み込むと前の内容が残る**: 「別のワークフローを読み込んだ。一部以前のワークフローの内容が残ったまま表示される(クリックするか他のタブから戻ると消える)」との報告。Labタブの「Setting 1」列(Checkpoint/LoRA/VAE/Prompt/KSampler)はGenerateUIの現在の読み込み内容をライブ反映する仕組みだが、その再描画(`refreshLabLiveDefaults()`)は「サブタブをLabに切り替えたとき」の一箇所(`generate-tab.js`のサブタブクリックハンドラ)でしかトリガーされていなかった。Labタブを既に開いた状態で別のワークフローを読み込んだ場合、`loadWorkflowIntoEditor()`側では再描画が一切呼ばれず、前のワークフローの表示がそのまま残っていた。「セルをクリックしたり他のタブから戻ると直る」という報告内容は、それらの操作がたまたま再描画をトリガーしていたことの裏返しだった。`loadWorkflowIntoEditor()`の末尾で、現在アクティブなサブタブがLabの場合は`refreshLabLiveDefaults()`を追加で呼ぶよう修正。
|
||||
|
||||
**2. 改善: Galleryタブの生成後の更新が遅い**: 「生成後の画像表示に更新して読み直すのに時間がかかる」との報告。実データ(Output先の`Text2Img`フォルダ、画像9,777枚)で`_scan_folder()`を実測したところ**7.5秒**かかっており、原因を切り分けたところ`entry.stat(follow_symlinks=True)`と、各ファイルごとに呼んでいた`Path(entry.path).resolve()`の2箇所がボトルネックと判明。特に`resolve()`の再解決が支配的で、これだけで1ファイルあたり約0.7ms×9,777件のファイルシステム往復が発生していた。`_scan_folder()`の呼び出し元(`list_images()`/`_bg_index_folder()`)は既にフォルダ自体を`Path(folder_path).resolve()`済みで渡しているため、`entry.path`は既に絶対パスであり、エントリ単位での再`resolve()`は不要だった。`entry.is_file()`/`entry.stat()`を`follow_symlinks=False`に変更し、パス生成も`entry.path.replace("\\", "/")`に簡略化(出力フォルダにシンボリックリンクの画像が置かれる想定はないため安全と判断)。同じ理由で`os.walk()`ベースだった`_scan_folder_recursive()`(ImagePrompt/Style Catalog用の再帰スキャン)も、`os.scandir()`を自前で再帰しDirEntryベースでstatする実装に書き換えた。実測で9,777枚のフォルダに対する`list_images()`全体が0.14秒(コールド)/0.08秒(フォルダキャッシュ温存時)まで短縮(約50倍高速化)。
|
||||
|
||||
**3. 改善: Galleryフォルダツリーのrootをデフォルト展開状態に**: 上記の高速化を受けて「rootフォルダはデフォルトで展開状態で表示したい」との要望。フォルダスキャンが軽くなったことでサブフォルダの内容もすぐ確認できるようになった一方、`renderTreeNode()`はrootも他のノードと同じく初期状態は折りたたみ(▶)だったため気付きにくかった。root(`isRoot`)の場合のみ、ツリー構築時に矢印を展開済み(▼)にし子ノードを即座にレンダリングするよう`gallery-tab.js`を修正。
|
||||
|
||||
**4. READMEスクリーンショット刷新**: 「以下のスクリーンショットは削除したい」との依頼でGenUI Feeder / GenUI LoRA Stack / Models Multi-select Menu / Tagger Tab / Customizeの5件をREADME.mdの表と`docs/`実ファイルの両方から削除(残った項目は2列レイアウトに詰め直し)。続けてVideo Tab・Lab Tab(2枚)・AI TOOL Tabの新規スクリーンショット計4枚をユーザーが`docs/`に配置し、READMEの表に追加した。
|
||||
|
||||
**検証**: JS/Pythonとも`node --check`/`ast.parse()`で構文確認。Gallery高速化は開発元リポジトリの`GalleryService`を実データ(実際の`Text2Img`フォルダ、9,777枚)に対してvenv Pythonから直接呼び出し、修正前後の`_scan_folder()`/`list_images()`の実行時間をベンチマークして改善を数値で確認(7.5秒→0.063秒、`list_images()`全体は0.14秒)。返却されるパス文字列が修正前と同じ絶対パス形式であることも確認済み。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更・削除した全ファイル(`generate-tab.js`, `gallery_service.py`, `gallery-tab.js`, `README.md`, `docs/`配下の画像)を都度両フォルダへ同期し`diff`/存在確認で完全一致を確認、Pythonファイル変更のため`__pycache__`もクリア。全項目ともユーザー自身の実機確認で解消・反映を確認済み。
|
||||
|
||||
**How to apply**: あるタブ/サブタブの表示を「別の場所(GenerateUI本体など)の現在の状態をライブ反映する」設計にする場合、再描画のトリガーを「そのタブへの切替」1箇所だけに置くと、切替を経由しない別の更新経路(今回はワークフロー読み込み)で表示が古いまま取り残される。ライブ反映される値の更新元(今回は`loadWorkflowIntoEditor()`)側でも、現在表示中のタブを見て能動的に再描画を呼ぶ必要がある。パフォーマンス調査では「体感で重い処理」を分解して各行を実測するまで真犯人を決め打ちしない方がよい — 今回も当初`follow_symlinks=True`のstat呼び出しを主因と想定したが、実測すると`Path.resolve()`の方が支配的だった。Windows上で大量ファイルを扱う`os.scandir()`ループでは、`entry.path`が既に呼び出し元でresolve済みのフォルダ配下の絶対パスであることを踏まえ、エントリ単位での`resolve()`/`follow_symlinks=True`はファイル数に比例した無視できないコストになり得るため避ける。バックエンドの表示速度を改善した際は、それに応じてフロントエンドのデフォルトUI状態(今回は折りたたみ初期状態)も見直す価値がある — 「遅いから畳んでおく」という妥協がボトルネック解消後も残ったままになりがち。
|
||||
|
||||
関連: [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.2
|
||||
|
||||
### 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
|
||||
@@ -15,10 +812,12 @@ v0.5.0/v0.5.1で専用Videoタブを整備したMiniMax H3動画生成ワーク
|
||||
**3. バグ修正: LabタブKsampler列が動画ワークフローでも表示される**: 「Model同様に該当ノードがなければ非表示にしたい」との要望に対し当初`sampler_nodes.length > 0`で判定したが、実機確認で「まだ表示される」との報告。原因はMiniMax H3のサブグラフ内部に実際に`SamplerCustomAdvanced`が配線されており(内部プリセットの一部で、ユーザーが調整すべきものではない)、単純な有無判定では引っかからなかったこと。判定を`comfyWorkflow.isVideoWorkflow(analysis)`ベースに切り替え、動画ワークフロー全体ではKsampler列自体を常に非表示にするよう修正した。
|
||||
|
||||
**4. Videoタブ拡張: Frame抽出 / アニメーションGIF変換**: Eagleプラグイン(video-to-frame, video2gif)を参考に要望された2機能。Eagle版はNode.js+ffmpegバイナリ直接呼び出しの実装だったため、ComfyUI-Workflow-Studioの構成(Python/aiohttpバックエンド)に合わせて設計し直した。フレーム抽出は`<video>`+`<canvas>`でブラウザ完結(サーバー往復・追加依存なし)。GIF変換はユーザー確認の上サーバー側実装を選択:PyAV(デコード)+ Pillow(GIFエンコード)— どちらもComfyUI本体が既に要求している依存のため、ffmpegバイナリや新規ライブラリの追加インストールは不要。`py/services/video_service.py` / `py/routes/video_routes.py`を新規追加し、`folder_paths.get_save_image_path()`でComfyUI標準の自動採番保存パターンをそのまま踏襲。合成テスト動画(PyAVで生成)を使いvenv環境でデコード→GIFエンコードの実処理を検証済み。
|
||||
- あわせてVideoタブの中央パネルを再設計:「結果」見出し/「まだ結果がありません」表示を廃止し、生成結果とドロップされた任意のローカル動画を同じプレビュー枠に統合、横に**Video Source**ドロップ/クリック選択エリア(Clearボタン付き)を新設。右パネルの「Properties」プレースホルダーを**Frame**/**GIF**の2タブに置き換え。生成結果に限らず任意の動画に対してFrame/GIF機能が使える設計にした。
|
||||
|
||||
- あわせてVideoタブの中央パネルを再設計:「結果」見出し/「まだ結果がありません」表示を廃止し、生成結果とドロップされた任意のローカル動画を同じプレビュー枠に統合、横に**Video Source**ドロップ/クリック選択エリア(Clearボタン付き)を新設。右パネルの「Properties」プレースホルダーを**Frame**/**GIF**の2タブに置き換え。生成結果に限らず任意の動画に対してFrame/GIF機能が使える設計にした。
|
||||
|
||||
**5. データ管理の監査とZIPフルバックアップ機能の新規実装**: 「設定のデータ管理を現在のバージョンで対応できているか確認してほしい」との依頼を受け、既存の`_DATA_FILES`(単一JSONバンドル方式)と実際のデータディレクトリを突き合わせ監査。**`civitai_cache.json`(3.6MB、CivitAI連携キャッシュ)がエクスポート対象から漏れていた**ことを発見し即修正。また、ヘルプが謳う「export all plugin data」に反し、フォルダ形式のデータ(`ai_skills/`、`lab_plan/`、`style/`)とSQLite DB(`tagger.db`)が単一JSONバンドル方式では原理的に扱えず完全に対象外になっていた点をユーザーに報告、ZIP形式のフルバックアップ機能を新規実装する方針で合意。`DATA_DIR`配下を丸ごとZIP化する`GET /api/wfm/settings/export-full`/`POST /api/wfm/settings/import-full`を追加(一時ファイル系`gmic_temp`/`thumb_cache`と、他custom_node(comfyui-impact-pack)へのシンボリックリンクである`wildcard/`のみ除外、`settings.json`はAPIキーを除外してから含める)。復元側はZip Slip対策(`../`等でDATA_DIR外に書き込もうとするエントリは`relative_to()`チェックでスキップ)を実装し、悪意あるZIPでの検証済み。
|
||||
- 追加要望「デフォルトワークフローフォルダー(`user/default/workflows`)とwildcardフォルダー(`comfyui-impact-pack/wildcards`)もオプションでバックアップに含めたいが、インポートは除外し手動対応としたい」に対応:エクスポート画面にオプトインのチェックボックスを2つ追加、有効時はZIP内に`_external/`プレフィックスで格納し、復元処理は`_external/`配下のエントリを常にスキップする実装にした(他プラグイン/ComfyUI本体が管理する領域を自動上書きしないため)。実データ(ワークフロー535件・wildcard 34件)で検証済み。
|
||||
|
||||
- 追加要望「デフォルトワークフローフォルダー(`user/default/workflows`)とwildcardフォルダー(`comfyui-impact-pack/wildcards`)もオプションでバックアップに含めたいが、インポートは除外し手動対応としたい」に対応:エクスポート画面にオプトインのチェックボックスを2つ追加、有効時はZIP内に`_external/`プレフィックスで格納し、復元処理は`_external/`配下のエントリを常にスキップする実装にした(他プラグイン/ComfyUI本体が管理する領域を自動上書きしないため)。実データ(ワークフロー535件・wildcard 34件)で検証済み。
|
||||
|
||||
**6. Send to LI node機能をGalleryタブにも追加 + ボタン名統一**: Image Editタブに既にあった「選択画像をComfyUIキャンバス上の選択中ノードのimageウィジェットへ送る」機能(`window.opener.wfmSendImageToSelectedNode`、`node_sets_menu.js`で定義されたクロスウィンドウ橋渡し)を、Galleryタブの選択画像にも使えるよう追加。Galleryの画像は任意のフォルダにあり得るため、既存の「Send GenUI Image」と異なりLoadImageノードの有無に依存せず、`/upload/image`でinputフォルダへアップロードしてからファイル名を書き込む方式にした。あわせてImage Edit側の既存ボタン名「Send to Workflow」を「Send to LI node」に統一(トースト文言・コメント・ヘルプも合わせて更新)。
|
||||
|
||||
@@ -73,7 +872,8 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**4. First Frame/Last Frameを両方optionalに、LoadImageノードが無いワークフローにも対応**: 「First Frameなしでも動作させたい(ノードをバイパスで処理)。またノードのないワークフローにも対応させたい」との要望。`minimax_test1.json`(first_frame/last_frame双方とも未接続、対応するLoadImageノードがトップレベルに存在しない構成)を新たに確認し、`locateMiniMaxNodes()`のLoadImageノード必須チェックを撤廃。first_frame未指定時はリンクなしのまま実行(バイパス)し、画像が指定された場合のみその場でLoadImageノードを動的に注入する設計に変更(ノードID/リンクIDはclone内の実際の最大値+1で採番するため重複の心配がない)。first_frameスロットにもlast_frameと同様のClearボタンを追加した。
|
||||
|
||||
**5. バグ修正: mp4のタグ/メモがComfyUI再起動後に消える**: 「タグ、メモadd、保存操作はできるが記録されていない。画像ファイルもできていない。エラー表示はなし、ブラウザもなし」との報告を受け、`comfyui_8189.log`を直接確認したところ`Cleaned up 189 stale metadata entries from ...\Text2Img`という決定的なログを発見。**根本原因**: `cleanup_stale_images()`は「現在表示中のフォルダ配下」のメタデータキーを全階層(サブフォルダ含む)対象にstale判定するが、Galleryがルートフォルダを非再帰スキャンした場合、`existing_paths`(現在のファイル一覧)にはルート直下のファイルしか含まれない。Videoタブで生成した動画は`SaveVideo`の`filename_prefix`設定で`video/MiniMax_H3/`というサブフォルダに保存されるため、ルートフォルダを開くたびに「サブフォルダ内ファイルは非再帰スキャンには映らない→存在しないと誤判定→メタデータ削除」が発生し、189件ものタグ/メモ/お気に入り情報が消えていた。`cleanup_stale_images()`に`recursive`引数を追加し、非再帰スキャン時はfolder_path直下のみを対象にするよう修正(`gallery_service.py`の`list_images()`から`recursive`をそのまま伝播)。既に削除済みのデータはバックアップが無く復元不可能だった旨をユーザーに伝えた。
|
||||
- 副次的に発見したフロントエンドのバグも合わせて修正: `saveMetaField()`がサーバーのレスポンス(`{"ok": bool}`)を一切チェックせず常に成功したかのようにローカル状態を楽観的更新していたため、サーバー側で保存が拒否されてもUI上はエラーなく成功したように見えてしまう作りになっていた。レスポンスの`ok`フィールドを検証し失敗時は例外を投げるよう変更、`addTag`/`removeTag`/メモ保存ハンドラは戻り値を見て成功時のみローカル状態を更新するよう修正した。保険的措置として`save_image_meta`(バックエンド)の保存キーも`get_image_metadata`と同じ`resolve()`後のパスに統一した。
|
||||
|
||||
- 副次的に発見したフロントエンドのバグも合わせて修正: `saveMetaField()`がサーバーのレスポンス(`{"ok": bool}`)を一切チェックせず常に成功したかのようにローカル状態を楽観的更新していたため、サーバー側で保存が拒否されてもUI上はエラーなく成功したように見えてしまう作りになっていた。レスポンスの`ok`フィールドを検証し失敗時は例外を投げるよう変更、`addTag`/`removeTag`/メモ保存ハンドラは戻り値を見て成功時のみローカル状態を更新するよう修正した。保険的措置として`save_image_meta`(バックエンド)の保存キーも`get_image_metadata`と同じ`resolve()`後のパスに統一した。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、新規Video Tabヘルプページを追加。README.mdはバージョンバッジ更新、Workflow Tab(サイドパネルツールバーの3ボタン構成)、Gallery Tab(Downloadボタンの新配置・mp4対応・GenUIボタンへの改名)、新規Video Tabセクション(3ペイン構成・optional First/Last Frame・ノードなしワークフロー対応)を追加した。
|
||||
|
||||
@@ -124,7 +924,8 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**4. yamlワイルドカードを使うサンプルワークフロー**: 「yamlファイルのワイルドカードを使用するワークフローを作成してもらうことはできますか」との依頼。調査の結果、このプラグイン自体のワイルドカード展開(SPA側の`_expandWildcardsInWorkflow`)は`.txt`拡張子固定でyaml非対応であり、yaml形式は`ImpactWildcardEncode`/`ImpactWildcardProcessor`ノード(Impact Pack、サーバー側処理)でのみ解決できることが判明。プラグインのwildcardディレクトリはjunction経由でImpact Packの`wildcards/`フォルダへ直結しているため、通常のWildcard File Manager経由で保存したyamlファイルがそのままImpactWildcardEncodeから参照可能。サンプルyamlワイルドカードファイル(`ws_sample_season_lighting.yaml`)とImpactWildcardEncodeノードを使うサンプルワークフロー(`ws_yaml_wildcard_sample.json`)を作成し、実際にComfyUIで生成→出力PNGのメタデータで`populated_text`が階層キー参照から正しく展開されていることを確認した。
|
||||
|
||||
**5. Galleryタブ詳細パネルに「Prompt」タブ新設**: 「メタデータでも確認できるが頻度を考えるとマウス操作が多いため、プロンプトだけはこちらでも表示としたい。ボタンは右端でデフォルトで選択状態としたい」との要望。当初は単純な全文連結表示で実装したが、「メタデータタブの表示の様に複数プロンプトがあるケースも考慮したい」とのフィードバックを受け、Metadataタブの`extractPrompts`/`buildPromptItem`をexportし、完全に同じPOS/NEGバッジ付きリスト→クリックで全文表示のUIに作り直した。
|
||||
- さらに実機確認の中で、同一画像でもMetadataタブとGalleryタブでpositive数が食い違うケース(`Krea2_turbo_00133_.png`、Metadataでは2件のPositiveが見える)を発見。原因は埋め込みデータの優先順位の違い — Metadataタブは`prompt`(API形式、サブグラフ展開済みでフラット)を優先するのに対し、Gallery側サーバーAPIは`workflow`(UI形式、ノード位置等を保持)を優先していた。トップレベルとサブグラフに独立した2系統のサンプリングを持つワークフローでは、UI形式のトップレベルnodes配列に一方の系統しか含まれず、`extractPromptsLiteGraph`の早期returnロジックがその系統しか拾えないため。既存のLoad GenUI/JSON表示/Copy & Send Canvas等には一切影響しないよう、`gallery_service.py`に優先順位を選べる`_extract_embedded_workflow`ヘルパーを追加し、新規`extract_prompt_workflow_from_metadata`(API形式優先)を`/wfm/gallery/image/workflow`のレスポンスへ`prompt_workflow`として追加、Galleryの Promptタブの抽出だけこちらを使うようにした。Python側の変更を反映するため、ユーザー自身の手でComfyUIを再起動していただいた。
|
||||
|
||||
- さらに実機確認の中で、同一画像でもMetadataタブとGalleryタブでpositive数が食い違うケース(`Krea2_turbo_00133_.png`、Metadataでは2件のPositiveが見える)を発見。原因は埋め込みデータの優先順位の違い — Metadataタブは`prompt`(API形式、サブグラフ展開済みでフラット)を優先するのに対し、Gallery側サーバーAPIは`workflow`(UI形式、ノード位置等を保持)を優先していた。トップレベルとサブグラフに独立した2系統のサンプリングを持つワークフローでは、UI形式のトップレベルnodes配列に一方の系統しか含まれず、`extractPromptsLiteGraph`の早期returnロジックがその系統しか拾えないため。既存のLoad GenUI/JSON表示/Copy & Send Canvas等には一切影響しないよう、`gallery_service.py`に優先順位を選べる`_extract_embedded_workflow`ヘルパーを追加し、新規`extract_prompt_workflow_from_metadata`(API形式優先)を`/wfm/gallery/image/workflow`のレスポンスへ`prompt_workflow`として追加、Galleryの Promptタブの抽出だけこちらを使うようにした。Python側の変更を反映するため、ユーザー自身の手でComfyUIを再起動していただいた。
|
||||
|
||||
**6. Send to Canvasのフォールバック方式を刷新**: 「このワークフローを生成UIタブに読み込むと...またキャンバスへ送るでAPI形式のためタイトルをキャンバスへドラッグのメッセージが表示されますがこの動作を変更したい...UI形式のサムネイルがないパターンのワークフローも同様にしたい」との要望。`window.opener`経由の直接ロードに失敗した場合の従来のフォールバック(`localStorage`+パネルタイトルのドラッグ、API形式のワークフローはそもそも非対応でエラーのみ)を、Workflow Studio LibraryのW(Workflows)タブを該当ワークフローで絞り込み表示する方式に統一した。`node_sets_menu.js`に`showWorkflowInLibrary()`をexportし、`top_menu_extension.js`が新規localStorageキー`wfm_library_filter_request`への`storage`イベントを監視して呼び出す(同一オリジンの別タブでのみ発火する仕様を利用)。フォーマット(UI形式・API形式)を問わず同じ導線になり、サムネイル付きの実物のワークフローカードからユーザー自身がドラッグできるようになった。Galleryタブの「Copy & Send Canvas」(画像埋め込みのその場限りのワークフローが対象で、そもそもLibrary一覧=保存済みファイルには存在しない)は今回のスコープ外として変更していない。
|
||||
|
||||
@@ -151,14 +952,15 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**2. GenerateUIタブ: Save ボタンをBatch/Labサブタブで非表示化**: 「ワークフローのSaveボタンをBatch、Labサブタブ選択時、非表示にしたい」との要望。両サブタブは現在ロード中のワークフロー本体ではなく、ワークフローの複製やLab独自のPlanファイルを操作するため、Saveボタンが誤解を招く(クリックしても実際に触っているワークフローとは別物が保存される)ことへの対処。サブタブ切替ハンドラ(`generate-tab.js`)にワンライナーの表示切替を追加。
|
||||
|
||||
**3. Promptタブに「Table」表示を新設**: 「現在のプロンプトタブをタブ化してテーブルタブを追加したい(既存はフォームタブとする)。テーブルタブではプロンプトのプリセット、ワイルドカード、スタイルがそれぞれ表形式で一覧表示、編集可能としたい」との要望を起点に、複数回のフィードバックを経て以下の形に発展した:
|
||||
- 既存の3カラムレイアウト(AI Assistant / Presets・Preset Manager / Wildcard・Style)をまるごと「Form」パネルとしてラップし、新設の「Table」パネルと上部のトップレベルタブで切り替える構成にした。
|
||||
- Tableパネル内にPresets・Wildcards・Styleの3つの編集可能テーブルを新設(`prompt-table.js`新規ファイル)。既存の`prompt-tab.js`が保持するAPIヘルパー(`fetchPresets`/`apiUpdatePreset`/`wcSaveFile`/`styleApiUpdate`等)とリフレッシュ関数を`export`して再利用し、Table側での編集は即座にForm側にも反映されるようにした。
|
||||
- **「Presets右隣にPresets Groupを追加したい」**: グループ管理専用の4つ目のサブタブを新設。グループの作成・リネーム・削除・メンバー一覧(バッジ表示・×で削除)に対応。
|
||||
- **「共通で左端に番号(自動)列を追加したい。個別の削除ボタンは誤操作が予想されるため上部にボタンを設置し番号列で該当番号選択、削除という手順にしたい」**: 4テーブル共通で自動採番の`#`列を新設し、番号クリックで行選択(複数可・赤ハイライト)→ツールバーの「Delete」ボタンで一括削除、という手順に統一。個別の行削除ボタンは全廃した。新規行追加時は番号セルの代わりに小さな✓(Add)/✕(Cancel)アイコンを表示する設計にした。
|
||||
- **「バッチグループの登録方法を変更したい」**: PresetsテーブルにForm側と同じ仕組みの「B」列(クリックでグレー⇔黄色にトグルしBatchグループへ登録/解除)と、Batchグループを一括クリアする「BC」ボタンをツールバー上部に追加。Presets Groupテーブルからは予約グループ「Batch」を非表示にし、Batch管理をPresetsテーブル側のB列/BCボタンへ一本化した。
|
||||
- **「PresetsのコピーボタンとグループドロップダウンをテーブルのPresetsタブに配置したい。これによりPresets GroupタブのAdd Presetsをなくしたい(プリセットが多くなった際に選択が困難なため)」**: Presets Groupテーブルの「Add Preset」列(プリセットが多いと使いづらい長大なドロップダウン)を廃止。代わりにPresetsテーブルのツールバーに「PP Copy」「NP Copy」(選択中1件のPositive/Negativeをクリップボードへコピー)と、グループ選択+「Add to Group」(選択中の複数プリセットを一括で選んだグループへ追加)を新設した。プリセット側を検索・選択してから短いグループ一覧から選ぶ流れにしたことで、プリセット数が多くても操作しやすい設計になった。
|
||||
- **「選択クリアボタンも追加したい」**: 4テーブル共通で「Deselect」ボタンをツールバーへ追加し、選択状態を一括解除できるようにした。
|
||||
- **「プリセット、ワイルドカード、スタイルで番号をWクリックでモーダルでプロンプトの表示、編集したい」**: `#`列のダブルクリックで、既存の共有モーダル基盤(`app.js`の`openModal`/`closeModal`)を再利用したプロンプト編集モーダルを開くようにした。Presets/Styleは正負プロンプト切り替えボタン+共有の大型テキストエリア(切り替えても両方の編集内容を保持)、Wildcardsはファイル内容のみのテキストエリアで、Save/Closeボタンを備える。
|
||||
|
||||
- 既存の3カラムレイアウト(AI Assistant / Presets・Preset Manager / Wildcard・Style)をまるごと「Form」パネルとしてラップし、新設の「Table」パネルと上部のトップレベルタブで切り替える構成にした。
|
||||
- Tableパネル内にPresets・Wildcards・Styleの3つの編集可能テーブルを新設(`prompt-table.js`新規ファイル)。既存の`prompt-tab.js`が保持するAPIヘルパー(`fetchPresets`/`apiUpdatePreset`/`wcSaveFile`/`styleApiUpdate`等)とリフレッシュ関数を`export`して再利用し、Table側での編集は即座にForm側にも反映されるようにした。
|
||||
- **「Presets右隣にPresets Groupを追加したい」**: グループ管理専用の4つ目のサブタブを新設。グループの作成・リネーム・削除・メンバー一覧(バッジ表示・×で削除)に対応。
|
||||
- **「共通で左端に番号(自動)列を追加したい。個別の削除ボタンは誤操作が予想されるため上部にボタンを設置し番号列で該当番号選択、削除という手順にしたい」**: 4テーブル共通で自動採番の`#`列を新設し、番号クリックで行選択(複数可・赤ハイライト)→ツールバーの「Delete」ボタンで一括削除、という手順に統一。個別の行削除ボタンは全廃した。新規行追加時は番号セルの代わりに小さな✓(Add)/✕(Cancel)アイコンを表示する設計にした。
|
||||
- **「バッチグループの登録方法を変更したい」**: PresetsテーブルにForm側と同じ仕組みの「B」列(クリックでグレー⇔黄色にトグルしBatchグループへ登録/解除)と、Batchグループを一括クリアする「BC」ボタンをツールバー上部に追加。Presets Groupテーブルからは予約グループ「Batch」を非表示にし、Batch管理をPresetsテーブル側のB列/BCボタンへ一本化した。
|
||||
- **「PresetsのコピーボタンとグループドロップダウンをテーブルのPresetsタブに配置したい。これによりPresets GroupタブのAdd Presetsをなくしたい(プリセットが多くなった際に選択が困難なため)」**: Presets Groupテーブルの「Add Preset」列(プリセットが多いと使いづらい長大なドロップダウン)を廃止。代わりにPresetsテーブルのツールバーに「PP Copy」「NP Copy」(選択中1件のPositive/Negativeをクリップボードへコピー)と、グループ選択+「Add to Group」(選択中の複数プリセットを一括で選んだグループへ追加)を新設した。プリセット側を検索・選択してから短いグループ一覧から選ぶ流れにしたことで、プリセット数が多くても操作しやすい設計になった。
|
||||
- **「選択クリアボタンも追加したい」**: 4テーブル共通で「Deselect」ボタンをツールバーへ追加し、選択状態を一括解除できるようにした。
|
||||
- **「プリセット、ワイルドカード、スタイルで番号をWクリックでモーダルでプロンプトの表示、編集したい」**: `#`列のダブルクリックで、既存の共有モーダル基盤(`app.js`の`openModal`/`closeModal`)を再利用したプロンプト編集モーダルを開くようにした。Presets/Styleは正負プロンプト切り替えボタン+共有の大型テキストエリア(切り替えても両方の編集内容を保持)、Wildcardsはファイル内容のみのテキストエリアで、Save/Closeボタンを備える。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUI Tab(Saveボタン非表示の説明)・Prompt Tab(新規カード「Form / Table」「Table View」10項目)・Gallery Tab(Metadataサブタブの説明)・Metadata Tab(Galleryタブ内へ移動した旨の注記、`metadata-tab.js`側の独自i18n適用ロジックへ追加)を更新。README.mdはGenerateUI TabのSaveボタン説明を拡張、Prompt Tabセクションに新規ブロックを追加、「Metadata Tab」セクションをGalleryタブの4番目のサブタブとして「Metadata Gallery subtab」に改称の上、Style_Catalog Gallery subtabの直後へ移設した。
|
||||
|
||||
@@ -181,10 +983,11 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
ユーザーから「トップバーで保存した2つのワークフロー(I2I_rmbg.json/I2I_rmbg_2.json)を生成UIタブで読み込んで生成するとRemove Background(RMBG)ノードが機能せず透過画像が作成されない。ComfyUIのExport(API)で保存した3つ目のファイルは正しく動く」との報告を受けて調査した一連のセッション。
|
||||
|
||||
**1. ワークフロー解析の調査**: RMBGノードは`background_color`という「ウィジェットを持たない特殊な入力(COLORCODE型、`model`より前の位置)」を持つ特徴的な構造で、UI形式→API形式変換時に`widgets_values`の対応がずれる可能性を最初に疑った。`comfyui-workflow.js`の`convertUiToApi()`を段階的に検証:
|
||||
- `/object_info/RMBG`を実機から取得し、`_getWidgetInputNames()`が`forceInput`相当(widgetキーを持たない入力)を正しく除外し、`background_color`がウィジェット名リストから漏れることを確認。
|
||||
- ユーザー提供の`I2I_rmbg_2.json`をブラウザ内(Playwright MCP)で実際に`convertUiToApi()`にかけ、変換結果を確認 — `model`/`sensitivity`/`background`など全フィールドが正しくマッピングされていた。
|
||||
- その変換結果を実際に`/prompt`へキューイングして実行、さらにGenerateUIタブの本物のGenerateボタンをクリックする経路でも実行 — いずれも出力PNGはRGBA・アルファチャンネル0〜255の**正しい透過画像**だった。
|
||||
- 結論: 現在デプロイされているv0.4.3のワークフロー解析ロジックに該当バグは再現せず、コード変更は行わなかった。報告時点でより古いバージョンを使っていた可能性が高いと判断し、ユーザーに調査結果と再検証手順を報告した。
|
||||
|
||||
- `/object_info/RMBG`を実機から取得し、`_getWidgetInputNames()`が`forceInput`相当(widgetキーを持たない入力)を正しく除外し、`background_color`がウィジェット名リストから漏れることを確認。
|
||||
- ユーザー提供の`I2I_rmbg_2.json`をブラウザ内(Playwright MCP)で実際に`convertUiToApi()`にかけ、変換結果を確認 — `model`/`sensitivity`/`background`など全フィールドが正しくマッピングされていた。
|
||||
- その変換結果を実際に`/prompt`へキューイングして実行、さらにGenerateUIタブの本物のGenerateボタンをクリックする経路でも実行 — いずれも出力PNGはRGBA・アルファチャンネル0〜255の**正しい透過画像**だった。
|
||||
- 結論: 現在デプロイされているv0.4.3のワークフロー解析ロジックに該当バグは再現せず、コード変更は行わなかった。報告時点でより古いバージョンを使っていた可能性が高いと判断し、ユーザーに調査結果と再検証手順を報告した。
|
||||
|
||||
**2. Labタブ: モデルの無いワークフローで無意味なキーフレームが作成される問題**: 調査の副産物として、RMBGのみ(Checkpoint/LoRA/VAEいずれのノードも無い)ワークフローをLabタブで開くと、Model列に「1: (ワークフローの設定のまま)」という空のキーフレーム行が常に表示されてしまうことを実機で発見。ユーザーから「不要なため作成しない形にしたい」との要望を受けて修正。`_buildWorkflowForIteration()`は対象ノードIDが存在しない場合、書き込みを黙ってスキップするだけ(`write()`の`wf[nodeId]`ガード)なので、この行はRun時に何の効果も持たない死んだコントロールだった。`_hasNodeForColumn(col)`を新設し、`_renderColumn()`と`_initColumnButtons()`の+ボタンで判定 — 対応ノードが無い列は「このワークフローに該当ノードがありません」というプレースホルダーを表示し、キーフレーム追加もブロックする。VAEだけは特殊扱いで、専用VAELoaderが無くてもCheckpointノードが存在すれば内蔵VAEの上書き注入が意味を持つため(既存の`_buildWorkflowForIteration`のVAELoader自動注入ロジック)、Checkpoint存在時はVAE列も通常表示のままにした。
|
||||
|
||||
@@ -209,6 +1012,7 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
ユーザーから「生成UIタブのLab機能のLora、ModelタブのLoraのLoraノード自体をバイパスする機能を追加したい。Lab機能ではキーフレームごとに切り替え対応可能としたい」との要望を受けて着手。同じセッション内で続けて「Lab機能のLoraにLora syntax、Trigger wordsの適用を可能にしたい」「AI TOOLタブのCHATの画像生成でチャットによる設定項目を増やしたい」という要望にも順次対応した一連のセッション。
|
||||
|
||||
**1. LoRAノードのバイパス機能**: 実装方針の分岐点(ComfyUI本来のBypass(mode:4)相当の配線迂回か、strength=0にする簡易実装か)をAskUserQuestionでユーザーに確認し、簡易実装(strength=0)を選択。理由は、Lab/GenerateUIが扱う`workflow`は既に`comfyui-workflow.js`の`convertUiToApi()`でAPI形式に変換済みで`mode`の概念自体が失われており、`mode:4`を書き込んでも実行エンジンには一切効果がないため——本物のBypass相当の配線再接続を自前実装するのは過剰投資と判断した。
|
||||
|
||||
- **GenerateUI Model タブ**: LoRA列ヘッダーの「Refresh Stack group」ボタン左に⛔トグルボタンを追加(`comfyui-editor.js`の`toggleLoraNodeBypass()`/`_loraNodeBypass`/`_loraBypassSnapshot`)。対象ノードのstrength値をゼロ化し、退避したスナップショットから復元をトグルする。Lora Managerノードの場合はLoRAリスト自体(複数エントリ)は差し替えず維持したまま各エントリのstrength/clipStrengthだけを0にする。Single/Stack Applyボタンを押すとバイパス状態は自動クリアされ、あとから無言で再適用されることはない。
|
||||
- **Lab機能**: 既存の行単位「bypassed」(キーフレーム自体を無視するスキップ機能)とは独立した、LoRA列専用の`nodeBypass`フィールドをキーフレームの値に追加。編集モーダルに「LoRAノードをバイパス(強度を0にする)」チェックボックスを新設。`_applyLabLoraToWorkflow()`で`nodeBypass`時はstrength/active/textを0/false/空に強制する。
|
||||
|
||||
@@ -217,9 +1021,10 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**3. AI TOOL Chat: generate_imageツールのパラメータ拡張**: AskUserQuestionで追加したい項目をユーザーに確認し、KSampler系(steps/cfg/sampler_name/scheduler/denoise/seed)・画像サイズ(width/height)・生成枚数(batch_size)を選択。`comfyui-editor.js`に`setGenerationParams()`を新設し、Lab機能の`_buildWorkflowForIteration`と同じ`sampler_nodes`のstepsNodeId/cfgNodeId等のノードID間接参照パターンを再利用することで、Advanced Samplingワークフロー(Flux.1/2、SD3.5等でKSamplerが複数ノードに分割される構成)にも対応した。seedは`window._wfmGenerateTab.generate()`(実体は`handleGenerate`)へのオプション引数として渡すよう、`generate-tab.js`の`handleGenerate`/`_coreGenerate`にgenOptions引数を追加(既存の呼び出し元との後方互換は維持)。batch_size>1時は複数画像がチャットバブルに表示されるよう`appendChatBubble`に`imageUrls`(複数URL)対応を追加した。実機のOllama(ornith-1.5:9b)でツール呼び出しから画像生成までEnd-to-Endで検証し、指定した各パラメータが正しく反映されることを確認した。
|
||||
|
||||
**4. Resolution Selectorノード対応**: ユーザーから実際のワークフロー画像(aspect_ratio COMBO・megapixels・multipleウィジェットを持つ「Resolution Selector」ノード)を見せられ、「3:2などで指定可能か」と問われて調査。`/object_info/ResolutionSelector`で確認したところComfyUI本体組み込みノード(`comfy_extras.nodes_resolution`)で、aspect_ratioは`"3:2 (Photo)"`のような固定ラベル付きCOMBO値と判明した。当初のwidth/height直接指定実装ではこの手のノードに非対応だったため、追加で以下を実装:
|
||||
- `comfyui-workflow.js`の`analyzeWorkflow()`に`resolution_selector_nodes`カテゴリを新設。
|
||||
- `comfyui-editor.js`に`resolveAspectRatioOption(nodeType, requested)`を新設。LLMが渡す短い`"3:2"`形式を、`/object_info`から取得した実際のCOMBO選択肢と前方一致でマッチさせ`"3:2 (Photo)"`のような完全な文字列に解決する(未知の比率はそのままフォールバックし、ComfyUI側の検証エラーに委ねる)。
|
||||
- `setGenerationParams()`にResolution Selector優先ロジックを追加:aspect_ratio指定時はEmptyLatentImageへのwidth/height直接書き込みをスキップし、ResolutionSelectorノードのaspect_ratioだけを書き換える——EmptyLatentImageのwidth/heightがResolutionSelectorの出力へのリンクになっている場合、直接数値を書き込むとそのリンクを上書きしてしまい、意図した比率指定が無視されてしまうため。
|
||||
|
||||
- `comfyui-workflow.js`の`analyzeWorkflow()`に`resolution_selector_nodes`カテゴリを新設。
|
||||
- `comfyui-editor.js`に`resolveAspectRatioOption(nodeType, requested)`を新設。LLMが渡す短い`"3:2"`形式を、`/object_info`から取得した実際のCOMBO選択肢と前方一致でマッチさせ`"3:2 (Photo)"`のような完全な文字列に解決する(未知の比率はそのままフォールバックし、ComfyUI側の検証エラーに委ねる)。
|
||||
- `setGenerationParams()`にResolution Selector優先ロジックを追加:aspect_ratio指定時はEmptyLatentImageへのwidth/height直接書き込みをスキップし、ResolutionSelectorノードのaspect_ratioだけを書き換える——EmptyLatentImageのwidth/heightがResolutionSelectorの出力へのリンクになっている場合、直接数値を書き込むとそのリンクを上書きしてしまい、意図した比率指定が無視されてしまうため。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、`helpLab16`/`helpLab17`(Lab LoRAバイパス・プロンプト適用)、`helpGen29`(GenerateUI Model LoRAバイパスボタン)を新規追加し、`helpAi3`(AI TOOL Chatの説明)を拡張パラメータの説明を含めて更新した。README.mdのGenerateUI Tab/Lab subtab/AI TOOL Tabの各セクションも同内容で更新した。
|
||||
|
||||
@@ -5281,8 +6086,7 @@ SPA および Workflow Studio Library サイドパネルの両方に AI タブ
|
||||
**`applyTextareaFontSize(size)` 追加(export)**
|
||||
|
||||
- `<style id="wfm-ta-font-size-style">` を `<head>` に注入
|
||||
- 対象セレクタ(対象を 8 箇所に一括適用):
|
||||
| 対象 | ID |
|
||||
- 対象セレクタ(対象を 8 箇所に一括適用):| 対象 | ID |
|
||||
| ---------------------------------------- | -------------------------- |
|
||||
| Generate UI — Positive Prompt | `#wfm-prompt-pos-text` |
|
||||
| Generate UI — Negative Prompt | `#wfm-prompt-neg-text` |
|
||||
@@ -6566,6 +7370,7 @@ Feeder 専用スタイルを末尾に追加:
|
||||
|
||||
- **型バッジの削除:** ThumbView・CardView・SidePanelの `renderSideInfo` から `typeBadge = badgeHtml(typeLabel)` を削除。ユーザー定義バッジのみ表示
|
||||
- **`GENUI_TYPE_MAP` 追加:**
|
||||
|
||||
```javascript
|
||||
const GENUI_TYPE_MAP = {
|
||||
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
|
||||
@@ -6579,6 +7384,7 @@ Feeder 専用スタイルを末尾に追加:
|
||||
|
||||
hypernetwork / embedding は対象外
|
||||
- **`applyToGenUI(modelName, modelType)`** 追加:
|
||||
|
||||
- `comfyUI.currentWorkflow` からターゲット `inputKey` を持つノードを検索
|
||||
- `node.inputs[inputKey] = modelName` で直接セット
|
||||
- `<select id="wfm-model-${key}">` の表示値も同期
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ketle-man
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -11,6 +11,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Workflow, model, image, and prompt management
|
||||
- AI-powered prompt writing assistance, translation, and tag generation
|
||||
- File drop & Gallery-linked metadata viewing — inspect and reuse generation settings instantly
|
||||
- Semantic image search in the Gallery — find images by describing them, or list images similar to a selected one
|
||||
|
||||
**⚡ GenerateUI** — a productivity-focused generation tab connected to every other tab
|
||||
|
||||
@@ -26,18 +27,18 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- 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)
|
||||
Thumbnail / Table views, Thumbnail side panel, Badge filtering, Search, Side panel tabs, Badge management, AI summary, Import / Export, Side panel toolbar buttons, Default view setting, Search clear (✕), Clear all filters (✕ Clear), Sort, GenerateUI Check
|
||||
|
||||
### Canvas Snapshot (v0.1.2)
|
||||
One-click capture, Auto-save as thumbnail, Embedded workflow metadata, Auto-import
|
||||
|
||||
### GenerateUI Tab (v0.3.5)
|
||||
6-tab layout, Save button, Input tab, Model tab, Settings tab, Always-visible Raw JSON, Bypass/Mute node handling, One-click generation, Seed control, Style selector, Create Catalog / Catalog buttons, Batch type selector, Batch tab, UI-to-API conversion, Video workflow support, Eagle integration
|
||||
6-tab layout, Save button, Input tab, Model tab, Settings tab, Always-visible Raw JSON, Bypass/Mute node handling, One-click generation, Seed control, Style selector, Create Catalog / Catalog buttons, Batch type selector, Batch tab, UI-to-API conversion, Video workflow support, Eagle integration, Qwen Image 2.1 support, Ming Image support
|
||||
|
||||
### Feeder subtab (v0.3.5 / v0.3.42)
|
||||
ImageFeeder node control, Image library, Selection management, Presets, Continuous Run loop, Index sync, Seed, WFS_GalleryFeeder node, Node & group selector, Image grid, After Gen modes, Run / Stop controls
|
||||
@@ -49,10 +50,10 @@ Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column —
|
||||
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, Ollama connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
|
||||
2-column layout, Collapsible sections, Theme selection, Theme customization, Workflows directory, Gallery output directory, Eagle connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, VRAM Management (Ollama), Video Playback Volume, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
|
||||
|
||||
### Gallery Tab (v0.3.44)
|
||||
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, 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, Performance
|
||||
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, WebM support, Embedded metadata reading fixes, Audio support, File conversion, Move To folder filter, Semantic Search, Audio waveform colors, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Nanobanana tab, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send to LV node button, Send CC button, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, PSD file support, Performance
|
||||
|
||||
### ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)
|
||||
Visual prompt library, 3-column layout, Prompt builder (right pane), Plain-text prompt storage, ponyxlWildcardsVault format support, Seed data importer
|
||||
@@ -61,7 +62,7 @@ Visual prompt library, 3-column layout, Prompt builder (right pane), Plain-text
|
||||
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
|
||||
Now inside Gallery, 3-column layout, File drop, MP4 embedded metadata, Settings section, Nanobanana section, Model extraction, LoRA extraction, Prompt extraction, Prompt actions, Format support, Format note
|
||||
|
||||
### Nodes Tab (v0.1.7)
|
||||
Node Browser, Search & Filter, Package badges, Node detail panel, Node Sets, Right-click context menu, Multi-select, Search clear (✕), Clear all filters (✕ Clear)
|
||||
@@ -70,16 +71,16 @@ Node Browser, Search & Filter, Package badges, Node detail panel, Node Sets, Rig
|
||||
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, Ollama VLM, Single tab, Single output options, Batch tab, DB tab, Dependencies
|
||||
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, New button, Tools, Layer panel, Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
|
||||
Layer-based image editor, Loading images, Open PSD button, Close button, New button, Tools, Layer panel, Duplicate layer (⧉), Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
|
||||
|
||||
### Video Tab (v0.5.0 – v0.5.2)
|
||||
Dedicated MiniMax H3 video generation UI, independent of GenerateUI, 3-pane layout, First Frame / Last Frame are both optional, Node lookup instead of hardcoded IDs, Load in Video, Preview frame + Video Source panel, Frame tab, GIF tab
|
||||
### Video Tab (v0.5.0 – v0.7.4)
|
||||
Dedicated video generation UI, independent of GenerateUI, Plan subtab, Base settings row, Edit subtab, Edit: Multi-track, Edit: Audio / BGM, Edit: Text overlays, Edit: Crop, Edit: Undo / Redo, Edit project persistence, Asset subtab, Project subtab, Two center previews, First Frame / Last Frame are both optional, Semantic node lookup, not hardcoded IDs, Field highlight color, Load in Video, Frame tab, GIF tab, Playback volume
|
||||
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, Decision API client, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Model unload, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
|
||||
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
|
||||
Tab layout (W / N / P / M / I / A), W — Workflows tab, N — Nodes tab, M — Models tab, P — Prompts tab, I — Information tab, A — AI TOOL tab, Drag & drop workflows, Drag & drop nodes, Drag & drop prompts, Send to Canvas, Copy prompts, Double-click, Search, ⚙ Theme settings
|
||||
@@ -157,9 +158,15 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); needs an API key set via `UNSLOTH_API_KEY` in a `.env` file, or Unsloth's Keyless API access → "Chat and inference" turned on — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **Gemini API key / EmbeddingGemma 2** (v0.8.2) — only for the Gallery's Semantic Search: either set `GEMINI_API_KEY` in the plugin's `.env` (key from [Google AI Studio](https://aistudio.google.com); free tier available, paid beyond it), or use the local **EmbeddingGemma 2** (no key; `onnxruntime`, `tokenizers` and `huggingface_hub` — normally already present with ComfyUI; the model downloads on first use). `onnxruntime-gpu` makes the local model ≈45× faster on NVIDIA GPUs
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
- **[psd-tools](https://pypi.org/project/psd-tools/)** (v0.6.0) — enables all PSD (Photoshop) features: Gallery bulk "Create PSD" export, Gallery browsing/thumbnails for `.psd` files, and Image Edit's Save PSD / Open PSD buttons; `pip install -r requirements.txt` (ships prebuilt wheels, no C compiler required); without it, PSD-related actions show an error toast with the install command instead of failing silently
|
||||
|
||||
#### Backend connection security (v0.7.12)
|
||||
|
||||
Server-side proxies for Ollama, LM Studio, Lemonade, Unsloth, the Tagger VLM and Eagle only connect to **localhost / 127.0.0.1 / ::1** (any port); `http(s)` only, no credentials in the URL, and redirects are never followed. To use a backend on another machine (e.g. an Ollama server on your LAN), list it in the `WFS_ALLOWED_BACKEND_HOSTS` environment variable (comma-separated `host` or `host:port`, e.g. `192.168.1.20:11434,gpu-box`) and restart ComfyUI. If you start ComfyUI with `--listen`, anyone who can reach it can use these routes, so only do that on a trusted network.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Workflow, model, image, and prompt management
|
||||
- AI-powered prompt writing assistance, translation, and tag generation
|
||||
- File drop & Gallery-linked metadata viewing — inspect and reuse generation settings instantly
|
||||
- Semantic image search in the Gallery — find images by describing them, or list images similar to a selected one
|
||||
|
||||
**⚡ GenerateUI** — a productivity-focused generation tab connected to every other tab
|
||||
|
||||
@@ -22,7 +23,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,29 +31,29 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
| :-------------------------------------: | :---------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Prompt Input Assistance | Gen UI Feeder |
|
||||
| :----------------------------------------------------------: | :---------------------------------------: |
|
||||
|  |  |
|
||||
| Prompt Input Assistance | GenUI Batch |
|
||||
| :----------------------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| GenUI LoRA Stack | GenUI Batch |
|
||||
| :---------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Models Multi-select Menu | Top Bar |
|
||||
| :-----------------------------------------------------------: | :---------------------------: |
|
||||
|  |  |
|
||||
| Top Bar | Image Edit Tab |
|
||||
| :----------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| WS Library | Library Information |
|
||||
| :----------------------------------: | :---------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Tagger Tab | Image Edit Tab |
|
||||
| :-------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
| Video Tab | AI TOOL Tab |
|
||||
| :----------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Customize | |
|
||||
| :--------------------------------: | :-: |
|
||||
|  | |
|
||||
| Video Tab - Edit | Video Tab - Asset |
|
||||
| :----------------------------------: | :-----------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Lab Tab | Lab Index Image |
|
||||
| :--------------------------------: | :------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,10 +70,12 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
|
||||
- **AI summary** — generate workflow descriptions using Ollama
|
||||
- **Import / Export** — import workflows from files or clipboard; **Send to Canvas** button sends the selected workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **Side panel toolbar buttons** (v0.5.0) — selecting a card shows three buttons in the top toolbar: **Load in GenerateUI**, **Load in Video** (new — loads a MiniMax H3 Image-to-Video workflow into the Video tab; shows an error toast for workflows that don't contain that node), and **Send to Canvas**; the detail modal's duplicate copies of these three buttons were removed in favor of this single toolbar location
|
||||
- **Side panel toolbar buttons** (v0.5.0) — selecting a card shows three buttons in the top toolbar: **Load in GenerateUI**, **Load in Video** (loads a MiniMax H3 Image-to-Video, LTX-2.5 (Text/Image/First&Last-Frame to Video), or Wan2.2 (Text to Video) workflow into the Video tab (LTX-2.5/Wan2.2 support: v0.5.4); shows an error toast for workflows that don't contain a supported node), and **Send to Canvas**; the detail modal's duplicate copies of these three buttons were removed in favor of this single toolbar location
|
||||
- **Default view setting** — persist your preferred view mode (Thumbnail / Table)
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, group filter, and badge filter (ALL) in one click
|
||||
- **Sort** (v0.7.2) — Date (Newest/Oldest) / Name (A-Z/Z-A) dropdown next to the group filter, persisted via localStorage — same pattern as the Gallery tab's sort control
|
||||
- **GenerateUI Check** (v0.7.13) — tells whether a workflow will run in GenerateUI before you load it. The side panel shows the format badge (UI / API / App) and two ✅ / ⚠ / ❌ result badges below the workflow name, checked automatically on selection; the detail modal shows the format badge, a **GenerateUI Check** button and the same badges below the title. Hover a badge for the reasons. **Conversion** runs the same UI→API conversion as GenerateUI: ❌ for unsupported formats, conversion errors or node types not installed in this ComfyUI (the prompt would be rejected), ⚠ for values not available here (models, LoRAs, images…) — UI-format workflows get them silently replaced with the first choice, API-format workflows are sent as-is and rejected by ComfyUI's validation. **Operability** is the share of nodes with settings that GenerateUI's fields expose (✅ 80%+ / ⚠ 50%+ / ❌ below), listing the nodes you'd still have to change in the node graph. Results aren't saved; a check takes about 20 ms once ComfyUI's node list (`/object_info`) is cached (the first one after opening the page waits about 10 s for it; the detail modal prefetches it). The Decision Model was evaluated for the operability rating but couldn't tell good and bad workflows apart, so it is rule-based
|
||||
|
||||
</details>
|
||||
|
||||
@@ -117,8 +120,12 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Right pane (Batch Queue)** — 7 columns: Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / **Style**; each column header has an enable checkbox (radio behavior: only one at a time); count shown per column; Style batch applies each selected style sequentially to a workflow copy
|
||||
- **Lora batch prompt sync** (v0.3.69) — for each selected LoRA, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt (rebuilt from the original prompt each time, not accumulated), then restored to the pre-batch state when the batch completes or is stopped; if a selected LoRA is not yet recognized by ComfyUI's model list, a warning toast suggests a Refresh or restart
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and Guider-based "Advanced Sampling" workflows (`SamplerCustomAdvanced`/`SamplerCustom` + `CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `KSamplerSelect` + `*Scheduler` — Flux.2 Klein, LongCat, Boogu, HiDream E1, etc.)
|
||||
- **MultiType widget-value bug fix** (v0.5.4) — a node input declaring a comma-joined MultiType (e.g. LTX-2.5's `LTXVEmptyLatentAudio.frame_rate: "FLOAT,INT"`) was silently dropped from the widget list instead of being recognized as a scalar widget, which shifted every subsequent widget's value onto the wrong input (surfaced as `LTXVEmptyLatentAudio.batch_size` receiving `frame_rate`'s value and video generation failing with "Expected size 1 but got size 25")
|
||||
- **Video workflow support** (v0.5.2) — loading a MiniMax H3 (or other all-in-one video-generation) workflow now works from GenerateUI too, not just the dedicated Video tab: the Input tab's Prompt field picks up its `prompt` widget, the Image tab picks up its LoadImage-based `first_frame` the same way any other I2I workflow does, and generation runs with a longer timeout since video generation takes much longer than a still image; the result preview shows the generated mp4 inline (`<video>`) instead of trying to render it as an image
|
||||
- **LTX-2.5 / Wan2.2 support** (v0.5.4) — these follow ComfyUI's regular node-graph pattern rather than an all-in-one node, so they needed their own detection: Positive/Negative Prompt now traces through LTX-2.5's `LTXVDualCFGGuider`/`LTXVConditioning` nodes to the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's `EmptyHunyuanLatentVideo` and LTX-2.5's `EmptyLTXVLatentVideo` (width/height/batch size, plus an added **Length (frames)** field) instead of showing "No EmptyLatentImage node found"; the extended generation timeout now also triggers off a `SaveVideo`/`CreateVideo` node anywhere in the graph, not just an all-in-one node
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
- **Qwen Image 2.1 support** (v0.7.3) — `TextEncodeQwenImage21` (the unified T2I/Edit encoder used by Comfy-Org's Qwen-Image-2.1 workflow templates) is now recognized: its `prompt`/`negative_prompt` widgets populate the Input tab's Positive/Negative Prompt textareas and are editable like any other prompt node; its `resolution` (a pixel-budget int, not width/height) is exposed for Raw JSON editing. No extra work was needed for the model's up to 10 reference images (`image_1`…`image_10`, used for image editing) — each one arrives as an ordinary LoadImage node, so it already gets its own card in the Input tab's Image sub-panel via the existing generic LoadImage detection
|
||||
- **Ming Image support** (v0.7.5) — `TextEncodeMingImageEdit` (single `prompt` widget, up to 8 optional `images.image_N` reference inputs; role taken from the sampler/guider wiring, so the empty-prompt negative instance in layer-decomposition workflows shows as Negative) and `EmptyQwenImageLayeredLatentImage` (Latent panel; `layers` kept as-is) are recognized in GenerateUI, and Gallery/Metadata/sidepanel I tab now show their prompts as Positive. The Sampler field is disabled (N/A) when the SAMPLER input comes from a node without `sampler_name` (e.g. `SamplerLCM`). **Fix:** top-level nodes whose widgets are converted to wired inputs (e.g. `EmptyLatentImage` width/height fed by `GetImageSize`) keep those values in `widgets_values`; they were not stripped outside subgraphs, shifting later values (`batch_size` became `1024`, KSampler `steps`/`cfg` misread). Also: `BasicGuider`'s `conditioning` is treated as positive, and a linked text input wins over the stale widget text in UI-format metadata
|
||||
|
||||
</details>
|
||||
|
||||
@@ -184,7 +191,8 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<summary><h3>Prompt Tab</h3></summary>
|
||||
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard/Style support (right)
|
||||
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
- **AI chat assistant** — generate and refine prompts interactively using Ollama, LM Studio, Lemonade, or Unsloth (multi-backend: v0.5.4)
|
||||
- **⚙ Settings modal** (v0.5.4) — replaces the old central Settings-tab panel; opens a dedicated modal (Backend / Connection / Model / Generation / Unload / Save) scoped to this tab only, saved under its own `localStorage` key so it stays independent of the Tagger tab's VLM settings; **Thinking mode** and **Max tokens** apply to every chat request, and an **Unload** button frees the selected model from Ollama/LM Studio/Lemonade (Unsloth has no such API and is shown as unsupported)
|
||||
- **Image attachment** — attach reference images for vision-capable models
|
||||
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
|
||||
- **Prompt presets** — save/load reusable prompt templates (positive & negative) with category support
|
||||
@@ -225,16 +233,18 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Workflows directory** — configure which folder to scan for workflows
|
||||
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
|
||||
- **Eagle connection** — set Eagle API endpoint for auto-save
|
||||
- **Ollama connection** — configure Ollama server URL
|
||||
- **CivitAI Host** — choose which site opens when clicking a model link: `civitai.com` (SFW only) or `civitai.red` (unrestricted); saved to `settings.json` and synced to `localStorage` for the Models tab to use without extra fetches
|
||||
- **CivitAI API Key** — optional Bearer token for authenticated CivitAI access; stored in `settings.json` (excluded from data exports); environment variable `CIVITAI_API_KEY` takes priority if set
|
||||
- **Default workflow** — set a workflow to auto-load on startup
|
||||
- **Default Checkpoint** (v0.3.73) — optional checkbox to force every checkpoint loader node's model to a chosen default whenever a workflow is loaded into GenerateUI; a safeguard against generation errors or garbled images from a stale/mismatched checkpoint saved in the workflow file
|
||||
- ~~AI Assistant Settings~~ (removed in v0.5.4) — the central Ollama backend/URL/model panel that used to live here was replaced by a ⚙ Settings button + modal directly in the Prompt tab and the Tagger tab, each with its own independent configuration (backend, URL, model, Thinking mode, Max tokens, and a model Unload button) — see those tabs below; the AI TOOL tab's own Settings pane is unaffected
|
||||
- **VRAM Management (Ollama)** (v0.7.13) — right before every image generation from Workflow Studio (GenerateUI, Lab, batches, Video, AI TOOL chat generation — anything that goes through its queue), unload the models Ollama has loaded so ComfyUI has free VRAM. **Before generating**: Do nothing (default) / Unload only as much as needed when free VRAM is below a target (biggest model first, one at a time, stopping once the target is reached) / Unload everything every time; **Target free VRAM (GB)** for the second mode; **Unload all now** button. Free VRAM is measured with `nvidia-smi` (plus ComfyUI's own unused torch cache), because ComfyUI's free-VRAM figure may not change when Ollama loads a model; non-NVIDIA GPUs fall back to ComfyUI's value. Targets Ollama on localhost — the default `127.0.0.1:11434` plus the AI TOOL / Prompt / Tagger AI settings and the Decision Model when they use Ollama (other hosts only via `WFS_ALLOWED_BACKEND_HOSTS`); LM Studio etc. and ComfyUI's own models are not touched. A toast reports what was unloaded, an unreachable target or a failure; generation continues either way. Running a workflow from the ComfyUI canvas itself is not covered. Server route: `POST /api/wfm/vram/prepare` (JSON only). Ported from ComfyUI Comic Creator
|
||||
- **Video Playback Volume** (v0.5.4) — the video preview's volume is remembered here and shared across every video player in the plugin (Video tab, Gallery tab), instead of resetting to maximum whenever a different video is opened
|
||||
- **Data Management** — export core settings/metadata/prompts/CivitAI cache to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
|
||||
- **Full Backup (ZIP)** (v0.5.2) — additionally covers what the JSON bundle can't represent: folder-based data (AI Skills, Lab Plans, Style presets) and the Tagger SQLite database; two opt-in checkboxes can also include ComfyUI's default workflows folder (`user/default/workflows`) and the Impact Pack's wildcard folder in the export — both of those are export-only and stored under an `_external/` prefix in the ZIP that Restore always skips, since auto-overwriting files a different plugin/ComfyUI core manages would be unsafe; copy them back by hand if needed. Restoring overwrites existing files; restart ComfyUI afterward for either restore path
|
||||
- **Full Backup (ZIP)** (v0.5.2) — additionally covers what the JSON bundle can't represent: folder-based data (AI Skills, Lab Plans, Video Plans, Video Edit projects, Style presets) and the Tagger SQLite database; two opt-in checkboxes can also include ComfyUI's default workflows folder (`user/default/workflows`) and the Impact Pack's wildcard folder in the export — both of those are export-only and stored under an `_external/` prefix in the ZIP that Restore always skips, since auto-overwriting files a different plugin/ComfyUI core manages would be unsafe; copy them back by hand if needed. A third opt-in checkbox (v0.7.6) bundles the clips/BGM/sounds/overlay clips that saved Video Edit projects reference in ComfyUI's input folder (the project JSON only stores references) under `_video_edit_media/`; unlike `_external/`, these are restored into the input folder, never overwriting a file that already exists there. Restoring overwrites existing files; restart ComfyUI afterward for either restore path
|
||||
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
|
||||
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`; the same color and mechanism also drives the Video tab's field highlighting (v0.5.4, see above)
|
||||
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
|
||||
- **G'MIC-Qt Integration** — set the path to `gmic_qt.exe` for the Image Edit Filter tool; download G'MIC-Qt Standalone from `gmic.eu/download.html` (Windows: `gmic_qt-win64.zip`), extract anywhere, then enter the full path to `gmic_qt.exe` and click Save
|
||||
- **Language** — English / Japanese / Chinese
|
||||
@@ -253,27 +263,41 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Download** (v0.5.0) — a dedicated orange **Download** button in the detail panel's action-button row (next to Tagger); replaces the old preview-image hover overlay, which blocked video playback controls
|
||||
- **MP4 video support** (v0.5.0) — `.mp4` files are browsable, previewable (detail panel with native `<video controls>`, lightbox, and Compare), and included in favorites/tags/groups/move/delete/export alongside images; server-side thumbnails are generated from the first video frame via PyAV (falls back to a placeholder if PyAV/ffmpeg decoding isn't available); a small ▶ badge overlays video thumbnails in the grid
|
||||
- **MP4 embedded metadata** (v0.5.1) — the Prompt and JSON tabs now also work for `.mp4` files: reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4 (via PyAV server-side), including all-in-one video-generation nodes that carry their prompt directly on a `prompt` input rather than a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
|
||||
- **WebM support** (v0.7.14) — `.webm` files get the same treatment as `.mp4` everywhere above (browsing, thumbnails, dimensions/duration, embedded workflow/prompt metadata, favorites/tags/groups/move/delete/export, Compare, Comic Creator video send): all the video-specific code paths go through PyAV, which handles both containers identically, so no format-specific logic was needed beyond recognizing the extension
|
||||
|
||||
- **Embedded metadata reading fixes** (v0.8.0) — PNG chunks appended after `IEND` (ComfyUI-Custom-Scripts writes `workflow` there) are now read; `.webp` files expose their EXIF `workflow`/`prompt` to the Prompt and JSON tabs; images without a ComfyUI workflow (A1111 / Forge / Fooocus) show their `parameters` prompts in the Prompt tab; API-format `ImageMetadataLoRALoader` LoRAs are listed in the Metadata tab and the side-panel I tab
|
||||
- **Audio support** (v0.8.0) — `.wav` / `.mp3` / `.ogg` / `.m4a` / `.flac` / `.opus` files are browsable alongside images and videos (for ComfyUI music generation output): thumbnails are waveform images generated server-side (PyAV, cached), the detail panel / lightbox / Compare show the waveform with a built-in audio player (shared volume setting with videos), the Info tab shows the duration, and the Prompt / JSON tabs read the `prompt` / `workflow` tags that SaveAudio-type nodes embed (FLAC / MP3 / Opus); favorites, tags, groups, move, delete, and export work as for other files, while image-only actions (PSD export, Edit Layers) skip audio; in the Video tab's Asset sub-tab, audio files added to the Video Assets group appear with an "Audio Only" filter, an inline player, and **Set as BGM** / **Add as sound** buttons that feed the Edit sub-tab
|
||||
- **File conversion** (v0.8.0) — a **Convert...** button in the Gallery bulk bar (shown when the selection contains audio or video) converts audio files to FLAC / MP3 / Opus / M4A / WAV, or extracts the audio track of videos, using PyAV (no system ffmpeg needed); bitrate, sample rate (44.1 / 48 kHz) and channels (mono / stereo) are selectable, output goes next to the source as a new file (`name.ext`, or `name_converted.ext` if taken). ComfyUI `prompt` / `workflow` tags (and title / artist / album tags) are carried over to formats that can hold them — WAV cannot, so they are skipped there; favorite / tags / memo / groups can be carried over, and the original can optionally be deleted after a successful conversion (with a confirmation). `.ogg` is readable but not offered as an output (the bundled PyAV has no Vorbis encoder)
|
||||
- **Move To folder filter** (v0.8.0) — the Move To dialog (detail panel and bulk bar) has a filter box that narrows the folder list by name or path (space = AND), Enter moves to the first match; the move toast now reads "Moved N file(s)" since audio/video files are moved too
|
||||
- **Semantic Search** (v0.8.2) — find images by meaning instead of keywords. The **S** button next to the search box turns it on: the search box then takes a free-text description (e.g. "sunset city street"), Enter searches, and results are ordered by similarity with a score badge on each thumbnail. Turning **S** on also shows a second toolbar row (left-aligned): **Similar** (lists images similar to the selected one; selecting another image keeps the list, **↻** re-runs it for the newly selected image, pressing Similar again returns to the normal list), the **⚙** settings button, and the result count. Searches cover the selected folder and its subfolders; sort and tag/group filters do not apply to semantic results
|
||||
- **Index window (⚙)** — three tabs. **Index**: target folder (optionally with subfolders), "new/changed only" or "rebuild all", progress bar, stop, delete all. **Model**: choose with radio buttons between **Gemini Embedding 2** (API; set `GEMINI_API_KEY` in the plugin's `.env` and restart ComfyUI — the free tier exists but content sent on it may be used by Google to improve its products, beyond it the API is paid) and **EmbeddingGemma 2** (runs locally, free, no key; the ~300 MB q4 ONNX model is downloaded on first use), plus vector size (Gemini 768 / 1536 / 3072, EmbeddingGemma 2 128 / 256 / 512 / 768), and for the local model the device (auto / CPU / GPU CUDA), CPU thread count (default: half the cores) and image precision (high / standard ≈2.5× faster / light ≈5× faster). **Search**: minimum score for similar-image results (e.g. `0.6` shows only images scoring 0.6 or higher), minimum score for text search, and maximum results — stored per model because score levels differ between models
|
||||
- **Index state in the folder tree** — a green dot next to a folder's image count means every image directly in that folder is indexed; the index is one shared store, so folders can be indexed one at a time. Images, plus videos / PSD via their thumbnails (audio is skipped), are embedded; changing the model, vector size or image precision requires "Rebuild all"
|
||||
- **Local GPU** — EmbeddingGemma 2 is a port of Transformers.js' pipeline onto `onnxruntime` (no `transformers` support needed). On an RTX 4070 it indexes ≈0.13 s per image with CUDA versus ≈6 s on the CPU (≈45× faster, identical vectors); to use the GPU, install `onnxruntime-gpu` in place of `onnxruntime` (`pip uninstall onnxruntime onnxruntime-gpu` then `pip install onnxruntime-gpu`). DirectML is not supported (its vision model fails). The model is released from memory when indexing finishes
|
||||
- **Audio waveform colors** (v0.8.0) — waveform thumbnails are colored per format (MP3 blue, WAV green, FLAC amber, OGG/Opus purple, M4A rose)
|
||||
- **Info tab: dimensions & duration** (v0.5.1) — the detail panel's Info tab now also shows image dimensions (width×height) or video duration, in addition to filename, size, date, tags, and memo
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations: select group → "Add to Group" / "Remove from Group"; Favorite All / Unfavorite All; Compare (2–4 images); Move To...; **Export** (downloads all selected images as a ZIP file); Delete File
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations: select group → "Add to Group" / "Remove from Group"; Favorite All / Unfavorite All; Compare (2–4 images); Move To...; **Export** (downloads all selected images as a ZIP file); **Create PSD** (v0.6.0, combines the selected images into one layered PSD file, positioned top-left on a canvas sized to the largest image); **Edit as Layers** (v0.6.0, opens all selected images in the Image Edit tab at once, each as its own layer); Delete File
|
||||
- **Image Compare** — select 2–4 images with Ctrl+click, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; grid adapts to the number of selected images
|
||||
- **Prompt search** — text search covers filename, tags, memo, and prompt text (A1111 `parameters` field cached on first detail panel open)
|
||||
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
|
||||
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab; **`__Feeder__`** is a reserved group (🔒 prefix) that cannot be renamed or deleted; used by the Feeder Gallery mode to define the generation queue; **FC** button in the toolbar clears all members without deleting the group
|
||||
- **Thumbnail F button** — cyan overlay button (top-left of each image card) toggles the image's membership in the `__Feeder__` group; cyan and always-visible when the image is in the group; visible on hover only when inactive
|
||||
- **Favorites** — star images inline without reopening the detail panel
|
||||
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel; four tabs — **Info** / **JSON** / **Groups** / **Prompt**
|
||||
- **Prompt tab** (v0.4.7) — selected by default (the most frequently checked piece of info shouldn't need an extra click); lists every positive/negative prompt candidate found in the embedded workflow with POS/NEG badges, reusing the exact same list→click→full-text UI as the Metadata tab; extracts from the API-format embedding when available rather than the UI-format one, since a workflow with independent top-level and subgraph sampling chains (e.g. Krea-2) can otherwise have some of its prompts missed
|
||||
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel; four or five tabs — **Info** / **JSON** / **Groups** / **Prompt** / **Nanobanana** (only for `cc_nanobanana`-folder images, see below)
|
||||
- **Prompt tab** (v0.4.7) — selected by default (the most frequently checked piece of info shouldn't need an extra click); lists every positive/negative prompt candidate found in the embedded workflow with POS/NEG badges, reusing the exact same list→click→full-text UI as the Metadata tab; extracts from the API-format embedding when available rather than the UI-format one, since a workflow with independent top-level and subgraph sampling chains (e.g. Krea-2) can otherwise have some of its prompts missed; hidden entirely for images in the `cc_nanobanana` folder (see Nanobanana tab below), since they carry no embedded ComfyUI workflow to extract a prompt from
|
||||
- **Nanobanana tab** (v0.7.14) — shown only for images in a `cc_nanobanana` folder (written by ComfyUI Comic Creator's Nanobanana tab) that have a matching entry in that folder's `history.jsonl` generation log; shows engine, model, image size, seed, timestamp, and the prompt/negative prompt actually used — the same info is also surfaced in the Metadata tab (see below) when opened from here via the **Metadata** button
|
||||
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab; **Copy & Send Canvas** button copies the JSON to the clipboard and sends the workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **GenUI button** (renamed from "Load GenUI" in v0.5.0 for readability) — loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; shows a warning toast if no workflow is embedded or the format is unsupported; Metadata button is styled green, GenUI button uses the primary accent color
|
||||
- **Image Edit button** — toolbar button; sends the selected image directly to the Image Edit tab as the base layer
|
||||
- **Image Edit button** — toolbar button; sends the selected image directly to the Image Edit tab as the base layer; PSD files (v0.6.0) are fully decomposed into their original layers instead of being flattened into one image
|
||||
- **Send GenUI Image button** — toolbar button (next to Image Edit); uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded
|
||||
- **Send to LI node button** (v0.5.2) — toolbar button (next to Send GenUI Image); uploads the selected image and writes its filename into the `image` widget of the node currently selected on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination and cross-window bridge as the Image Edit tab's **Send to LI node** button (renamed from "Send to Workflow" in this same release), so both entry points now share one consistent name; requires Workflow Studio to have been opened from ComfyUI's top menu button
|
||||
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay
|
||||
- **Send to LV node button** (v0.7.14) — toolbar button (next to Send to LI node), the video equivalent: uploads the selected video and writes its filename into the `file` widget of the node currently selected on the ComfyUI canvas, restricted to ComfyUI's native **Load Video** node (`LoadVideo`) since `file` as a widget name isn't unique enough to search for generically the way `image` is; only enabled when the selected item is a video (mp4/webm); same cross-window bridge and launch requirement as Send to LI node
|
||||
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay; when the selected file is an MP4 video (v0.6.3), it is routed to Comic Creator's Layout-tab Video tool instead — inserted as a new video object in the selected panel (playable, capturable to a still frame) rather than as a flattened image; sending a video while the Image tab is the intended destination is not yet supported and shows an info toast instead
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — toolbar button next to the view-mode (Thumbnail/Table) switch (v0.5.2, moved from the toolbar's rightmost position so it sits with the other display-affecting controls); resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
|
||||
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
|
||||
- **Output folder configurable** — set the scanned output folder from Settings tab
|
||||
- **SVG file support** (v0.3.77) — `.svg` files are browsable, previewable (detail panel and lightbox), and included in favorites/tags/groups/move/delete/export alongside raster formats; served as-is with no rasterized thumbnail, since `<img>` scales vector graphics natively at any size
|
||||
- **PSD file support** (v0.6.0) — `.psd` files are browsable alongside other formats; thumbnails and full-size previews show every layer merged together (hidden layers excluded), rendered server-side via `psd-tools` and disk-cached; the underlying PSD file itself is never modified — only the Gallery preview is a flattened rendering
|
||||
- **Performance** — server-side 256px JPEG thumbnail generation with disk cache (`data/thumb_cache/`); infinite-scroll paging (50 images per page, IntersectionObserver); folder-level mtime cache (60s TTL); bulk operations use single-request API endpoints
|
||||
|
||||
</details>
|
||||
@@ -306,13 +330,16 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<summary><h3>Metadata Gallery subtab (v0.3.8, moved from a top-level tab in v0.4.6)</h3></summary>
|
||||
|
||||
- **Now inside Gallery** (v0.4.6) — this used to be its own top-level tab; it's now the fourth Gallery sub-tab, to the right of Style_Catalog — everything below works exactly the same as before
|
||||
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
|
||||
- **3-column layout** — Drop zone (left) | Model info + LoRA + Prompt (center) | Settings + Nanobanana (right, v0.7.14 — LoRA and Prompt moved here from the right column, swapping places with these two new sections)
|
||||
- **File drop** — drop a ComfyUI-generated PNG / WebP / MP4 or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
|
||||
- **MP4 embedded metadata** (v0.5.1) — reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4's `moov/udta/meta` box (parsed client-side, no server round-trip); also recognizes all-in-one video-generation nodes that carry their prompt directly on a `prompt` input instead of a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
|
||||
- **MP4 embedded metadata** (v0.5.1) — reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4's `moov/udta/meta` box (parsed client-side, no server round-trip); also recognizes all-in-one video-generation nodes that carry their prompt directly on a `prompt` input instead of a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`); WebM isn't supported here (this parser only understands MP4's ISO-BMFF container) — the Gallery tab's own video metadata reading (PyAV-based, see Gallery Tab above) covers WebM instead
|
||||
- **Settings section** (v0.7.14) — image size (width×height) and, when the embedded workflow has a KSampler-type node, its Seed / Steps / CFG / Sampler / Scheduler / Denoise values, read from the API-format embedding's named inputs (UI-format workflows are converted through the same UI→API path GenerateUI uses first)
|
||||
- **Nanobanana section** (v0.7.14) — shown only for images from ComfyUI Comic Creator's Nanobanana tab (a `cc_nanobanana`-folder image opened via the Gallery tab's **Metadata** button, with a matching entry in that folder's `history.jsonl` log); shows engine, model, image size, seed, timestamp, and the prompt/negative prompt actually used — since these images carry no embedded ComfyUI workflow, this section's data replaces what would otherwise be a "no metadata found" error, with the rest of the layout (Checkpoint/LoRA/Prompt/Settings) simply staying empty
|
||||
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2, Mage-Flow, LongCat, Boogu, HiDream E1, FireRed, Krea-2 Turbo); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
|
||||
- **LoRA extraction** — lists all LoRA models with `strength_model / strength_clip` values
|
||||
- **Prompt extraction** — lists prompts with POS / NEG badges when positive/negative can be determined; when distinction is not possible (e.g. `SamplerCustomAdvanced`, intermediate nodes, cross-level connections), prompts are shown without a badge as plain **Text**; click any entry to view the full text below
|
||||
- **CLIP Text Encode edit+ support** (v0.3.92) — `CLIPTextEncodeEditPlus` nodes are resolved using the same RAW / EDIT / front / back combination rule as the node itself, correctly merging the linked `text1` (e.g. a Lora Manager's trigger words) with the editable `text_edit` field instead of showing only one side
|
||||
- **Custom multi-output text node fix** (v0.7.14) — nodes whose text output comes from a differently-named widget (e.g. ComfyUI-LiveChatStream's `response`/`positive`/`negative` outputs, backed by `response_text`/`prompt_text`/`negative_text` widgets) previously resolved to nothing when the API-format embedding was used, since the generic key lookup didn't recognize those widget names — LiveChatStream is now resolved by name explicitly; confirmed working with response text up to 40,000 characters
|
||||
- **Prompt actions** — Copy to clipboard, **GenUI:P/N** (set GenerateUI positive/negative prompt), **Prompt:P/N** (set Prompt tab preset positive/negative)
|
||||
- **Format support** — ComfyUI PNG/WebP/MP4/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Krea-2 Turbo subgraph workflows), SD WebUI, SD Forge, Fooocus
|
||||
- **Format note** — supported formats and covered model types are always shown in the left column
|
||||
@@ -368,11 +395,12 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **3 sub-tabs** — Single / Batch / DB for single-image tagging, folder batch processing, and tag database management
|
||||
- **Model support** — WD Tagger (ONNX, NCHW/NHWC auto-detect), SwinV2 (ONNX), DeepDanbooru (.h5, requires TensorFlow); place each model in its own subfolder under `ComfyUI/models/tagger/<model-name>/` containing the `.onnx` + `selected_tags.csv` (or `.h5` + label file)
|
||||
- **Threshold sliders** — General threshold (default 0.35) and Character threshold for `character:` prefix tags (default 0.85); sliders update the display value in real time
|
||||
- **Ollama VLM** — optional vision model alongside WD Tagger; set API URL and select model (↻ to refresh list); results from both models are merged into one comma-separated tag string
|
||||
- **VLM tagging** (v0.3.38, multi-backend: v0.5.4) — optional vision model alongside WD Tagger, running on Ollama, LM Studio, Lemonade, or Unsloth through the same unified backend used by the Prompt tab; results from both models are merged into one comma-separated tag string
|
||||
- **⚙ Settings modal** (v0.5.4) — a Settings button (Single and Batch tabs) opens the same modal component as the Prompt tab's AI Assistant (Backend / Connection / Model / Generation / Unload), but saved under its own `localStorage` key so Tagger's VLM backend/model choice is independent of the Prompt tab's; **Thinking mode** and **Max tokens** apply to VLM requests too, and the **Unload** button frees the selected model (Ollama/LM Studio/Lemonade; Unsloth unsupported) — replaces the old Tagger-only direct Ollama URL/model fields
|
||||
- **Single tab** — drag & drop an image onto the preview area or click Upload; Gallery detail panel **Tagger** button opens the selected image directly here
|
||||
- **Single output options** — (1) **GenUI:P** — appends tags to the GenerateUI tab's positive prompt and immediately applies to the loaded workflow; (2) **Send to Prompt** — appends tags to the Prompt tab's positive textarea; (3) **Save to Gallery** — saves tags to Gallery image metadata (requires image opened from Gallery); (4) **Write to File** — embeds tags into JPEG EXIF (`ImageDescription`) or PNG `tEXt` chunk (`Tags` key), other formats get a `.tags.json` sidecar; (5) **Save to DB** — stores in internal SQLite database
|
||||
- **Batch tab** — enter a folder path, configure WD Tagger and Ollama settings; output options: **Save to DB** (default on), **Write to File** (EXIF/PNG metadata), **Write .txt** (creates `<filename>.txt` alongside each image with all tags); real-time progress bar and log; Stop cancels after the current image
|
||||
- **DB tab** — searchable SQLite database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records
|
||||
- **Batch tab** — enter a folder path, configure WD Tagger thresholds and (via the same ⚙ Settings modal as Single) the VLM backend; output options: **Save to DB** (default on), **Write to File** (EXIF/PNG metadata), **Write .txt** (creates `<filename>.txt` alongside each image with all tags); real-time progress bar and log; Stop cancels after the current image
|
||||
- **DB tab** — searchable SQLite database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records; **bulk delete** (v0.5.8) — per-row checkboxes plus a header checkbox to select/deselect all, a confirmation dialog before deleting the selected records
|
||||
- **Dependencies** — install into ComfyUI's embedded Python: `python_embedded\python.exe -m pip install -r requirements.txt`; for GPU inference use `onnxruntime-gpu`; TensorFlow (DeepDanbooru) is optional and commented out in `requirements.txt`
|
||||
|
||||
</details>
|
||||
@@ -380,39 +408,55 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<details>
|
||||
<summary><h3>Image Edit Tab (v0.3.65)</h3></summary>
|
||||
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG
|
||||
- **Loading images** — drag & drop onto the canvas, Upload button, or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG or a fully layered PSD
|
||||
- **Loading images** — drag & drop onto the canvas, **File Open** button (renamed from Upload in v0.6.0), or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
- **Open PSD button** (v0.6.0, next to File Open) — loads a layered PSD file from disk and decomposes it into individual layers, restoring each layer's position, size, visibility, opacity, and blend mode; groups and non-raster layers (adjustment layers, etc.) are flattened into their rendered appearance
|
||||
- **Close button** (v0.6.0, next to Open PSD) — closes the current document after a confirmation prompt, discarding all layers and unsaved edits and returning to the empty placeholder view
|
||||
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers and automatically adds an empty Layer 1 (v0.3.70)
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Fill (G), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Select** — click to select; drag to move; drag corner handles to resize; drag circle handle to rotate; Flip H / Flip V / Rotate angle in the options bar; double-click a text layer to re-edit its content
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge; **pen tablet support** (v0.6.2) — brush size and opacity respond to pen pressure (mouse behaves exactly as before); the canvas listens for Pointer Events so pen, touch, and mouse all work through the same code path
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends the current canvas composite (plus the active mask layer's content, if one is selected) to a MaskEditorOne node on the ComfyUI canvas — the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph (same selection logic as **Send to LI node**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Fill** (v0.6.2) — bucket fill; click a contiguous same-color region on the active layer to flood-fill it (scanline-based, ported from the Comic Creator plugin's Image tab); **Properties pane**: Mode (Solid / Gradient) — Solid: Color picker; Gradient: Shape (Linear / Radial), a draggable color-stop Ramp (click a stop to select it, drag to reposition, +/- to add/remove stops, color swatch to edit the selected stop's color), and a Direction dial (drag to set angle/radius); both Tolerance (0–255, color-match threshold) and Opacity apply to either mode; Ramp and Direction dial are pointer-event based with pointer capture, so pen tablets work the same as mouse
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **Mask** on the second row of the Layers header (renamed from "M" and moved to its own row in v0.6.0) to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); brush size responds to pen pressure on a tablet (v0.6.2, same as the Draw tool); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends the current canvas composite (plus the active mask layer's content, if one is selected) to a MaskEditorOne node on the ComfyUI canvas — the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph (same selection logic as **Send to LI node**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
|
||||
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
|
||||
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
|
||||
- **Inpaint** (v0.3.71) — composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like `VAEEncodeForInpaint`); also supports workflows built around the **Mask Editor One** custom node (`comfyui-mask-editor-one`) instead of LoadImage — image/mask are pushed via its node_id-keyed server cache and `layer_data` widget; **Properties pane**: Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), Denoise (applied to KSampler), Run button; **Use dedicated workflow** checkbox — when off, generates against the workflow currently loaded in GenerateUI; when on, pick a saved workflow from the dropdown to load, edit, and generate entirely in the background without disturbing what's shown in GenerateUI; the result image is shown back in the Properties pane when generation completes; requires a Mask layer painted first (Mask tool)
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load; header is arranged in two rows (v0.6.0) — +, **⧉ Duplicate**, −, ↑, ↓ on top, **Mask** alone below
|
||||
- **Duplicate layer (⧉)** (v0.6.0) — copies the active layer, including its pixels, position, size, opacity, blend mode, and visibility, and inserts the copy directly above the original as the new active layer; works on any layer type (image, draw, text, mask)
|
||||
- **Layer lock** — locked layers show an orange bounding box and 🔒 icon on the canvas; move/resize/rotate are disabled while locked; click the 🔓 button in the layer row to unlock
|
||||
- **Text quality** — text layers are rendered at their measured bounding-box size; resizing with the Select tool regenerates the canvas at the new display resolution so text stays sharp
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Send to LI node** (renamed from "Send to Workflow" in v0.5.2) — uploads to ComfyUI's input folder via `/upload/image`, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable; the Gallery tab's toolbar has the same button (v0.5.2, see Gallery Tab below) so a selected Gallery image can be sent the same way without going through Image Edit first
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Save PSD** (v0.6.0, downloads a layered PSD file with every layer preserved separately — position, rotation, flip, and scale are baked into each layer's pixels since PSD layers cannot store a rotation angle, while opacity, blend mode, and visibility are kept as real, editable PSD layer attributes; clipping masks are merged into their target layer since PSD's native layer-mask mechanism isn't used); **Send to LI node** (renamed from "Send to Workflow" in v0.5.2) — uploads to ComfyUI's input folder via `/upload/image`, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable; the Gallery tab's toolbar has the same button (v0.5.2, see Gallery Tab below) so a selected Gallery image can be sent the same way without going through Image Edit first
|
||||
- **Canvas navigation** — scroll-wheel zoom, Space + drag to pan; zoom indicator in the bottom bar
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), Delete (remove selected layer when 2+ exist)
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), G (Fill), Delete (remove selected layer when 2+ exist)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Video Tab (v0.5.0 – v0.5.2)</h3></summary>
|
||||
<summary><h3>Video Tab (v0.5.0 – v0.7.4)</h3></summary>
|
||||
|
||||
- **Dedicated MiniMax H3 video generation UI, independent of GenerateUI** — a purpose-built tab for the MiniMax H3 (Image to Video) workflow; does not share state with the GenerateUI tab, and model selection (UNet/CLIP/VAE/Audio VAE) is fixed to the loaded workflow's own defaults rather than editable here
|
||||
- **3-pane layout** — left: form (First Frame / Last Frame / Prompt / Aspect Ratio / Megapixels / Multiple / Duration / Noise Seed / Generate); center: preview frame + Video Source panel, plus an Editing pane reserved for future video-editing tools; right: **Frame** / **GIF** property tabs (v0.5.2, see below)
|
||||
- **First Frame / Last Frame are both optional** — leaving First Frame empty runs the workflow text-only (the node is simply left unconnected — a "bypass"); workflows that don't even include a LoadImage node for First Frame are supported out of the box, and a LoadImage node is only injected into the graph on the fly if an image is actually supplied
|
||||
- **Node lookup instead of hardcoded IDs** — locates the MiniMax H3 subgraph instance, its linked Resolution Selector, and (if present) its linked LoadImage node by walking the workflow's subgraph definitions and links, so a workflow doesn't need to match the reference `minimax_test.json`'s exact node IDs
|
||||
- **Load in Video** (Workflow tab) — load any workflow containing a MiniMax H3 Image-to-Video node straight into this tab from the Workflow tab's side-panel toolbar (next to Load in GenerateUI); unsupported workflows show an error toast instead of loading
|
||||
- **Preview frame + Video Source panel** (v0.5.2, replaces the old Result panel) — the center pane's preview shows either a just-generated video or one loaded independently of generation; a **Video Source** drop zone next to it accepts any local video file (drag-and-drop or click to pick), with a **Clear** button to reset back to empty — the Frame/GIF tools below work on whatever is currently shown here, generated or not; generated results are still saved to Gallery metadata (embedded workflow JSON) automatically, so the Gallery tab's Workflow/Prompt panels work on generated videos the same way they do for images
|
||||
- **Frame tab** (v0.5.2) — captures the preview video's current playback position as a still frame: play/seek to the position you want, click **Capture Frame** (client-side canvas, no server round-trip), then **Save to Output** to write it as a PNG into ComfyUI's own Output folder (auto-numbered, same pattern as other generated images)
|
||||
- **GIF tab** (v0.5.2) — converts the preview video to an animated GIF; set Start/End (seconds), FPS, and an optional Max Width, then **Convert to GIF** — the server decodes the video (PyAV) and encodes an animated GIF (Pillow), both dependencies ComfyUI itself already requires, so no ffmpeg binary or extra install is needed; the result is saved into ComfyUI's Output folder and previewed inline
|
||||
- **Dedicated video generation UI, independent of GenerateUI** — a purpose-built tab for MiniMax H3 (Image to Video), LTX-2.5 (Text / Image / First & Last Frame to Video), and Wan2.2 (Text to Video) workflows (LTX-2.5/Wan2.2: v0.5.4); does not share state with the GenerateUI tab, and model selection (UNet/CLIP/VAE/Audio VAE) is fixed to the loaded workflow's own defaults rather than editable here
|
||||
- **Plan subtab** (center panel, batch redesign in v0.5.5) — a **plan** is a sequence of timeline blocks, each a fully self-contained generation job with its own Prompt, Duration, and First/Last Image; split the timeline with **+ Split** / **+ Block** / **Delete**, then **Run** to generate every block in order (Pause/Cancel work mid-run) — a plan with a single block behaves exactly like the old one-shot form. First Image can be None, an explicit image, or "use previous block's last frame" (chaining — an explicit Last Image on the previous block always wins over extracting one from its generated output). Save / Save As / Clear a plan (`ws_videoplan_*.json`, with a PNG contact-sheet thumbnail that embeds the full plan data so dropping the image back on works too)
|
||||
- **Base settings row** (topbar, v0.5.7) — Aspect Ratio / Megapixels / Multiple / Duration (seconds) / Noise Seed on one row — Duration always reflects the currently selected block (each block keeps its own value), the rest are plan-wide — plus a second row of model-specific fields: Width / Height (become directly editable for workflows with no Resolution Selector node, e.g. Wan2.2 and LTX-2.5's First & Last Frame to Video), Turbo Mode / Strength / Steps (MiniMax H3's turbo LoRA — Turbo Mode doubles as Wan2.2's own turbo toggle, sharing one field), and Prompt Enhance (LTX-2.5 only). Every field in both rows is enabled only when the loaded workflow actually has it — see Field highlight color below (one exception: Wan2.2's Text-to-Video variant doesn't expose Noise Seed at all, so it alone leaves that field disabled)
|
||||
- **Edit subtab** (v0.7.0) — a lightweight clip timeline for trimming and concatenating video/image clips outside of generation: add clips via the Video Source panel's **Add to Edit** button or the Asset subtab's **Send to Edit** button, drag blocks to reorder (or use the ◀/▶ toolbar buttons), Duplicate/Delete the selected clip, Clear to empty the timeline. The trim panel for a selected video clip shows a Clipchamp-style scrubber — a time ruler, a draggable playhead synced to the Source preview, and draggable in/out handles with time badges (image clips get a simple hold-duration field instead); the playhead's own time badge sits a row above the in/out handle badges (v0.7.2) so it stays legible even when the playhead lands right on a trim point, and the scrubber has extra headroom/height and spacing from the clip name above it (v0.7.2). **▶ Preview** plays every ready clip back-to-back (trimmed to its in/out points) in the result preview, and **Export** builds and runs the actual ComfyUI graph (trim → crop → concatenate if more than one clip → save), tagging the result into Video Temp automatically. Multi-clip exports keep each clip's own audio (v0.7.4 — previously they were always silent). **Clear** empties the clips and also resets the Audio section to its defaults (v0.7.4)
|
||||
- **Edit: Multi-track** (v0.7.8) — the buttons above the timeline switch the track view: **Video** (blue), **Overlay** (blue), **Text** (green) and **Audio** (red), and the tool menu below follows it (Video: trim + crop / Overlay: overlay clips / Text: text overlays / Audio: audio settings); Save / Load / Export stay fixed on the right. **Text** now belongs to the timeline (absolute seconds, no longer to a clip): drag a band to move it — even across clips — or drag its edges to retime it; projects saved with clip-relative text are converted on load. **Audio** adds any number of **+ Sound...** clips (sound effects, voice) that you place by dragging their bands, trim by dragging an edge, with their own start / length / volume, on top of the full-length BGM. **Overlay** adds picture-in-picture clips (video or image) via **+ Overlay clip...** or the Asset subtab's **Add as overlay (PinP)** button, each with its own time, position (9 presets or X/Y), size and opacity, shown as dashed boxes in the previews and composited over the main video in the same pass that burns in text on Export (an overlay's own audio is not mixed). Undo/Redo and project Save/Load cover all of it, and Full Backup's "Include media used by Video Edit projects" now also bundles the sounds' and overlays' media.
|
||||
- **Edit: Audio / BGM** (v0.7.4) — the export panel's **Audio** section sets the soundtrack: **Keep original audio** (with its own volume in dB) keeps every clip's audio aligned to its segment even across multiple clips (image clips contribute silence), and one **BGM** file is laid over the whole video with its own volume and start offset (a longer BGM is cut to the video length). Pick the BGM with **BGM...**, by dropping an audio/video file onto the Audio section, or with **Set as BGM** on a video in the Asset subtab (that video's audio track is used; videos without audio are rejected). Built entirely inside the export graph with ComfyUI core audio nodes (EmptyAudio / AudioMerge / AudioConcat / LoadAudio / TrimAudioDuration / AudioAdjustVolume) passed to ConcatenateVideo's `complete_audio` — no extra dependencies. ▶ Preview plays the BGM alongside the clips
|
||||
- **Edit: Text overlays** (v0.7.4) — each clip can carry any number of text overlays (**+ Add text** under the trim panel): text, start/end seconds within the clip, size as % of frame height, color, 9-position anchor, outline, and an optional background box. They follow the clip when it's reordered, are previewed live over the Source/Result panes, and are burned in on Export as a final pass (PyAV decode → Pillow → H.264 encode, frame by frame so memory stays flat; the soundtrack is remuxed untouched). Japanese/CJK text renders with a system CJK font (Meiryo / Yu Gothic / Noto Sans JP on Windows)
|
||||
- **Edit: Crop** (v0.7.4) — **Edit crop** puts a draggable/corner-resizable rectangle over the Source preview (optionally locked to 16:9 / 9:16 / 1:1 / 4:3 / 3:4); **Done** keeps it, **Cancel** restores the crop from before editing, and switching to another subtab/tab ends editing like Done. Export applies it with ComfyUI core `VideoCrop`; the output size is the first video clip's cropped size, and other clips of a different size are center-fit to it. Text overlay previews are drawn relative to the cropped frame
|
||||
- **Edit: Undo / Redo** (v0.7.4) — **↶ / ↷** in the timeline toolbar (or Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z outside text fields) step through edits to clips, trims, crops, text and audio settings; a deleted clip comes back without re-uploading. History resets when a project is loaded
|
||||
- **Edit project persistence** (v0.7.2) — **Save** / **Save As** / **Load** buttons in the Edit subtab's export panel persist the timeline itself (clip order, trim points, and which uploaded server-side file each clip refers to — plus crops, text overlays and audio/BGM settings since v0.7.4) as a `ws_videoeditproj_*.json` project file; Load re-fetches and re-probes each clip from its saved server reference, so the original files need to still exist
|
||||
- **Asset subtab** (center panel, next to Plan/Edit — moved here from the sidebar in v0.7.0) — browses generated videos/images through three groups: **Video Assets** (a manually-curated keeper group you organize yourself, on your own schedule), **Video Temp** (every plan run's output lands here automatically regardless of whether the plan itself was saved — a **Clear Video Temp** button in the Gallery tab's detail panel empties it in one click), and **All Video Assets** (the union of both); a filename search box, a video/image type filter dropdown (All Types / Video Only / Image Only, v0.7.2), and a thumbnail-grid/table (▦/☰) view toggle sit above the list (v0.7.0). Selecting an item shows it in the center preview and lets you tag/memo it, **Send to Edit** (queues it as a clip in the Edit subtab), **Set as BGM** for videos (v0.7.4 — uses its audio track as the Edit BGM), jump straight to the full Gallery tab, or — for images only — **Set as First Image** / **Set as Last Image** (v0.7.0, uploads it straight into the currently-selected Plan block and switches to the Plan subtab)
|
||||
- **Project subtab** (sidebar) — lists saved plan files while the center Plan subtab is active, or saved Edit projects while the center Edit subtab is active (v0.7.2 — thumbnail/block-or-clip-count/last-updated) — click one to load it into the matching editor, or delete it with the × button; the panel collapses back down when its own tab is clicked again, and switching Plan↔Edit while it's open refreshes the list in place
|
||||
- **Two center previews** (v0.5.5) — the left preview shows either the video currently selected in the Asset subtab or whatever's dropped/picked in the **Video Source** panel to its left (**Clear** resets it to empty); the right preview always shows the most recently generated clip; both preview frames are a fixed height (v0.7.0) so switching sources never shifts the Plan/Edit/Asset panel below them. The Frame/GIF tools act on whichever preview was touched most recently
|
||||
- **First Frame / Last Frame are both optional** — leaving First Image empty runs the workflow text-only (the node is simply left unconnected — a "bypass"); workflows that don't even include a LoadImage node for First Frame are supported out of the box (Wan2.2's Text-to-Video graph has neither slot at all), and a LoadImage node is only injected into the graph on the fly if an image is actually supplied
|
||||
- **Semantic node lookup, not hardcoded IDs** (generalized in v0.5.4) — detects which of the three families is loaded from its subgraph's internal node types (or, for Wan2.2, the subgraph's own name — it has no distinctive internal node type), then locates its Resolution Selector and LoadImage node(s) by the subgraph's *public input label* (`prompt`, `duration`, `width`, `first_frame`, `last_frame`) rather than a fixed slot index — so it keeps working even though the three families expose those inputs under different internal names; a workflow no longer needs to match any bundled reference file's exact node IDs, and no fallback test workflow is auto-loaded on tab open
|
||||
- **Field highlight color** — Prompt / Duration / First Image / Last Image / Aspect Ratio / Megapixels / Multiple / Noise Seed / Width / Height / Turbo Mode / Strength / Steps / Prompt Enhance labels turn the same highlight color as the GenerateUI Model tab (shared setting, customizable in the Settings tab — see below) when the loaded workflow actually exposes that field; e.g. a Wan2.2 workflow (no Resolution Selector node) leaves Aspect Ratio / Megapixels / Multiple unhighlighted while Width / Height light up instead
|
||||
- **Load in Video** (Workflow tab) — load any workflow containing a MiniMax H3 Image-to-Video node, an LTX-2.5 Text/Image/First&Last-Frame-to-Video node, or a Wan2.2 Text-to-Video node straight into this tab from the Workflow tab's side-panel toolbar (next to Load in GenerateUI) — starts a fresh single-block plan seeded from the workflow's own current values; unsupported workflows show an error toast instead of loading
|
||||
- **Frame tab** (right panel) — captures the active preview's current playback position as a still frame: play/seek to the position you want, click **Capture Frame** (client-side canvas, no server round-trip), then **Save to Output** to write it as a PNG into ComfyUI's own Output folder (auto-numbered, same pattern as other generated images); an optional **Add to Video Assets group** checkbox (v0.7.0) also tags the saved frame into Gallery's Video Assets group
|
||||
- **GIF tab** (right panel) — converts the active preview to an animated GIF; set Start/End (seconds), FPS, and an optional Max Width, then **Convert to GIF** — the server decodes the video (PyAV) and encodes an animated GIF (Pillow), both dependencies ComfyUI itself already requires, so no ffmpeg binary or extra install is needed; the result is saved into ComfyUI's Output folder and previewed inline
|
||||
- **Playback volume** — the preview player's volume is saved to the Settings tab and shared across every video player in the plugin (Video tab, Gallery tab), so it no longer resets to maximum each time a different video is opened
|
||||
|
||||
</details>
|
||||
|
||||
@@ -432,10 +476,12 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Chat pane — SVG generation, no ComfyUI workflow involved** (v0.3.77) — the LLM can also produce SVG graphics directly as text; if a reply contains SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`), it's rendered inline as a preview and a "Save to Gallery" button writes it as a `.svg` file to the Gallery tab's output folder; unlike the Tool Calling image generation flow above, this is plain text pattern matching, so it works with any model regardless of tool-calling support
|
||||
- **Chat pane — Skills** (v0.3.79) — select a `.md` skill file from a dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); a pencil button opens an in-pane manager (list + inline editor, same pattern as the Prompt tab's wildcard file manager) to create, edit, or delete skill files stored in `user/default/Workflow-Studio/ai_skills/`; a file's `---`-delimited frontmatter (`name`/`description`) is shown in the dropdown; two starter skills are bundled — **SVG Icon Generator** (explicitly decomposes the subject into parts before drawing, which markedly improves recognizability from small local models) and **Skill Creator** (interviews you conversationally and drafts a new skill as a ```skill code block; a "Save as new skill" button appears under such replies and opens the manager pre-filled with the draft)
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade / Unsloth), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Unsloth backend** (v0.3.99) — Unsloth Desktop's OpenAI-compatible API always requires an API key, even for local access; unlike the other backends it's proxied server-side (`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`) so the key never reaches the browser: set `UNSLOTH_API_KEY` in a `.env` file in the plugin folder (copy `.env.example`, loaded via `python-dotenv` in `prestartup_script.py`) and restart ComfyUI; also requires **"Auto model switching (OpenAI API)"** to be turned on in the Unsloth Desktop app's own Settings screen — with it off, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI
|
||||
- **Unsloth backend** (v0.3.99) — Unsloth Desktop's OpenAI-compatible API requires an API key even for local access unless its **Keyless API access → "Chat and inference"** setting is on; unlike the other backends it's proxied server-side (`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`) so the key never reaches the browser: set `UNSLOTH_API_KEY` in a `.env` file in the plugin folder (copy `.env.example`, loaded via `python-dotenv` in `prestartup_script.py`) and restart ComfyUI — or, since v0.7.7, leave `UNSLOTH_API_KEY` unset and turn on Keyless API access → "Chat and inference" in Unsloth Desktop (requests are then relayed without an `Authorization` header, still only to localhost; that Unsloth setting also opens the API to your private LAN, and "Allow tools" should stay off). A 401 with no key set returns a message explaining both options; also requires **"Auto model switching (OpenAI API)"** to be turned on in the Unsloth Desktop app's own Settings screen — with it off, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI
|
||||
- **Decision API client** (v0.7.5, foundation) — `static/js/decision-client.js` calls Unsloth Desktop's Decision API (`/v1/systemone`, TypeSafe-compatible Laya decision models) through the same key-attaching server proxy: `decide(state, questions)` / `decideMany`, question builders `noul`/`choice`/`score`, and `isYes`/`pickChoice`/`pickScore` helpers that threshold on `probabilities` (as Unsloth recommends) and flag low-probability answers as not confident. Requires **Decision API → Serve requests** enabled in Unsloth Desktop. **Ollama 0.35+ backend** (v0.7.7) — Ollama's own `/v1/systemone` (decision models `tev1` 4B / `tev1:0.8b` 812 MB / `nimble` 9B / `clef` 27B vision-capable, pulled with `ollama pull`; no key, called directly from the browser); the model list shows only installed models whose `capabilities` include `decision`, with a ↻ refresh button and a pull hint when none are installed. **Image input** (v0.7.14, ported from ComfyUI Comic Creator) — most decision models are text-only, but Ollama's `clef` accepts images directly: `decide()` takes an optional 4th `images` argument (base64, no `data:` prefix), `imageToBase64()` converts a Blob/data URL/image URL, and `supportsDecisionVision()` checks whether the currently-configured model accepts them (via `/api/show`'s `capabilities`/`projector_info`, since not every Ollama build reports `vision` for decision models) — call it before passing images, since a non-vision model errors out on them; confirmed working end to end against `clef`. Connection settings live in the Settings tab's own **Decision Model** section (v0.7.6): backend (Unsloth / Ollama), URL, model and default probability threshold, stored under `wfm_decision_settings` independently of the AI TOOL / Tagger backends, with a Test button (one yes/no question). No feature uses it yet
|
||||
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
|
||||
- **Settings pane — Generation** (v0.3.98) — **Thinking mode** toggle (off by default; sends Ollama's `think` flag, and regardless of backend, any `<think>...</think>` block is stripped from the output when off so reasoning models don't clutter Translation/Chat/TOOLS results) and **Max tokens** (Ollama: `options.num_predict`; LM Studio/Lemonade/Unsloth: `max_tokens`; `0` = unlimited)
|
||||
- **Model unload** (v0.5.4) — an Unload button next to the model selector frees VRAM/memory by unloading the currently selected model from Ollama (`keep_alive:0` via `/api/generate`, the officially documented method — Ollama has no dedicated unload endpoint), LM Studio (`/api/v1/models/unload`), or Lemonade (`/api/v1/unload`); Unsloth exposes no equivalent API and the button is disabled for that backend. **Since v0.7.13**, with the Ollama backend the Unload button here and in the Prompt / Tagger ⚙ Settings modals unloads **every** model loaded in Ollama on localhost, decision models included — the same server step as Settings → VRAM Management's **Unload all now** (a toast shows the models unloaded and the free VRAM before → after)
|
||||
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API directly from the browser (`/v1/chat/completions`, `/v1/models`); Unsloth via the same OpenAI-compatible API but relayed through a server-side proxy that attaches the `Authorization: Bearer` header; VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade/Unsloth)
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
|
||||
@@ -448,14 +494,15 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
|
||||
- **Tab layout (W / N / P / M / I / A)** — compact single-letter tabs with full name shown on hover
|
||||
- **W — Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
|
||||
- **N — Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
|
||||
- **N — Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view; **Alt+click** a node to add it to (or remove it from) Favorites; the tab now opens without waiting for `/object_info` (v0.8.0)
|
||||
- **Category sub-tab** — dropdown to filter nodes by top-level category
|
||||
- **Package sub-tab** — dropdown to filter nodes by custom node package name
|
||||
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs); LoRA groups show an **All N LoRAs** item — drag to canvas to place a `Lora Loader (LoraManager)` node with all LoRAs pre-loaded
|
||||
- **P — Prompts tab** — browse prompt presets with All / ★ Favorites / Categories sub-tabs; **Groups sub-tab** (row 2) — view presets by group (shared with the Batch tab's `wfm_prompt_preset_groups`)
|
||||
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP/MP4 or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
|
||||
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP/MP4 or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, `Lora Loader (LoraManager)`, and `ImageMetadataLoRALoader` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
|
||||
- **MP4 embedded metadata** (v0.5.1) — reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4's `moov/udta/meta` box, parsed entirely client-side (same box-walking logic as the Gallery tab's Metadata sub-tab); also recognizes all-in-one video-generation nodes that carry their prompt directly on a `prompt` input rather than a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
|
||||
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **VRAM (Ollama) → Unload all now** (v0.7.13) — in the A tab's Settings sub-tab; unloads every model loaded in Ollama on localhost, decision models included (same as Workflow Studio's Settings → VRAM Management)
|
||||
- **model sub-tab** — Checkpoint, VAE, Diffusion Model, and Text Encoder; drag items to canvas to place the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`); double-click also places at canvas center
|
||||
- **lora sub-tab** — detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format `inputs.loras.__value__` supported); shows `strength_model / strength_clip` values; drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (appears for 1+ LoRAs) drags all LoRAs into a single `Lora Loader (LoraManager)` node with LoRA syntax pre-filled
|
||||
- **Prompts sub-tab** — POS / NEG badge list; drag a prompt to place `CLIPTextEncode` with text pre-filled; click any entry to view full text + Copy button
|
||||
@@ -552,9 +599,15 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); needs an API key set via `UNSLOTH_API_KEY` in a `.env` file, or Unsloth's Keyless API access → "Chat and inference" turned on — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **Gemini API key / EmbeddingGemma 2** (v0.8.2) — only for the Gallery's Semantic Search: either set `GEMINI_API_KEY` in the plugin's `.env` (key from [Google AI Studio](https://aistudio.google.com); free tier available, paid beyond it), or use the local **EmbeddingGemma 2** (no key; `onnxruntime`, `tokenizers` and `huggingface_hub` — normally already present with ComfyUI; the model downloads on first use). `onnxruntime-gpu` makes the local model ≈45× faster on NVIDIA GPUs
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
- **[psd-tools](https://pypi.org/project/psd-tools/)** (v0.6.0) — enables all PSD (Photoshop) features: Gallery bulk "Create PSD" export, Gallery browsing/thumbnails for `.psd` files, and Image Edit's Save PSD / Open PSD buttons; `pip install -r requirements.txt` (ships prebuilt wheels, no C compiler required); without it, PSD-related actions show an error toast with the install command instead of failing silently
|
||||
|
||||
#### Backend connection security (v0.7.12)
|
||||
|
||||
Server-side proxies for Ollama, LM Studio, Lemonade, Unsloth, the Tagger VLM and Eagle only connect to **localhost / 127.0.0.1 / ::1** (any port); `http(s)` only, no credentials in the URL, and redirects are never followed. To use a backend on another machine (e.g. an Ollama server on your LAN), list it in the `WFS_ALLOWED_BACKEND_HOSTS` environment variable (comma-separated `host` or `host:port`, e.g. `192.168.1.20:11434,gpu-box`) and restart ComfyUI. If you start ComfyUI with `--listen`, anyone who can reach it can use these routes, so only do that on a trusted network.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -1 +0,0 @@
|
||||
{"0": "models-tab.js", "1": "prompt-tab.js", "2": "models_routes.py", "3": "generate-tab.js", "4": "lab-tab.js", "5": "gallery_routes.py", "6": "gallery-tab.js", "7": "ImageEditTab", "8": "ai-tab.js", "10": "feeder-tab.js", "11": "NodesService", "12": "settings_routes.py", "13": "metadata-tab.js", "14": "node_sets_menu.js", "15": "GalleryService", "16": "workflow-tab.js", "17": "tagger_routes.py", "18": "WildcardService", "19": "t", "20": "renderContent", "21": "comfyui-editor.js", "23": "TaggerService", "24": "top_menu_extension.js", "25": "settings-tab.js", "26": "setupAiHandlers", "27": "PromptsService", "28": "workflow_routes.py", "29": "GalleryMetadataStore", "30": "WorkflowService", "31": "lab_routes.py", "32": "SkillService", "33": "LayerManager", "34": "SelectTool", "35": "CivitaiService", "36": "DrawTool", "37": "MaskTool", "39": "gallery_service.py", "41": "image-prompt-tab.js", "42": "KSampler Node", "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": "i18n.js", "57": ".list_images", "58": "MaskAlphaTool", "59": "MaskColorTool", "60": "gallery_feeder_extension.js", "61": "createPanel", "62": "util.js", "63": "BgRemove", "65": "WFS_GalleryFeeder", "66": "app.js", "67": "InpaintI2IActions", "68": "Sam3Segmentation", "69": "fromWorkflow", "70": "_extractPromptsAPI", "71": "generate_doc_index.py", "72": "WS Inpaint Basic Workflow", "73": "._bg_index_folder", "74": "SettingsService", "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": "civitai_service.py", "81": "WFS_PromptText", "82": "workflow_service.py", "84": "config.py", "85": "Tagger Tab requirements.txt", "88": "Chinese Language Support", "89": "Version 0.3.95", "97": "Lab Subtab", "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", "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", "135": "Feeder Subtab", "137": "Help & Support Tab", "140": "Metadata Tab", "144": "comfyui-workflow-studio", "146": "py/__init__.py", "147": "nodes/__init__.py", "148": "routes/__init__.py", "149": "services/__init__.py", "150": "python-dotenv", "155": "SD1.5 Basic Text-to-Image Workflow", "156": "SDXL Text-to-Image Basic Workflow Screenshot", "9": "Backend API Routes", "22": "External Plugin Ecosystem", "38": "Registry Publish & Docs", "40": "Generate & Export Utilities", "43": "ComfyUI Client & Mask Bridge", "64": "Workflow Analyzer Internals", "80": "Doc Index Automation", "83": "Project Directory Layout", "86": "Data Storage Files", "87": "AI Tool Version History", "90": "Windows Long Path Handling", "91": "Route Registration", "92": "Caching & Unique IDs", "93": "Service Layer", "94": "Lazy Input Evaluation", "95": "HuggingFace Config", "96": "Node Cache Invalidation", "98": "Mask Editor One Release", "99": "PowerShell Encoding", "109": "Chinese Locale", "134": "English Locale", "136": "ComfyUI folder_paths API", "138": "i18n System", "139": "Japanese Locale", "141": "Node Class Mappings", "142": "OrderedDict Usage", "143": "Output Directory API", "145": "ComfyUI PromptServer", "151": "Silent Failure Diagnosis", "152": "Temp Directory API", "153": "Path Utility", "154": "Input Validation Hook"}
|
||||
@@ -1,546 +0,0 @@
|
||||
# Graph Report - ComfyUI-Workflow-Studio (2026-08-22)
|
||||
|
||||
## Corpus Check
|
||||
- 20 files · ~567,102 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 2320 nodes · 5560 edges · 157 communities (79 shown, 78 thin omitted)
|
||||
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 328 edges (avg confidence: 0.87)
|
||||
- Token cost: 22,191 input · 55,365 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- models-tab.js
|
||||
- prompt-tab.js
|
||||
- models_routes.py
|
||||
- generate-tab.js
|
||||
- lab-tab.js
|
||||
- gallery_routes.py
|
||||
- gallery-tab.js
|
||||
- ImageEditTab
|
||||
- ai-tab.js
|
||||
- Backend API Routes
|
||||
- feeder-tab.js
|
||||
- NodesService
|
||||
- settings_routes.py
|
||||
- metadata-tab.js
|
||||
- node_sets_menu.js
|
||||
- GalleryService
|
||||
- workflow-tab.js
|
||||
- tagger_routes.py
|
||||
- WildcardService
|
||||
- t
|
||||
- renderContent
|
||||
- comfyui-editor.js
|
||||
- External Plugin Ecosystem
|
||||
- TaggerService
|
||||
- top_menu_extension.js
|
||||
- settings-tab.js
|
||||
- setupAiHandlers
|
||||
- PromptsService
|
||||
- workflow_routes.py
|
||||
- GalleryMetadataStore
|
||||
- WorkflowService
|
||||
- lab_routes.py
|
||||
- SkillService
|
||||
- LayerManager
|
||||
- SelectTool
|
||||
- CivitaiService
|
||||
- DrawTool
|
||||
- MaskTool
|
||||
- Registry Publish & Docs
|
||||
- gallery_service.py
|
||||
- Generate & Export Utilities
|
||||
- image-prompt-tab.js
|
||||
- KSampler Node
|
||||
- ComfyUI Client & Mask Bridge
|
||||
- style-catalog-tab.js
|
||||
- add
|
||||
- comfyui-workflow.js
|
||||
- BlurTool
|
||||
- MaskShapeTool
|
||||
- MaskVectorTool
|
||||
- ShapeTool
|
||||
- gmic_routes.py
|
||||
- ollama_routes.py
|
||||
- TextTool
|
||||
- analyze_workflow
|
||||
- MaskTextTool
|
||||
- i18n.js
|
||||
- .list_images
|
||||
- MaskAlphaTool
|
||||
- MaskColorTool
|
||||
- gallery_feeder_extension.js
|
||||
- createPanel
|
||||
- util.js
|
||||
- BgRemove
|
||||
- Workflow Analyzer Internals
|
||||
- WFS_GalleryFeeder
|
||||
- app.js
|
||||
- InpaintI2IActions
|
||||
- Sam3Segmentation
|
||||
- fromWorkflow
|
||||
- _extractPromptsAPI
|
||||
- generate_doc_index.py
|
||||
- WS Inpaint Basic Workflow
|
||||
- ._bg_index_folder
|
||||
- SettingsService
|
||||
- import_style_prompt_seed.py
|
||||
- Image Edit (FireRed Image Edit 1.1) node
|
||||
- Version 0.3.9
|
||||
- DWPose Image Feeder Workflow
|
||||
- civitai_service.py
|
||||
- Doc Index Automation
|
||||
- WFS_PromptText
|
||||
- workflow_service.py
|
||||
- Project Directory Layout
|
||||
- config.py
|
||||
- Tagger Tab requirements.txt
|
||||
- Data Storage Files
|
||||
- AI Tool Version History
|
||||
- Chinese Language Support
|
||||
- Version 0.3.95
|
||||
- Windows Long Path Handling
|
||||
- Route Registration
|
||||
- Caching & Unique IDs
|
||||
- Service Layer
|
||||
- Lazy Input Evaluation
|
||||
- HuggingFace Config
|
||||
- Node Cache Invalidation
|
||||
- Lab Subtab
|
||||
- Mask Editor One Release
|
||||
- PowerShell Encoding
|
||||
- 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 Locale
|
||||
- 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 Locale
|
||||
- Feeder Subtab
|
||||
- ComfyUI folder_paths API
|
||||
- Help & Support Tab
|
||||
- i18n System
|
||||
- Japanese Locale
|
||||
- Metadata Tab
|
||||
- Node Class Mappings
|
||||
- OrderedDict Usage
|
||||
- Output Directory API
|
||||
- comfyui-workflow-studio
|
||||
- ComfyUI PromptServer
|
||||
- python-dotenv
|
||||
- Silent Failure Diagnosis
|
||||
- Temp Directory API
|
||||
- Path Utility
|
||||
- Input Validation Hook
|
||||
- SD1.5 Basic Text-to-Image Workflow
|
||||
- SDXL Text-to-Image Basic Workflow Screenshot
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `t()` - 226 edges
|
||||
2. `showToast()` - 186 edges
|
||||
3. `ImageEditTab` - 53 edges
|
||||
4. `escapeHtml()` - 53 edges
|
||||
5. `GalleryService` - 45 edges
|
||||
6. `ComfyUI-Workflow-Studio` - 41 edges
|
||||
7. `initPromptTab()` - 33 edges
|
||||
8. `setup_routes()` - 32 edges
|
||||
9. `initSettingsTab()` - 31 edges
|
||||
10. `add()` - 31 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `ComfyUI-Workflow-Studio` --references--> `ImagePrompt Gallery Subtab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `Prompt Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `Gallery Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `GenerateUI Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `Image Edit Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
|
||||
## Import Cycles
|
||||
- 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/BgRemove.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/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/gallery-tab.js -> static/js/image-prompt-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/models/badges.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/image-edit/InpaintI2IActions.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/MaskEditorOneBridge.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BlurTool.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/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/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`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/comfyui-editor.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`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.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]
|
||||
- **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]
|
||||
- **Graphify Index Generation** — readme_index, devlog_index, generate_doc_index, graphifyignore, readme, devlog, claude_hooks, git_hooks [EXTRACTED 0.85]
|
||||
- **AI Feature Backends** — ollama, lm_studio, lemonade_server, unsloth [INFERRED 0.80]
|
||||
- **Image/Gallery Implementation References** — comfyui_custom_scripts, comfyui_gallery, infinite_image_browsing [INFERRED 0.70]
|
||||
- **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]
|
||||
- **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 (157 total, 78 thin omitted)
|
||||
|
||||
### Community 0 - "models-tab.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (120): openModal(), t(), badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange() (+112 more)
|
||||
|
||||
### Community 1 - "prompt-tab.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (107): closeModal(), refreshStylesList(), addChatMessage(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), applyToGenerateUI(), attachFile() (+99 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.06
|
||||
Nodes (64): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _buildSimpleGroupList(), _ckptBatch (+56 more)
|
||||
|
||||
### Community 4 - "lab-tab.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (67): _expandWildcardsInWorkflow(), _annotatedImageRef(), _applyLabLoraToWorkflow(), _applyPlanData(), _applyStyleToText(), _buildIndexImageDataUrl(), _buildLoraPromptInjection(), _buildPlanData() (+59 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.09
|
||||
Nodes (54): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+46 more)
|
||||
|
||||
### Community 8 - "ai-tab.js"
|
||||
Cohesion: 0.09
|
||||
Nodes (51): appendChatBubble(), _appendSkillSaveButton(), _appendSvgPreview(), _applyGenOptions(), buildTranslationMessages(), callChat(), callLLM(), callVLM() (+43 more)
|
||||
|
||||
### Community 9 - "Backend API Routes"
|
||||
Cohesion: 0.05
|
||||
Nodes (46): aiohttp, Jinja2, py/routes, _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test() (+38 more)
|
||||
|
||||
### Community 10 - "feeder-tab.js"
|
||||
Cohesion: 0.10
|
||||
Nodes (49): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+41 more)
|
||||
|
||||
### Community 11 - "NodesService"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): handle_create_set(), handle_delete_set(), handle_export_set(), handle_get_groups(), handle_get_metadata(), handle_list_sets(), handle_save_groups(), handle_save_metadata() (+20 more)
|
||||
|
||||
### Community 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 (37): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+29 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 - "workflow-tab.js"
|
||||
Cohesion: 0.13
|
||||
Nodes (35): readJsonStorage(), badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows() (+27 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 - "t"
|
||||
Cohesion: 0.12
|
||||
Nodes (35): _applyI18n(), _batchStart(), _batchStop(), _dbDelete(), _dbExport(), _dbLoad(), _dbSave(), _dbSearch() (+27 more)
|
||||
|
||||
### Community 20 - "renderContent"
|
||||
Cohesion: 0.14
|
||||
Nodes (35): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), createModelItem(), esc(), extractPackageName(), fetchModelList(), getNodeCategory() (+27 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 - "External Plugin Ecosystem"
|
||||
Cohesion: 0.06
|
||||
Nodes (33): Canvas Snapshot, CivitAI, ComfyUI-Custom-Scripts, ComfyUI-Gallery, ComfyUI-Lora-Manager, comfyui-mask-editor-one, README Index — ComfyUI-Workflow-Studio, ComfyUI-Workflow-Studio (+25 more)
|
||||
|
||||
### 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.13
|
||||
Nodes (29): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), injectStyles(), NODE_SETS_TOOLTIP, saveSelectedAsNodeSet(), attachTopMenuButton(), beforeRegisterNodeDef() (+21 more)
|
||||
|
||||
### Community 25 - "settings-tab.js"
|
||||
Cohesion: 0.12
|
||||
Nodes (29): applyCustomFont(), applyCustomOverrides(), applyTheme(), BG_PATTERNS, buildLangOptions(), clearCustomOverrides(), COLOR_TARGETS, FONT_OPTIONS (+21 more)
|
||||
|
||||
### Community 26 - "setupAiHandlers"
|
||||
Cohesion: 0.11
|
||||
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
|
||||
|
||||
### Community 27 - "PromptsService"
|
||||
Cohesion: 0.13
|
||||
Nodes (16): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+8 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 - "lab_routes.py"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): handle_delete(), handle_get_content(), handle_list(), handle_save(), handle_save_index_to_output(), Application, Request, Response (+6 more)
|
||||
|
||||
### Community 32 - "SkillService"
|
||||
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 - "Registry Publish & Docs"
|
||||
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 40 - "Generate & Export Utilities"
|
||||
Cohesion: 0.17
|
||||
Nodes (8): showToast(), _applyDefaultCheckpointIfEnabled(), loadWorkflowIntoEditor(), saveCurrentWorkflow(), FileExport, GmicIntegration, _loadPlanWorkflow(), onLoadGenUIClick()
|
||||
|
||||
### Community 41 - "image-prompt-tab.js"
|
||||
Cohesion: 0.20
|
||||
Nodes (21): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+13 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 - "ComfyUI Client & Mask Bridge"
|
||||
Cohesion: 0.16
|
||||
Nodes (8): comfyUI, comfyEditor, comfyWorkflow, 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 - "i18n.js"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+4 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.24
|
||||
Nodes (12): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), loadModelsData() (+4 more)
|
||||
|
||||
### Community 62 - "util.js"
|
||||
Cohesion: 0.31
|
||||
Nodes (10): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), getEagleSettings(), getSettings(), _joinBytes(), _n2b() (+2 more)
|
||||
|
||||
### Community 64 - "Workflow Analyzer Internals"
|
||||
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 - "app.js"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize()
|
||||
|
||||
### 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.31
|
||||
Nodes (7): main(), PostToolUse hook: regenerate README.index.md / DEVLOG.index.md right after…, Path, generate_devlog_index(), generate_readme_index(), main(), 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 - "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 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.25
|
||||
Nodes (8): Models Tab, Nodes Tab, Prompt Tab, Version 0.1.2, Version 0.1.7, Version 0.2.3, Version 0.3.9, Workflow Tab
|
||||
|
||||
### 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 - "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 80 - "Doc Index Automation"
|
||||
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 - "Project Directory Layout"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): ComfyUI, SPA, static, templates, web/comfyui
|
||||
|
||||
### Community 84 - "config.py"
|
||||
Cohesion: 0.40
|
||||
Nodes (3): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Settings management service.
|
||||
|
||||
### 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 Storage Files"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): data directory, metadata.json, node_metadata.json, settings.json
|
||||
|
||||
### Community 87 - "AI Tool Version History"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): AI TOOL Tab, Version 0.3.14, Version 0.3.99
|
||||
|
||||
### 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 Long Path Handling"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): LongPathsEnabled, MAX_PATH, Windows
|
||||
|
||||
## Knowledge Gaps
|
||||
- **228 isolated node(s):** `state`, `groups`, `state`, `WF_RESERVED_GROUPS`, `AI_BACKEND_DEFAULT_URLS` (+223 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **78 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 `Generate & Export Utilities` to `models-tab.js`, `prompt-tab.js`, `generate-tab.js`, `lab-tab.js`, `gallery-tab.js`, `ImageEditTab`, `ai-tab.js`, `feeder-tab.js`, `workflow-tab.js`, `t`, `comfyui-editor.js`, `settings-tab.js`, `LayerManager`, `image-prompt-tab.js`, `ComfyUI Client & Mask Bridge`, `style-catalog-tab.js`, `BlurTool`, `BgRemove`, `app.js`, `InpaintI2IActions`, `Sam3Segmentation`?**
|
||||
_High betweenness centrality (0.062) - this node is a cross-community bridge._
|
||||
- **Why does `t()` connect `models-tab.js` to `prompt-tab.js`, `app.js`, `generate-tab.js`, `lab-tab.js`, `gallery-tab.js`, `ai-tab.js`, `Generate & Export Utilities`, `feeder-tab.js`, `image-prompt-tab.js`, `style-catalog-tab.js`, `metadata-tab.js`, `workflow-tab.js`, `t`, `comfyui-editor.js`, `i18n.js`, `settings-tab.js`?**
|
||||
_High betweenness centrality (0.048) - this node is a cross-community bridge._
|
||||
- **Why does `GalleryService` connect `GalleryService` to `gallery_routes.py`, `.get_image_prompt_root`, `gallery_service.py`, `.get_style_catalog_root`, `._bg_index_folder`, `.start_background_index`, `.list_images`, `GalleryMetadataStore`?**
|
||||
_High betweenness centrality (0.027) - this node is a cross-community bridge._
|
||||
- **What connects `state`, `groups`, `state` to the rest of the system?**
|
||||
_228 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `models-tab.js` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.06185919343814081 - nodes in this community are weakly interconnected._
|
||||
- **Should `prompt-tab.js` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05572545022086307 - 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._
|
||||
@@ -1,866 +0,0 @@
|
||||
{
|
||||
"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": 1787369336.5249329,
|
||||
"seen": 1787380810.3284276,
|
||||
"ast_hash": "14b3ced4743af977596001606be49000",
|
||||
"semantic_hash": "14b3ced4743af977596001606be49000"
|
||||
},
|
||||
"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": 1787373450.5713615,
|
||||
"seen": 1787380810.352496,
|
||||
"ast_hash": "6d2e3cd1bbf2e99a5298cfb502a18d9e",
|
||||
"semantic_hash": "6d2e3cd1bbf2e99a5298cfb502a18d9e"
|
||||
},
|
||||
"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": 1787294395.7691903,
|
||||
"seen": 1787294443.2937605,
|
||||
"ast_hash": "34f4730b81cef4d6393b5867945e41de",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"README.index.md": {
|
||||
"mtime": 1787294395.7601545,
|
||||
"seen": 1787294443.293762,
|
||||
"ast_hash": "ccb030b076d60806eaff45744274cdfe",
|
||||
"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": 1787376568.1601257,
|
||||
"seen": 1787380810.3524997,
|
||||
"ast_hash": "ca7fa878074a6fd12d5a478109c929d9",
|
||||
"semantic_hash": "ca7fa878074a6fd12d5a478109c929d9"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,51 @@ from pathlib import Path
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = PLUGIN_DIR / ".env"
|
||||
|
||||
|
||||
def _maybe_use_selector_event_loop():
|
||||
"""Windows: ProactorEventLoop(IOCP) を SelectorEventLoop に切り替える(既定はオフ)。
|
||||
|
||||
環境によっては、ソケットclose(closesocket/NtClose)でイベントループがOSカーネル内で停止し、
|
||||
ComfyUI全体が無応答になる(再起動するまでポートも解放されない)。Selector方式はIOCPを使わず、
|
||||
この停止を避けられる可能性がある。副作用: asyncioのサブプロセス機能が使えない、
|
||||
同時接続数の上限(約512)がある。
|
||||
|
||||
有効化: 環境変数 WFS_SELECTOR_LOOP=1、または
|
||||
user/default/Workflow-Studio/settings.json に "windows_selector_event_loop": true
|
||||
ComfyUIがイベントループを作成する前に呼ぶ必要があるため、ここ(prestartup)で設定する。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
enabled = os.environ.get("WFS_SELECTOR_LOOP", "").strip().lower() in ("1", "true", "yes")
|
||||
if not enabled:
|
||||
try:
|
||||
import json
|
||||
candidates = [
|
||||
PLUGIN_DIR.parent.parent / "user" / "default" / "Workflow-Studio" / "settings.json",
|
||||
PLUGIN_DIR / "data" / "settings.json", # config.py fallback when user/default is absent
|
||||
]
|
||||
for settings in candidates:
|
||||
if settings.is_file():
|
||||
with open(settings, "r", encoding="utf-8") as f:
|
||||
enabled = bool(json.load(f).get("windows_selector_event_loop", False))
|
||||
break
|
||||
except Exception:
|
||||
enabled = False
|
||||
if not enabled:
|
||||
return
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
print("[Workflow Studio] Using WindowsSelectorEventLoopPolicy (windows_selector_event_loop enabled)")
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to switch to selector event loop: {e}")
|
||||
|
||||
|
||||
_maybe_use_selector_event_loop()
|
||||
|
||||
if ENV_FILE.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
@@ -18,7 +63,7 @@ if ENV_FILE.exists():
|
||||
except ImportError:
|
||||
print(
|
||||
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY / GEMINI_API_KEY will not be loaded from .env."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
|
||||
|
||||
@@ -71,6 +71,8 @@ MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
|
||||
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
|
||||
VIDEO_PLAN_DIR = DATA_DIR / "video_plan"
|
||||
VIDEO_EDIT_PROJECT_DIR = DATA_DIR / "video_edit_project"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
|
||||
@@ -9,6 +9,8 @@ import urllib.error
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.url_guard import require_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -92,7 +94,7 @@ def _eagle_add_from_path(eagle_url, file_path, name, tags):
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
with _guarded_urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
@@ -127,7 +129,7 @@ def _eagle_add(eagle_url, image_url, name, tags):
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
with _guarded_urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
@@ -138,7 +140,7 @@ def _eagle_test(eagle_url):
|
||||
f"{eagle_url}/api/application/info",
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with _guarded_urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
@@ -146,7 +148,7 @@ async def handle_add(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/eagle/add - Add image to Eagle."""
|
||||
try:
|
||||
body = await request.json()
|
||||
eagle_url = body.get("eagleUrl", "http://localhost:41595")
|
||||
eagle_url = require_backend_url(body.get("eagleUrl", "http://localhost:41595"))
|
||||
image_url = body.get("url", "")
|
||||
name = body.get("name", "image.png")
|
||||
tags = body.get("tags", [])
|
||||
@@ -193,7 +195,7 @@ async def handle_test(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/eagle/test - Test Eagle connection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
eagle_url = body.get("eagleUrl", "http://localhost:41595")
|
||||
eagle_url = require_backend_url(body.get("eagleUrl", "http://localhost:41595"))
|
||||
result = await asyncio.to_thread(_eagle_test, eagle_url)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
|
||||
@@ -10,10 +10,12 @@ from aiohttp import web
|
||||
|
||||
from ..config import DATA_DIR
|
||||
from ..services.gallery_service import GalleryService
|
||||
from ..services.semantic_service import SemanticService, SemanticError, DEFAULT_DIM, MODEL as SEMANTIC_DEFAULT_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = GalleryService(DATA_DIR)
|
||||
_semantic = SemanticService(DATA_DIR, _service)
|
||||
|
||||
|
||||
def _init_allowed_root() -> None:
|
||||
@@ -55,8 +57,22 @@ def _init_allowed_root() -> None:
|
||||
_init_allowed_root()
|
||||
|
||||
|
||||
# Windowsのレジストリ依存で音声のMIMEが返らない/不統一なため明示登録する(<audio>の再生に必要)
|
||||
for _ext, _mime in ((".wav", "audio/wav"), (".mp3", "audio/mpeg"), (".ogg", "audio/ogg"),
|
||||
(".opus", "audio/ogg"), (".m4a", "audio/mp4"), (".flac", "audio/flac")):
|
||||
mimetypes.add_type(_mime, _ext)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/folders", list_folders)
|
||||
app.router.add_get("/wfm/gallery/semantic/status", semantic_status)
|
||||
app.router.add_get("/wfm/gallery/semantic/folders", semantic_folders)
|
||||
app.router.add_post("/wfm/gallery/semantic/index", semantic_index)
|
||||
app.router.add_post("/wfm/gallery/semantic/cancel", semantic_cancel)
|
||||
app.router.add_post("/wfm/gallery/semantic/search", semantic_search)
|
||||
app.router.add_post("/wfm/gallery/semantic/similar", semantic_similar)
|
||||
app.router.add_post("/wfm/gallery/semantic/clear", semantic_clear)
|
||||
app.router.add_post("/wfm/gallery/semantic/test", semantic_test)
|
||||
app.router.add_get("/wfm/gallery/images", list_images)
|
||||
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
|
||||
@@ -81,6 +97,7 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
|
||||
# バルク操作
|
||||
app.router.add_post("/wfm/gallery/bulk/favorite", bulk_favorite)
|
||||
app.router.add_post("/wfm/gallery/convert", convert_file_route)
|
||||
app.router.add_post("/wfm/gallery/bulk/group", bulk_group)
|
||||
# フォルダ・ファイル操作
|
||||
app.router.add_post("/wfm/gallery/folder", create_folder_route)
|
||||
@@ -88,6 +105,148 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
|
||||
app.router.add_post("/wfm/gallery/images/move", move_images_route)
|
||||
app.router.add_post("/wfm/gallery/images/export-zip", export_images_zip)
|
||||
app.router.add_post("/wfm/gallery/images/export-psd", export_images_psd)
|
||||
app.router.add_post("/wfm/gallery/image/export-psd-layers", export_psd_from_layers)
|
||||
app.router.add_post("/wfm/gallery/image/import-psd-layers", import_psd_layers)
|
||||
app.router.add_get("/wfm/gallery/image/psd-layers", get_psd_layers)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# セマンティック検索 (Gemini Embedding 2)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _semantic_error(e: SemanticError) -> web.Response:
|
||||
# code はフロントのi18n用。message は詳細(APIキー等の秘密は含まない)
|
||||
return web.json_response({"error": str(e), "code": e.code}, status=400)
|
||||
|
||||
|
||||
def _num(body: dict, key: str, default, lo, hi, cast=int):
|
||||
"""リクエストの数値パラメータを検証して範囲内に収める。数値でなければ400相当のSemanticErrorにする"""
|
||||
raw = body.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
raw = default
|
||||
try:
|
||||
v = cast(raw)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
raise SemanticError("bad_param", key)
|
||||
if v != v: # NaN
|
||||
raise SemanticError("bad_param", key)
|
||||
return max(lo, min(v, hi))
|
||||
|
||||
|
||||
async def _json_body(request: web.Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
return body if isinstance(body, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def semantic_status(request: web.Request) -> web.Response:
|
||||
folder = request.rel_url.query.get("folder", "")
|
||||
recursive = request.rel_url.query.get("recursive", "true") == "true"
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(_semantic.status, folder, recursive))
|
||||
except Exception as e:
|
||||
logger.error("semantic_status error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_folders(request: web.Request) -> web.Response:
|
||||
root = request.rel_url.query.get("root", "")
|
||||
try:
|
||||
return web.json_response({"folders": await asyncio.to_thread(_semantic.folder_status, root)})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_folders error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _apply_local_options(body: dict) -> None:
|
||||
"""リクエストに実行デバイス/スレッド数が含まれていれば反映・保存する"""
|
||||
if "device" in body:
|
||||
_semantic.set_local_options(str(body.get("device", "auto")), _num(body, "threads", 0, 0, 256))
|
||||
|
||||
|
||||
async def semantic_index(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
_apply_local_options(body)
|
||||
await asyncio.to_thread(
|
||||
_semantic.start_index,
|
||||
str(body.get("folder", "")),
|
||||
bool(body.get("recursive", True)),
|
||||
"all" if body.get("mode") == "all" else "missing",
|
||||
str(body.get("model", SEMANTIC_DEFAULT_MODEL)),
|
||||
_num(body, "dim", DEFAULT_DIM, 0, 100000),
|
||||
_num(body, "soft", 280, 0, 100000),
|
||||
)
|
||||
return web.json_response({"ok": True})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_index error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_cancel(request: web.Request) -> web.Response:
|
||||
_semantic.cancel()
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def semantic_search(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
limit = _num(body, "limit", 200, 1, 1000)
|
||||
min_score = _num(body, "min_score", 0.0, -1.0, 1.0, float)
|
||||
images = await asyncio.to_thread(
|
||||
_semantic.search_text, str(body.get("query", "")), str(body.get("folder", "")),
|
||||
bool(body.get("recursive", True)), limit, min_score,
|
||||
)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_search error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_similar(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
limit = _num(body, "limit", 200, 1, 1000)
|
||||
min_score = _num(body, "min_score", 0.0, -1.0, 1.0, float)
|
||||
images = await asyncio.to_thread(
|
||||
_semantic.search_similar, str(body.get("path", "")), str(body.get("folder", "")),
|
||||
bool(body.get("recursive", True)), limit, min_score,
|
||||
)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
logger.error("semantic_similar error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def semantic_clear(request: web.Request) -> web.Response:
|
||||
try:
|
||||
await asyncio.to_thread(_semantic.clear)
|
||||
return web.json_response({"ok": True})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
|
||||
|
||||
async def semantic_test(request: web.Request) -> web.Response:
|
||||
body = await _json_body(request)
|
||||
try:
|
||||
_apply_local_options(body)
|
||||
res = await asyncio.to_thread(_semantic.test_connection, str(body.get("model", SEMANTIC_DEFAULT_MODEL)), _num(body, "dim", DEFAULT_DIM, 0, 100000))
|
||||
return web.json_response({"ok": True, **res})
|
||||
except SemanticError as e:
|
||||
return _semantic_error(e)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -181,7 +340,10 @@ async def serve_image(request: web.Request) -> web.Response:
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
|
||||
img_path = _service.serve_image(path)
|
||||
# PSDの合成処理はCPU負荷が高く同期実行するとイベントループ全体をブロックする
|
||||
# (他の並行リクエスト、例えば別画像のサムネイル配信も止まってしまう)ためexecutorで実行する
|
||||
loop = asyncio.get_event_loop()
|
||||
img_path = await loop.run_in_executor(None, _service.serve_image, path)
|
||||
if img_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
@@ -203,7 +365,10 @@ async def serve_thumb(request: web.Request) -> web.Response:
|
||||
except ValueError:
|
||||
width = 256
|
||||
|
||||
thumb_path = _service.serve_thumbnail(path, width)
|
||||
# PSD/MP4のサムネイル生成はCPU負荷が高く同期実行するとイベントループ全体をブロックするため
|
||||
# executorで実行する(他画像のサムネイル配信が巻き添えで遅延・停止するのを防ぐ)
|
||||
loop = asyncio.get_event_loop()
|
||||
thumb_path = await loop.run_in_executor(None, _service.serve_thumbnail, path, width)
|
||||
if thumb_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
@@ -225,6 +390,21 @@ async def serve_thumb(request: web.Request) -> web.Response:
|
||||
# バルク操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def convert_file_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/convert — 音声/動画の音声を別形式へ変換(1ファイルずつ呼ぶ)"""
|
||||
try:
|
||||
body = await request.json()
|
||||
path = body.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"ok": False, "error": "path required"}, status=400)
|
||||
# 変換はCPU負荷が高く同期実行するとイベントループ全体が止まるためexecutorで実行する
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.convert_file, path, body)
|
||||
return web.json_response(result, status=200 if result.get("ok") else 400)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def bulk_favorite(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/bulk/favorite — 複数画像のお気に入りを一括設定"""
|
||||
try:
|
||||
@@ -405,7 +585,7 @@ async def create_group(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_RESERVED_GROUPS = {"__Feeder__"}
|
||||
_RESERVED_GROUPS = {"__Feeder__", "__Video Assets__", "__Video Temp__"}
|
||||
|
||||
|
||||
async def rename_group(request: web.Request) -> web.Response:
|
||||
@@ -574,3 +754,97 @@ async def export_images_zip(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error exporting images to ZIP: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def export_images_psd(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/images/export-psd - 複数画像を1つのPSD(レイヤー分割)にしてダウンロード"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
return web.json_response({"error": "paths required"}, status=400)
|
||||
|
||||
psd_bytes = _service.build_psd_from_images(paths)
|
||||
|
||||
return web.Response(
|
||||
body=psd_bytes,
|
||||
content_type="image/vnd.adobe.photoshop",
|
||||
headers={"Content-Disposition": "attachment; filename=gallery_export.psd"}
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD export dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting images to PSD: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def export_psd_from_layers(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/export-psd-layers - Image Editタブのレイヤー配列を1つのPSDにしてダウンロード"""
|
||||
try:
|
||||
body = await request.json()
|
||||
width = int(body.get("width", 0))
|
||||
height = int(body.get("height", 0))
|
||||
layers = body.get("layers", [])
|
||||
if not layers:
|
||||
return web.json_response({"error": "layers required"}, status=400)
|
||||
|
||||
psd_bytes = _service.build_psd_from_layers(width, height, layers)
|
||||
|
||||
return web.Response(
|
||||
body=psd_bytes,
|
||||
content_type="image/vnd.adobe.photoshop",
|
||||
headers={"Content-Disposition": "attachment; filename=image_edit_export.psd"}
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD export dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting Image Edit layers to PSD: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def import_psd_layers(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/import-psd-layers - アップロードされたPSDをレイヤー配列(JSON)に分解して返す"""
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None:
|
||||
return web.json_response({"error": "file required"}, status=400)
|
||||
psd_bytes = await field.read(decode=False)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.import_psd_layers, psd_bytes)
|
||||
return web.json_response(result)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD import dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error importing PSD layers: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_psd_layers(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/image/psd-layers - サーバー上のPSDファイルをレイヤー配列(JSON)に分解して返す
|
||||
(Galleryから直接Image Editへ送る際、アップロードの往復を挟まずに済ませるため)"""
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.get_psd_layers_from_path, path)
|
||||
return web.json_response(result)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD import dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error reading PSD layers: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -82,9 +82,9 @@ async def handle_open(request: web.Request) -> web.Response:
|
||||
# Retrieve setting for gmic path
|
||||
data = _settings.load()
|
||||
gmic_exe = data.get("gmic_qt_path", "")
|
||||
if not os.path.exists(gmic_exe):
|
||||
if not os.path.isabs(gmic_exe) or os.path.splitext(gmic_exe)[1].lower() != ".exe" or not os.path.isfile(gmic_exe):
|
||||
return web.json_response({
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure the path in settings."
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure an absolute .exe path in settings."
|
||||
}, status=400)
|
||||
|
||||
ext = ".png"
|
||||
|
||||
@@ -20,6 +20,7 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/models/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/models/metadata", handle_save_metadata)
|
||||
app.router.add_get("/api/wfm/models/preview", handle_get_preview)
|
||||
app.router.add_get("/api/wfm/models/preview-keys", handle_get_preview_keys)
|
||||
app.router.add_get("/api/wfm/models/groups", handle_get_groups)
|
||||
app.router.add_post("/api/wfm/models/groups", handle_save_groups)
|
||||
app.router.add_post("/api/wfm/models/civitai/fetch", handle_civitai_fetch)
|
||||
@@ -139,14 +140,20 @@ async def handle_civitai_fetch(request: web.Request) -> web.Response:
|
||||
)
|
||||
if not sha256:
|
||||
return web.json_response({"error": "Failed to calculate hash"}, status=500)
|
||||
# Cache the hash in model metadata
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"sha256": sha256}
|
||||
)
|
||||
# Cache the hash (+ size/mtime so a replaced file invalidates it) in model metadata
|
||||
try:
|
||||
st = file_path.stat()
|
||||
hash_meta = {"sha256": sha256, "sha256Size": st.st_size, "sha256Mtime": st.st_mtime}
|
||||
except OSError:
|
||||
hash_meta = {"sha256": sha256}
|
||||
await asyncio.to_thread(_service.update_metadata, model_name, hash_meta)
|
||||
|
||||
# Fetch from CivitAI
|
||||
info = await asyncio.to_thread(_civitai.fetch_by_hash, sha256)
|
||||
if not info:
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"civitaiNotFound": True}
|
||||
)
|
||||
return web.json_response({
|
||||
"status": "not_found",
|
||||
"sha256": sha256,
|
||||
@@ -166,6 +173,10 @@ async def handle_civitai_fetch(request: web.Request) -> web.Response:
|
||||
if preview_saved:
|
||||
logger.info("Auto-saved preview for %s from CivitAI", model_name)
|
||||
|
||||
if model_meta.get("civitaiNotFound"):
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"civitaiNotFound": False}
|
||||
)
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"sha256": sha256,
|
||||
@@ -244,69 +255,159 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
def on_progress(current, total, model_name, status):
|
||||
progress_q.put({"current": current, "total": total, "model": model_name, "status": status})
|
||||
|
||||
# 保存済みsha256(size/mtime付き)を渡して再ハッシュを避ける
|
||||
import time as _time
|
||||
all_meta0 = _service.get_all_metadata()
|
||||
known = {}
|
||||
for n, _p in model_files:
|
||||
m = all_meta0.get(n) or {}
|
||||
if m.get("sha256"):
|
||||
known[n] = {"sha256": m["sha256"], "size": m.get("sha256Size"), "mtime": m.get("sha256Mtime")}
|
||||
|
||||
# 未計算のものは Lora Manager が保持済みのsha256を借りる(サイズ一致時のみ。ハッシュ計算を省く)
|
||||
if len(known) < len(model_files):
|
||||
from ..services.lora_manager_bridge import fetch_lm_hashes, _norm
|
||||
# Hostヘッダは信用せず、自分自身のループバック+実際のポートへ接続する(Host偽装/DNSリバインディング対策)
|
||||
sockname = request.transport.get_extra_info("sockname") if request.transport else None
|
||||
port = sockname[1] if sockname else (request.url.port or 8188)
|
||||
origin = f"http://127.0.0.1:{port}"
|
||||
lm = await asyncio.to_thread(fetch_lm_hashes, origin, model_type)
|
||||
for n, p in model_files:
|
||||
if n in known:
|
||||
continue
|
||||
e = lm.get(_norm(p))
|
||||
try:
|
||||
if e and e.get("size") is not None and int(e["size"]) == p.stat().st_size:
|
||||
known[n] = {"sha256": e["sha256"], "size": int(e["size"]), "mtime": None}
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ハッシュ確定ごとに溜め、5秒おきにメタデータへ書き出す(途中で止めても計算済み分が残る)
|
||||
pending_hashes = {}
|
||||
last_flush = [_time.monotonic()]
|
||||
|
||||
def flush_hashes():
|
||||
if not pending_hashes:
|
||||
return
|
||||
try:
|
||||
all_meta = _service.get_all_metadata()
|
||||
for mname, (sha, size, mtime) in list(pending_hashes.items()):
|
||||
entry = all_meta.setdefault(mname, {"tags": [], "favorite": False, "memo": ""})
|
||||
entry["sha256"] = sha
|
||||
entry["sha256Size"] = size
|
||||
entry["sha256Mtime"] = mtime
|
||||
_service._save_metadata(all_meta)
|
||||
pending_hashes.clear()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to flush hashes: %s", e)
|
||||
last_flush[0] = _time.monotonic()
|
||||
|
||||
def on_hash(name, sha, size, mtime):
|
||||
pending_hashes[name] = (sha, size, mtime)
|
||||
if _time.monotonic() - last_flush[0] > 5:
|
||||
flush_hashes()
|
||||
|
||||
# Start batch in background thread
|
||||
import threading
|
||||
cancel = threading.Event()
|
||||
loop = asyncio.get_event_loop()
|
||||
fetch_task = loop.run_in_executor(None, _civitai.batch_fetch, model_files, on_progress)
|
||||
fetch_task = loop.run_in_executor(
|
||||
None, lambda: _civitai.batch_fetch(model_files, on_progress, known, on_hash, cancel=cancel)
|
||||
)
|
||||
|
||||
# Stream progress events
|
||||
import time as _time
|
||||
try:
|
||||
while not fetch_task.done():
|
||||
# Drain queue
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
while not fetch_task.done():
|
||||
# Drain queue
|
||||
# Drain remaining events
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Drain remaining events
|
||||
while not progress_q.empty():
|
||||
except (ConnectionResetError, asyncio.CancelledError) as e:
|
||||
# クライアント切断(再読み込み・タブ遷移): 重いハッシュ計算を打ち切り、計算済み分だけ保存して終了
|
||||
logger.info("civitai batch: client disconnected, cancelling (%s)", type(e).__name__)
|
||||
cancel.set()
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.shield(fetch_task)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.to_thread(flush_hashes)
|
||||
if isinstance(e, asyncio.CancelledError):
|
||||
raise
|
||||
return response
|
||||
|
||||
# Get results and update metadata
|
||||
results = fetch_task.result()
|
||||
try:
|
||||
results = fetch_task.result()
|
||||
finally:
|
||||
await asyncio.to_thread(flush_hashes)
|
||||
meta_updates = {}
|
||||
for model_name, result in results.items():
|
||||
sha256 = result.get("sha256")
|
||||
if sha256:
|
||||
meta_updates[model_name] = sha256
|
||||
|
||||
# Bulk update sha256 in metadata
|
||||
# Bulk update sha256 (+ CivitAI not-found marker) in metadata
|
||||
if meta_updates:
|
||||
all_meta = _service.get_all_metadata()
|
||||
for mname, sha in meta_updates.items():
|
||||
if mname not in all_meta:
|
||||
all_meta[mname] = {"tags": [], "favorite": False, "memo": ""}
|
||||
all_meta[mname]["sha256"] = sha
|
||||
r = results[mname]
|
||||
if r.get("civitai"):
|
||||
all_meta[mname].pop("civitaiNotFound", None)
|
||||
elif not r.get("fetch_failed") and not r.get("error"):
|
||||
all_meta[mname]["civitaiNotFound"] = _service._now_iso()
|
||||
_service._save_metadata(all_meta)
|
||||
|
||||
# Auto-set previews for models without preview image
|
||||
# (ネットワークI/Oなのでイベントループを塞がないようスレッドで並列実行)
|
||||
await response.write(send_sse("progress", {
|
||||
"current": len(model_files), "total": len(model_files), "model": "", "status": "previews",
|
||||
}))
|
||||
dirs = _get_model_dirs(model_type)
|
||||
preview_saved_count = 0
|
||||
for model_name, result in results.items():
|
||||
civitai = result.get("civitai")
|
||||
if not civitai or not civitai.get("images"):
|
||||
continue
|
||||
if _service.find_preview_image(model_type, model_name):
|
||||
continue
|
||||
model_file = None
|
||||
for d in dirs:
|
||||
p = d / model_name
|
||||
if p.is_file():
|
||||
model_file = p
|
||||
break
|
||||
if not model_file:
|
||||
continue
|
||||
preview_path = model_file.parent / (model_file.stem + ".preview.png")
|
||||
if CivitaiService.download_image(civitai["images"][0], preview_path):
|
||||
preview_saved_count += 1
|
||||
logger.info("Auto-saved preview for %s from CivitAI", model_name)
|
||||
|
||||
def _save_previews():
|
||||
jobs = []
|
||||
for model_name, result in results.items():
|
||||
civitai = result.get("civitai")
|
||||
if not civitai or not civitai.get("images"):
|
||||
continue
|
||||
if _service.find_preview_image(model_type, model_name):
|
||||
continue
|
||||
model_file = None
|
||||
for d in dirs:
|
||||
p = d / model_name
|
||||
if p.is_file():
|
||||
model_file = p
|
||||
break
|
||||
if not model_file:
|
||||
continue
|
||||
jobs.append((model_name, civitai["images"][0],
|
||||
model_file.parent / (model_file.stem + ".preview.png")))
|
||||
if not jobs:
|
||||
return 0
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
oks = list(pool.map(lambda j: CivitaiService.download_image(j[1], j[2]), jobs))
|
||||
for (mname, _u, _p), ok in zip(jobs, oks):
|
||||
if ok:
|
||||
logger.info("Auto-saved preview for %s from CivitAI", mname)
|
||||
return sum(1 for ok in oks if ok)
|
||||
|
||||
preview_saved_count = await asyncio.to_thread(_save_previews)
|
||||
|
||||
# Send final result
|
||||
summary = {
|
||||
@@ -314,6 +415,7 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
"found": sum(1 for r in results.values() if r.get("civitai")),
|
||||
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
|
||||
"errors": sum(1 for r in results.values() if r.get("error")),
|
||||
"needs_hash": sum(1 for r in results.values() if r.get("needs_hash")),
|
||||
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
|
||||
"preview_saved": preview_saved_count,
|
||||
}
|
||||
@@ -325,6 +427,22 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
# ── Model Preview Image ───────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_preview_keys(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/preview-keys?type=checkpoint
|
||||
|
||||
プレビュー画像を持つモデルのキー一覧を返す(無いモデルへの画像リクエスト=404を避けるため)。
|
||||
"""
|
||||
model_type = request.query.get("type", "")
|
||||
if not model_type:
|
||||
return web.json_response({"error": "type required"}, status=400)
|
||||
try:
|
||||
keys = await asyncio.to_thread(_service.list_preview_keys, model_type)
|
||||
return web.json_response({"keys": keys})
|
||||
except Exception as e:
|
||||
logger.error("Error listing preview keys: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_preview(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/preview?type=checkpoint&name=model.safetensors"""
|
||||
model_type = request.query.get("type", "")
|
||||
@@ -632,7 +750,8 @@ async def handle_list_model_files(request: web.Request) -> web.Response:
|
||||
if not model_type or model_type not in MODEL_TYPE_FOLDER_KEYS:
|
||||
return web.json_response({"error": "invalid type"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_model_files, model_type)
|
||||
extra_only = request.query.get("extra_only") == "1"
|
||||
result = await asyncio.to_thread(_service.list_model_files, model_type, extra_only)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing model files for %s: %s", model_type, e)
|
||||
|
||||
@@ -15,6 +15,7 @@ _service = NodesService()
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all node management API routes."""
|
||||
# Node metadata
|
||||
app.router.add_get("/api/wfm/nodes/light", handle_get_light_nodes)
|
||||
app.router.add_get("/api/wfm/nodes/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/nodes/metadata", handle_save_metadata)
|
||||
# Node groups
|
||||
@@ -28,6 +29,50 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/node-sets/export", handle_export_set)
|
||||
|
||||
|
||||
# ── Light node list ────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_light_node_list() -> list[dict]:
|
||||
"""全ノードの軽量情報(INPUT_TYPES を呼ばない)。
|
||||
|
||||
/object_info は各ノードの INPUT_TYPES() を呼ぶため、モデル一覧を走査するノードが
|
||||
多い環境では十数秒かかる。Nodesタブの一覧表示に必要なのはクラス属性だけなので、
|
||||
それらのみを読んで高速に返す(入力定義は呼び出し側が /object_info で後から補う)。
|
||||
"""
|
||||
import nodes as comfy_nodes
|
||||
|
||||
result = []
|
||||
for name, cls in list(comfy_nodes.NODE_CLASS_MAPPINGS.items()):
|
||||
try:
|
||||
return_types = getattr(cls, "RETURN_TYPES", ()) or ()
|
||||
result.append({
|
||||
"name": name,
|
||||
"display_name": comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS.get(name, name),
|
||||
"description": getattr(cls, "DESCRIPTION", "") or "",
|
||||
"category": getattr(cls, "CATEGORY", "sd") or "sd",
|
||||
"python_module": getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes",
|
||||
"output": list(return_types),
|
||||
"output_name": list(getattr(cls, "RETURN_NAMES", None) or return_types),
|
||||
"output_node": getattr(cls, "OUTPUT_NODE", False) is True,
|
||||
"search_aliases": list(getattr(cls, "SEARCH_ALIASES", []) or []),
|
||||
"deprecated": bool(getattr(cls, "DEPRECATED", False)),
|
||||
"experimental": bool(getattr(cls, "EXPERIMENTAL", False)),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("light node info failed for %s: %s", name, e)
|
||||
return result
|
||||
|
||||
|
||||
async def handle_get_light_nodes(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/nodes/light"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_build_light_node_list)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error building light node list: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Node Metadata ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import urllib.request
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..services.url_guard import require_backend_url, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,11 +40,12 @@ async def handle_models(request: web.Request) -> web.Response:
|
||||
import asyncio
|
||||
try:
|
||||
cfg = _get_ollama_config()
|
||||
base_url = require_backend_url(cfg["url"])
|
||||
|
||||
def _fetch():
|
||||
url = f"{cfg['url']}/api/tags"
|
||||
url = f"{base_url}/api/tags"
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
@@ -61,11 +63,12 @@ async def handle_test(request: web.Request) -> web.Response:
|
||||
import asyncio
|
||||
try:
|
||||
cfg = _get_ollama_config()
|
||||
base_url = require_backend_url(cfg["url"])
|
||||
|
||||
def _test():
|
||||
url = f"{cfg['url']}/api/tags"
|
||||
url = f"{base_url}/api/tags"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
return resp.status == 200
|
||||
|
||||
ok = await asyncio.to_thread(_test)
|
||||
@@ -83,7 +86,7 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
body = await request.json()
|
||||
cfg = _get_ollama_config()
|
||||
model = body.get("model") or cfg["model"]
|
||||
base_url = (body.get("url") or cfg["url"]).rstrip("/")
|
||||
base_url = require_backend_url(body.get("url") or cfg["url"])
|
||||
messages = body.get("messages", [])
|
||||
|
||||
def _chat():
|
||||
@@ -100,7 +103,7 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
resp_data = await asyncio.to_thread(_chat)
|
||||
|
||||
@@ -5,13 +5,14 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR, VIDEO_EDIT_PROJECT_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +38,8 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/settings", handle_post)
|
||||
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
|
||||
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
|
||||
app.router.add_get("/api/wfm/settings/models-dir", handle_get_models_dir)
|
||||
app.router.add_post("/api/wfm/settings/models-dir", handle_set_models_dir)
|
||||
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
|
||||
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
@@ -121,6 +124,47 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_models_dir(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/models-dir - 追加モデルフォルダ設定と現在有効な探索先を返す。"""
|
||||
try:
|
||||
from ..services.models_service import (
|
||||
MODEL_TYPE_FOLDER_KEYS, _get_model_dirs, parse_extra_model_roots,
|
||||
)
|
||||
saved = (await asyncio.to_thread(_service.load)).get("models_dir", "")
|
||||
effective = await asyncio.to_thread(
|
||||
lambda: {t: [str(d) for d in _get_model_dirs(t)] for t in MODEL_TYPE_FOLDER_KEYS}
|
||||
)
|
||||
return web.json_response({
|
||||
"saved": saved,
|
||||
"roots": parse_extra_model_roots(saved),
|
||||
"effective": effective,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting models dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_set_models_dir(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/models-dir - 追加モデルフォルダを保存(`;`/改行区切りで複数可、空でリセット)。"""
|
||||
try:
|
||||
from ..services.models_service import parse_extra_model_roots
|
||||
body = await request.json()
|
||||
roots = parse_extra_model_roots(body.get("models_dir", ""))
|
||||
missing = [r for r in roots if not Path(r).is_dir()]
|
||||
if missing:
|
||||
return web.json_response(
|
||||
{"error": f"Folder not found: {', '.join(missing)}"}, status=400)
|
||||
saved = ";".join(roots)
|
||||
await asyncio.to_thread(_service.update, {"models_dir": saved})
|
||||
|
||||
from ..routes.models_routes import _service as models_service
|
||||
models_service._scan_cache.clear()
|
||||
return await handle_get_models_dir(request)
|
||||
except Exception as e:
|
||||
logger.error("Error setting models dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _get_comfyui_output_dir() -> str:
|
||||
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
|
||||
try:
|
||||
@@ -255,8 +299,8 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
# 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/, style/) and
|
||||
# the SQLite tagger.db that the JSON bundle can't represent. Excludes transient/cache
|
||||
# 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"}
|
||||
@@ -305,7 +349,67 @@ def _add_external_dir_to_zip(zf: zipfile.ZipFile, src_dir: Path, zip_prefix: str
|
||||
zf.write(path, rel)
|
||||
|
||||
|
||||
def _build_full_backup_zip(include_workflows: bool = False, include_wildcard: bool = False) -> bytes:
|
||||
# Video Edit projects (video_edit_project/*.json, inside DATA_DIR so always backed up) only
|
||||
# store *references* — serverRef {filename, subfolder, type:"input"} — to clips, the BGM,
|
||||
# timeline sounds and overlay (PinP) clips uploaded into ComfyUI's input dir, not the media itself. Backing up just the JSON means a
|
||||
# restored project on another machine (or after input/ is cleaned up) opens with its clips
|
||||
# missing. Opt-in (include_video_media) because the media can be large; stored under its own
|
||||
# prefix so _apply_full_backup_zip can route it back into the input dir, not DATA_DIR.
|
||||
_VIDEO_MEDIA_PREFIX = "_video_edit_media/"
|
||||
|
||||
|
||||
def _comfyui_input_dir() -> Path:
|
||||
import folder_paths
|
||||
return Path(folder_paths.get_input_directory())
|
||||
|
||||
|
||||
def _iter_video_edit_media_refs():
|
||||
"""Yields (subfolder, filename) for every input-dir file referenced by a saved Video
|
||||
Edit project (clips + BGM + timeline sounds + overlay clips). Unreadable project files
|
||||
are skipped."""
|
||||
if not VIDEO_EDIT_PROJECT_DIR.is_dir():
|
||||
return
|
||||
for path in sorted(VIDEO_EDIT_PROJECT_DIR.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
# "sounds" / "pips" are top-level lists of {name, serverRef, ...} (absent in older projects).
|
||||
refs = [
|
||||
c.get("serverRef")
|
||||
for key in ("clips", "sounds", "pips")
|
||||
for c in data.get(key) or []
|
||||
if isinstance(c, dict)
|
||||
]
|
||||
bgm = (data.get("audio") or {}).get("bgm")
|
||||
if isinstance(bgm, dict):
|
||||
refs.append(bgm.get("serverRef"))
|
||||
for ref in refs:
|
||||
if not isinstance(ref, dict) or not ref.get("filename"):
|
||||
continue
|
||||
if (ref.get("type") or "input") != "input":
|
||||
continue
|
||||
yield (ref.get("subfolder") or "").strip("/\\"), ref["filename"]
|
||||
|
||||
|
||||
def _add_video_edit_media_to_zip(zf: zipfile.ZipFile) -> None:
|
||||
input_dir = _comfyui_input_dir().resolve()
|
||||
seen = set()
|
||||
for subfolder, filename in _iter_video_edit_media_refs():
|
||||
src = (input_dir / subfolder / filename).resolve()
|
||||
try:
|
||||
rel = src.relative_to(input_dir) # a crafted project file must not reach outside input/
|
||||
except ValueError:
|
||||
continue
|
||||
if rel in seen or not src.is_file():
|
||||
continue
|
||||
seen.add(rel)
|
||||
zf.write(src, _VIDEO_MEDIA_PREFIX + str(rel).replace("\\", "/"))
|
||||
|
||||
|
||||
def _build_full_backup_zip(include_workflows: bool = False, include_wildcard: bool = False,
|
||||
include_video_media: bool = False) -> bytes:
|
||||
"""Zips everything under DATA_DIR except the excluded names above. Walks
|
||||
top-level entries first so an excluded symlinked directory (wildcard/) is never
|
||||
even opened, rather than filtering after a recursive walk has already followed it.
|
||||
@@ -327,10 +431,12 @@ def _build_full_backup_zip(include_workflows: bool = False, include_wildcard: bo
|
||||
_add_external_dir_to_zip(zf, _default_workflows_dir(), "_external/default_workflows")
|
||||
if include_wildcard:
|
||||
_add_external_dir_to_zip(zf, _impact_pack_wildcard_dir(), "_external/wildcard")
|
||||
if include_video_media:
|
||||
_add_video_edit_media_to_zip(zf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _apply_full_backup_zip(zip_bytes: bytes) -> dict:
|
||||
def _apply_full_backup_zip(zip_source) -> dict:
|
||||
"""Extracts a full-backup ZIP into DATA_DIR, overwriting existing files. Guards
|
||||
against Zip Slip (entries whose path resolves outside DATA_DIR via '..' or an
|
||||
absolute path) by checking each entry before writing it.
|
||||
@@ -339,17 +445,43 @@ def _apply_full_backup_zip(zip_bytes: bytes) -> dict:
|
||||
_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": []}
|
||||
stay in the ZIP for the user to copy back manually if they want them.
|
||||
|
||||
Entries under "_video_edit_media/" (media referenced by Video Edit projects) are restored
|
||||
into ComfyUI's input dir instead, but never overwrite an existing file there — a file
|
||||
with the same name is assumed to be the same upload (ComfyUI dedupes uploads by renaming),
|
||||
and input/ is shared with every other workflow."""
|
||||
summary = {"extracted": [], "skipped": [], "media_restored": [], "media_existing": []}
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
base = DATA_DIR.resolve()
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
input_base = None
|
||||
# zip_source: raw bytes, or a seekable file object (the upload handler spools to a temp file).
|
||||
if isinstance(zip_source, (bytes, bytearray)):
|
||||
zip_source = io.BytesIO(zip_source)
|
||||
with zipfile.ZipFile(zip_source) as zf:
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
if info.filename.startswith("_external/"):
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
if info.filename.startswith(_VIDEO_MEDIA_PREFIX):
|
||||
if input_base is None:
|
||||
input_base = _comfyui_input_dir().resolve()
|
||||
target = (input_base / info.filename[len(_VIDEO_MEDIA_PREFIX):]).resolve()
|
||||
try:
|
||||
target.relative_to(input_base)
|
||||
except ValueError:
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
if target.exists():
|
||||
summary["media_existing"].append(info.filename)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info) as src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
summary["media_restored"].append(info.filename)
|
||||
continue
|
||||
target = (base / info.filename).resolve()
|
||||
try:
|
||||
target.relative_to(base)
|
||||
@@ -368,11 +500,15 @@ async def handle_export_full(request: web.Request) -> web.Response:
|
||||
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."""
|
||||
_build_full_backup_zip) — both restore-excluded, export-only, manual-copy-back.
|
||||
include_video_media=1 adds the input-dir media referenced by Video Edit projects,
|
||||
which *is* restored (into input/, without overwriting)."""
|
||||
try:
|
||||
include_workflows = request.rel_url.query.get("include_workflows") == "1"
|
||||
include_wildcard = request.rel_url.query.get("include_wildcard") == "1"
|
||||
zip_bytes = await asyncio.to_thread(_build_full_backup_zip, include_workflows, include_wildcard)
|
||||
include_video_media = request.rel_url.query.get("include_video_media") == "1"
|
||||
zip_bytes = await asyncio.to_thread(
|
||||
_build_full_backup_zip, include_workflows, include_wildcard, include_video_media)
|
||||
return web.Response(
|
||||
body=zip_bytes,
|
||||
content_type="application/zip",
|
||||
@@ -390,8 +526,18 @@ async def handle_import_full(request: web.Request) -> web.Response:
|
||||
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)
|
||||
# Stream to a temp file with read_chunk() instead of field.read(): aiohttp (3.14+)
|
||||
# enforces ComfyUI's --max-upload-size (default 100 MB) inside BodyPartReader.read(),
|
||||
# and a backup with the opt-in workflows/wildcard/Video Edit media easily exceeds it
|
||||
# ("Request Entity Too Large"). Also avoids holding the whole ZIP in memory.
|
||||
with tempfile.TemporaryFile() as tmp:
|
||||
while True:
|
||||
chunk = await field.read_chunk(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
await asyncio.to_thread(tmp.write, chunk)
|
||||
tmp.seek(0)
|
||||
summary = await asyncio.to_thread(_apply_full_backup_zip, tmp)
|
||||
return web.json_response({"status": "ok", **summary})
|
||||
except zipfile.BadZipFile:
|
||||
return web.json_response({"error": "Not a valid ZIP file"}, status=400)
|
||||
|
||||
@@ -18,8 +18,8 @@ _db = TaggerDbService(TAGGER_DB_FILE)
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/tagger/models", handle_models)
|
||||
app.router.add_post("/wfm/tagger/predict", handle_predict)
|
||||
app.router.add_get("/wfm/tagger/ollama/models", handle_ollama_models)
|
||||
app.router.add_post("/wfm/tagger/ollama/predict", handle_ollama_predict)
|
||||
app.router.add_get("/wfm/tagger/vlm/models", handle_vlm_models)
|
||||
app.router.add_post("/wfm/tagger/vlm/predict", handle_vlm_predict)
|
||||
app.router.add_post("/wfm/tagger/batch/start", handle_batch_start)
|
||||
app.router.add_get("/wfm/tagger/batch/status", handle_batch_status)
|
||||
app.router.add_post("/wfm/tagger/batch/stop", handle_batch_stop)
|
||||
@@ -27,6 +27,7 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/tagger/db/search", handle_db_search)
|
||||
app.router.add_put("/wfm/tagger/db/{id}", handle_db_update)
|
||||
app.router.add_delete("/wfm/tagger/db/{id}", handle_db_delete)
|
||||
app.router.add_delete("/wfm/tagger/db", handle_db_delete_bulk)
|
||||
app.router.add_get("/wfm/tagger/db/export", handle_db_export)
|
||||
app.router.add_post("/wfm/tagger/db/save", handle_db_save)
|
||||
app.router.add_post("/wfm/tagger/write_meta", handle_write_meta)
|
||||
@@ -61,25 +62,29 @@ async def handle_predict(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_models(request: web.Request) -> web.Response:
|
||||
async def handle_vlm_models(request: web.Request) -> web.Response:
|
||||
backend = request.rel_url.query.get("backend", "ollama")
|
||||
api_url = request.rel_url.query.get("api_url", "http://127.0.0.1:11434")
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.ollama_models, api_url)
|
||||
models = await asyncio.to_thread(_svc.vlm_models, backend, api_url)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_predict(request: web.Request) -> web.Response:
|
||||
async def handle_vlm_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.ollama_predict,
|
||||
_svc.vlm_predict,
|
||||
body.get("image_b64", ""),
|
||||
body.get("backend", "ollama"),
|
||||
body.get("api_url", "http://127.0.0.1:11434"),
|
||||
body.get("model", ""),
|
||||
body.get("prompt", ""),
|
||||
int(body.get("max_tags", 40)),
|
||||
bool(body.get("thinking_mode", False)),
|
||||
int(body.get("max_tokens", 0)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
@@ -96,7 +101,8 @@ async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
bool(body.get("use_ollama", False)),
|
||||
body.get("ollama_api", "http://127.0.0.1:11434"),
|
||||
body.get("vlm_backend", "ollama"),
|
||||
body.get("vlm_api_url", "http://127.0.0.1:11434"),
|
||||
body.get("ollama_model", ""),
|
||||
body.get("ollama_prompt", ""),
|
||||
int(body.get("ollama_max_tags", 40)),
|
||||
@@ -104,6 +110,8 @@ async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
bool(body.get("write_file", False)),
|
||||
bool(body.get("write_txt", False)),
|
||||
_db,
|
||||
bool(body.get("thinking_mode", False)),
|
||||
int(body.get("max_tokens", 0)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
@@ -161,6 +169,27 @@ async def handle_db_delete(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_db_delete_bulk(request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
ids = data.get("ids", [])
|
||||
|
||||
if not ids:
|
||||
return web.json_response(
|
||||
{"error": "No IDs specified"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
ids = [int(row_id) for row_id in ids]
|
||||
|
||||
deleted_count = await asyncio.to_thread(_db.delete_bulk, ids)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"count": deleted_count,
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_db_export(request: web.Request) -> web.Response:
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,13 @@ for local access (Authorization: Bearer sk-unsloth-...). The key lives in
|
||||
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
|
||||
never sent to the frontend — the frontend calls this proxy with the target
|
||||
path/method/payload, and the server attaches the Authorization header before
|
||||
relaying to Unsloth's OpenAI-compatible API.
|
||||
relaying to Unsloth's OpenAI-compatible API — and, via the same relay, to its
|
||||
Decision API (/v1/systemone, TypeSafe-compatible Laya decision models; see
|
||||
static/js/decision-client.js).
|
||||
|
||||
The key is optional: when UNSLOTH_API_KEY is unset the request is relayed
|
||||
without an Authorization header, which works when Unsloth Desktop has
|
||||
Keyless API access → "Chat and inference" turned on (localhost / private LAN).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -13,19 +19,19 @@ import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.url_guard import HINT as _URL_HINT, is_allowed_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
|
||||
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions"}
|
||||
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions", "/v1/systemone"}
|
||||
# baseUrl is client-supplied (so a custom Unsloth port works), but the
|
||||
# Authorization header carries a real secret — restrict the host to loopback
|
||||
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
|
||||
# server (SSRF). Port is unrestricted.
|
||||
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
# Authorization header carries a real secret — restrict the host (loopback, or
|
||||
# WFS_ALLOWED_BACKEND_HOSTS set by the operator) so this proxy can't be used to
|
||||
# exfiltrate UNSLOTH_API_KEY to an arbitrary server (SSRF). Redirects are not followed.
|
||||
|
||||
|
||||
def _get_api_key():
|
||||
@@ -33,13 +39,12 @@ def _get_api_key():
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def _is_allowed_base_url(base_url):
|
||||
"""Only relay (and attach the API key) to a loopback host."""
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
# Shown when a keyless request is rejected — also used by the Tagger's Unsloth VLM path.
|
||||
KEYLESS_REJECTED_MESSAGE = (
|
||||
"Unsloth rejected the request without an API key (HTTP 401). Either set UNSLOTH_API_KEY "
|
||||
"in the plugin's .env (copy .env.example) and restart ComfyUI, or turn on Keyless API "
|
||||
"access -> \"Chat and inference\" in Unsloth Desktop's Settings -> API."
|
||||
)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
@@ -48,9 +53,10 @@ def setup_routes(app: web.Application):
|
||||
|
||||
|
||||
async def handle_proxy(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible / Decision API.
|
||||
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions" | "/v1/systemone",
|
||||
method: "GET" | "POST", payload }
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
@@ -63,25 +69,20 @@ async def handle_proxy(request: web.Request) -> web.Response:
|
||||
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
|
||||
return web.json_response({"message": "Unsupported proxy target"}, status=400)
|
||||
|
||||
if not _is_allowed_base_url(base_url):
|
||||
return web.json_response({
|
||||
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
|
||||
}, status=400)
|
||||
if not is_allowed_backend_url(base_url):
|
||||
return web.json_response({"message": _URL_HINT}, status=400)
|
||||
|
||||
# No key → relay without Authorization (Unsloth's Keyless API access); if Unsloth
|
||||
# still demands one, the 401 below explains both ways to fix it.
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
return web.json_response({
|
||||
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI.",
|
||||
}, status=401)
|
||||
|
||||
def _fetch():
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
with _guarded_urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
@@ -90,7 +91,11 @@ async def handle_proxy(request: web.Request) -> web.Response:
|
||||
except HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
|
||||
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
|
||||
if e.code == 401 and not _get_api_key():
|
||||
return web.json_response({"message": KEYLESS_REJECTED_MESSAGE}, status=401)
|
||||
# Include a short excerpt of Unsloth's own error body — Decision API schema errors
|
||||
# (e.g. a malformed question) are otherwise impossible to diagnose from the frontend.
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code} {detail[:300]}".strip()}, status=e.code)
|
||||
except URLError as e:
|
||||
logger.warning("Unsloth proxy connection error: %s", e)
|
||||
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
"""Video tab utility API routes: last-frame capture, animated GIF conversion."""
|
||||
"""Video tab API routes: last-frame capture, animated GIF conversion, Edit
|
||||
tab probe/text-overlay burn-in, and Plan/Edit project file management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import VIDEO_PLAN_DIR, VIDEO_EDIT_PROJECT_DIR
|
||||
from ..services.video_service import VideoService
|
||||
from ..services.video_plan_service import VideoPlanService
|
||||
from ..services.video_edit_project_service import VideoEditProjectService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = VideoService()
|
||||
_plan_service = VideoPlanService(VIDEO_PLAN_DIR)
|
||||
_edit_project_service = VideoEditProjectService(VIDEO_EDIT_PROJECT_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/video/frame/save-to-output", handle_save_frame)
|
||||
app.router.add_post("/api/wfm/video/to-gif", handle_to_gif)
|
||||
app.router.add_get("/api/wfm/video/edit/probe", handle_probe_video)
|
||||
app.router.add_post("/api/wfm/video/edit/overlay-text", handle_overlay_text)
|
||||
|
||||
app.router.add_get("/api/wfm/video/plans", handle_list_plans)
|
||||
app.router.add_get("/api/wfm/video/plans/content", handle_get_plan_content)
|
||||
app.router.add_post("/api/wfm/video/plans/save", handle_save_plan)
|
||||
app.router.add_post("/api/wfm/video/plans/delete", handle_delete_plan)
|
||||
app.router.add_post("/api/wfm/video/index-image/save-to-output", handle_save_index_to_output)
|
||||
|
||||
app.router.add_get("/api/wfm/video/edit/projects", handle_list_edit_projects)
|
||||
app.router.add_get("/api/wfm/video/edit/projects/content", handle_get_edit_project_content)
|
||||
app.router.add_post("/api/wfm/video/edit/projects/save", handle_save_edit_project)
|
||||
app.router.add_post("/api/wfm/video/edit/projects/delete", handle_delete_edit_project)
|
||||
|
||||
|
||||
async def handle_save_frame(request: web.Request) -> web.Response:
|
||||
@@ -57,3 +76,164 @@ async def handle_to_gif(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error converting video to GIF: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_probe_video(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
subfolder = request.rel_url.query.get("subfolder", "")
|
||||
type_ = request.rel_url.query.get("type", "input")
|
||||
if not filename:
|
||||
return web.json_response({"error": "filename required"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.probe_video, filename, subfolder, type_)
|
||||
return web.json_response(result)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error probing video: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_overlay_text(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
subfolder = body.get("subfolder", "")
|
||||
type_ = body.get("type", "output")
|
||||
overlays = body.get("overlays") or []
|
||||
delete_source = bool(body.get("delete_source"))
|
||||
pips = body.get("pips") or []
|
||||
if not filename:
|
||||
return web.json_response({"error": "filename required"}, status=400)
|
||||
if not isinstance(overlays, list) or not isinstance(pips, list):
|
||||
return web.json_response({"error": "overlays and pips must be lists"}, status=400)
|
||||
|
||||
# Decode+re-encode of every frame — run off the event loop so other
|
||||
# requests aren't stalled for the duration of the burn-in.
|
||||
result = await asyncio.to_thread(
|
||||
_service.overlay_text_on_video, filename, subfolder, type_, overlays, "video/wfm_edit", delete_source, pips
|
||||
)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error burning text overlay into video: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_list_plans(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_plan_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing video plans: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_plan_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_plan_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting video plan content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_plan(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_plan_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving video plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_plan(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_plan_service.delete_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting video plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_list_edit_projects(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_edit_project_service.list_projects)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing video edit projects: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_edit_project_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_edit_project_service.get_project, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting video edit project content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_edit_project(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
result = await asyncio.to_thread(_edit_project_service.save_project, filename, data)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving video edit project: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_edit_project(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_edit_project_service.delete_project, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting video edit project: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_index_to_output(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_plan_service.save_index_image_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving video index image to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""VRAM management: unload Ollama models before image generation to free VRAM.
|
||||
|
||||
Ported from ComfyUI Comic Creator (/api/ccc/vram/prepare), which follows ComfyUI-LiveChatStream's
|
||||
vram_prepare. ComfyUI's own free-VRAM figure (get_free_memory / system_stats vram_free) may not react
|
||||
to Ollama loading/unloading models (measured on ComfyUI_5: 8462MB before and after loading a 9B model
|
||||
while nvidia-smi went 6530 -> 469MB), so free VRAM is measured with nvidia-smi plus ComfyUI's own
|
||||
unused torch cache, falling back to get_free_memory when nvidia-smi is unavailable.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.url_guard import is_allowed_backend_url, LOOPBACK_HOSTS, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MB = 1024 * 1024
|
||||
_OLLAMA_DEFAULT_URL = "http://127.0.0.1:11434"
|
||||
_MAX_URLS = 6 # incl. the default; each unreachable URL costs a connect timeout
|
||||
_lock = asyncio.Lock() # never run concurrently (unloading and measuring interfere)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/vram/prepare", handle_prepare)
|
||||
|
||||
|
||||
def _smi_free_mb(ident):
|
||||
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
out = subprocess.run(
|
||||
["nvidia-smi", "-i", str(ident), "--query-gpu=memory.free", "--format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=5, creationflags=flags,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
raise RuntimeError(out.stderr.strip() or "nvidia-smi failed")
|
||||
return float(out.stdout.strip().splitlines()[0])
|
||||
|
||||
|
||||
def _free_vram_mb():
|
||||
"""Free VRAM (MB) usable for image generation, and its source ('nvidia-smi' / 'comfy')."""
|
||||
import torch
|
||||
import comfy.model_management as mm
|
||||
dev = mm.get_torch_device()
|
||||
if getattr(dev, "type", "cpu") == "cuda":
|
||||
try:
|
||||
st = torch.cuda.memory_stats(dev)
|
||||
cached = max(0, st["reserved_bytes.all.current"] - st["active_bytes.all.current"])
|
||||
try:
|
||||
free = _smi_free_mb(f"GPU-{torch.cuda.get_device_properties(dev).uuid}")
|
||||
except Exception:
|
||||
free = _smi_free_mb(dev.index or 0)
|
||||
return free + cached / _MB, "nvidia-smi"
|
||||
except Exception as e:
|
||||
logger.warning("[WFS] nvidia-smi free VRAM unavailable, falling back to ComfyUI: %s", e)
|
||||
return mm.get_free_memory(dev) / _MB, "comfy"
|
||||
|
||||
|
||||
def _wait_settled(prev_mb, timeout_s=6.0):
|
||||
"""After an unload, wait until free VRAM grows and levels off (Ollama runner exit); return it (MB)."""
|
||||
now = prev_mb
|
||||
last = None
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.4)
|
||||
now, _ = _free_vram_mb()
|
||||
if last is not None and abs(now - last) < 32 and now > prev_mb + 32:
|
||||
break
|
||||
last = now
|
||||
return now
|
||||
|
||||
|
||||
def _ollama_json(base, path, payload=None, timeout=60):
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(base + path, data=data, headers={"Content-Type": "application/json"},
|
||||
method="POST" if data is not None else "GET")
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
|
||||
|
||||
def _ollama_loaded_names(base):
|
||||
_, raw = _ollama_json(base, "/api/ps", timeout=5)
|
||||
return {m.get("name") for m in json.loads(raw.decode("utf-8")).get("models", [])}
|
||||
|
||||
|
||||
def _ollama_unload(base, name):
|
||||
"""Unload with keep_alive=0. Models that reject /api/generate (e.g. decision models) are retried
|
||||
with /api/chat. Ollama sometimes unloads a little later even after an error response, so finally
|
||||
check /api/ps to see whether it is still loaded."""
|
||||
errors = []
|
||||
for path, payload in (("/api/generate", {"model": name, "keep_alive": 0}),
|
||||
("/api/chat", {"model": name, "messages": [], "keep_alive": 0})):
|
||||
try:
|
||||
status, _ = _ollama_json(base, path, payload)
|
||||
if status == 200:
|
||||
return True
|
||||
errors.append(f"{path}: HTTP {status}")
|
||||
except Exception as e:
|
||||
errors.append(f"{path}: {e}")
|
||||
for _ in range(5):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
if name not in _ollama_loaded_names(base):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("[WFS] vram: unload failed for %s: %s", name, "; ".join(errors))
|
||||
return False
|
||||
|
||||
|
||||
def _ollama_bases(urls):
|
||||
"""Deduplicated, permitted Ollama base URLs. localhost / 127.0.0.1 / ::1 count as one server.
|
||||
URLs the backend guard rejects are skipped (logged) rather than failing the whole request."""
|
||||
if urls is None:
|
||||
urls = []
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("urls must be an array")
|
||||
bases, seen = [], set()
|
||||
for raw in [_OLLAMA_DEFAULT_URL, *urls]:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
base = raw.strip().rstrip("/")
|
||||
if not is_allowed_backend_url(base):
|
||||
logger.warning("[WFS] vram: skipped backend URL not permitted by the guard: %s", base)
|
||||
continue
|
||||
p = urlparse(base)
|
||||
host = (p.hostname or "").lower()
|
||||
key = ("loopback" if host in LOOPBACK_HOSTS else host, p.scheme, p.port or (443 if p.scheme == "https" else 80))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
bases.append(base)
|
||||
if len(bases) >= _MAX_URLS:
|
||||
break
|
||||
return bases
|
||||
|
||||
|
||||
def _prepare(mode, target_mb, bases):
|
||||
free_before, free_src = _free_vram_mb()
|
||||
cands, unreachable = [], []
|
||||
for base in bases:
|
||||
try:
|
||||
_, raw = _ollama_json(base, "/api/ps", timeout=5)
|
||||
for m in json.loads(raw.decode("utf-8")).get("models", []):
|
||||
if m.get("name"):
|
||||
cands.append({"base": base, "name": m["name"], "vram_mb": int(m.get("size_vram", 0) / _MB)})
|
||||
except Exception as e:
|
||||
# Ollama not running etc. Normal for the default URL, so not a failure; keep the reason in the
|
||||
# log only (the response doesn't reveal the state of caller-chosen local ports)
|
||||
logger.info("[WFS] vram: %s/api/ps failed: %r", base, e)
|
||||
unreachable.append({"url": base})
|
||||
unloaded, failed = [], []
|
||||
free_after = free_before
|
||||
if mode == "auto":
|
||||
# Ollama's size_vram excludes the KV cache etc. and is smaller than the VRAM actually freed.
|
||||
# Choosing by estimate could free too much, so unload the biggest first, one at a time, and stop
|
||||
# as soon as the measured free VRAM reaches the target
|
||||
cands.sort(key=lambda c: c["vram_mb"], reverse=True)
|
||||
for c in cands:
|
||||
if free_after >= target_mb:
|
||||
break
|
||||
if _ollama_unload(c["base"], c["name"]):
|
||||
unloaded.append(c)
|
||||
free_after = _wait_settled(free_after)
|
||||
else:
|
||||
failed.append(c)
|
||||
else:
|
||||
for c in cands:
|
||||
(unloaded if _ollama_unload(c["base"], c["name"]) else failed).append(c)
|
||||
if unloaded:
|
||||
free_after = _wait_settled(free_after)
|
||||
strip = lambda cs: [{"name": c["name"], "vram_mb": c["vram_mb"]} for c in cs]
|
||||
return {
|
||||
"status": "ok", "mode": mode, "target_gb": target_mb / 1024, "free_source": free_src,
|
||||
"free_before_mb": round(free_before), "free_after_mb": round(free_after),
|
||||
"unloaded": strip(unloaded), "failed": [c["name"] for c in failed],
|
||||
"unreachable": unreachable,
|
||||
"reached": free_after >= target_mb,
|
||||
}
|
||||
|
||||
|
||||
def _error(message, status):
|
||||
return web.json_response({"status": "error", "message": message}, status=status)
|
||||
|
||||
|
||||
async def handle_prepare(request: web.Request) -> web.Response:
|
||||
"""POST {mode:'auto'|'all', target_gb, urls:[Ollama URLs]}.
|
||||
auto: only when free VRAM is below target_gb, unload the models using the most VRAM until it is reached.
|
||||
all: unload every loaded model.
|
||||
The default 127.0.0.1:11434 is always included; other URLs must pass the backend URL guard."""
|
||||
# JSON only, so another site can't unload models with a simple (preflight-free) text/plain request
|
||||
if request.content_type != "application/json":
|
||||
return _error("Content-Type must be application/json", 415)
|
||||
try:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("body must be a JSON object")
|
||||
mode = data.get("mode")
|
||||
if mode not in ("auto", "all"):
|
||||
raise ValueError("mode must be auto or all")
|
||||
target_gb = float(data.get("target_gb", 8) or 0)
|
||||
if not math.isfinite(target_gb):
|
||||
raise ValueError("target_gb must be a finite number")
|
||||
target_mb = min(max(target_gb, 0.0), 64.0) * 1024
|
||||
bases = _ollama_bases(data.get("urls"))
|
||||
except Exception as e:
|
||||
return _error(str(e), 400)
|
||||
if _lock.locked():
|
||||
return _error("VRAM management is already running", 409)
|
||||
try:
|
||||
async with _lock:
|
||||
result = await asyncio.get_running_loop().run_in_executor(None, _prepare, mode, target_mb, bases)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("[WFS] vram prepare error: %s", e)
|
||||
return _error(str(e), 500)
|
||||
@@ -56,6 +56,38 @@ _MAX_RETRIES = 3
|
||||
_BATCH_CHUNK_SIZE = 100
|
||||
|
||||
|
||||
# サイドカーJSON(.metadata.json / .cm-info.json)の最大サイズ。巨大ファイルでメモリを使い切らないための上限
|
||||
_SIDECAR_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _as_int(v):
|
||||
"""数値IDだけを受け付ける(文字列に細工されたIDをURL等に埋め込まないため)。不正ならNone。"""
|
||||
try:
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
n = int(v)
|
||||
return n if n > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# プレビュー画像のダウンロード許可ホスト(サイドカーJSON由来のURLでSSRF/ローカルファイル読み取りをさせない)
|
||||
_IMAGE_HOST_SUFFIXES = (".civitai.com", ".civitai.red", ".civitai.green")
|
||||
_IMAGE_HOST_EXACT = ("civitai.com", "civitai.red", "civitai.green")
|
||||
_IMAGE_MAX_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
def _is_allowed_image_url(url):
|
||||
"""https かつ CivitAI 系ホストのURLだけ許可する(file:// や内部アドレス、他ホストは拒否)。"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
u = urlparse(str(url).strip())
|
||||
host = (u.hostname or "").lower()
|
||||
return u.scheme == "https" and (host in _IMAGE_HOST_EXACT or host.endswith(_IMAGE_HOST_SUFFIXES))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class CivitaiService:
|
||||
"""Fetch and cache CivitAI model metadata."""
|
||||
|
||||
@@ -117,14 +149,16 @@ class CivitaiService:
|
||||
# ── ハッシュ計算 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def calculate_sha256(file_path, chunk_size=65536):
|
||||
"""Calculate SHA256 hash of a file."""
|
||||
def calculate_sha256(file_path, chunk_size=512 * 1024, cancel=None):
|
||||
"""Calculate SHA256 hash of a file. cancel(threading.Event)がセットされたらNoneを返して中断。"""
|
||||
h = hashlib.sha256()
|
||||
path = Path(file_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
if cancel is not None and cancel.is_set():
|
||||
return None
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
@@ -142,8 +176,11 @@ class CivitaiService:
|
||||
"""
|
||||
sha256_lower = sha256_hash.lower()
|
||||
cache = self._load_cache()
|
||||
if sha256_lower in cache:
|
||||
return cache[sha256_lower]
|
||||
cached = cache.get(sha256_lower)
|
||||
# Stability Matrix(.cm-info.json)由来の簡易情報は画像一覧を持たないため、
|
||||
# 明示的な取得/更新ではAPIから完全な情報で上書きする
|
||||
if cached is not None and cached.get("source") != "cm-info":
|
||||
return cached
|
||||
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash.upper()}"
|
||||
|
||||
@@ -198,6 +235,7 @@ class CivitaiService:
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash"
|
||||
results = {h.lower(): None for h in sha256_hashes}
|
||||
cache = self._load_cache()
|
||||
failed = set()
|
||||
|
||||
for chunk_start in range(0, len(sha256_hashes), _BATCH_CHUNK_SIZE):
|
||||
chunk = sha256_hashes[chunk_start:chunk_start + _BATCH_CHUNK_SIZE]
|
||||
@@ -239,6 +277,7 @@ class CivitaiService:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI batch POST error: %s %s", e.code, e.reason)
|
||||
failed.update(chunk_lower)
|
||||
break
|
||||
except (URLError, Exception) as e:
|
||||
if attempt < _MAX_RETRIES - 1:
|
||||
@@ -247,8 +286,11 @@ class CivitaiService:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.warning("CivitAI batch POST failed: %s", e)
|
||||
failed.update(chunk_lower)
|
||||
break
|
||||
|
||||
# 通信失敗したチャンクは「CivitAIに存在しない」と区別するため記録する
|
||||
self.last_failed_hashes = failed
|
||||
self._save_cache()
|
||||
return results
|
||||
|
||||
@@ -338,64 +380,295 @@ class CivitaiService:
|
||||
|
||||
# ── バッチフェッチ ────────────────────────────────────────
|
||||
|
||||
def batch_fetch(self, model_files, progress_callback=None):
|
||||
@staticmethod
|
||||
def _known_hash(known, st):
|
||||
"""メタデータに保存済みのsha256が、現在のファイルに対して有効ならそれを返す。
|
||||
|
||||
size/mtime が記録されていれば一致を確認する(ファイル差し替え検知)。
|
||||
記録の無い旧データは信頼する。
|
||||
"""
|
||||
sha = (known or {}).get("sha256")
|
||||
if not sha:
|
||||
return None
|
||||
try:
|
||||
ks, km = known.get("size"), known.get("mtime")
|
||||
if ks is not None and int(ks) != st.st_size:
|
||||
return None
|
||||
if km is not None and abs(float(km) - st.st_mtime) > 2:
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return str(sha).lower()
|
||||
|
||||
def _read_sidecar(self, file_path, size):
|
||||
"""サイドカーからsha256とCivitAI情報を読む(ハッシュ計算・通信不要)。
|
||||
|
||||
Lora Manager の `<stem>.metadata.json` → Stability Matrix の `<stem>.cm-info.json` の順に試す。
|
||||
Returns: (sha256_lower or None, extracted_info or None)
|
||||
"""
|
||||
sha, info = self._read_lm_sidecar(file_path, size)
|
||||
if sha:
|
||||
return sha, info
|
||||
return self._read_cm_info(file_path)
|
||||
|
||||
@staticmethod
|
||||
def _parse_iso_ts(value):
|
||||
"""ISO8601文字列→epoch秒。.NETの7桁小数秒にも対応。失敗時はNone。"""
|
||||
try:
|
||||
import re
|
||||
from datetime import datetime
|
||||
s = str(value).replace("Z", "+00:00")
|
||||
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
|
||||
return datetime.fromisoformat(s).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _read_cm_info(self, file_path):
|
||||
"""Stability Matrix の `<stem>.cm-info.json` から sha256 とCivitAI簡易情報を読む。
|
||||
|
||||
ファイルサイズ情報が無いため、ImportedAt より後にモデルファイルが更新されていたら不採用。
|
||||
画像一覧は持たないので info には "source": "cm-info" を付け、個別取得で完全情報に置き換えられる。
|
||||
"""
|
||||
sc = file_path.with_name(file_path.stem + ".cm-info.json")
|
||||
try:
|
||||
if sc.stat().st_size > _SIDECAR_MAX_BYTES: # 巨大ファイルによるメモリ消費を避ける
|
||||
return None, None
|
||||
with open(sc, "r", encoding="utf-8-sig") as f:
|
||||
d = json.load(f)
|
||||
if not isinstance(d, dict):
|
||||
return None, None
|
||||
sha = str((d.get("Hashes") or {}).get("SHA256", "")).lower()
|
||||
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
|
||||
return None, None
|
||||
imported = self._parse_iso_ts(d.get("ImportedAt"))
|
||||
if imported is None or file_path.stat().st_mtime > imported + 2:
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
info = None
|
||||
try:
|
||||
if d.get("Source", 0) == 0 and _as_int(d.get("VersionId")): # 0 = Civitai
|
||||
info = self._info_from_cm_info(d)
|
||||
except Exception:
|
||||
info = None
|
||||
return sha, info
|
||||
|
||||
@staticmethod
|
||||
def _info_from_cm_info(d):
|
||||
"""cm-info.json を _extract_info と同じ形式のdictに変換する。"""
|
||||
model_id, version_id = _as_int(d.get("ModelId")), _as_int(d.get("VersionId"))
|
||||
stats = d.get("Stats") or {}
|
||||
return {
|
||||
"versionId": version_id,
|
||||
"modelId": model_id,
|
||||
"modelName": d.get("ModelName", ""),
|
||||
"versionName": d.get("VersionName", ""),
|
||||
"type": d.get("ModelType", ""),
|
||||
"description": d.get("VersionDescription") or d.get("ModelDescription", ""),
|
||||
"tags": [str(x) for x in (d.get("Tags") or []) if isinstance(x, (str, int, float))][:100],
|
||||
"nsfw": bool(d.get("Nsfw", False)),
|
||||
"nsfwLevel": 0,
|
||||
"air": "",
|
||||
"creator": d.get("AuthorUsername") or "",
|
||||
"images": [],
|
||||
"imageDetails": [],
|
||||
"trainedWords": [str(x) for x in (d.get("TrainedWords") or []) if isinstance(x, (str, int, float))][:200],
|
||||
"baseModel": d.get("BaseModel") or "",
|
||||
"fileSize": 0,
|
||||
"fileMeta": d.get("FileMetadata") or {},
|
||||
"fileHashes": d.get("Hashes") or {},
|
||||
"downloadUrl": "",
|
||||
"modelUrl": (
|
||||
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
|
||||
if model_id else f"https://civitai.com/model-versions/{version_id}"
|
||||
),
|
||||
"stats": {
|
||||
"downloadCount": stats.get("downloadCount", 0),
|
||||
"thumbsUpCount": stats.get("thumbsUpCount", 0),
|
||||
"thumbsDownCount": stats.get("thumbsDownCount", 0),
|
||||
},
|
||||
"updatedAt": "",
|
||||
"publishedAt": "",
|
||||
"source": "cm-info",
|
||||
}
|
||||
|
||||
def _read_lm_sidecar(self, file_path, size):
|
||||
"""`<stem>.metadata.json` (Lora Manager) サイドカーからsha256とCivitAI情報を読む。
|
||||
|
||||
サイドカーのsizeがファイルサイズと一致する場合のみ採用する。
|
||||
Returns: (sha256_lower or None, extracted_info or None)
|
||||
"""
|
||||
sc = file_path.with_name(file_path.stem + ".metadata.json")
|
||||
try:
|
||||
if sc.stat().st_size > _SIDECAR_MAX_BYTES:
|
||||
return None, None
|
||||
with open(sc, "r", encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
if not isinstance(d, dict):
|
||||
return None, None
|
||||
sha = str(d.get("sha256", "")).lower()
|
||||
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
|
||||
return None, None
|
||||
if d.get("size") is None or int(float(d["size"])) != size:
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
info = None
|
||||
civ = d.get("civitai")
|
||||
if isinstance(civ, dict) and civ.get("id"):
|
||||
# 別ファイルの情報を取り込まないよう、files[].hashes.SHA256 に一致するものだけ採用
|
||||
matched = any(
|
||||
str(f.get("hashes", {}).get("SHA256", "")).lower() == sha
|
||||
for f in civ.get("files", []) if isinstance(f, dict)
|
||||
)
|
||||
if matched:
|
||||
try:
|
||||
info = self._extract_info(civ)
|
||||
except Exception:
|
||||
info = None
|
||||
return sha, info
|
||||
|
||||
def batch_fetch(self, model_files, progress_callback=None, known=None, on_hash=None, workers=2, cancel=None, hash_missing=False):
|
||||
"""Batch fetch CivitAI info for multiple model files.
|
||||
|
||||
Phase 1: SHA256 計算("hashing")
|
||||
Phase 0: 保存済みsha256 / サイドカーから解決(ハッシュ計算・通信なし)
|
||||
Phase 1: 残りのSHA256を並列計算("hashing")— hash_missing=True のときのみ。
|
||||
既定では計算せず needs_hash を返す
|
||||
Phase 2: POST で一括取得("fetching")— キャッシュ済みはスキップ
|
||||
|
||||
Args:
|
||||
model_files: list of (model_name, file_path) tuples
|
||||
progress_callback: fn(current, total, model_name, status) called per model
|
||||
known: { model_name: {"sha256", "size", "mtime"} } メタデータ保存済みハッシュ
|
||||
on_hash: fn(model_name, sha256, size, mtime) ハッシュが新たに確定するたびに呼ばれる
|
||||
workers: ハッシュ計算の並列数
|
||||
cancel: threading.Event。セットされると未処理分を打ち切って戻る(クライアント切断時)
|
||||
|
||||
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
|
||||
"""
|
||||
results = {}
|
||||
total = len(model_files)
|
||||
cache = self._load_cache()
|
||||
known = known or {}
|
||||
finished = 0
|
||||
cache_dirty = False
|
||||
|
||||
# Phase 1: ハッシュ計算
|
||||
hashes_needed = [] # [(model_name, sha256_lower, original_index)]
|
||||
hashes_needed = [] # [(model_name, sha256_lower)]
|
||||
to_hash = [] # [(model_name, file_path, stat)]
|
||||
|
||||
for i, (model_name, file_path) in enumerate(model_files):
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "hashing")
|
||||
|
||||
sha256 = self.calculate_sha256(file_path)
|
||||
if not sha256:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
def _resolved(name, sha, st, new_hash):
|
||||
"""sha256確定後の共通処理。キャッシュにあれば完了、無ければ取得待ちへ。"""
|
||||
nonlocal finished
|
||||
results[name] = {"sha256": sha, "civitai": None}
|
||||
if new_hash and on_hash:
|
||||
on_hash(name, sha, st.st_size, st.st_mtime)
|
||||
if sha in cache:
|
||||
results[name]["civitai"] = cache[sha]
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total, model_name, "not_found")
|
||||
progress_callback(finished, total, name, "cached")
|
||||
else:
|
||||
hashes_needed.append((name, sha))
|
||||
|
||||
# Phase 0: ハッシュ計算なしで解決できるものを先に処理
|
||||
for model_name, file_path in model_files:
|
||||
try:
|
||||
st = Path(file_path).stat()
|
||||
except OSError:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(finished, total, model_name, "not_found")
|
||||
continue
|
||||
|
||||
sha256_lower = sha256.lower()
|
||||
results[model_name] = {"sha256": sha256_lower, "civitai": None}
|
||||
sha = self._known_hash(known.get(model_name), st)
|
||||
if sha:
|
||||
_resolved(model_name, sha, st, new_hash=False)
|
||||
continue
|
||||
|
||||
if sha256_lower in cache:
|
||||
results[model_name]["civitai"] = cache[sha256_lower]
|
||||
sha, info = self._read_sidecar(Path(file_path), st.st_size)
|
||||
if sha:
|
||||
if info and sha not in cache:
|
||||
cache[sha] = info
|
||||
cache_dirty = True
|
||||
_resolved(model_name, sha, st, new_hash=True)
|
||||
continue
|
||||
|
||||
to_hash.append((model_name, file_path, st))
|
||||
|
||||
if cache_dirty:
|
||||
self._save_cache()
|
||||
|
||||
logger.info("CivitAI batch: total=%d resolved_without_hash=%d need_hash=%d hash_missing=%s",
|
||||
total, len(results), len(to_hash), hash_missing)
|
||||
# ハッシュ未確定のものは、hash_missing=False(既定)なら計算せず "needs_hash" として返す
|
||||
# (個別取得でハッシュ計算する運用。全件ハッシュ計算は負荷が高いため行わない)
|
||||
if to_hash and not hash_missing:
|
||||
for model_name, _fp, _st in to_hash:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "needs_hash": True}
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total, model_name, "cached")
|
||||
else:
|
||||
hashes_needed.append((model_name, sha256_lower, i))
|
||||
progress_callback(finished, total, model_name, "needs_hash")
|
||||
to_hash = []
|
||||
|
||||
# Phase 1: 残りを並列でハッシュ計算(hashlibは大きなチャンクでGILを解放する)
|
||||
if to_hash:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _hash(item):
|
||||
return self.calculate_sha256(item[1], cancel=cancel)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
||||
futures = {pool.submit(_hash, item): item for item in to_hash}
|
||||
for fut in as_completed(futures):
|
||||
if cancel is not None and cancel.is_set():
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
break
|
||||
model_name, _fp, st = futures[fut]
|
||||
try:
|
||||
sha = fut.result()
|
||||
except Exception:
|
||||
sha = None
|
||||
if not sha:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
progress_callback(finished, total, model_name, "not_found")
|
||||
continue
|
||||
if progress_callback:
|
||||
progress_callback(finished, total, model_name, "hashing")
|
||||
_resolved(model_name, sha.lower(), st, new_hash=True)
|
||||
|
||||
if cancel is not None and cancel.is_set():
|
||||
return results
|
||||
|
||||
# Phase 2: POST で一括取得(未キャッシュ分)
|
||||
if hashes_needed:
|
||||
if progress_callback:
|
||||
progress_callback(len(results), total, "", "fetching")
|
||||
progress_callback(finished, total, "", "fetching")
|
||||
|
||||
# 同一ハッシュが複数モデルに対応する場合を考慮
|
||||
sha256_to_entries: dict[str, list] = {}
|
||||
for name, sha256_lower, idx in hashes_needed:
|
||||
sha256_to_entries.setdefault(sha256_lower, []).append((name, idx))
|
||||
sha256_to_names: dict[str, list] = {}
|
||||
for name, sha256_lower in hashes_needed:
|
||||
sha256_to_names.setdefault(sha256_lower, []).append(name)
|
||||
|
||||
batch_results = self._batch_fetch_post(list(sha256_to_entries.keys()))
|
||||
logger.info("CivitAI batch: POST %d hashes", len(sha256_to_names))
|
||||
batch_results = self._batch_fetch_post(list(sha256_to_names.keys()))
|
||||
logger.info("CivitAI batch: POST done")
|
||||
failed = getattr(self, "last_failed_hashes", set())
|
||||
|
||||
for sha256_lower, info in batch_results.items():
|
||||
for name, idx in sha256_to_entries.get(sha256_lower, []):
|
||||
for name in sha256_to_names.get(sha256_lower, []):
|
||||
results[name]["civitai"] = info
|
||||
if not info and sha256_lower in failed:
|
||||
results[name]["fetch_failed"] = True
|
||||
finished += 1
|
||||
if progress_callback:
|
||||
status = "found" if info else "not_found"
|
||||
progress_callback(idx + 1, total, name, status)
|
||||
progress_callback(finished, total, name, status)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "", "done")
|
||||
@@ -407,10 +680,25 @@ class CivitaiService:
|
||||
@staticmethod
|
||||
def download_image(url, save_path, timeout=15):
|
||||
"""Download an image from URL and save to save_path. Returns True on success."""
|
||||
if not _is_allowed_image_url(url):
|
||||
logger.warning("Refusing to download preview from non-CivitAI URL: %s", str(url)[:120])
|
||||
return False
|
||||
try:
|
||||
from urllib.request import HTTPRedirectHandler, HTTPSHandler, build_opener
|
||||
|
||||
class _SafeRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if not _is_allowed_image_url(newurl): # リダイレクト先も検証
|
||||
raise ValueError("redirect to disallowed host")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
opener = build_opener(_SafeRedirect, HTTPSHandler(context=_get_ssl_context()))
|
||||
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
|
||||
with urlopen(req, timeout=timeout, context=_get_ssl_context()) as resp:
|
||||
data = resp.read()
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
data = resp.read(_IMAGE_MAX_BYTES + 1)
|
||||
if len(data) > _IMAGE_MAX_BYTES:
|
||||
logger.warning("Preview too large, skipped: %s", str(url)[:120])
|
||||
return False
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Audio conversion (and audio extraction from video) using PyAV.
|
||||
|
||||
Gallery の「Convert」機能のコア。PyAV(av)だけで完結し、システムの ffmpeg には依存しない。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 出力フォーマット -> (拡張子, エンコーダ, 標準サンプル形式, ビットレート指定可否, タグ保持可否)
|
||||
# .ogg は PyAV 同梱ビルドに Vorbis エンコーダが無いため出力対象外(入力は読める)。
|
||||
# wav は任意キー(prompt/workflow)のタグを持てないため、メタデータ引き継ぎの対象外。
|
||||
OUTPUT_FORMATS = {
|
||||
"flac": {"ext": ".flac", "codec": "flac", "sample_fmt": "s16", "bitrate": False, "tags": True},
|
||||
"mp3": {"ext": ".mp3", "codec": "libmp3lame", "sample_fmt": "fltp", "bitrate": True, "tags": True},
|
||||
"opus": {"ext": ".opus", "codec": "libopus", "sample_fmt": "flt", "bitrate": True, "tags": True},
|
||||
"m4a": {"ext": ".m4a", "codec": "aac", "sample_fmt": "fltp", "bitrate": True, "tags": True},
|
||||
"wav": {"ext": ".wav", "codec": "pcm_s16le", "sample_fmt": "s16", "bitrate": False, "tags": False},
|
||||
}
|
||||
|
||||
# 引き継ぐタグ(小文字で比較)。ComfyUIの prompt/workflow と一般的な曲情報のみ。
|
||||
_CARRY_TAGS = {"prompt", "workflow", "title", "artist", "album", "date", "genre", "comment"}
|
||||
|
||||
_OPUS_RATES = (8000, 12000, 16000, 24000, 48000)
|
||||
|
||||
|
||||
def supports_tags(fmt: str) -> bool:
|
||||
return bool(OUTPUT_FORMATS.get(fmt, {}).get("tags"))
|
||||
|
||||
|
||||
def _channel_layout(opt: str, src_channels: int) -> str:
|
||||
if opt == "mono":
|
||||
return "mono"
|
||||
if opt == "stereo":
|
||||
return "stereo"
|
||||
return "mono" if src_channels == 1 else "stereo"
|
||||
|
||||
|
||||
def convert_audio(src: Path, dst: Path, fmt: str, *, bitrate_kbps: int = 0,
|
||||
sample_rate: int = 0, channels: str = "keep",
|
||||
inherit_metadata: bool = True) -> dict:
|
||||
"""src(音声または音声トラックを持つ動画)から dst へ変換する。
|
||||
|
||||
sample_rate=0 / channels="keep" は元の設定を維持。opusは48kHz固定扱い。
|
||||
戻り値: {"tags_written": bool}(タグを持てない形式や元にタグが無い場合は False)
|
||||
"""
|
||||
import av
|
||||
|
||||
spec = OUTPUT_FORMATS[fmt]
|
||||
tags_written = False
|
||||
with av.open(str(src)) as in_c:
|
||||
if not in_c.streams.audio:
|
||||
raise ValueError("No audio stream in the source file")
|
||||
in_stream = in_c.streams.audio[0]
|
||||
|
||||
rate = sample_rate or in_stream.rate or 44100
|
||||
if fmt == "opus":
|
||||
rate = min(_OPUS_RATES, key=lambda r: abs(r - rate)) if sample_rate else 48000
|
||||
elif fmt == "mp3" and rate not in (8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000):
|
||||
rate = 44100
|
||||
layout = _channel_layout(channels, in_stream.channels)
|
||||
|
||||
options = {"movflags": "use_metadata_tags"} if fmt == "m4a" else {}
|
||||
with av.open(str(dst), "w", options=options) as out_c:
|
||||
# タグ: 引き継ぎ対象かつ形式がタグを持てる場合のみ
|
||||
if inherit_metadata and spec["tags"]:
|
||||
src_tags = dict(in_c.metadata or {})
|
||||
for k, v in (in_stream.metadata or {}).items(): # Opus等はストリーム側にタグを持つ
|
||||
src_tags.setdefault(k, v)
|
||||
for k, v in src_tags.items():
|
||||
if k.lower() in _CARRY_TAGS and isinstance(v, str) and v:
|
||||
out_c.metadata[k.lower()] = v
|
||||
tags_written = True
|
||||
|
||||
out_stream = out_c.add_stream(spec["codec"], rate=rate)
|
||||
out_stream.layout = layout
|
||||
if spec["bitrate"] and bitrate_kbps:
|
||||
out_stream.bit_rate = int(bitrate_kbps) * 1000
|
||||
|
||||
resampler = av.AudioResampler(format=out_stream.format.name, layout=layout, rate=rate)
|
||||
|
||||
def _encode(frames):
|
||||
for f in frames:
|
||||
for pkt in out_stream.encode(f):
|
||||
out_c.mux(pkt)
|
||||
|
||||
for frame in in_c.decode(in_stream):
|
||||
_encode(resampler.resample(frame))
|
||||
_encode(resampler.resample(None))
|
||||
for pkt in out_stream.encode(None):
|
||||
out_c.mux(pkt)
|
||||
return {"tags_written": tags_written}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -38,6 +39,9 @@ class GalleryMetadataStore:
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._lock = threading.RLock()
|
||||
# ディスクへの実書き込み(JSONシリアライズ+ファイルI/O)を直列化するための
|
||||
# 別ロック。_save_to_disk() を参照。
|
||||
self._write_lock = threading.Lock()
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
@@ -53,17 +57,56 @@ class GalleryMetadataStore:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
# 破損ファイルをリネームしてバックアップしておく。ここでリネームせずに
|
||||
# 空の self._data のまま次の save() が走ると、破損直前までの全メタデータ
|
||||
# (タグ/メモ/グループ登録)が跡形もなく上書きされ、二度と復元できなくなる
|
||||
# (実際に発生した不具合: _save_to_disk が非アトミック書き込みだったため、
|
||||
# 大量画像フォルダのBGインデックス処理中の頻繁な保存とComfyUIの終了タイミング
|
||||
# が重なるとファイルが不完全な状態のまま残ることがあった — _save_to_disk側は
|
||||
# os.replace()によるアトミック書き込みに修正済みだが、過去に壊れたファイルや
|
||||
# 他要因による破損に備えてここでも保護しておく)。
|
||||
try:
|
||||
backup_path = self.file_path.with_name(self.file_path.name + ".corrupt")
|
||||
self.file_path.replace(backup_path)
|
||||
logger.warning("GalleryMetadataStore: corrupt file backed up to %s", backup_path)
|
||||
except Exception as backup_err:
|
||||
logger.warning("GalleryMetadataStore: failed to back up corrupt file: %s", backup_err)
|
||||
|
||||
def _save_to_disk(self):
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド。
|
||||
# ここではself._lock配下でスナップショット(deepcopy)を取るだけに留め、実際のJSON
|
||||
# シリアライズ+ファイルI/Oはロックの外、別途self._write_lockで直列化して行う。
|
||||
# 理由: gallery_metadata.jsonは画像数が多い環境では1MBを超えることがあり、
|
||||
# self._lockを保持したままjson.dump+ファイル書き込みまで行うと、その間
|
||||
# 他スレッドのget()(検索リクエストなど)が丸ごとブロックされてしまう
|
||||
# (実際に発生した不具合: バックグラウンドインデックス処理が10枚ごとに保存する際、
|
||||
# 検索リクエストのget()呼び出しがロック待ちになり、50枚程度のフォルダに対する
|
||||
# 検索でも数秒〜十数秒かかっていた)。deepcopy自体はメモリ内操作でファイルI/Oより
|
||||
# 大幅に軽いため、ロック内に残しても実用上のブロック時間は無視できるレベルになる。
|
||||
snapshot = copy.deepcopy(self._data)
|
||||
return self._write_snapshot(snapshot)
|
||||
|
||||
def _write_snapshot(self, snapshot: dict) -> bool:
|
||||
"""self._lockの外から呼ばれる。一時ファイルに書き出してから置き換える
|
||||
(os.replace相当のPath.replace)ことで書き込みをアトミックにする。素朴な
|
||||
open(path, "w") での直接上書きだと、書き込み完了前に他プロセスが読むと
|
||||
不完全なJSONを掴む(torn read)うえ、書き込み中にComfyUIプロセスが終了すると
|
||||
ファイルが壊れたまま残る — 大量画像フォルダのBGインデックス処理で書き込み
|
||||
頻度が高い環境ほど発生しやすく、次回起動時のロード失敗→空データでの全上書き
|
||||
という形でユーザーの登録済みグループ/タグ/メモが消失する原因になっていた。
|
||||
self._write_lockで直列化し、複数スレッドの書き込みが競合して古いスナップショット
|
||||
が新しいものを上書きしないようにする。"""
|
||||
with self._write_lock:
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.file_path.with_name(self.file_path.name + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(snapshot, f, ensure_ascii=False, indent=2)
|
||||
tmp_path.replace(self.file_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
|
||||
def _normalize_path(self, image_path: str) -> str:
|
||||
return str(Path(image_path)).replace("\\", "/")
|
||||
@@ -83,6 +126,24 @@ class GalleryMetadataStore:
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
def save_batch(self, entries: dict[str, dict]) -> bool:
|
||||
"""複数画像のメタデータを1回のディスク書き込みでまとめて保存する。
|
||||
バックグラウンドインデックス処理のように多数の画像を連続して保存する場合、
|
||||
1件ずつsave()を呼ぶと画像の数だけJSONシリアライズ+ファイルI/Oが発生してしまう
|
||||
(実際に発生した不具合: gallery_metadata.jsonが1MBを超える環境で10枚ごとに
|
||||
個別save()していたため、検索リクエストが完了するまで数秒〜十数秒かかっていた)。"""
|
||||
if not entries:
|
||||
return True
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
with self._lock:
|
||||
for image_path, data in entries.items():
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""ComfyUI-Lora-Manager 連携: LMが保持済みのsha256を再利用してハッシュ計算を省く。
|
||||
|
||||
LMの `/api/lm/{prefix}/list` は sha256 / file_path / file_size を返す(CivitAI詳細は薄いので
|
||||
情報そのものは使わず、重いハッシュ計算の省略にのみ利用する)。LM未導入・失敗時は空を返す。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.request import urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 当アプリのモデルタイプ → LMのルート接頭辞
|
||||
_LM_PREFIX = {"checkpoint": "checkpoints", "lora": "loras", "embedding": "embeddings"}
|
||||
|
||||
_PAGE_SIZE = 100 # LM側の上限
|
||||
_MAX_PAGES = 200
|
||||
_TIMEOUT = 5
|
||||
|
||||
|
||||
def _norm(path):
|
||||
return os.path.normcase(os.path.abspath(path))
|
||||
|
||||
|
||||
def fetch_lm_hashes(origin, model_type):
|
||||
"""Returns { normalized_file_path: {"sha256", "size"} }。LMが使えなければ {}。"""
|
||||
prefix = _LM_PREFIX.get(model_type)
|
||||
if not prefix:
|
||||
return {}
|
||||
out = {}
|
||||
try:
|
||||
page = 1
|
||||
while page <= _MAX_PAGES:
|
||||
url = f"{origin}/api/lm/{prefix}/list?page={page}&page_size={_PAGE_SIZE}"
|
||||
with urlopen(url, timeout=_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
for it in data.get("items", []):
|
||||
sha, fp = it.get("sha256"), it.get("file_path")
|
||||
if sha and fp and len(str(sha)) == 64:
|
||||
out[_norm(fp)] = {"sha256": str(sha).lower(), "size": it.get("file_size")}
|
||||
if page >= int(data.get("total_pages", 1) or 1):
|
||||
break
|
||||
page += 1
|
||||
except Exception as e:
|
||||
logger.info("Lora Manager hashes unavailable (%s): %s", prefix, e)
|
||||
return {}
|
||||
return out
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -41,32 +42,126 @@ MODEL_TYPE_FOLDER_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _iter_files(root):
|
||||
"""root 配下の全ファイルを返す。フォルダのシンボリックリンク/ジャンクションも辿る。
|
||||
|
||||
リンクが祖先フォルダを指していて循環する場合のみ、その枝には入らない
|
||||
(rglob だとパス長上限まで再帰し続ける恐れがある)。
|
||||
"""
|
||||
def walk(d, ancestors):
|
||||
try:
|
||||
entries = list(os.scandir(d))
|
||||
except OSError:
|
||||
return
|
||||
for e in entries:
|
||||
try:
|
||||
if e.is_dir(): # リンクは辿る
|
||||
real = os.path.realpath(e.path)
|
||||
if real in ancestors:
|
||||
logger.warning("Skipping looping folder link: %s -> %s", e.path, real)
|
||||
continue
|
||||
yield from walk(e.path, ancestors | {real})
|
||||
elif e.is_file():
|
||||
yield Path(e.path)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
root = Path(root)
|
||||
yield from walk(root, frozenset([os.path.realpath(root)]))
|
||||
|
||||
|
||||
def _is_within(path, root):
|
||||
"""path が root 配下か。`..` は正規化するが、リンクは解決しない(リンク先フォルダへの移動を許可)。"""
|
||||
try:
|
||||
a, r = os.path.abspath(path), os.path.abspath(root)
|
||||
return os.path.commonpath([os.path.normcase(a), os.path.normcase(r)]) == os.path.normcase(r)
|
||||
except ValueError: # 別ドライブなど
|
||||
return False
|
||||
|
||||
|
||||
# 追加モデルルート配下で探すサブフォルダ名(大小無視)。ComfyUI標準名 + Stability Matrix名
|
||||
_EXTRA_ROOT_SUBDIRS = {
|
||||
"checkpoint": ["checkpoints", "StableDiffusion"],
|
||||
"lora": ["loras", "Lora"],
|
||||
"vae": ["vae", "VAE"],
|
||||
"controlnet": ["controlnet", "ControlNet"],
|
||||
"unet": ["diffusion_models", "DiffusionModels", "unet"],
|
||||
"textencoder": ["text_encoders", "TextEncoders", "clip"],
|
||||
"hypernetwork": ["hypernetworks", "HyperNetworks"],
|
||||
"embedding": ["embeddings", "Embeddings"],
|
||||
}
|
||||
|
||||
|
||||
def parse_extra_model_roots(value):
|
||||
"""設定値(`;` または改行区切りの文字列)を重複なしのパス文字列リストにする。"""
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = ";".join(str(v) for v in value)
|
||||
parts = str(value or "").replace("\n", ";").split(";")
|
||||
seen, out = set(), []
|
||||
for p in parts:
|
||||
p = p.strip().strip('"')
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _get_extra_model_dirs(model_type):
|
||||
"""設定 `models_dir` で追加指定されたルート配下の、該当タイプのフォルダを返す。"""
|
||||
names = _EXTRA_ROOT_SUBDIRS.get(model_type)
|
||||
if not names:
|
||||
return []
|
||||
try:
|
||||
from .settings_service import SettingsService
|
||||
roots = parse_extra_model_roots(SettingsService().load().get("models_dir", ""))
|
||||
except Exception:
|
||||
return []
|
||||
wanted = {n.lower() for n in names}
|
||||
found = []
|
||||
for root in roots:
|
||||
try:
|
||||
for e in os.scandir(root):
|
||||
if e.is_dir() and e.name.lower() in wanted:
|
||||
found.append(Path(e.path))
|
||||
except OSError:
|
||||
logger.warning("Configured models_dir not accessible: %s", root)
|
||||
return found
|
||||
|
||||
|
||||
def _get_model_dirs(model_type):
|
||||
"""Get all model directories for a type using ComfyUI's folder_paths.
|
||||
|
||||
Returns a list of Path objects for all configured model directories
|
||||
(includes extra_model_paths.yaml settings).
|
||||
(includes extra_model_paths.yaml settings and the `models_dir` setting).
|
||||
Falls back to plugin-relative path if folder_paths is unavailable.
|
||||
"""
|
||||
folder_key = MODEL_TYPE_FOLDER_KEYS.get(model_type)
|
||||
if not folder_key:
|
||||
return []
|
||||
|
||||
result = []
|
||||
try:
|
||||
import folder_paths # type: ignore # ComfyUI module
|
||||
paths = folder_paths.get_folder_paths(folder_key)
|
||||
result = [Path(p) for p in paths if Path(p).is_dir()]
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug("folder_paths unavailable (%s), using fallback", e)
|
||||
|
||||
# Fallback: custom_nodes/../../models/{folder_key}
|
||||
plugin_dir = Path(__file__).resolve().parent.parent.parent
|
||||
models_dir = plugin_dir.parent.parent / "models" / folder_key
|
||||
if models_dir.is_dir():
|
||||
return [models_dir]
|
||||
return []
|
||||
if not result:
|
||||
# Fallback: custom_nodes/../../models/{folder_key}
|
||||
plugin_dir = Path(__file__).resolve().parent.parent.parent
|
||||
models_dir = plugin_dir.parent.parent / "models" / folder_key
|
||||
if models_dir.is_dir():
|
||||
result = [models_dir]
|
||||
|
||||
# 設定で追加されたルート(既存と同一実体は除外)
|
||||
known = {os.path.normcase(os.path.realpath(p)) for p in result}
|
||||
for d in _get_extra_model_dirs(model_type):
|
||||
key = os.path.normcase(os.path.realpath(d))
|
||||
if key not in known:
|
||||
known.add(key)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
class ModelsService:
|
||||
@@ -111,6 +206,12 @@ class ModelsService:
|
||||
entry["sha256"] = updates["sha256"]
|
||||
if "badges" in updates:
|
||||
entry["badges"] = updates["badges"]
|
||||
if "civitaiNotFound" in updates:
|
||||
# CivitAIに存在しなかった記録(一括取得のスキップ用)。None/False で解除
|
||||
if updates["civitaiNotFound"]:
|
||||
entry["civitaiNotFound"] = self._now_iso()
|
||||
else:
|
||||
entry.pop("civitaiNotFound", None)
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
data[model_name] = entry
|
||||
self._save_metadata(data)
|
||||
@@ -131,9 +232,7 @@ class ModelsService:
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
@@ -145,17 +244,16 @@ class ModelsService:
|
||||
self._scan_cache[model_type] = (now, names)
|
||||
return names
|
||||
|
||||
def list_model_files(self, model_type: str) -> list:
|
||||
def list_model_files(self, model_type: str, extra_only: bool = False) -> list:
|
||||
"""モデルタイプのモデルファイル名をソートして返す(拡張子付き、/区切り)。
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。
|
||||
extra_only=True なら設定 models_dir で追加されたフォルダのみ対象。"""
|
||||
dirs = _get_extra_model_dirs(model_type) if extra_only else _get_model_dirs(model_type)
|
||||
seen = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
name = f.name
|
||||
if name.endswith(_DISABLED_SUFFIX):
|
||||
name = name[: -len(_DISABLED_SUFFIX)]
|
||||
@@ -280,8 +378,8 @@ class ModelsService:
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
|
||||
for f in _iter_files(d):
|
||||
if f.name.endswith(_DISABLED_SUFFIX):
|
||||
try:
|
||||
rel = str(f.relative_to(d))
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
@@ -291,6 +389,32 @@ class ModelsService:
|
||||
pass
|
||||
return disabled
|
||||
|
||||
def list_preview_keys(self, model_type):
|
||||
"""プレビュー画像を持つモデルのキー(`サブフォルダ/ファイル名(拡張子なし)`、小文字・/区切り)を返す。
|
||||
|
||||
一覧表示で「プレビューが無いモデルには画像リクエストを出さない」ために、フォルダを1回走査して求める
|
||||
(画像ごとの404リクエストと接続の開閉を避ける)。判定は find_preview_image と同じ拡張子・サイズ条件。
|
||||
"""
|
||||
keys = set()
|
||||
for d in _get_model_dirs(model_type):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in _iter_files(d):
|
||||
lname = f.name.lower()
|
||||
exts = [e for e in _PREVIEW_EXTENSIONS if lname.endswith(e)]
|
||||
if not exts:
|
||||
continue
|
||||
try:
|
||||
if f.stat().st_size < 100:
|
||||
continue
|
||||
parent = str(f.parent.relative_to(d)).replace("\\", "/")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
prefix = "" if parent == "." else parent.lower() + "/"
|
||||
for e in exts:
|
||||
keys.add(prefix + lname[: -len(e)])
|
||||
return sorted(keys)
|
||||
|
||||
def find_preview_image(self, model_type, model_name):
|
||||
"""Find preview image for a model file.
|
||||
|
||||
@@ -311,7 +435,8 @@ class ModelsService:
|
||||
for type_dir in dirs:
|
||||
# model_name can include subdirectory (e.g., "subdir/model.safetensors")
|
||||
model_path = type_dir / model_name
|
||||
if not model_path.is_file():
|
||||
# 無効化されたモデル(<name>.disabled)でも、隣のプレビュー画像は探す
|
||||
if not model_path.is_file() and not (type_dir / (model_name + _DISABLED_SUFFIX)).is_file():
|
||||
continue
|
||||
|
||||
stem = model_path.stem
|
||||
@@ -436,11 +561,10 @@ class ModelsService:
|
||||
errors.append({"model": model_name, "error": "Cannot determine root directory"})
|
||||
continue
|
||||
|
||||
# Destination directory — verify it stays within root_dir after resolution
|
||||
# Destination directory — verify it stays within root_dir (`..`/絶対パスは拒否。
|
||||
# フォルダのシンボリックリンク/ジャンクション配下への移動は許可する)
|
||||
dest_dir = root_dir / dest_subdir if dest_subdir else root_dir
|
||||
try:
|
||||
dest_dir.resolve().relative_to(root_dir.resolve())
|
||||
except ValueError:
|
||||
if not _is_within(dest_dir, root_dir):
|
||||
errors.append({"model": model_name, "error": "Destination is outside model root"})
|
||||
continue
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -44,8 +44,7 @@ def extract_png_workflow(buf):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# IENDで止めない: IENDの後ろに追記されたworkflowチャンクも読む
|
||||
pos += length + 12
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
EmbeddingGemma 2 ローカル実行 (onnx-community/embeddinggemma-2-ONNX)
|
||||
|
||||
onnxruntime + tokenizers + huggingface_hub だけで動かす(transformers の対応を待たない)。
|
||||
前処理は Transformers.js の Gemma4ImageProcessor / EmbeddingGemma2Processor の移植:
|
||||
画像 -> アスペクト比維持でリサイズ(48px倍数, 最大2520パッチ) -> 16pxパッチ化 -> vision_encoder
|
||||
-> image_features を text model の <|image|> トークン位置に差し込み -> sentence_embedding(768)
|
||||
モデルは初回のみ固定リポジトリからダウンロードする(約300MB, q4)。
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPO_ID = "onnx-community/embeddinggemma-2-ONNX"
|
||||
LOCAL_MODEL = "embeddinggemma-2"
|
||||
LOCAL_DIMS = (128, 256, 512, 768) # MRL対応次元
|
||||
DEFAULT_LOCAL_DIM = 768
|
||||
# 量子化精度 -> ファイル名サフィックス
|
||||
DTYPES = {"q4": "_q4", "q8": "_quantized", "fp16": "_fp16", "fp32": ""}
|
||||
DEFAULT_DTYPE = "q4"
|
||||
|
||||
_PATCH = 16
|
||||
_POOL = 3
|
||||
SOFT_TOKENS = (70, 140, 280) # 画像1枚あたりのソフトトークン数(多いほど高精度・低速)
|
||||
DEFAULT_SOFT = 280
|
||||
_MAX_TOKENS = 1024 # テキスト入力のトークン数上限(画像用プロンプトは約290トークン)
|
||||
_MAX_IMAGE_SIDE = 2048 # 画像の長辺上限(超える場合は縮小してから処理)
|
||||
_IMAGE_TOKEN = "<|image|>"
|
||||
_BOI = "<|image>"
|
||||
_EOI = "<image|>"
|
||||
|
||||
DEVICES = ("auto", "cpu", "cuda", "dml")
|
||||
_PROVIDER_BY_DEVICE = {"cuda": "CUDAExecutionProvider", "dml": "DmlExecutionProvider"}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_runtime = None # ((dtype, device, threads), _Runtime)
|
||||
|
||||
|
||||
_opts = {"device": "auto", "threads": 0} # 実行デバイス/CPUスレッド数(configureで更新)
|
||||
|
||||
|
||||
def configure(device: str = "auto", threads: int = 0) -> None:
|
||||
"""ローカル推論の実行設定。次回のensure_runtimeから反映(device/threads変更時はセッション再作成)。"""
|
||||
_opts["device"] = device if device in DEVICES else "auto"
|
||||
_opts["threads"] = max(0, min(int(threads), (os.cpu_count() or 1) * 2))
|
||||
|
||||
|
||||
def current_config() -> dict:
|
||||
return dict(_opts)
|
||||
|
||||
|
||||
def release() -> None:
|
||||
"""セッションを破棄してVRAM/RAMを解放する(インデックス作成後など)。次回利用時に再ロードされる。"""
|
||||
global _runtime
|
||||
with _lock:
|
||||
_runtime = None
|
||||
|
||||
|
||||
def active_provider() -> str:
|
||||
"""直近にロードしたセッションの実行プロバイダ(未ロードなら空)"""
|
||||
rt = _runtime[1] if _runtime else None
|
||||
return rt.providers[0].replace("ExecutionProvider", "") if rt else ""
|
||||
|
||||
|
||||
def available_devices() -> dict:
|
||||
"""onnxruntime が使える実行デバイス。GPU用ビルド(onnxruntime-gpu / -directml)が無ければcpuのみ。"""
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
prov = ort.get_available_providers()
|
||||
version = ort.__version__
|
||||
except Exception:
|
||||
return {"cpu": True, "cuda": False, "dml": False, "ort_version": ""}
|
||||
return {"cpu": True, "cuda": "CUDAExecutionProvider" in prov, "dml": "DmlExecutionProvider" in prov,
|
||||
"ort_version": version, "cpu_count": os.cpu_count() or 1}
|
||||
|
||||
|
||||
def default_threads() -> int:
|
||||
"""既定のCPUスレッド数: 画像生成と同居しても邪魔になりにくいよう、コアの半分(最低1)"""
|
||||
return max(1, (os.cpu_count() or 2) // 2)
|
||||
|
||||
|
||||
def _target_size(h: int, w: int, max_patches: int) -> tuple[int, int]:
|
||||
"""Gemma4ImageProcessor の get_aspect_ratio_preserving_size"""
|
||||
factor = math.sqrt(max_patches * _PATCH ** 2 / (h * w))
|
||||
side = _POOL * _PATCH
|
||||
th = int(math.floor(factor * h / side)) * side
|
||||
tw = int(math.floor(factor * w / side)) * side
|
||||
max_side = (max_patches // _POOL ** 2) * side
|
||||
if th == 0 and tw == 0:
|
||||
raise ValueError("image too small")
|
||||
if th == 0:
|
||||
th, tw = side, min((w // h) * side, max_side)
|
||||
elif tw == 0:
|
||||
tw, th = side, min((h // w) * side, max_side)
|
||||
return th, tw
|
||||
|
||||
|
||||
class _Runtime:
|
||||
def __init__(self, model_dir: Path, dtype: str, device: str = "auto", threads: int = 0):
|
||||
import onnxruntime as ort
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
sfx = DTYPES[dtype]
|
||||
so = ort.SessionOptions()
|
||||
so.log_severity_level = 3 # CUDA使用時のMemcpy警告などを抑止
|
||||
if threads > 0:
|
||||
so.intra_op_num_threads = threads
|
||||
so.inter_op_num_threads = 1
|
||||
avail = ort.get_available_providers()
|
||||
if device in _PROVIDER_BY_DEVICE:
|
||||
providers = [_PROVIDER_BY_DEVICE[device]] if _PROVIDER_BY_DEVICE[device] in avail else []
|
||||
elif device == "cpu":
|
||||
providers = []
|
||||
else: # auto: 使えるGPUを優先
|
||||
providers = [p for p in ("CUDAExecutionProvider", "DmlExecutionProvider") if p in avail]
|
||||
if "DmlExecutionProvider" in providers:
|
||||
# DirectMLは並列実行/メモリパターン最適化に非対応
|
||||
so.enable_mem_pattern = False
|
||||
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
||||
providers.append("CPUExecutionProvider")
|
||||
self.providers = providers
|
||||
|
||||
def make(name):
|
||||
path = str(model_dir / "onnx" / f"{name}{sfx}.onnx")
|
||||
try:
|
||||
return ort.InferenceSession(path, so, providers=providers)
|
||||
except Exception as e:
|
||||
if providers == ["CPUExecutionProvider"]:
|
||||
raise
|
||||
logger.warning("EmbeddingGemma2: %s failed on %s (%s); falling back to CPU", name, providers[0], e)
|
||||
self.providers = ["CPUExecutionProvider"]
|
||||
return ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
|
||||
|
||||
self.text = make("model")
|
||||
self.vision = make("vision_encoder")
|
||||
self.tok = Tokenizer.from_file(str(model_dir / "tokenizer.json"))
|
||||
self.tok.no_truncation()
|
||||
self.tok.no_padding()
|
||||
|
||||
def _run_text(self, text: str, image_features: np.ndarray | None) -> np.ndarray:
|
||||
ids = np.asarray([self.tok.encode(text).ids[:_MAX_TOKENS]], dtype=np.int64)
|
||||
feeds = {
|
||||
"input_ids": ids,
|
||||
"attention_mask": np.ones_like(ids),
|
||||
"image_features": image_features if image_features is not None else np.zeros((0, 512), np.float32),
|
||||
"video_features": np.zeros((0, 512), np.float32),
|
||||
"audio_features": np.zeros((0, 512), np.float32),
|
||||
}
|
||||
return self.text.run(["sentence_embedding"], feeds)[0][0]
|
||||
|
||||
def embed_text(self, text: str, prefix: str) -> np.ndarray:
|
||||
return self._run_text(f"{prefix}{text.strip()}", None)
|
||||
|
||||
def embed_image(self, image, soft_tokens: int = DEFAULT_SOFT) -> np.ndarray:
|
||||
from PIL import Image
|
||||
im = image.convert("RGB")
|
||||
if im.width < 1 or im.height < 1:
|
||||
raise ValueError("empty image")
|
||||
if max(im.size) > _MAX_IMAGE_SIDE: # 巨大画像でメモリを使い切らないよう先に縮小
|
||||
im.thumbnail((_MAX_IMAGE_SIDE, _MAX_IMAGE_SIDE), Image.LANCZOS)
|
||||
max_patches = soft_tokens * _POOL ** 2
|
||||
th, tw = _target_size(im.height, im.width, max_patches)
|
||||
if (th, tw) != (im.height, im.width):
|
||||
im = im.resize((tw, th), Image.BICUBIC)
|
||||
arr = np.asarray(im, dtype=np.float32) * (1.0 / 255.0) # H,W,3
|
||||
ph, pw = th // _PATCH, tw // _PATCH
|
||||
n = ph * pw
|
||||
# (pH, dy, pW, dx, C) -> (pH, pW, dy, dx, C) -> (n, 768)
|
||||
patches = arr.reshape(ph, _PATCH, pw, _PATCH, 3).transpose(0, 2, 1, 3, 4).reshape(n, _PATCH * _PATCH * 3)
|
||||
# パディング無し(n個ちょうど)で渡す: 計算量が減り、パディングは出力に影響しない
|
||||
pixel = patches[None].astype(np.float32)
|
||||
pos = np.zeros((1, n, 2), dtype=np.int64)
|
||||
rows, cols = np.divmod(np.arange(n), pw)
|
||||
pos[0, :, 0], pos[0, :, 1] = cols, rows
|
||||
feats = self.vision.run(["image_features"], {"pixel_values": pixel, "pixel_position_ids": pos})[0]
|
||||
text = f"{_BOI}{_IMAGE_TOKEN * (n // _POOL ** 2)}{_EOI}"
|
||||
return self._run_text(text, feats.astype(np.float32))
|
||||
|
||||
|
||||
def model_dir(data_dir: Path) -> Path:
|
||||
return data_dir / "models" / LOCAL_MODEL
|
||||
|
||||
|
||||
def is_downloaded(data_dir: Path, dtype: str = DEFAULT_DTYPE) -> bool:
|
||||
d, sfx = model_dir(data_dir), DTYPES.get(dtype, "_q4")
|
||||
need = [d / "tokenizer.json"] + [d / "onnx" / f"{n}{sfx}.onnx{e}" for n in ("model", "vision_encoder") for e in ("", "_data")]
|
||||
return all(p.exists() for p in need)
|
||||
|
||||
|
||||
def ensure_runtime(data_dir: Path, dtype: str = DEFAULT_DTYPE, progress=None,
|
||||
device: str | None = None, threads: int | None = None) -> _Runtime:
|
||||
"""モデルをダウンロード(未取得時)してセッションを返す。dtype/device/threads変更時は作り直す。"""
|
||||
global _runtime
|
||||
if dtype not in DTYPES:
|
||||
dtype = DEFAULT_DTYPE
|
||||
device = _opts["device"] if device is None else device
|
||||
threads = _opts["threads"] if threads is None else threads
|
||||
if device not in DEVICES:
|
||||
device = "auto"
|
||||
key = (dtype, device, int(threads))
|
||||
with _lock:
|
||||
if _runtime and _runtime[0] == key:
|
||||
return _runtime[1]
|
||||
d, sfx = model_dir(data_dir), DTYPES[dtype]
|
||||
if not is_downloaded(data_dir, dtype):
|
||||
if progress:
|
||||
progress("downloading")
|
||||
from huggingface_hub import hf_hub_download
|
||||
files = ["tokenizer.json"] + [f"onnx/{n}{sfx}.onnx{e}" for n in ("model", "vision_encoder") for e in ("", "_data")]
|
||||
for f in files:
|
||||
hf_hub_download(REPO_ID, f, local_dir=str(d))
|
||||
if progress:
|
||||
progress("loading")
|
||||
_runtime = (key, _Runtime(d, dtype, device, int(threads)))
|
||||
return _runtime[1]
|
||||
|
||||
|
||||
def _finish(vec: np.ndarray, dim: int) -> np.ndarray:
|
||||
v = np.asarray(vec, dtype=np.float32)
|
||||
if dim < v.shape[0]:
|
||||
v = v[:dim]
|
||||
n = float(np.linalg.norm(v))
|
||||
return v / n if n > 0 else v
|
||||
|
||||
|
||||
def embed_text(data_dir: Path, text: str, dim: int, dtype: str = DEFAULT_DTYPE) -> np.ndarray:
|
||||
rt = ensure_runtime(data_dir, dtype)
|
||||
return _finish(rt.embed_text(text[:8000], "task: search result | query: "), dim)
|
||||
|
||||
|
||||
def embed_image(data_dir: Path, image, dim: int, soft: int = DEFAULT_SOFT, dtype: str = DEFAULT_DTYPE) -> np.ndarray:
|
||||
rt = ensure_runtime(data_dir, dtype)
|
||||
return _finish(rt.embed_image(image, soft if soft in SOFT_TOKENS else DEFAULT_SOFT), dim)
|
||||
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
Semantic Service - Gallery用セマンティック検索(Gemini Embedding 2)
|
||||
|
||||
画像(動画・PSDはサムネイル)をGemini Embedding APIでベクトル化してローカルに保存し、
|
||||
テキストクエリ/類似画像でコサイン類似度検索する。インデックス構築後の検索は
|
||||
(テキストクエリの埋め込み1回を除き)ローカル処理でAPI費用はかからない。
|
||||
|
||||
APIキーは環境変数 GEMINI_API_KEY (または GOOGLE_API_KEY) から読む(.env経由)。
|
||||
settings.json やフロントエンドには出さない。接続先は固定ホストのみ(SSRF対策)。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import semantic_local as local
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
||||
MODEL = "gemini-embedding-2" # Gemini API (クラウド)
|
||||
LOCAL_MODEL = local.LOCAL_MODEL # EmbeddingGemma 2 (ローカルONNX)
|
||||
DEFAULT_DIM = 1536
|
||||
VALID_DIMS = (768, 1536, 3072)
|
||||
# バックエンド(モデル名) -> 選択可能な出力次元
|
||||
DIMS_BY_MODEL = {MODEL: VALID_DIMS, LOCAL_MODEL: local.LOCAL_DIMS}
|
||||
DEFAULT_DIM_BY_MODEL = {MODEL: DEFAULT_DIM, LOCAL_MODEL: local.DEFAULT_LOCAL_DIM}
|
||||
IMAGE_MAX_SIZE = 768 # 送信前の長辺リサイズ(px)
|
||||
_MAX_QUERY_CHARS = 2000 # 検索文の最大長
|
||||
_WORKERS = 4 # 並列API呼び出し数
|
||||
_CHECKPOINT_EVERY = 100 # この件数ごとにディスクへ保存
|
||||
_RETRIES = 4
|
||||
|
||||
# PILで直接開ける(静止画/GIFは先頭フレーム)
|
||||
_PIL_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
|
||||
# GalleryService.serve_thumbnail 経由でJPEG化する(動画先頭フレーム/PSD合成)
|
||||
_THUMB_EXTS = {".mp4", ".webm", ".psd"}
|
||||
INDEXABLE_EXTS = _PIL_EXTS | _THUMB_EXTS
|
||||
|
||||
|
||||
class SemanticError(Exception):
|
||||
"""ユーザーに見せられるエラー(code はフロントのi18n用)"""
|
||||
|
||||
def __init__(self, code: str, message: str = ""):
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
return (os.environ.get("GEMINI_API_KEY", "") or os.environ.get("GOOGLE_API_KEY", "")).strip()
|
||||
|
||||
|
||||
def _normalize(v: np.ndarray) -> np.ndarray:
|
||||
n = float(np.linalg.norm(v))
|
||||
return v / n if n > 0 else v
|
||||
|
||||
|
||||
def _call_embed(content: dict, task_type: str, dim: int) -> np.ndarray:
|
||||
key = get_api_key()
|
||||
if not key:
|
||||
raise SemanticError("no_api_key", "GEMINI_API_KEY is not set")
|
||||
body = {"model": f"models/{MODEL}", "content": content, "taskType": task_type}
|
||||
if dim != 768:
|
||||
body["outputDimensionality"] = dim
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
url = f"{API_BASE}/models/{MODEL}:embedContent"
|
||||
last_err = ""
|
||||
for attempt in range(_RETRIES + 1):
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json", "x-goog-api-key": key},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as res:
|
||||
payload = json.loads(res.read().decode("utf-8"))
|
||||
values = (payload.get("embedding") or {}).get("values")
|
||||
if not values:
|
||||
raise SemanticError("bad_response", "no embedding.values in response")
|
||||
return _normalize(np.asarray(values, dtype=np.float32))
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
msg = json.loads(e.read().decode("utf-8")).get("error", {}).get("message", "")
|
||||
except Exception:
|
||||
msg = ""
|
||||
last_err = f"HTTP {e.code}: {msg or e.reason}"
|
||||
if e.code in (400, 403) and ("API key" in msg or "API_KEY" in msg or e.code == 403):
|
||||
raise SemanticError("api_key_invalid", last_err)
|
||||
if e.code in (429, 500, 502, 503, 504) and attempt < _RETRIES:
|
||||
time.sleep(1.5 * (2 ** attempt))
|
||||
continue
|
||||
raise SemanticError("api_error", last_err)
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
||||
last_err = str(getattr(e, "reason", e))
|
||||
if attempt < _RETRIES:
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
raise SemanticError("network_error", last_err)
|
||||
raise SemanticError("api_error", last_err)
|
||||
|
||||
|
||||
def embed_text(text: str, dim: int) -> np.ndarray:
|
||||
return _call_embed({"parts": [{"text": text[:8000]}]}, "RETRIEVAL_QUERY", dim)
|
||||
|
||||
|
||||
def embed_image_bytes(data: bytes, mime: str, dim: int) -> np.ndarray:
|
||||
import base64
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return _call_embed({"parts": [{"inlineData": {"mimeType": mime, "data": b64}}]}, "RETRIEVAL_DOCUMENT", dim)
|
||||
|
||||
|
||||
class SemanticService:
|
||||
def __init__(self, data_dir: Path, gallery_service):
|
||||
self.data_dir = data_dir
|
||||
self.gallery = gallery_service
|
||||
self._file = data_dir / "semantic_index.npz"
|
||||
self._lock = threading.RLock()
|
||||
self._vecs: dict[str, np.ndarray] = {} # path -> 正規化済みベクトル
|
||||
self._stamp: dict[str, tuple[int, float]] = {} # path -> (size, mtime)
|
||||
self._dim = 0 # インデックスの次元(0=空)
|
||||
self._model = MODEL # インデックスを作ったモデル
|
||||
self._soft = local.DEFAULT_SOFT # ローカルモデルの画像ソフトトークン数(インデックスと一致させる)
|
||||
self._settings_file = data_dir / "semantic_settings.json"
|
||||
self._load_settings()
|
||||
self._mat_cache = None # (paths, matrix)
|
||||
self._loaded = False
|
||||
self._dirty = 0
|
||||
# ジョブ
|
||||
self._job = self._idle_job()
|
||||
self._cancel = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
# ── ローカル推論の実行設定(デバイス/スレッド数) ─────────────
|
||||
|
||||
def _load_settings(self) -> None:
|
||||
try:
|
||||
if self._settings_file.exists():
|
||||
d = json.loads(self._settings_file.read_text(encoding="utf-8"))
|
||||
local.configure(str(d.get("device", "auto")), int(d.get("threads", 0)))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: failed to load settings: %s", e)
|
||||
local.configure("auto", local.default_threads())
|
||||
|
||||
def set_local_options(self, device: str, threads: int) -> None:
|
||||
"""実行デバイス/CPUスレッド数を反映して保存する(検索時のクエリ埋め込みにも使われる)"""
|
||||
local.configure(device, threads)
|
||||
try:
|
||||
self._settings_file.write_text(json.dumps(local.current_config()), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: failed to save settings: %s", e)
|
||||
|
||||
# ── 永続化 ─────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _idle_job() -> dict:
|
||||
return {"running": False, "total": 0, "done": 0, "failed": 0, "current": "",
|
||||
"error": "", "error_code": "", "cancelled": False, "finished": False}
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
with self._lock:
|
||||
if self._loaded:
|
||||
return
|
||||
self._loaded = True
|
||||
if not self._file.exists():
|
||||
return
|
||||
try:
|
||||
with np.load(self._file, allow_pickle=False) as z:
|
||||
model = str(z["model"])
|
||||
if model not in DIMS_BY_MODEL:
|
||||
return
|
||||
self._model = model
|
||||
self._soft = int(z["soft"]) if "soft" in z.files else local.DEFAULT_SOFT
|
||||
paths = [str(p) for p in z["paths"]]
|
||||
sizes, mtimes, vecs = z["sizes"], z["mtimes"], z["vecs"]
|
||||
self._dim = int(z["dim"])
|
||||
for i, p in enumerate(paths):
|
||||
self._vecs[p] = vecs[i]
|
||||
self._stamp[p] = (int(sizes[i]), float(mtimes[i]))
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: failed to load index: %s", e)
|
||||
self._vecs.clear()
|
||||
self._stamp.clear()
|
||||
self._dim = 0
|
||||
|
||||
def _save(self) -> None:
|
||||
with self._lock:
|
||||
if not self._vecs:
|
||||
if self._file.exists():
|
||||
self._file.unlink()
|
||||
self._dirty = 0
|
||||
return
|
||||
paths = list(self._vecs.keys())
|
||||
tmp = self._file.with_suffix(".tmp.npz")
|
||||
np.savez(
|
||||
tmp,
|
||||
model=np.array(self._model), dim=np.array(self._dim), soft=np.array(self._soft),
|
||||
paths=np.array(paths),
|
||||
sizes=np.array([self._stamp[p][0] for p in paths], dtype=np.int64),
|
||||
mtimes=np.array([self._stamp[p][1] for p in paths], dtype=np.float64),
|
||||
vecs=np.stack([self._vecs[p] for p in paths]).astype(np.float32),
|
||||
)
|
||||
os.replace(tmp, self._file)
|
||||
self._dirty = 0
|
||||
|
||||
def _put(self, path: str, vec: np.ndarray, size: int, mtime: float) -> None:
|
||||
with self._lock:
|
||||
self._vecs[path] = vec
|
||||
self._stamp[path] = (size, mtime)
|
||||
self._mat_cache = None
|
||||
self._dirty += 1
|
||||
|
||||
# ── 状態 ───────────────────────────────────────────────
|
||||
|
||||
def _scope_entries(self, folder: str, recursive: bool) -> list[tuple[str, str, int, float]]:
|
||||
p = Path(folder).resolve()
|
||||
if not p.is_dir() or not self.gallery._check_path_allowed(p):
|
||||
raise SemanticError("folder_not_allowed", str(folder))
|
||||
raw = self.gallery._scan_folder_recursive(p) if recursive else self.gallery._scan_folder(p)
|
||||
return [e for e in raw if Path(e[0]).suffix.lower() in INDEXABLE_EXTS]
|
||||
|
||||
def status(self, folder: str = "", recursive: bool = True) -> dict:
|
||||
self._ensure_loaded()
|
||||
out = {
|
||||
"has_key": bool(get_api_key()),
|
||||
"model": MODEL,
|
||||
"backends": {
|
||||
MODEL: {"dims": list(VALID_DIMS), "default_dim": DEFAULT_DIM, "ready": bool(get_api_key())},
|
||||
LOCAL_MODEL: {"dims": list(local.LOCAL_DIMS), "default_dim": local.DEFAULT_LOCAL_DIM,
|
||||
"ready": True, "downloaded": local.is_downloaded(self.data_dir)},
|
||||
},
|
||||
"index_model": self._model,
|
||||
"index_dim": self._dim,
|
||||
"index_soft": self._soft,
|
||||
"local": {
|
||||
"devices": local.available_devices(),
|
||||
"options": local.current_config(),
|
||||
"default_threads": local.default_threads(),
|
||||
"soft_tokens": list(local.SOFT_TOKENS),
|
||||
"default_soft": local.DEFAULT_SOFT,
|
||||
},
|
||||
"indexed_total": len(self._vecs),
|
||||
"job": dict(self._job),
|
||||
}
|
||||
if folder:
|
||||
try:
|
||||
entries = self._scope_entries(folder, recursive)
|
||||
with self._lock:
|
||||
done = sum(1 for _, ap, sz, mt in entries if self._is_fresh(ap, sz, mt))
|
||||
out["scope_total"] = len(entries)
|
||||
out["scope_indexed"] = done
|
||||
except SemanticError:
|
||||
pass
|
||||
return out
|
||||
|
||||
def folder_status(self, root: str) -> dict:
|
||||
"""フォルダごとの索引状況 {絶対パス: {"indexable": n, "indexed": m}} (直下の画像のみ。Galleryツリーの枚数表示と同じ単位)"""
|
||||
self._ensure_loaded()
|
||||
entries = self._scope_entries(root, True)
|
||||
out: dict[str, dict] = {}
|
||||
with self._lock:
|
||||
for _, ap, sz, mt in entries:
|
||||
d = out.setdefault(ap.rsplit("/", 1)[0], {"indexable": 0, "indexed": 0})
|
||||
d["indexable"] += 1
|
||||
if self._is_fresh(ap, sz, mt):
|
||||
d["indexed"] += 1
|
||||
return out
|
||||
|
||||
def _is_fresh(self, path: str, size: int, mtime: float) -> bool:
|
||||
st = self._stamp.get(path)
|
||||
return st is not None and st[0] == size and abs(st[1] - mtime) < 1e-3
|
||||
|
||||
# ── インデックス作成ジョブ ────────────────────────────
|
||||
|
||||
def start_index(self, folder: str, recursive: bool, mode: str, model: str, dim: int,
|
||||
soft: int = local.DEFAULT_SOFT) -> None:
|
||||
self._ensure_loaded()
|
||||
if model not in DIMS_BY_MODEL:
|
||||
raise SemanticError("bad_model", model)
|
||||
if model == MODEL and not get_api_key():
|
||||
raise SemanticError("no_api_key")
|
||||
if dim not in DIMS_BY_MODEL[model]:
|
||||
raise SemanticError("bad_dim", str(dim))
|
||||
if model != LOCAL_MODEL or soft not in local.SOFT_TOKENS:
|
||||
soft = local.DEFAULT_SOFT # ソフトトークン数はローカルモデルのみの設定
|
||||
with self._lock:
|
||||
if self._job["running"]:
|
||||
raise SemanticError("already_running")
|
||||
entries = self._scope_entries(folder, recursive)
|
||||
mismatch = bool(self._vecs) and (self._dim != dim or self._model != model or self._soft != soft)
|
||||
if mismatch and mode != "all":
|
||||
raise SemanticError("dim_mismatch", f"{self._model}/{self._dim}/{self._soft}")
|
||||
if mode == "all":
|
||||
# 範囲内の既存ベクトルを破棄して作り直す(モデル/次元違いなら全消去)
|
||||
if mismatch:
|
||||
self._vecs.clear()
|
||||
self._stamp.clear()
|
||||
else:
|
||||
for _, ap, _, _ in entries:
|
||||
self._vecs.pop(ap, None)
|
||||
self._stamp.pop(ap, None)
|
||||
self._mat_cache = None
|
||||
self._dim = dim
|
||||
self._model = model
|
||||
self._soft = soft
|
||||
todo = [e for e in entries if not self._is_fresh(e[1], e[2], e[3])]
|
||||
# スコープ内で消えたファイルの残骸を掃除(スコープ配下のキーのみ)
|
||||
self._prune_missing(folder, recursive, {e[1] for e in entries})
|
||||
self._cancel.clear()
|
||||
self._job = self._idle_job()
|
||||
self._job.update(running=True, total=len(todo))
|
||||
self._thread = threading.Thread(target=self._run_index, args=(todo, model, dim, soft), daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _prune_missing(self, folder: str, recursive: bool, existing: set[str]) -> None:
|
||||
base = Path(folder).resolve().as_posix().rstrip("/") + "/"
|
||||
for p in list(self._vecs.keys()):
|
||||
if not p.startswith(base):
|
||||
continue
|
||||
if not recursive and "/" in p[len(base):]:
|
||||
continue
|
||||
if p not in existing:
|
||||
self._vecs.pop(p, None)
|
||||
self._stamp.pop(p, None)
|
||||
self._mat_cache = None
|
||||
self._dirty += 1
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancel.set()
|
||||
|
||||
def _run_index(self, todo: list, model: str, dim: int, soft: int = local.DEFAULT_SOFT) -> None:
|
||||
def work(entry):
|
||||
if self._cancel.is_set():
|
||||
return
|
||||
name, ap, size, mtime = entry
|
||||
self._job["current"] = name
|
||||
try:
|
||||
vec = self._embed_image(model, dim, Path(ap), soft)
|
||||
self._put(ap, vec, size, mtime)
|
||||
self._job["done"] += 1
|
||||
except SemanticError as e:
|
||||
if e.code in ("no_api_key", "api_key_invalid"):
|
||||
self._job["error_code"], self._job["error"] = e.code, str(e)
|
||||
self._cancel.set()
|
||||
else:
|
||||
self._job["failed"] += 1
|
||||
if not self._job["error"]:
|
||||
self._job["error_code"], self._job["error"] = e.code, str(e)
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: embed failed for %s: %s", ap, e)
|
||||
self._job["failed"] += 1
|
||||
if self._dirty >= _CHECKPOINT_EVERY:
|
||||
try:
|
||||
self._save()
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: checkpoint failed: %s", e)
|
||||
|
||||
try:
|
||||
if model == LOCAL_MODEL:
|
||||
# 初回はモデルのダウンロード/ロードを先に行う(失敗したらジョブ全体を中止)
|
||||
try:
|
||||
local.ensure_runtime(self.data_dir, progress=lambda ph: self._job.update(current=f"({ph} model...)"))
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: local model load failed: %s", e)
|
||||
self._job["error_code"], self._job["error"] = "local_error", str(e)
|
||||
self._cancel.set()
|
||||
workers = 1 if model == LOCAL_MODEL else _WORKERS # ローカルはORT側がマルチスレッド
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
list(ex.map(work, todo))
|
||||
finally:
|
||||
try:
|
||||
self._save()
|
||||
except Exception as e:
|
||||
logger.warning("SemanticService: save failed: %s", e)
|
||||
if model == LOCAL_MODEL:
|
||||
local.release() # 画像生成とVRAM/RAMを奪い合わないよう、作成後はモデルを解放(検索時に再ロード)
|
||||
self._job["running"] = False
|
||||
self._job["finished"] = True
|
||||
self._job["cancelled"] = self._cancel.is_set() and not self._job["error_code"]
|
||||
self._job["current"] = ""
|
||||
|
||||
def _load_pil(self, path: Path):
|
||||
"""埋め込み用に縮小したPIL画像を返す(動画/PSDはサムネイルから)"""
|
||||
from PIL import Image
|
||||
if path.suffix.lower() in _THUMB_EXTS:
|
||||
thumb = self.gallery.serve_thumbnail(str(path), IMAGE_MAX_SIZE)
|
||||
if not thumb:
|
||||
raise SemanticError("thumb_failed", str(path))
|
||||
with Image.open(thumb) as im:
|
||||
return im.convert("RGB")
|
||||
with Image.open(path) as im:
|
||||
im.seek(0)
|
||||
im = im.convert("RGBA") if im.mode in ("RGBA", "LA", "P") else im.convert("RGB")
|
||||
im.thumbnail((IMAGE_MAX_SIZE, IMAGE_MAX_SIZE), Image.LANCZOS)
|
||||
return im
|
||||
|
||||
def _embed_image(self, model: str, dim: int, path: Path, soft: int | None = None) -> np.ndarray:
|
||||
from PIL import Image
|
||||
im = self._load_pil(path)
|
||||
if model == LOCAL_MODEL:
|
||||
if im.mode == "RGBA": # 透過部は白背景に合成
|
||||
bg = Image.new("RGB", im.size, (255, 255, 255))
|
||||
bg.paste(im, mask=im.getchannel("A"))
|
||||
im = bg
|
||||
try:
|
||||
return local.embed_image(self.data_dir, im, dim, self._soft if soft is None else soft)
|
||||
except Exception as e:
|
||||
raise SemanticError("local_error", str(e))
|
||||
buf = io.BytesIO()
|
||||
if im.mode == "RGBA":
|
||||
im.save(buf, "PNG")
|
||||
return embed_image_bytes(buf.getvalue(), "image/png", dim)
|
||||
im.save(buf, "JPEG", quality=88)
|
||||
return embed_image_bytes(buf.getvalue(), "image/jpeg", dim)
|
||||
|
||||
def _embed_text(self, model: str, dim: int, text: str) -> np.ndarray:
|
||||
if model == LOCAL_MODEL:
|
||||
try:
|
||||
return local.embed_text(self.data_dir, text, dim)
|
||||
except Exception as e:
|
||||
raise SemanticError("local_error", str(e))
|
||||
return embed_text(text, dim)
|
||||
|
||||
# ── 検索 ───────────────────────────────────────────────
|
||||
|
||||
def _matrix(self):
|
||||
with self._lock:
|
||||
if self._mat_cache is None:
|
||||
paths = list(self._vecs.keys())
|
||||
mat = np.stack([self._vecs[p] for p in paths]) if paths else np.zeros((0, max(self._dim, 1)), np.float32)
|
||||
self._mat_cache = (paths, mat)
|
||||
return self._mat_cache
|
||||
|
||||
def _rank(self, qvec: np.ndarray, folder: str, recursive: bool, limit: int, exclude: str = "",
|
||||
min_score: float = 0.0) -> list[dict]:
|
||||
entries = self._scope_entries(folder, recursive)
|
||||
info = {ap: (name, sz, mt) for name, ap, sz, mt in entries}
|
||||
paths, mat = self._matrix()
|
||||
if not paths:
|
||||
raise SemanticError("index_empty")
|
||||
if mat.shape[1] != qvec.shape[0]:
|
||||
raise SemanticError("dim_mismatch", str(self._dim))
|
||||
scores = mat @ qvec
|
||||
order = np.argsort(-scores)
|
||||
out = []
|
||||
for i in order:
|
||||
if float(scores[i]) < min_score:
|
||||
break # スコア降順なので、以降はすべてしきい値未満
|
||||
p = paths[i]
|
||||
if p == exclude or p not in info:
|
||||
continue
|
||||
name, sz, mt = info[p]
|
||||
item = self.gallery.make_list_item(name, p, sz, mt)
|
||||
item["score"] = round(float(scores[i]), 4)
|
||||
out.append(item)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
def search_text(self, query: str, folder: str, recursive: bool, limit: int, min_score: float = 0.0) -> list[dict]:
|
||||
self._ensure_loaded()
|
||||
query = (query or "").strip()[:_MAX_QUERY_CHARS]
|
||||
if not query:
|
||||
return []
|
||||
if not self._vecs:
|
||||
raise SemanticError("index_empty")
|
||||
qvec = self._embed_text(self._model, self._dim, query)
|
||||
return self._rank(qvec, folder, recursive, limit, min_score=min_score)
|
||||
|
||||
def search_similar(self, path: str, folder: str, recursive: bool, limit: int, min_score: float = 0.0) -> list[dict]:
|
||||
self._ensure_loaded()
|
||||
p = Path(path).resolve()
|
||||
if not p.is_file() or not self.gallery._check_path_allowed(p):
|
||||
raise SemanticError("folder_not_allowed", path)
|
||||
ap = p.as_posix()
|
||||
vec = self._vecs.get(ap)
|
||||
if vec is None or not self._is_fresh(ap, p.stat().st_size, p.stat().st_mtime):
|
||||
# 未インデックスの基準画像は、その場で1枚だけ埋め込む
|
||||
if not self._vecs:
|
||||
raise SemanticError("index_empty")
|
||||
if p.suffix.lower() not in INDEXABLE_EXTS:
|
||||
raise SemanticError("unsupported_type", p.suffix)
|
||||
vec = self._embed_image(self._model, self._dim, p)
|
||||
st = p.stat()
|
||||
self._put(ap, vec, st.st_size, st.st_mtime)
|
||||
self._save()
|
||||
return self._rank(vec, folder, recursive, limit, exclude=ap, min_score=min_score)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
if self._job["running"]:
|
||||
raise SemanticError("already_running")
|
||||
self._vecs.clear()
|
||||
self._stamp.clear()
|
||||
self._dim = 0
|
||||
self._mat_cache = None
|
||||
self._save()
|
||||
|
||||
def test_connection(self, model: str, dim: int) -> dict:
|
||||
if model not in DIMS_BY_MODEL:
|
||||
raise SemanticError("bad_model", model)
|
||||
if dim not in DIMS_BY_MODEL[model]:
|
||||
dim = DEFAULT_DIM_BY_MODEL[model]
|
||||
n = int(self._embed_text(model, dim, "test").shape[0])
|
||||
return {"dimensions": n, "provider": local.active_provider() if model == LOCAL_MODEL else "API"}
|
||||
@@ -5,6 +5,7 @@ import io
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,6 +86,20 @@ class TaggerDbService:
|
||||
conn.execute("DELETE FROM images WHERE id=?", (row_id,))
|
||||
conn.commit()
|
||||
|
||||
def delete_bulk(self, row_ids: List[int]) -> int:
|
||||
if not row_ids:
|
||||
return 0
|
||||
|
||||
placeholders = ",".join("?" for _ in row_ids)
|
||||
|
||||
with self._conn() as conn:
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM images WHERE id IN ({placeholders})",
|
||||
row_ids,
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
def export_csv(self) -> str:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -5,11 +5,15 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .url_guard import HINT as _URL_HINT, is_allowed_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, PngImagePlugin
|
||||
@@ -18,6 +22,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_model_lock = threading.Lock()
|
||||
|
||||
# api_url is client-supplied per request, so every backend (Ollama / OpenAI-compatible / Unsloth)
|
||||
# goes through url_guard: loopback or WFS_ALLOWED_BACKEND_HOSTS only, no redirects. This also
|
||||
# stops the Unsloth API key (Authorization header) from being sent to an arbitrary server.
|
||||
|
||||
|
||||
class TaggerService:
|
||||
def __init__(self, models_dir: Path, settings_file: Path):
|
||||
@@ -185,19 +193,33 @@ class TaggerService:
|
||||
def _predict_onnx(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
|
||||
shape = data["input_shape"]
|
||||
fmt = "NHWC"
|
||||
size = (224, 224)
|
||||
size = 224
|
||||
if len(shape) == 4:
|
||||
if shape[1] == 3 and isinstance(shape[2], int) and shape[2] > 0:
|
||||
fmt = "NCHW"
|
||||
size = (int(shape[3]), int(shape[2]))
|
||||
size = int(shape[2])
|
||||
elif shape[3] == 3 and isinstance(shape[1], int) and shape[1] > 0:
|
||||
fmt = "NHWC"
|
||||
size = (int(shape[2]), int(shape[1]))
|
||||
size = int(shape[1])
|
||||
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
arr = np.array(img.resize(size, Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
|
||||
# WD Tagger系標準の前処理: 透過は白背景で合成 → アスペクト比を維持したまま
|
||||
# 白背景で正方形にパディング → リサイズ → RGB→BGR → 0-255のfloat32のまま(NHWC)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
canvas = Image.new("RGBA", img.size, (255, 255, 255))
|
||||
canvas.paste(img, mask=img)
|
||||
img = canvas.convert("RGB")
|
||||
|
||||
w, h = img.size
|
||||
max_dim = max(w, h)
|
||||
padded = Image.new("RGB", (max_dim, max_dim), (255, 255, 255))
|
||||
padded.paste(img, ((max_dim - w) // 2, (max_dim - h) // 2))
|
||||
if max_dim != size:
|
||||
padded = padded.resize((size, size), Image.Resampling.BICUBIC)
|
||||
|
||||
arr = np.array(padded, dtype=np.float32)
|
||||
arr = np.ascontiguousarray(arr[:, :, ::-1]) # RGB -> BGR
|
||||
if fmt == "NCHW":
|
||||
arr = arr.transpose((2, 0, 1))
|
||||
arr = np.expand_dims(arr, 0)
|
||||
@@ -223,39 +245,123 @@ class TaggerService:
|
||||
tags.append(label.replace("character:", ""))
|
||||
return tags
|
||||
|
||||
# ── Ollama VLM ────────────────────────────────────────────
|
||||
# ── VLM Tagging (Ollama / LM Studio / Lemonade / Unsloth) ──
|
||||
|
||||
def ollama_models(self, api_url: str) -> list:
|
||||
# キー未設定でUnslothに拒否された場合の案内(py/routes/unsloth_routes.pyと同文)
|
||||
_UNSLOTH_KEYLESS_REJECTED = (
|
||||
"Unsloth rejected the request without an API key (HTTP 401). Either set UNSLOTH_API_KEY "
|
||||
"in the plugin's .env (copy .env.example) and restart ComfyUI, or turn on Keyless API "
|
||||
"access -> \"Chat and inference\" in Unsloth Desktop's Settings -> API."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _unsloth_api_key() -> Optional[str]:
|
||||
"""UNSLOTH_API_KEY を環境変数(.env)から取得。未設定ならNone — その場合はAuthorization無しで
|
||||
送る(Unsloth DesktopのKeyless API access → "Chat and inference" がオンなら通る)。"""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
@staticmethod
|
||||
def _strip_thinking_tags(text: str) -> str:
|
||||
text = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<thinking>[\s\S]*?</thinking>", "", text, flags=re.IGNORECASE)
|
||||
return text.strip()
|
||||
|
||||
def vlm_models(self, backend: str, api_url: str) -> list:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if not is_allowed_backend_url(api_url):
|
||||
logger.error("vlm_models: %s", _URL_HINT)
|
||||
return []
|
||||
if backend == "ollama":
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with _guarded_urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
|
||||
headers = {}
|
||||
if backend == "unsloth":
|
||||
key = self._unsloth_api_key()
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/v1/models", headers=headers)
|
||||
with _guarded_urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
return sorted(m["id"] for m in data.get("data", []))
|
||||
except Exception as e:
|
||||
logger.error("ollama_models: %s", e)
|
||||
if (backend == "unsloth" and isinstance(e, urllib.error.HTTPError) and e.code == 401
|
||||
and not self._unsloth_api_key()):
|
||||
logger.error("vlm_models: %s", self._UNSLOTH_KEYLESS_REJECTED)
|
||||
return []
|
||||
logger.error("vlm_models: %s", e)
|
||||
return []
|
||||
|
||||
def ollama_predict(self, image_b64: str, api_url: str, model: str, prompt: str, max_tags: int = 40) -> dict:
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096},
|
||||
}).encode("utf-8")
|
||||
def vlm_predict(self, image_b64: str, backend: str, api_url: str, model: str, prompt: str, max_tags: int = 40,
|
||||
thinking_mode: bool = False, max_tokens: int = 0) -> dict:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
content = data.get("message", {}).get("content", "")
|
||||
if not is_allowed_backend_url(api_url):
|
||||
return {"error": _URL_HINT}
|
||||
if backend == "ollama":
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
|
||||
"stream": False,
|
||||
"think": bool(thinking_mode),
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096},
|
||||
}
|
||||
if max_tokens > 0:
|
||||
body["options"]["num_predict"] = max_tokens
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with _guarded_urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
content = data.get("message", {}).get("content", "")
|
||||
else:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if backend == "unsloth":
|
||||
key = self._unsloth_api_key()
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}},
|
||||
]}],
|
||||
"stream": False,
|
||||
}
|
||||
if max_tokens > 0:
|
||||
body["max_tokens"] = max_tokens
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/v1/chat/completions",
|
||||
data=payload,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with _guarded_urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
content = message.get("content", "")
|
||||
# Unslothはreasoningを別フィールドで返すため<think>タグに畳んで統一的に扱う
|
||||
reasoning = message.get("reasoning_content")
|
||||
if reasoning:
|
||||
content = f"<think>{reasoning}</think>{content}"
|
||||
|
||||
if not thinking_mode:
|
||||
content = self._strip_thinking_tags(content)
|
||||
tags = [t.strip() for t in content.split(",") if t.strip()][:max_tags]
|
||||
return {"tags": ", ".join(tags), "count": len(tags)}
|
||||
except Exception as e:
|
||||
logger.error("ollama_predict: %s", e)
|
||||
logger.error("vlm_predict: %s", e)
|
||||
if (backend == "unsloth" and isinstance(e, urllib.error.HTTPError) and e.code == 401
|
||||
and not self._unsloth_api_key()):
|
||||
return {"error": self._UNSLOTH_KEYLESS_REJECTED}
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── ファイルメタデータ書込 ────────────────────────────────
|
||||
@@ -334,8 +440,9 @@ class TaggerService:
|
||||
self._batch_state["stop"] = True
|
||||
|
||||
def batch_start(self, folder: str, model_name: str, threshold: float, char_threshold: float,
|
||||
use_ollama: bool, ollama_api: str, ollama_model: str, ollama_prompt: str,
|
||||
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service) -> dict:
|
||||
use_ollama: bool, vlm_backend: str, vlm_api_url: str, ollama_model: str, ollama_prompt: str,
|
||||
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service,
|
||||
thinking_mode: bool = False, max_tokens: int = 0) -> dict:
|
||||
if self._batch_state["running"]:
|
||||
return {"error": "Batch already running"}
|
||||
folder_path = Path(folder)
|
||||
@@ -370,7 +477,8 @@ class TaggerService:
|
||||
|
||||
vlm_tags = ""
|
||||
if use_ollama and ollama_model:
|
||||
res2 = self.ollama_predict(b64, ollama_api, ollama_model, ollama_prompt, ollama_max_tags)
|
||||
res2 = self.vlm_predict(b64, vlm_backend, vlm_api_url, ollama_model, ollama_prompt, ollama_max_tags,
|
||||
thinking_mode, max_tokens)
|
||||
vlm_tags = res2.get("tags", "")
|
||||
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Outbound URL guard for backend proxies (SSRF mitigation).
|
||||
|
||||
Several routes (Ollama chat, Tagger VLM, Eagle, Unsloth relay) connect to a backend whose
|
||||
address is supplied by the browser. Without a check, any caller that can reach ComfyUI could
|
||||
make the server connect to an arbitrary host (cloud metadata, LAN services, file://).
|
||||
|
||||
Allowed targets:
|
||||
- loopback hosts (localhost / 127.0.0.1 / ::1), any port
|
||||
- hosts listed by the operator in the WFS_ALLOWED_BACKEND_HOSTS environment variable
|
||||
(comma-separated "host" or "host:port"; e.g. "192.168.1.20:11434,gpu-box")
|
||||
|
||||
Only http(s) URLs without credentials are accepted, and redirects are never followed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
ALLOWED_HOSTS_ENV = "WFS_ALLOWED_BACKEND_HOSTS"
|
||||
|
||||
HINT = (
|
||||
"Backend URL must point to localhost/127.0.0.1/::1. To use another host, list it in the "
|
||||
f"{ALLOWED_HOSTS_ENV} environment variable (e.g. 192.168.1.20:11434) and restart ComfyUI."
|
||||
)
|
||||
|
||||
|
||||
def _operator_allowlist():
|
||||
raw = os.environ.get(ALLOWED_HOSTS_ENV, "")
|
||||
return {item.strip().lower() for item in raw.split(",") if item.strip()}
|
||||
|
||||
|
||||
def is_allowed_backend_url(url):
|
||||
"""True if url is an http(s) URL without credentials whose host[:port] is permitted."""
|
||||
try:
|
||||
parsed = urlparse((url or "").strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
if parsed.username or parsed.password:
|
||||
return False
|
||||
host = parsed.hostname.lower()
|
||||
if host in LOOPBACK_HOSTS:
|
||||
return True
|
||||
allow = _operator_allowlist()
|
||||
return host in allow or (parsed.port is not None and f"{host}:{parsed.port}" in allow)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def require_backend_url(url):
|
||||
"""Return url stripped of trailing slashes, or raise ValueError(HINT)."""
|
||||
url = (url or "").strip()
|
||||
if not is_allowed_backend_url(url):
|
||||
raise ValueError(HINT)
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
_opener = urllib.request.build_opener(_NoRedirect)
|
||||
|
||||
|
||||
def urlopen(req, timeout):
|
||||
"""urllib urlopen that never follows redirects (a 3xx surfaces as HTTPError)."""
|
||||
return _opener.open(req, timeout=timeout)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Video tab Edit subtab project file management (JSON timeline persistence).
|
||||
|
||||
Same CRUD shape as VideoPlanService (list/get/save/delete with a safe-path
|
||||
helper), kept as its own class rather than sharing a base because the two
|
||||
JSON schemas are unrelated (batch keyframe blocks vs. an ordered clip
|
||||
timeline) — see VideoPlanService's own docstring for the same reasoning.
|
||||
No index-image thumbnail support (unlike VideoPlanService) since the Edit
|
||||
project list is plain text/JSON only for now.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoEditProjectService:
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self.project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.project_dir / name).resolve()
|
||||
project_dir_resolved = self.project_dir.resolve()
|
||||
if resolved != project_dir_resolved and not str(resolved).startswith(str(project_dir_resolved) + "\\") and not str(resolved).startswith(str(project_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(self) -> list[dict]:
|
||||
if not self.project_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.project_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable video edit project %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", path.stem),
|
||||
"clip_count": len(data.get("clips", [])),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_project(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Project not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_project(self, filename: str, data: dict) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def delete_project(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Video tab plan file management service (JSON plan + optional PNG index thumbnail).
|
||||
|
||||
Duplicated from LabPlanService rather than shared — the two plan formats are
|
||||
unrelated (batch keyframe columns vs. sequential timeline blocks) and would
|
||||
gain nothing from a forced common base beyond coincidental line-count savings.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable video plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/video_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"block_count": len(data.get("blocks", [])),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Video_index") -> dict:
|
||||
"""Saves the contact-sheet index image into ComfyUI's own output folder,
|
||||
alongside the videos generated during the run (unlike save_index_image,
|
||||
which saves it next to the plan file in plan_dir)."""
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def delete_plan(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -8,6 +8,8 @@ implementation (e.g. Eagle's video2gif/video-to-frame plugins).
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,6 +20,184 @@ try:
|
||||
except Exception: # pragma: no cover - Pillow is a hard ComfyUI dependency
|
||||
_RESAMPLE = None
|
||||
|
||||
# Text overlay anchors: 9-grid "<vertical>-<horizontal>" (e.g. "bottom-center").
|
||||
_ANCHOR_V = ("top", "middle", "bottom")
|
||||
_ANCHOR_H = ("left", "center", "right")
|
||||
|
||||
# Fonts tried in order for text overlays. Pillow's built-in default font has no
|
||||
# CJK glyphs (Japanese/Chinese text would render as boxes), so a system font
|
||||
# with CJK coverage is preferred; load_default() is only the last resort.
|
||||
_FONT_CANDIDATES = {
|
||||
"win32": ["meiryo.ttc", "YuGothM.ttc", "NotoSansJP-Regular.ttf", "msgothic.ttc", "arial.ttf"],
|
||||
"darwin": [
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
],
|
||||
"linux": [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _load_overlay_font(size: int):
|
||||
from PIL import ImageFont
|
||||
|
||||
platform = "win32" if sys.platform.startswith("win") else ("darwin" if sys.platform == "darwin" else "linux")
|
||||
for name in _FONT_CANDIDATES[platform]:
|
||||
path = name
|
||||
if platform == "win32" and not os.path.isabs(name):
|
||||
path = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", name)
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default(size=size)
|
||||
|
||||
|
||||
def _render_text_layer(width: int, height: int, ov: dict):
|
||||
"""Renders one overlay's text onto a transparent full-frame RGBA layer, so
|
||||
it can be alpha-composited over every frame in its time range without
|
||||
re-rendering the text per frame (same idea as ComfyUI core's TextOverlay)."""
|
||||
from PIL import ImageColor, ImageDraw
|
||||
|
||||
text = str(ov.get("text") or "").replace("\\n", "\n")
|
||||
layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
if not text.strip():
|
||||
return layer
|
||||
|
||||
size = max(8, round(float(ov.get("font_size") or 6.0) / 100.0 * height))
|
||||
font = _load_overlay_font(size)
|
||||
stroke = max(1, round(size * 0.06)) if ov.get("outline", True) else 0
|
||||
spacing = round(size * 0.2)
|
||||
try:
|
||||
color = ImageColor.getrgb(str(ov.get("color") or "#ffffff"))
|
||||
except ValueError:
|
||||
color = (255, 255, 255)
|
||||
fill = (*color[:3], 255)
|
||||
|
||||
anchor = str(ov.get("anchor") or "bottom-center").split("-")
|
||||
v = anchor[0] if anchor and anchor[0] in _ANCHOR_V else "bottom"
|
||||
h = anchor[1] if len(anchor) > 1 and anchor[1] in _ANCHOR_H else "center"
|
||||
|
||||
draw = ImageDraw.Draw(layer)
|
||||
box = draw.multiline_textbbox((0, 0), text, font=font, spacing=spacing, stroke_width=stroke, align=h)
|
||||
tw, th = box[2] - box[0], box[3] - box[1]
|
||||
margin = round(0.04 * min(width, height))
|
||||
x = {"left": margin, "center": (width - tw) / 2, "right": width - margin - tw}[h] - box[0]
|
||||
y = {"top": margin, "middle": (height - th) / 2, "bottom": height - margin - th}[v] - box[1]
|
||||
|
||||
if ov.get("background"):
|
||||
pad = round(size * 0.3)
|
||||
draw.rounded_rectangle(
|
||||
(x + box[0] - pad, y + box[1] - pad, x + box[2] + pad, y + box[3] + pad),
|
||||
radius=pad, fill=(0, 0, 0, 140),
|
||||
)
|
||||
draw.multiline_text((x, y), text, font=font, fill=fill, spacing=spacing, align=h,
|
||||
stroke_width=stroke, stroke_fill=(0, 0, 0, 255))
|
||||
return layer
|
||||
|
||||
|
||||
def _fit_layer(img, base_w: int, base_h: int, pip: dict):
|
||||
"""Scales a picture-in-picture frame to its target width (a fraction of the
|
||||
base frame's width, aspect preserved), applies opacity, and returns
|
||||
(layer, left, top) with the position of its top-left corner on the base
|
||||
frame (pip["x"]/["y"] are the layer's centre as fractions of the frame)."""
|
||||
layer = img.convert("RGBA")
|
||||
pw = max(2, int(round(float(pip["scale"]) * base_w)))
|
||||
ph = max(2, int(round(pw * layer.height / max(1, layer.width))))
|
||||
layer = layer.resize((pw, ph), _RESAMPLE)
|
||||
opacity = float(pip.get("opacity", 1.0))
|
||||
if opacity < 0.999:
|
||||
layer.putalpha(layer.getchannel("A").point(lambda a: int(a * opacity)))
|
||||
left = int(round(float(pip["x"]) * base_w - pw / 2))
|
||||
top = int(round(float(pip["y"]) * base_h - ph / 2))
|
||||
return layer, left, top
|
||||
|
||||
|
||||
class _PipSource:
|
||||
"""One overlay clip's frame source. Images are decoded once; videos are
|
||||
decoded sequentially (seek once to the in-file start, then only forward),
|
||||
since the base video is also processed strictly in time order. After a
|
||||
video's last frame it holds that frame until the overlay's end."""
|
||||
|
||||
def __init__(self, path: Path, pip: dict, base_w: int, base_h: int):
|
||||
import av # type: ignore
|
||||
|
||||
self.pip = pip
|
||||
self.base_w, self.base_h = base_w, base_h
|
||||
self.start = float(pip["start"])
|
||||
self.end = self.start + float(pip["length"])
|
||||
self.src_start = max(0.0, float(pip.get("src_start") or 0.0))
|
||||
self.container = None
|
||||
self._cache_key = None
|
||||
self._cache = None
|
||||
if pip.get("kind") == "image":
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
self.static = _fit_layer(im, base_w, base_h, pip)
|
||||
else:
|
||||
self.static = None
|
||||
self.container = av.open(str(path))
|
||||
self.stream = self.container.streams.video[0]
|
||||
if self.src_start > 0.05:
|
||||
self.container.seek(int(self.src_start * 1_000_000))
|
||||
self.gen = self.container.decode(self.stream)
|
||||
self.cur = None
|
||||
self.nxt = None
|
||||
self.done = False
|
||||
|
||||
def _time(self, frame) -> float:
|
||||
return float(frame.pts * self.stream.time_base) if frame.pts is not None else 0.0
|
||||
|
||||
def layer_at(self, t: float):
|
||||
"""(layer, left, top) for timeline time t, or None when inactive."""
|
||||
if not (self.start <= t < self.end):
|
||||
return None
|
||||
if self.static is not None:
|
||||
return self.static
|
||||
target = self.src_start + (t - self.start)
|
||||
while True:
|
||||
if self.nxt is None and not self.done:
|
||||
try:
|
||||
self.nxt = next(self.gen)
|
||||
except StopIteration:
|
||||
self.done = True
|
||||
if self.nxt is not None and self._time(self.nxt) <= target + 1e-3:
|
||||
self.cur, self.nxt = self.nxt, None
|
||||
continue
|
||||
break
|
||||
frame = self.cur or self.nxt
|
||||
if frame is None:
|
||||
return None
|
||||
key = id(frame)
|
||||
if key != self._cache_key:
|
||||
self._cache = _fit_layer(frame.to_image(), self.base_w, self.base_h, self.pip)
|
||||
self._cache_key = key
|
||||
return self._cache
|
||||
|
||||
def close(self):
|
||||
if self.container is not None:
|
||||
self.container.close()
|
||||
self.container = None
|
||||
|
||||
|
||||
def _paste_layer(base, layer, left: int, top: int):
|
||||
"""Alpha-composites `layer` onto the RGBA `base` at (left, top), clipping
|
||||
whatever falls outside the frame (a plain alpha_composite() dest can't be
|
||||
negative)."""
|
||||
x0, y0 = max(0, left), max(0, top)
|
||||
x1, y1 = min(base.width, left + layer.width), min(base.height, top + layer.height)
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return
|
||||
piece = layer.crop((x0 - left, y0 - top, x1 - left, y1 - top))
|
||||
region = base.crop((x0, y0, x1, y1))
|
||||
region.alpha_composite(piece)
|
||||
base.paste(region, (x0, y0))
|
||||
|
||||
|
||||
class VideoService:
|
||||
def _resolve_media_path(self, filename: str, subfolder: str, type_: str) -> Path:
|
||||
@@ -57,6 +237,38 @@ class VideoService:
|
||||
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,
|
||||
@@ -125,3 +337,144 @@ class VideoService:
|
||||
optimize=True,
|
||||
)
|
||||
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": len(pil_frames)}
|
||||
|
||||
def overlay_text_on_video(
|
||||
self,
|
||||
filename: str,
|
||||
subfolder: str,
|
||||
type_: str,
|
||||
overlays: list,
|
||||
filename_prefix: str = "video/wfm_edit",
|
||||
delete_source: bool = False,
|
||||
pips: list = None,
|
||||
) -> dict:
|
||||
"""Burns timed text overlays - and picture-in-picture overlay clips
|
||||
(video or image, placed/scaled/faded on the frame) - into a video
|
||||
(Video Edit tab, Phase 3 + overlay track).
|
||||
|
||||
ComfyUI core has no time-ranged video text node (core TextOverlay draws
|
||||
on every frame of an IMAGE batch, top/bottom only), and routing the
|
||||
whole timeline through IMAGE tensors would hold every decoded frame in
|
||||
memory at once — so this streams instead: decode one frame, composite
|
||||
any overlay whose [start, end) covers it, encode, repeat. Audio packets
|
||||
are remuxed untouched (no audio re-encode), so the soundtrack built by
|
||||
the export graph (original audio / BGM) survives this pass as-is.
|
||||
|
||||
overlays: [{text, start, end, font_size (% of frame height), color,
|
||||
anchor ("<top|middle|bottom>-<left|center|right>"), outline, background}]
|
||||
with start/end in seconds from the start of the video.
|
||||
|
||||
pips: [{filename, subfolder, type (input), kind ("video"|"image"),
|
||||
src_start (in-file start, s), start (timeline s), length (s),
|
||||
x, y (centre, fractions of the frame), scale (width as a fraction of
|
||||
the frame width), opacity (0..1)}]. Overlay audio is not mixed.
|
||||
Pips are drawn first (in list order), then text on top.
|
||||
"""
|
||||
import av # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
|
||||
src_path = self._resolve_media_path(filename, subfolder, type_)
|
||||
items = []
|
||||
for ov in overlays or []:
|
||||
try:
|
||||
start = max(0.0, float(ov.get("start") or 0.0))
|
||||
end = float(ov.get("end") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end > start and str(ov.get("text") or "").strip():
|
||||
items.append((start, end, ov))
|
||||
valid_pips = []
|
||||
for pip in pips or []:
|
||||
try:
|
||||
if float(pip["length"]) > 0 and float(pip["scale"]) > 0 and pip.get("filename"):
|
||||
valid_pips.append(pip)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not items and not valid_pips:
|
||||
raise ValueError("No valid text or overlay clips")
|
||||
# Resolve overlay files up front so a missing one fails before any encoding.
|
||||
pip_paths = [
|
||||
self._resolve_media_path(p["filename"], p.get("subfolder", ""), p.get("type", "input"))
|
||||
for p in valid_pips
|
||||
]
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, out_filename, counter, out_subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{out_filename}_{counter:05}_.mp4"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
|
||||
in_c = av.open(str(src_path))
|
||||
out_c = None
|
||||
pip_sources = []
|
||||
try:
|
||||
in_v = in_c.streams.video[0]
|
||||
in_a = in_c.streams.audio[0] if in_c.streams.audio else None
|
||||
width, height = in_v.codec_context.width, in_v.codec_context.height
|
||||
rate = in_v.average_rate or in_v.guessed_rate or 24
|
||||
|
||||
pip_sources = [_PipSource(path, pip, width, height) for path, pip in zip(pip_paths, valid_pips)]
|
||||
layers = [(s, e, _render_text_layer(width, height, ov)) for s, e, ov in items]
|
||||
|
||||
out_c = av.open(str(save_path), mode="w")
|
||||
out_v = out_c.add_stream("libx264", rate=rate)
|
||||
out_v.width = width
|
||||
out_v.height = height
|
||||
out_v.pix_fmt = "yuv420p"
|
||||
out_v.options = {"crf": "18", "preset": "medium"}
|
||||
out_a = out_c.add_stream_from_template(in_a) if in_a is not None else None
|
||||
|
||||
streams = [in_v] + ([in_a] if in_a is not None else [])
|
||||
first_pts = None
|
||||
index = 0
|
||||
for packet in in_c.demux(*streams):
|
||||
if packet.stream.type == "audio":
|
||||
if packet.dts is None:
|
||||
continue
|
||||
packet.stream = out_a
|
||||
out_c.mux(packet)
|
||||
continue
|
||||
for frame in packet.decode():
|
||||
if frame.pts is not None and first_pts is None:
|
||||
first_pts = frame.pts
|
||||
t = float((frame.pts - first_pts) * in_v.time_base) if frame.pts is not None else index / float(rate)
|
||||
active = [layer for s, e, layer in layers if s <= t < e]
|
||||
pip_layers = [pl for pl in (src.layer_at(t) for src in pip_sources) if pl is not None]
|
||||
if active or pip_layers:
|
||||
img = frame.to_image().convert("RGBA")
|
||||
for layer, left, top in pip_layers:
|
||||
_paste_layer(img, layer, left, top)
|
||||
for layer in active:
|
||||
img.alpha_composite(layer)
|
||||
out_frame = av.VideoFrame.from_image(img.convert("RGB"))
|
||||
else:
|
||||
out_frame = frame
|
||||
out_frame.pts = index
|
||||
out_frame.time_base = out_v.codec_context.time_base
|
||||
index += 1
|
||||
for out_packet in out_v.encode(out_frame):
|
||||
out_c.mux(out_packet)
|
||||
for out_packet in out_v.encode(None):
|
||||
out_c.mux(out_packet)
|
||||
except BaseException:
|
||||
if out_c is not None:
|
||||
out_c.close()
|
||||
out_c = None
|
||||
save_path.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
if out_c is not None:
|
||||
out_c.close()
|
||||
for src in pip_sources:
|
||||
src.close()
|
||||
in_c.close()
|
||||
|
||||
# The export graph's intermediate file is only deleted when the caller
|
||||
# explicitly asks and it's one of this tab's own "_pre" intermediates in
|
||||
# output — never an arbitrary user file.
|
||||
if delete_source and type_ == "output" and Path(filename).name.startswith("wfm_edit_pre"):
|
||||
try:
|
||||
src_path.unlink()
|
||||
except OSError as e:
|
||||
logger.warning("Could not delete intermediate %s: %s", src_path, e)
|
||||
|
||||
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": index}
|
||||
|
||||
@@ -4,7 +4,7 @@ import jinja2
|
||||
from aiohttp import web
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR, VIDEO_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +57,22 @@ async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_video_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Video plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = VIDEO_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowStudio:
|
||||
"""Main entry point for Workflow Studio plugin."""
|
||||
|
||||
@@ -76,11 +92,14 @@ class WorkflowStudio:
|
||||
# Dynamic Lab plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
|
||||
|
||||
# Dynamic Video plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/video_plan/{filename}", serve_video_plan_file)
|
||||
|
||||
# Main page
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes, video_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes, video_routes, vram_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
@@ -97,5 +116,6 @@ class WorkflowStudio:
|
||||
skill_routes.setup_routes(app)
|
||||
lab_routes.setup_routes(app)
|
||||
video_routes.setup_routes(app)
|
||||
vram_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
|
||||
version = "0.5.2"
|
||||
license = "MIT"
|
||||
version = "0.8.2"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
|
||||
@@ -23,9 +23,23 @@ piexif>=1.1.3
|
||||
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
|
||||
python-dotenv>=1.0,<2.0
|
||||
|
||||
# -- Multi-layer PSD export (Gallery tab bulk action) --
|
||||
# Pure-Python, ships prebuilt wheels (no C compiler required on Windows).
|
||||
psd-tools>=1.10
|
||||
|
||||
# -- MP4 thumbnail generation (Gallery tab) --
|
||||
# Uses PyAV (av) to decode the first frame. ComfyUI core already requires av>=17.0.0,
|
||||
# so it is normally pre-installed in python_embeded and no action is needed here.
|
||||
# If missing, mp4 thumbnail generation simply fails and falls back to a placeholder
|
||||
# (Gallery itself keeps working). Not pinned here on purpose — see torch note above.
|
||||
# av>=17.0.0
|
||||
|
||||
# -- Gallery Semantic Search: local EmbeddingGemma 2 (optional) --
|
||||
# Uses onnxruntime (above) + tokenizers + huggingface_hub (both normally pre-installed with ComfyUI's
|
||||
# transformers). The model (~300 MB, q4) is downloaded to the data folder on first use.
|
||||
# GPU (NVIDIA/CUDA): replace onnxruntime with onnxruntime-gpu
|
||||
# pip uninstall onnxruntime onnxruntime-gpu
|
||||
# pip install onnxruntime-gpu
|
||||
# Local EmbeddingGemma 2 then indexes ~45x faster than on the CPU. DirectML is not supported.
|
||||
# tokenizers
|
||||
# huggingface_hub
|
||||
|
||||
@@ -867,6 +867,13 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-gallery-move-filter {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-gallery-move-modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -983,3 +990,167 @@
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* 音声ファイル: 波形画像+<audio> コントロール */
|
||||
.wfm-gallery-audio-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.wfm-gallery-audio-wave {
|
||||
object-fit: contain;
|
||||
max-height: 220px;
|
||||
background: #181c26;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wfm-gallery-audio-player {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 変換ダイアログ */
|
||||
.wfm-gallery-convert-modal {
|
||||
min-width: 360px;
|
||||
max-width: 480px;
|
||||
}
|
||||
.wfm-gallery-convert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.wfm-gallery-convert-row .wfm-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
.wfm-gallery-convert-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wfm-gallery-convert-note,
|
||||
.wfm-gallery-convert-status {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 音声: 詳細パネルのプレビュー枠は固定高さ(正方形)をやめ、波形+プレイヤーを収める */
|
||||
.wfm-gallery-detail-preview--audio {
|
||||
aspect-ratio: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding: 8px;
|
||||
}
|
||||
.wfm-gallery-detail-preview--audio .wfm-gallery-audio-wave {
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
/* ── セマンティック検索 ─────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-sem-toggle.active {
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 2行目(Similar / 更新)のボタン: Taggerの紫と区別できるティール */
|
||||
.wfm-sem-sim {
|
||||
background: #0d9488;
|
||||
border-color: #0d9488;
|
||||
color: #fff;
|
||||
}
|
||||
.wfm-sem-sim:hover {
|
||||
background: #0f766e;
|
||||
border-color: #0f766e;
|
||||
}
|
||||
/* 類似画像表示中(トグルON)は縁取りで状態を示す */
|
||||
#wfm-gallery-similar-btn.active {
|
||||
box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px #0d9488;
|
||||
}
|
||||
|
||||
.wfm-gallery-sem-row {
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.wfm-gallery-sem-count {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-score {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(13, 148, 136, 0.85);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-sem-modal { display: flex; flex-direction: column; gap: 10px; min-width: 420px; }
|
||||
.wfm-sem-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.wfm-sem-folder { font-family: monospace; font-size: 12px; word-break: break-all; opacity: 0.85; }
|
||||
.wfm-sem-hint { font-size: 11px; opacity: 0.7; margin: 0; }
|
||||
.wfm-sem-ok { color: #22c55e; }
|
||||
.wfm-sem-ng { color: #ef4444; }
|
||||
.wfm-sem-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.wfm-sem-bar { height: 8px; border-radius: 4px; background: var(--wfm-border, #444); overflow: hidden; }
|
||||
.wfm-sem-bar-fill { height: 100%; width: 0; background: #0d9488; transition: width 0.3s; }
|
||||
|
||||
/* フォルダツリー: 直下の画像が全件セマンティック索引済みのフォルダに付くドット */
|
||||
.wfm-gallery-tree-sem-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: 4px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* モーダルのヘルプボタンから飛んだ先の説明を一時的に強調 */
|
||||
.wfm-help-flash {
|
||||
animation: wfm-help-flash 2.5s ease-out;
|
||||
}
|
||||
@keyframes wfm-help-flash {
|
||||
0%, 60% { background: rgba(13, 148, 136, 0.35); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
.wfm-sem-local-opts { display: flex; flex-direction: column; gap: 10px; padding: 8px 10px; border: 1px solid var(--wfm-border, #444); border-radius: 6px; }
|
||||
|
||||
/* セマンティック検索モーダル: タブ */
|
||||
.wfm-sem-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--wfm-border, #444); }
|
||||
.wfm-sem-tab-btn {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: var(--wfm-text-secondary, #aaa);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wfm-sem-tab-btn:hover { color: var(--wfm-text, #fff); }
|
||||
.wfm-sem-tab-btn.active {
|
||||
color: var(--wfm-text, #fff);
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
border-color: var(--wfm-border, #444);
|
||||
margin-bottom: -1px;
|
||||
border-bottom: 1px solid var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
.wfm-sem-pane { display: flex; flex-direction: column; gap: 10px; min-height: 230px; }
|
||||
.wfm-sem-footer { justify-content: flex-end; border-top: 1px solid var(--wfm-border, #444); padding-top: 10px; }
|
||||
.wfm-sem-num { width: 80px; }
|
||||
.wfm-sem-danger { margin-left: auto; color: #ef4444; }
|
||||
|
||||
.wfm-sem-row .wfm-select { width: auto; min-width: 90px; }
|
||||
|
||||
@@ -311,8 +311,8 @@
|
||||
|
||||
.ie-layers-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 5px 6px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
@@ -323,7 +323,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-layers-header > span {
|
||||
.ie-layers-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ie-layers-header-row > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -3491,7 +3491,8 @@ body {
|
||||
|
||||
.wfm-help-page {
|
||||
display: none;
|
||||
max-width: 760px;
|
||||
/* 上部タブバー(AI TOOLタブの右端)あたりまで広げ、折り返しを減らす。狭い画面では領域幅に収まる */
|
||||
max-width: 1076px;
|
||||
}
|
||||
|
||||
.wfm-help-page.active {
|
||||
@@ -6081,3 +6082,79 @@ body {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Workflow detail modal — GenerateUI compatibility check row */
|
||||
.wfm-compat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wfm-compat-result {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
.wfm-compat-badge {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg);
|
||||
color: var(--wfm-text);
|
||||
cursor: help;
|
||||
}
|
||||
.wfm-compat-ok { border-color: #3a8a3a; }
|
||||
.wfm-compat-warn { border-color: #c08a20; }
|
||||
.wfm-compat-error { border-color: var(--wfm-danger, #c04040); }
|
||||
.wfm-compat-skip { opacity: 0.6; }
|
||||
.wfm-compat-badge:hover::after,
|
||||
.wfm-compat-badge:focus::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 1000;
|
||||
width: max-content;
|
||||
max-width: min(520px, 80vw);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-secondary, var(--wfm-bg));
|
||||
color: var(--wfm-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
text-align: left;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Side panel — format + GenerateUI compatibility badges row (below the title) */
|
||||
.wfm-side-compat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 10px 6px;
|
||||
min-height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-side-compat-row .wfm-format-badge:empty { display: none; }
|
||||
/* The side panel clips overflow, so anchor tips to the whole row and span the panel width */
|
||||
.wfm-side-compat-row { position: relative; }
|
||||
.wfm-compat-result--right .wfm-compat-badge { position: static; }
|
||||
.wfm-compat-result--right .wfm-compat-badge:hover::after,
|
||||
.wfm-compat-result--right .wfm-compat-badge:focus::after {
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 100%;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
.wfm-compat-row .wfm-format-badge:empty { display: none; }
|
||||
|
||||
@@ -250,6 +250,14 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#wfm-tagger-db-th-select, td.wfm-tagger-db-select {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#wfm-tagger-db-th-select input, .wfm-tagger-db-select input {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Shared AI backend settings modal (Backend / Connection / Model / Generation / Unload / Save).
|
||||
* Used by the Prompt tab and Tagger tab, each with its own localStorage key so their
|
||||
* settings stay independent — mirrors the AI TOOL tab's own settings pane (ai-tab.js).
|
||||
*/
|
||||
|
||||
import { openModal, closeModal, showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, unslothProxy, unloadAiModel } from "./util.js";
|
||||
import { unloadAllOllamaModels } from "./vram-prepare.js";
|
||||
|
||||
function loadCfg(storageKey) {
|
||||
return readJsonStorage(storageKey);
|
||||
}
|
||||
|
||||
function saveCfg(storageKey, patch) {
|
||||
const data = { ...loadCfg(storageKey), ...patch };
|
||||
localStorage.setItem(storageKey, JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
// 保存済み設定を {backend, url, model, thinkingMode, maxTokens} の形で返す
|
||||
export function getAiBackendConfig(storageKey) {
|
||||
const s = loadCfg(storageKey);
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return {
|
||||
backend,
|
||||
url,
|
||||
model: s.model || "",
|
||||
thinkingMode: !!s.thinkingMode,
|
||||
maxTokens: Math.max(0, parseInt(s.maxTokens, 10) || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function isValidBackendUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBackendModels(url, backend) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).models?.map(m => m.name) || [];
|
||||
} else if (backend === "unsloth") {
|
||||
const data = await unslothProxy(url, "/v1/models", "GET");
|
||||
return (data.data || []).map(m => m.id);
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} storageKey localStorage key holding {backend, backendUrl, model, thinkingMode, maxTokens}
|
||||
* @param {string} title modal title
|
||||
* @param {() => void} [onSaved] called after Save persists the config (e.g. to refresh a status label)
|
||||
*/
|
||||
export function openAiBackendSettingsModal(storageKey, title, onSaved) {
|
||||
const saved = loadCfg(storageKey);
|
||||
const backend = saved.backend || "ollama";
|
||||
|
||||
const html = `
|
||||
<div class="wfm-ai-settings-container">
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsBackend")}</div>
|
||||
<div class="wfm-ai-backend-row">
|
||||
${[["ollama", "Ollama"], ["lmstudio", "LM Studio"], ["lemonade", "Lemonade"], ["unsloth", "Unsloth"]].map(([v, label]) => `
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-aism-backend" value="${v}" ${v === backend ? "checked" : ""}> ${label}
|
||||
</label>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsConnection")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label">URL</label>
|
||||
<input type="text" id="wfm-aism-url" class="wfm-input wfm-ai-url-input" value="${saved.backendUrl || getAiBackendDefaultUrl(backend)}">
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<button id="wfm-aism-test-btn" class="wfm-btn">${t("aiSettingsTestBtn")}</button>
|
||||
<button id="wfm-aism-unload-btn" class="wfm-btn">${t("aiUnloadModel")}</button>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<span id="wfm-aism-test-result" class="wfm-ai-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsModelSection")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<select id="wfm-aism-model" class="wfm-select wfm-ai-model-select">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button id="wfm-aism-refresh-btn" class="wfm-btn">${t("aiSettingsRefreshBtn")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsGenerationTitle")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-aism-thinking"> ${t("aiSettingsThinkingMode")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label">${t("aiSettingsMaxTokens")}</label>
|
||||
<input type="number" id="wfm-aism-max-tokens" class="wfm-input" min="0" step="1" placeholder="${t("aiSettingsMaxTokensPlaceholder")}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<button id="wfm-aism-save-btn" class="wfm-btn wfm-btn-primary">${t("aiSettingsSaveBtn")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(title, html);
|
||||
|
||||
const urlInput = document.getElementById("wfm-aism-url");
|
||||
const modelSelect = document.getElementById("wfm-aism-model");
|
||||
const thinkingCheckbox = document.getElementById("wfm-aism-thinking");
|
||||
const maxTokensInput = document.getElementById("wfm-aism-max-tokens");
|
||||
const testResultEl = document.getElementById("wfm-aism-test-result");
|
||||
|
||||
thinkingCheckbox.checked = !!saved.thinkingMode;
|
||||
if (saved.maxTokens) maxTokensInput.value = saved.maxTokens;
|
||||
|
||||
function currentBackend() {
|
||||
return document.querySelector("input[name='wfm-aism-backend']:checked")?.value || "ollama";
|
||||
}
|
||||
|
||||
async function populateModels(selectedModel) {
|
||||
const url = urlInput.value.trim();
|
||||
try {
|
||||
const models = await fetchBackendModels(url, currentBackend());
|
||||
modelSelect.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === selectedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
} catch {
|
||||
modelSelect.innerHTML = `<option value="">${t("noModelsFound")}</option>`;
|
||||
}
|
||||
}
|
||||
populateModels(saved.model || "");
|
||||
|
||||
document.querySelectorAll("input[name='wfm-aism-backend']").forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
testResultEl.textContent = "";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-refresh-btn")?.addEventListener("click", () => populateModels(modelSelect.value));
|
||||
|
||||
document.getElementById("wfm-aism-test-btn")?.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!isValidBackendUrl(url)) {
|
||||
testResultEl.textContent = t("aiToastInvalidUrlInput");
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
return;
|
||||
}
|
||||
testResultEl.textContent = t("aiStatusConnecting");
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-working";
|
||||
try {
|
||||
const models = await fetchBackendModels(url, currentBackend());
|
||||
testResultEl.textContent = `${t("aiStatusConnectOk")} (${models.length} ${t("aiModels")})`;
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-ok";
|
||||
await populateModels(modelSelect.value);
|
||||
} catch (err) {
|
||||
testResultEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-unload-btn")?.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
const model = modelSelect.value;
|
||||
// Ollama: unload every loaded model incl. decision models (same server step as Settings → VRAM Management)
|
||||
if (currentBackend() === "ollama") { await unloadAllOllamaModels(url); return; }
|
||||
if (!model) { showToast(t("aiUnloadNoModel"), "error"); return; }
|
||||
try {
|
||||
await unloadAiModel(url, currentBackend(), model);
|
||||
showToast(t("aiUnloadDone"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message === "UNSUPPORTED" ? t("aiUnloadUnsupported") : t("aiUnloadFailed") + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-save-btn")?.addEventListener("click", () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
saveCfg(storageKey, {
|
||||
backend: currentBackend(),
|
||||
backendUrl: url,
|
||||
model: modelSelect.value,
|
||||
thinkingMode: !!thinkingCheckbox.checked,
|
||||
maxTokens: Math.max(0, parseInt(maxTokensInput.value, 10) || 0),
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
closeModal();
|
||||
if (onSaved) onSaved();
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml, unloadAiModel } from "./util.js";
|
||||
import { unloadAllOllamaModels } from "./vram-prepare.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
@@ -456,6 +457,22 @@ function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Unload model
|
||||
document.getElementById("wfm-ai-unload-btn")?.addEventListener("click", async () => {
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
const url = urlInput?.value?.trim() || "";
|
||||
const model = document.getElementById("wfm-ai-model-select")?.value || "";
|
||||
// Ollama: unload every loaded model incl. decision models (same server step as Settings → VRAM Management)
|
||||
if (backend === "ollama") { await unloadAllOllamaModels(url); return; }
|
||||
if (!model) { showToast(t("aiUnloadNoModel"), "error"); return; }
|
||||
try {
|
||||
await unloadAiModel(url, backend, model);
|
||||
showToast(t("aiUnloadDone"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message === "UNSUPPORTED" ? t("aiUnloadUnsupported") : t("aiUnloadFailed") + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Model refresh
|
||||
document.getElementById("wfm-ai-model-refresh-btn")?.addEventListener("click", () => refreshModels());
|
||||
|
||||
|
||||
@@ -245,6 +245,12 @@ function applyI18nToHtml() {
|
||||
if (galleryBulkMove) galleryBulkMove.textContent = t("galleryBulkMoveTo");
|
||||
const galleryBulkExport = document.getElementById("wfm-gallery-bulk-export");
|
||||
if (galleryBulkExport) galleryBulkExport.textContent = t("galleryBulkExport");
|
||||
const galleryBulkPsd = document.getElementById("wfm-gallery-bulk-psd");
|
||||
if (galleryBulkPsd) galleryBulkPsd.textContent = t("galleryBulkPsd");
|
||||
const galleryBulkEditLayers = document.getElementById("wfm-gallery-bulk-edit-layers");
|
||||
if (galleryBulkEditLayers) galleryBulkEditLayers.textContent = t("galleryBulkEditLayers");
|
||||
const galleryBulkConvert = document.getElementById("wfm-gallery-bulk-convert");
|
||||
if (galleryBulkConvert) galleryBulkConvert.textContent = t("galleryBulkConvert");
|
||||
const galleryBulkDelete = document.getElementById("wfm-gallery-bulk-delete");
|
||||
if (galleryBulkDelete) galleryBulkDelete.textContent = t("galleryBulkDelete");
|
||||
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
|
||||
@@ -319,10 +325,8 @@ function applyI18nToHtml() {
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
const presetsHeader = document.querySelector(".wfm-prompt-split-right .wfm-prompt-split-header");
|
||||
if (presetsHeader) presetsHeader.textContent = t("presetsSubtab");
|
||||
const ollamaRefreshBtn = document.getElementById("wfm-ollama-refresh-btn");
|
||||
if (ollamaRefreshBtn) ollamaRefreshBtn.textContent = t("refresh");
|
||||
const ollamaTestBtn = document.getElementById("wfm-ollama-test-btn");
|
||||
if (ollamaTestBtn) ollamaTestBtn.textContent = t("test");
|
||||
const ollamaSettingsBtn = document.getElementById("wfm-ollama-settings-btn");
|
||||
if (ollamaSettingsBtn) ollamaSettingsBtn.title = t("settings");
|
||||
const ollamaAttachBtn = document.getElementById("wfm-ollama-attach-btn");
|
||||
if (ollamaAttachBtn) ollamaAttachBtn.textContent = t("attachImage");
|
||||
const ollamaClearBtn = document.getElementById("wfm-ollama-clear-btn");
|
||||
@@ -470,7 +474,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-about-1": "helpAbout1", "wfm-help-about-2": "helpAbout2",
|
||||
"wfm-help-wf-1": "helpWf1", "wfm-help-wf-2": "helpWf2", "wfm-help-wf-3": "helpWf3",
|
||||
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5", "wfm-help-wf-6": "helpWf6",
|
||||
"wfm-help-wf-7": "helpWf7",
|
||||
"wfm-help-wf-7": "helpWf7", "wfm-help-wf-8": "helpWf8",
|
||||
"wfm-help-gen-1": "helpGen1", "wfm-help-gen-2": "helpGen2",
|
||||
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
|
||||
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
|
||||
@@ -487,6 +491,7 @@ function applyI18nToHtml() {
|
||||
"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",
|
||||
@@ -550,6 +555,11 @@ function applyI18nToHtml() {
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13", "wfm-help-settings-14": "helpSettings14",
|
||||
"wfm-help-settings-15": "helpSettings15", "wfm-help-settings-16": "helpSettings16",
|
||||
"wfm-help-settings-17": "helpSettings17",
|
||||
"wfm-help-settings-18": "helpSettings18", "wfm-help-settings-19": "helpSettings19",
|
||||
"wfm-help-settings-20": "helpSettings20", "wfm-help-settings-21": "helpSettings21",
|
||||
"wfm-help-settings-22": "helpSettings22",
|
||||
"wfm-help-settings-23": "helpSettings23", "wfm-help-settings-24": "helpSettings24", "wfm-help-settings-25": "helpSettings25",
|
||||
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
|
||||
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
|
||||
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
|
||||
@@ -575,6 +585,9 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gallery-22": "helpGallery22", "wfm-help-gallery-23": "helpGallery23",
|
||||
"wfm-help-gallery-24": "helpGallery24", "wfm-help-gallery-25": "helpGallery25",
|
||||
"wfm-help-gallery-26": "helpGallery26",
|
||||
"wfm-help-gallery-28": "helpGallery28", "wfm-help-gallery-29": "helpGallery29",
|
||||
"wfm-help-gallery-30": "helpGallery30", "wfm-help-gallery-31": "helpGallery31",
|
||||
"wfm-help-gallery-32": "helpGallery32", "wfm-help-gallery-33": "helpGallery33", "wfm-help-gallery-34": "helpGallery34", "wfm-help-gallery-35": "helpGallery35", "wfm-help-gallery-36": "helpGallery36",
|
||||
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
|
||||
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
|
||||
"wfm-help-tagger-title": "helpTaggerTitle",
|
||||
@@ -630,6 +643,8 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
|
||||
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
|
||||
"wfm-help-imageedit-30": "helpImageEdit30",
|
||||
"wfm-help-imageedit-31": "helpImageEdit31", "wfm-help-imageedit-32": "helpImageEdit32",
|
||||
"wfm-help-imageedit-33": "helpImageEdit33", "wfm-help-imageedit-34": "helpImageEdit34",
|
||||
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
|
||||
"wfm-help-imageedit-8b": "helpImageEdit8b",
|
||||
@@ -736,7 +751,9 @@ function applyI18nToHtml() {
|
||||
"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-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);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Handles communication with ComfyUI server (same origin)
|
||||
*/
|
||||
|
||||
import { prepareVramForGeneration } from "./vram-prepare.js";
|
||||
|
||||
function _uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
@@ -194,6 +196,8 @@ export const comfyUI = {
|
||||
|
||||
// Generation
|
||||
async queuePrompt(workflow, extraData = null) {
|
||||
// Settings tab "VRAM Management": unload Ollama models to free VRAM (no-op when off, never throws)
|
||||
await prepareVramForGeneration();
|
||||
const body = {
|
||||
prompt: workflow,
|
||||
client_id: this.clientId,
|
||||
|
||||
@@ -1382,6 +1382,9 @@ export const comfyEditor = {
|
||||
// (e.g. Flux2Scheduler has no named "scheduler" combo) so the field is disabled here.
|
||||
const schedulerAvailable = !!sampler && sampler.schedulerNodeId != null;
|
||||
const denoiseAvailable = !!sampler && sampler.denoiseNodeId != null;
|
||||
// Advanced samplers fed by a dedicated sampler node without a sampler_name combo
|
||||
// (e.g. Ming Image's SamplerLCM) have nothing for the Sampler dropdown to write to.
|
||||
const samplerNameAvailable = !!sampler && (!sampler.advanced || sampler.samplerNodeId != null);
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
|
||||
@@ -1394,7 +1397,7 @@ export const comfyEditor = {
|
||||
data-steps-node-id="${sampler.stepsNodeId ?? sampler.id}"
|
||||
data-cfg-key="${sampler.cfgKey || "cfg"}"
|
||||
data-cfg-node-id="${sampler.cfgNodeId ?? sampler.id}"
|
||||
data-sampler-node-id="${sampler.samplerNodeId ?? sampler.id}"
|
||||
data-sampler-node-id="${samplerNameAvailable ? (sampler.samplerNodeId ?? sampler.id) : ""}"
|
||||
data-scheduler-node-id="${sampler.schedulerNodeId ?? ""}"
|
||||
data-denoise-node-id="${sampler.denoiseNodeId ?? ""}">
|
||||
<div class="wfm-form-group">
|
||||
@@ -1411,8 +1414,10 @@ export const comfyEditor = {
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Sampler</label>
|
||||
<select class="wfm-select" id="wfm-settings-sampler-name">
|
||||
${this.models.samplers.map((s) => `<option value="${s}" ${s === sampler.sampler_name ? "selected" : ""}>${s}</option>`).join("")}
|
||||
<select class="wfm-select" id="wfm-settings-sampler-name" ${samplerNameAvailable ? "" : "disabled"}>
|
||||
${samplerNameAvailable
|
||||
? this.models.samplers.map((s) => `<option value="${s}" ${s === sampler.sampler_name ? "selected" : ""}>${s}</option>`).join("")
|
||||
: `<option value="">N/A</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
@@ -1446,6 +1451,12 @@ export const comfyEditor = {
|
||||
<label>Batch Size</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-batch" value="${latent.batch_size ?? 1}" min="1" max="64">
|
||||
</div>
|
||||
${latent.isVideoLatent ? `
|
||||
<div class="wfm-form-group">
|
||||
<label>Length (frames)</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-latent-length" value="${latent.length ?? ""}" placeholder="${latent.length === undefined ? "linked" : ""}" step="1" min="1">
|
||||
</div>
|
||||
` : ""}
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-latent-apply">Apply</button>
|
||||
${latent.width === undefined || latent.height === undefined ? `<p class="wfm-placeholder" style="margin-top:6px;">Width/Height is linked to another node (e.g. auto-follows the input image) — leave blank to keep it linked.</p>` : ""}
|
||||
<div style="margin-top:10px;border-top:1px solid var(--wfm-border);padding-top:10px;">
|
||||
@@ -1484,7 +1495,7 @@ export const comfyEditor = {
|
||||
if (stepsEl?.value !== "") write(ds.stepsNodeId, "steps", parseInt(stepsEl.value) || 20);
|
||||
const cfgEl = document.getElementById("wfm-settings-cfg");
|
||||
if (cfgEl?.value !== "") write(ds.cfgNodeId, ds.cfgKey || "cfg", parseFloat(cfgEl.value) || 7);
|
||||
write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
|
||||
if (ds.samplerNodeId) write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
|
||||
if (ds.schedulerNodeId) write(ds.schedulerNodeId, "scheduler", document.getElementById("wfm-settings-scheduler")?.value);
|
||||
const denoiseEl = document.getElementById("wfm-settings-denoise");
|
||||
if (ds.denoiseNodeId && denoiseEl?.value !== "") write(ds.denoiseNodeId, "denoise", parseFloat(denoiseEl.value) || 1.0);
|
||||
@@ -1502,6 +1513,8 @@ export const comfyEditor = {
|
||||
if (wEl?.value !== "") inputs.width = parseInt(wEl.value) || 512;
|
||||
if (hEl?.value !== "") inputs.height = parseInt(hEl.value) || 512;
|
||||
inputs.batch_size = parseInt(document.getElementById("wfm-settings-batch")?.value) || 1;
|
||||
const lenEl = document.getElementById("wfm-settings-latent-length");
|
||||
if (lenEl && lenEl.value !== "") inputs.length = parseInt(lenEl.value) || 1;
|
||||
_syncRawJson();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ let _objectInfoCache = null;
|
||||
// ckpt_name に対して発生した場合の記録。呼び出し元が読み込み直後に確認して警告表示できるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastCheckpointSubstitutions = [];
|
||||
// 同じフォールバックを全COMBO入力(LoRA・VAE・画像など)について記録したもの。
|
||||
// Workflowタブの「GenerateUI互換チェック」が、無警告で差し替わる値を一覧するのに使う。
|
||||
let _lastComboSubstitutions = [];
|
||||
|
||||
// convertUiToApi() で除外された Bypass(mode:4) / Mute(mode:2) ノードの記録。
|
||||
// API形式には mode 情報が残らないため、呼び出し元がRaw JSON表示時に
|
||||
@@ -75,6 +78,35 @@ async function _loadObjectInfo() {
|
||||
* Get ordered widget (non-link) input names for a node class from object_info.
|
||||
* These correspond to widgets_values in UI-format nodes.
|
||||
*/
|
||||
// Scalar widget types object_info can declare for a required/optional input.
|
||||
const _WIDGET_TYPE_SET = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", "COMFY_DYNAMICCOMBO_V3"]);
|
||||
|
||||
/**
|
||||
* Resolve a possibly-compound object_info type spec (e.g. LTXVEmptyLatentAudio's frame_rate:
|
||||
* "FLOAT,INT", a MultiType declaration meaning "widget defaults to FLOAT but also accepts an INT
|
||||
* link") down to the single scalar widget type it actually occupies a widgets_values slot as.
|
||||
* Preferring the explicit widgetType hint some MultiType specs carry, else the first comma-
|
||||
* separated segment. Returns null for link-only types (MODEL, CLIP, LATENT, etc.) or arrays that
|
||||
* aren't combo option lists.
|
||||
*
|
||||
* Without this, a plain `upper === "INT" || ...` exact-match check silently drops any
|
||||
* comma-joined MultiType widget from the name/type lists entirely — which corrupts every widget
|
||||
* declared after it in the node's schema order in _simulateWidgetValues (see its own docstring),
|
||||
* exactly the class of bug that produced e.g. LTXVEmptyLatentAudio's batch_size reading
|
||||
* frame_rate's stale value instead of its own.
|
||||
*/
|
||||
function _resolveWidgetType(type, spec) {
|
||||
if (Array.isArray(type)) return "COMBO";
|
||||
if (typeof type !== "string") return null;
|
||||
const explicit = spec?.[1]?.widgetType;
|
||||
if (typeof explicit === "string" && _WIDGET_TYPE_SET.has(explicit.toUpperCase())) return explicit.toUpperCase();
|
||||
for (const part of type.split(",")) {
|
||||
const upper = part.trim().toUpperCase();
|
||||
if (_WIDGET_TYPE_SET.has(upper)) return upper;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _getWidgetInputNames(objectInfo, classType) {
|
||||
const info = objectInfo[classType];
|
||||
if (!info) return [];
|
||||
@@ -89,31 +121,16 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// (e.g. CLIPTextEncodeEditPlus's text1/text2). Never has a widgets_values entry.
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
// Link types are strings like "MODEL", "CLIP", "CONDITIONING", "LATENT", "IMAGE", etc.
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", or an array of choices
|
||||
if (Array.isArray(type)) {
|
||||
// Combo/enum widget
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
|
||||
}
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", an array of choices, or a
|
||||
// comma-joined MultiType union of those (e.g. "FLOAT,INT") — see _resolveWidgetType.
|
||||
if (_resolveWidgetType(type, spec)) names.push(name);
|
||||
}
|
||||
|
||||
// Optional inputs that are widgets
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
if (_resolveWidgetType(type, spec)) names.push(name);
|
||||
}
|
||||
|
||||
return names;
|
||||
@@ -133,26 +150,14 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
const resolved = _resolveWidgetType(type, spec);
|
||||
if (resolved) types.push(resolved);
|
||||
}
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
const resolved = _resolveWidgetType(type, spec);
|
||||
if (resolved) types.push(resolved);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
@@ -289,15 +294,26 @@ function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInt
|
||||
const allWidgetInputs = (remappedNode.inputs || []).filter((i) => i.widget);
|
||||
if (!widgetsValues || allWidgetInputs.length === 0) return;
|
||||
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
remappedNode.widgets_values = _normalizeLinkedWidgetValues(objectInfo, remappedNode.type, widgetsValues, isLinkedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return widgets_values with the entries belonging to linked widgets removed when the array is in
|
||||
* the "legacy full" format (every widget present, linked ones included — what the current ComfyUI
|
||||
* frontend actually saves for a widget converted to a wired input, e.g. EmptyLatentImage
|
||||
* [1024, 2048, 1] with width/height fed by GetImageSize/PrimitiveInt). Returns the array unchanged
|
||||
* when it's already in the modern format (linked widgets contribute zero entries).
|
||||
*/
|
||||
function _normalizeLinkedWidgetValues(objectInfo, nodeType, widgetsValues, isLinkedName) {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, nodeType);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, nodeType);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, nodeType, name, selectedKey);
|
||||
|
||||
// If skipping linked widgets already accounts for every entry, widgets_values is already in
|
||||
// the modern (no dangling linked entries) format — nothing to strip.
|
||||
const skipSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
if (skipSim.consumed === widgetsValues.length) return;
|
||||
if (skipSim.consumed === widgetsValues.length) return widgetsValues;
|
||||
|
||||
// Otherwise it's "legacy full": recompute with every widget present to find exactly which
|
||||
// indices the linked ones occupy — including any control_after_generate extra a linked
|
||||
@@ -309,9 +325,7 @@ function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInt
|
||||
for (const i of fullSim.consumedIndices[name] || []) linkedIdx.add(i);
|
||||
}
|
||||
}
|
||||
if (linkedIdx.size > 0) {
|
||||
remappedNode.widgets_values = widgetsValues.filter((_, i) => !linkedIdx.has(i));
|
||||
}
|
||||
return linkedIdx.size > 0 ? widgetsValues.filter((_, i) => !linkedIdx.has(i)) : widgetsValues;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -606,6 +620,15 @@ export const comfyWorkflow = {
|
||||
return _lastCheckpointSubstitutions;
|
||||
},
|
||||
|
||||
getLastComboSubstitutions() {
|
||||
return _lastComboSubstitutions;
|
||||
},
|
||||
|
||||
// Cached /object_info (same snapshot convertUiToApi() uses)
|
||||
async getObjectInfo() {
|
||||
return _loadObjectInfo();
|
||||
},
|
||||
|
||||
getLastBypassedNodes() {
|
||||
return _lastBypassedNodes;
|
||||
},
|
||||
@@ -614,8 +637,33 @@ export const comfyWorkflow = {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
// Forces the next convertUiToApi() call to refetch /object_info instead of
|
||||
// reusing the cached snapshot. Needed whenever a file gets uploaded mid-session
|
||||
// after the cache was already populated (e.g. Video Plan's block-chaining
|
||||
// uploads an extracted frame between batch iterations) — a stale cache's
|
||||
// LoadImage "image" COMBO choices wouldn't include the just-uploaded filename
|
||||
// yet, so the COMBO-mismatch fallback a few lines below (silently substituting
|
||||
// choices[0] when a value isn't in the known list — see the file-level comment
|
||||
// at the top) would replace it with an unrelated pre-existing file instead.
|
||||
invalidateObjectInfoCache() {
|
||||
_objectInfoCache = null;
|
||||
},
|
||||
|
||||
// opts.readOnly: 表示用の読み取り変換。COMBO値の差し替えを行わず、Workflowタブの互換性
|
||||
// チェックが参照する _last* 記録も変更しない(Metadataタブなど表示目的の呼び出し用)。
|
||||
async convertUiToApi(workflow, opts = {}) {
|
||||
if (!opts.readOnly) return comfyWorkflow._convertUiToApiImpl(workflow, opts);
|
||||
const saved = [_lastCheckpointSubstitutions, _lastComboSubstitutions, _lastBypassedNodes, _lastMutedNodes];
|
||||
try {
|
||||
return await comfyWorkflow._convertUiToApiImpl(workflow, opts);
|
||||
} finally {
|
||||
[_lastCheckpointSubstitutions, _lastComboSubstitutions, _lastBypassedNodes, _lastMutedNodes] = saved;
|
||||
}
|
||||
},
|
||||
|
||||
async _convertUiToApiImpl(workflow, opts = {}) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastComboSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
_lastMutedNodes = [];
|
||||
if (!workflow.nodes || !workflow.links) return {};
|
||||
@@ -678,7 +726,14 @@ export const comfyWorkflow = {
|
||||
});
|
||||
|
||||
// 2. Map widgets_values to widget input names using object_info
|
||||
const widgets = node.widgets_values || [];
|
||||
let widgets = node.widgets_values || [];
|
||||
// Widgets converted to wired inputs (e.g. EmptyLatentImage width/height fed by
|
||||
// GetImageSize) usually still keep their value in widgets_values — strip those so the
|
||||
// loop below, which assumes linked widgets contribute zero entries, doesn't shift every
|
||||
// later widget (otherwise batch_size silently becomes the stale width value).
|
||||
if (Array.isArray(widgets) && widgets.length > 0 && linkedSlotNames.size > 0 && !node.properties?.__lm_widget_ids) {
|
||||
widgets = _normalizeLinkedWidgetValues(objectInfo, node.type, widgets, (name) => linkedSlotNames.has(name));
|
||||
}
|
||||
if (widgets.length > 0) {
|
||||
// Lora Loader (LoraManager): use __lm_widget_ids from node properties
|
||||
// widgets_values: [autocomplete_meta_obj, text_str, loras_array]
|
||||
@@ -764,7 +819,7 @@ export const comfyWorkflow = {
|
||||
const spec = allInputDefs[name];
|
||||
if (spec) {
|
||||
const choices = Array.isArray(spec[0]) ? spec[0] : null;
|
||||
if (choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (!opts.readOnly && choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (name === "ckpt_name") {
|
||||
_lastCheckpointSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
@@ -773,6 +828,14 @@ export const comfyWorkflow = {
|
||||
replaced: choices[0],
|
||||
});
|
||||
}
|
||||
_lastComboSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
type: node.type,
|
||||
title: node.title || node.type,
|
||||
input: name,
|
||||
original: val,
|
||||
replaced: choices[0],
|
||||
});
|
||||
val = choices[0];
|
||||
}
|
||||
}
|
||||
@@ -962,6 +1025,14 @@ export const comfyWorkflow = {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// LTXVDualCFGGuider (LTX-2.5's video+audio guider) — positive/negative both
|
||||
// commonly point at the *same* LTXVConditioning node (different output slots),
|
||||
// so this only seeds the role on that node; LTXVConditioning below splits it
|
||||
// back out per-slot onto its own positive/negative inputs.
|
||||
if (ct === "LTXVDualCFGGuider") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
|
||||
@@ -1038,6 +1109,13 @@ export const comfyWorkflow = {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// LTXVConditioning (LTX-2.5) — same shape as InstructPixToPixConditioning:
|
||||
// separate positive/negative inputs feeding separate outputs consumed by
|
||||
// LTXVDualCFGGuider, so each role must route only to its own input.
|
||||
if (ct === "LTXVConditioning") {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — used by Ernie
|
||||
// Image's prompt-enhancement toggle to pick between the raw PrimitiveStringMultiline
|
||||
// text and an LLM-generated (TextGenerate) rewrite. The rewrite can't be known
|
||||
@@ -1056,7 +1134,7 @@ export const comfyWorkflow = {
|
||||
// TextEncodeQwenImageEditPlus/TextEncodeQwenImageEdit — propagate role through its
|
||||
// linked "prompt" input when prompt-enhancement wiring feeds it via a switch chain
|
||||
// instead of a literal string (e.g. Krea-2's image-reference workflow).
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit" || ct === "TextEncodeMingImageEdit") {
|
||||
const tv = inputs.prompt;
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
@@ -1138,7 +1216,11 @@ export const comfyWorkflow = {
|
||||
cfg: typeof guiderInputs[guiderCfgKey] === "number" ? guiderInputs[guiderCfgKey] : undefined,
|
||||
cfgNodeId: guiderCfgKey in guiderInputs ? guiderId : null,
|
||||
cfgKey: guiderCfgKey,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
// samplerNodeId is null when the SAMPLER source has no sampler_name combo at all
|
||||
// (e.g. Ming Image's SamplerLCM, a dedicated sampler node with its own
|
||||
// s_noise knobs instead of KSamplerSelect) — the Settings UI disables the field.
|
||||
sampler_name: samplerSelInputs.sampler_name,
|
||||
samplerNodeId: "sampler_name" in samplerSelInputs ? samplerSelId : null,
|
||||
scheduler: undefined, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
@@ -1241,7 +1323,11 @@ export const comfyWorkflow = {
|
||||
// the base variant takes one — both share the same "prompt" widget). Only add when
|
||||
// "prompt" is a direct string; if linked (e.g. Krea-2's prompt-enhancement wiring),
|
||||
// the upstream literal node is resolved instead via the role propagation above.
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
// TextEncodeMingImageEdit (Ming Image) has the same single "prompt" widget plus up to
|
||||
// 8 optional images.image_N reference inputs (none wired = pure T2I); its role comes
|
||||
// from the sampler/guider wiring — Ming's layer-decomposition workflows use a second,
|
||||
// empty-prompt instance of it as the KSampler negative.
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit" || ct === "TextEncodeMingImageEdit") {
|
||||
const textVal = inputs.prompt;
|
||||
if (typeof textVal === "string") {
|
||||
result.prompt_nodes.push({
|
||||
@@ -1267,6 +1353,28 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// TextEncodeQwenImage21 (Qwen Image 2.1 unified T2I/Edit encoder) — prompt +
|
||||
// negative_prompt in one node (fixed roles, same shape as TextEncodeBooguEdit).
|
||||
// Also carries a "resolution" pixel-budget int (not width/height) and up to 10
|
||||
// optional images.image_N reference inputs (image_1 is the edit target when wired;
|
||||
// with no images connected the node behaves as pure T2I). The reference images
|
||||
// themselves need no special handling here — they arrive as ordinary LoadImage
|
||||
// nodes already picked up generically by the load_image_nodes pass below.
|
||||
if (ct === "TextEncodeQwenImage21") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
@@ -1354,7 +1462,10 @@ export const comfyWorkflow = {
|
||||
// GetImageSize node (auto-follows the reference image) rather than a direct number —
|
||||
// leave those as undefined, same treatment as TextEncodeMageFlowEdit below, so the
|
||||
// settings-tab UI shows "linked" instead of a stale default and Apply doesn't clobber it.
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage") {
|
||||
// EmptyQwenImageLayeredLatentImage (Qwen-Image-Layered / Ming Image layer decomposition)
|
||||
// has the same width/height/batch_size shape plus a "layers" count, left as-is.
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage"
|
||||
|| ct === "EmptyQwenImageLayeredLatentImage") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
@@ -1363,6 +1474,21 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Video latent nodes (Wan2.2's EmptyHunyuanLatentVideo, LTX-2.5's
|
||||
// EmptyLTXVLatentVideo) — same width/height/batch_size shape as EmptyLatentImage
|
||||
// plus a frame-count "length" input, usually linked to a duration*fps expression
|
||||
// (see video-tab.js's own Wan2.2/LTX-2.5 handling) rather than a literal number —
|
||||
// left undefined in that case so the settings-tab UI shows "linked".
|
||||
if (ct === "EmptyHunyuanLatentVideo" || ct === "EmptyLTXVLatentVideo") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title, isVideoLatent: true,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
length: typeof inputs.length === "number" ? inputs.length : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ResolutionSelector (ComfyUI core, comfy_extras.nodes_resolution): typically
|
||||
// feeds an EmptyLatentImage's width/height rather than being a latent node
|
||||
// itself. aspect_ratio is a fixed-choice COMBO (e.g. "3:2 (Photo)") — its exact
|
||||
@@ -1537,7 +1663,12 @@ export const comfyWorkflow = {
|
||||
// generation timeout (video generation runs far longer than a still image) — see
|
||||
// generate-tab.js _coreGenerate and lab-tab.js _runLabBatch.
|
||||
isVideoWorkflow(analysis) {
|
||||
return (analysis?.prompt_nodes || []).some((n) => /ImageToVideo|TextToVideo/.test(n.type));
|
||||
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) {
|
||||
@@ -1602,6 +1733,9 @@ function _getWidgetMapping(nodeType) {
|
||||
ImpactWildcardProcessor: ["wildcard_text"],
|
||||
ImpactWildcardEncode: ["wildcard_text"],
|
||||
WFS_PromptText: ["positive", "negative"],
|
||||
TextEncodeQwenImage21: ["prompt", "negative_prompt", "resolution"],
|
||||
TextEncodeMingImageEdit: ["prompt"],
|
||||
EmptyQwenImageLayeredLatentImage: ["width", "height", "layers", "batch_size"],
|
||||
};
|
||||
return mappings[nodeType] || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Decision model client (Unsloth Decision API / Laya, Ollama 0.35+ / tev1・nimble・clef).
|
||||
*
|
||||
* Laya is a *decision* model, not a generator: it takes a state (text or any JSON) plus typed
|
||||
* questions and returns calibrated probabilities in one forward pass — no free-form output, so no
|
||||
* format hallucination, and results can be thresholded directly. Most decision models are text-only
|
||||
* (image-based judgments must first be turned into text — Tagger tags / VLM caption), but Ollama's
|
||||
* "clef" models accept images directly (decide()'s `images` param — base64, no `data:` prefix; use
|
||||
* supportsDecisionVision() first since a non-vision model errors out on an images payload). This
|
||||
* file is ComfyUI-Comic-Creator's decision-client.js origin (CC ported it 2026-09-30) and CC later
|
||||
* added vision support (2026-10, based on ComfyUI-LiveChatStream's judgeImage pattern) which is
|
||||
* ported back here — keep the two in sync going forward.
|
||||
*
|
||||
* Both backends speak the same TypeSafe-compatible POST /v1/systemone. Unsloth needs an API key
|
||||
* even on localhost (unless Keyless API access is on), so its requests go through the same
|
||||
* server-side proxy as the Unsloth chat backend (py/routes/unsloth_routes.py, key from .env).
|
||||
* Ollama needs no key and allows CORS, so it's called directly from the browser like the existing
|
||||
* Ollama LLM backend. Backend/URL/model/threshold come from the Settings tab's own
|
||||
* "Decision Model" section (wfm_decision_settings), independent of the AI TOOL / Tagger backends so
|
||||
* a decision model can run alongside whichever LLM/VLM those use.
|
||||
* API reference: https://unsloth.ai/docs/models/decision-laya,
|
||||
* https://github.com/ollama/ollama/releases/tag/v0.35.0
|
||||
*
|
||||
* Usage (threshold defaults to the Settings tab value when omitted):
|
||||
* import { decide, noul, choice, score, isYes, pickChoice } from "./decision-client.js";
|
||||
* const answers = await decide(promptText, {
|
||||
* genre: choice("Which genre fits best?", { portrait: "a person is the main subject", landscape: "scenery" }),
|
||||
* text: noul("Does the image contain rendered text?"),
|
||||
* nsfw: score("How sexually explicit is this?", ["none", "suggestive", "explicit"]),
|
||||
* });
|
||||
* const genre = pickChoice(answers.genre, 0.8); // { value, probability, confident }
|
||||
*/
|
||||
|
||||
import { unslothProxy, readJsonStorage } from "./util.js";
|
||||
|
||||
// Limits documented by Unsloth's Decision API.
|
||||
export const DECISION_LIMITS = { maxQuestions: 64, maxChoiceOptions: 255, maxScoreLevels: 10 };
|
||||
|
||||
// Unsloth: "laya" = the model picked in Unsloth's settings. Explicit variants: "laya-multilingual"
|
||||
// (default download, 100+ languages), "laya-english", "laya-typed-decisions" (structured data such
|
||||
// as JSON records — e.g. workflow node summaries).
|
||||
// Ollama: decision models are ordinary pulled models whose /api/tags capabilities include
|
||||
// "decision" — listed live by listDecisionModels(); `models` below are only pull suggestions
|
||||
// (tev1 = 4B / 4.5 GB, tev1:0.8b = 812 MB, nimble = 9B / 9.5 GB, clef = 27B / ~18GB vision-capable —
|
||||
// needs Ollama 0.35.1+, clef-flash(9B) is unreliable as of Ollama 0.40.1, see ollama/ollama#18769).
|
||||
export const DECISION_BACKENDS = {
|
||||
unsloth: {
|
||||
label: "Unsloth",
|
||||
defaultUrl: "http://localhost:8888",
|
||||
models: ["laya", "laya-multilingual", "laya-english", "laya-typed-decisions"],
|
||||
defaultModel: "laya",
|
||||
},
|
||||
ollama: {
|
||||
label: "Ollama",
|
||||
defaultUrl: "http://localhost:11434",
|
||||
models: ["tev1", "tev1:0.8b", "nimble", "clef"],
|
||||
defaultModel: "tev1",
|
||||
},
|
||||
};
|
||||
export const DECISION_MODELS = DECISION_BACKENDS.unsloth.models;
|
||||
export const DEFAULT_DECISION_MODEL = DECISION_BACKENDS.unsloth.defaultModel;
|
||||
|
||||
export const DECISION_SETTINGS_KEY = "wfm_decision_settings";
|
||||
const DEFAULT_SETTINGS = {
|
||||
backend: "unsloth",
|
||||
baseUrl: DECISION_BACKENDS.unsloth.defaultUrl,
|
||||
model: DEFAULT_DECISION_MODEL,
|
||||
threshold: 0.8,
|
||||
};
|
||||
|
||||
/** Saved Decision Model settings merged over the defaults. */
|
||||
export function getDecisionSettings() {
|
||||
const saved = readJsonStorage(DECISION_SETTINGS_KEY);
|
||||
const merged = { ...DEFAULT_SETTINGS, ...saved };
|
||||
const th = Number(merged.threshold);
|
||||
merged.threshold = th > 0 && th <= 1 ? th : DEFAULT_SETTINGS.threshold;
|
||||
if (!DECISION_BACKENDS[merged.backend]) merged.backend = DEFAULT_SETTINGS.backend;
|
||||
const backend = DECISION_BACKENDS[merged.backend];
|
||||
if (!merged.baseUrl) merged.baseUrl = backend.defaultUrl;
|
||||
// A model name from the other backend (e.g. "laya" after switching to Ollama) can't work.
|
||||
const isLaya = /^laya/.test(merged.model || "");
|
||||
if (!merged.model || (merged.backend === "ollama" && isLaya) || (merged.backend === "unsloth" && !isLaya)) {
|
||||
merged.model = backend.defaultModel;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model choices for the Settings dropdown. Unsloth: its fixed Laya names. Ollama: installed models
|
||||
* whose /api/tags capabilities include "decision" (throws if Ollama can't be reached). Returns
|
||||
* { models, installed } — installed is false for Unsloth (names aren't checked against the server).
|
||||
*/
|
||||
export async function listDecisionModels(backend, baseUrl) {
|
||||
if (backend !== "ollama") return { models: [...DECISION_BACKENDS.unsloth.models], installed: false };
|
||||
const res = await fetch(`${(baseUrl || DECISION_BACKENDS.ollama.defaultUrl).replace(/\/+$/, "")}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const models = (data.models || [])
|
||||
.filter((m) => Array.isArray(m.capabilities) && m.capabilities.includes("decision"))
|
||||
.map((m) => m.name)
|
||||
.sort();
|
||||
return { models, installed: true };
|
||||
}
|
||||
|
||||
export function saveDecisionSettings(patch) {
|
||||
const data = { ...getDecisionSettings(), ...patch };
|
||||
localStorage.setItem(DECISION_SETTINGS_KEY, JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- Question builders ----
|
||||
|
||||
/** Yes/no question — answer.noul is the probability of "yes" (0..1). */
|
||||
export function noul(instructions) {
|
||||
return { type: "noul", instructions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick-one question. criteria: an array of option names, or { option: description } — descriptions
|
||||
* noticeably improve accuracy (options share a token budget, so past ~20 described options the
|
||||
* descriptions get trimmed).
|
||||
*/
|
||||
export function choice(instructions, criteria) {
|
||||
return { type: "choice", instructions, criteria };
|
||||
}
|
||||
|
||||
/** Rating question. levels: ordered labels from lowest (0) to highest, 2..10 entries. */
|
||||
export function score(instructions, levels) {
|
||||
return { type: "score", instructions, criteria: levels };
|
||||
}
|
||||
|
||||
// ---- Request ----
|
||||
|
||||
|
||||
function _validateQuestions(questions) {
|
||||
const entries = Object.entries(questions || {});
|
||||
if (entries.length === 0) throw new Error("decide(): no questions given");
|
||||
if (entries.length > DECISION_LIMITS.maxQuestions) {
|
||||
throw new Error(`decide(): ${entries.length} questions exceeds the limit of ${DECISION_LIMITS.maxQuestions}`);
|
||||
}
|
||||
for (const [key, q] of entries) {
|
||||
if (!q || !["noul", "choice", "score"].includes(q.type)) {
|
||||
throw new Error(`decide(): question "${key}" has an unknown type`);
|
||||
}
|
||||
const n = Array.isArray(q.criteria) ? q.criteria.length : Object.keys(q.criteria || {}).length;
|
||||
if (q.type === "choice" && (n < 2 || n > DECISION_LIMITS.maxChoiceOptions)) {
|
||||
throw new Error(`decide(): choice "${key}" needs 2-${DECISION_LIMITS.maxChoiceOptions} options (got ${n})`);
|
||||
}
|
||||
if (q.type === "score" && (n < 2 || n > DECISION_LIMITS.maxScoreLevels)) {
|
||||
throw new Error(`decide(): score "${key}" needs 2-${DECISION_LIMITS.maxScoreLevels} levels (got ${n})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask typed questions about one state. Returns the raw `answers` object keyed like `questions`.
|
||||
* The first call after Unsloth starts loads the model (10-20 s); later calls are sub-second on CPU.
|
||||
*
|
||||
* @param {string|object} state Text or any JSON (e.g. { prompt, tags, model }).
|
||||
* @param {object} questions { key: noul(...) | choice(...) | score(...) }, max 64.
|
||||
* @param {object} [opts] { backend, model, baseUrl } — override the saved Decision Model settings.
|
||||
* @param {string[]} [images] Base64 image data (no `data:` prefix), shared by all questions.
|
||||
* Vision-capable Ollama models only (e.g. clef) — check with
|
||||
* supportsDecisionVision() first; a non-vision model errors out.
|
||||
*/
|
||||
export async function decide(state, questions, opts = {}, images = []) {
|
||||
_validateQuestions(questions);
|
||||
const settings = getDecisionSettings();
|
||||
const backend = opts.backend || settings.backend;
|
||||
const baseUrl = (opts.baseUrl || settings.baseUrl).replace(/\/+$/, "");
|
||||
const payload = { model: opts.model || settings.model, state, questions };
|
||||
if (images?.length) payload.images = images;
|
||||
let data;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${baseUrl}/v1/systemone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
data = await res.json().catch(() => ({}));
|
||||
// Ollama reports failures as { error } (e.g. 'model "tev1" not found, try pulling it first').
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
} else {
|
||||
data = await unslothProxy(baseUrl, "/v1/systemone", "POST", payload);
|
||||
}
|
||||
if (!data || typeof data.answers !== "object") throw new Error(data?.error || "Decision API returned no answers");
|
||||
return data.answers;
|
||||
}
|
||||
|
||||
/** Blob / data URL / image URL → base64 (no `data:` prefix), for decide()'s `images` param. */
|
||||
export async function imageToBase64(src) {
|
||||
const blob = src instanceof Blob ? src : await (await fetch(src)).blob();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result).split(",")[1] || "");
|
||||
r.onerror = () => reject(r.error);
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the configured decision model accepts images. Ollama only — decision models' /api/show
|
||||
* capabilities list only "decision" (not "vision") on some Ollama builds, so this also checks for an
|
||||
* image encoder (projector_info) as a fallback. Unsloth can't be checked this way, so it returns
|
||||
* null (unknown) — callers should try and fall back to text-only on failure. Returns false on error.
|
||||
*
|
||||
* @param {object} [opts] { backend, model, baseUrl } — same overrides as decide().
|
||||
*/
|
||||
export async function supportsDecisionVision(opts = {}) {
|
||||
const settings = getDecisionSettings();
|
||||
const backend = opts.backend || settings.backend;
|
||||
if (backend !== "ollama") return null;
|
||||
const baseUrl = (opts.baseUrl || settings.baseUrl).replace(/\/+$/, "");
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/show`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: opts.model || settings.model }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return (Array.isArray(data.capabilities) && data.capabilities.includes("vision"))
|
||||
|| !!(data.projector_info && Object.keys(data.projector_info).length);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run decide() over many states with a small concurrency cap (the model runs locally, so flooding
|
||||
* it only queues requests). Per-item failures are returned as { error } instead of rejecting all.
|
||||
*/
|
||||
export async function decideMany(states, questions, opts = {}) {
|
||||
const concurrency = Math.max(1, opts.concurrency || 2);
|
||||
const results = new Array(states.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < states.length) {
|
||||
const i = next++;
|
||||
try {
|
||||
results[i] = { answers: await decide(states[i], questions, opts) };
|
||||
} catch (e) {
|
||||
results[i] = { error: e.message };
|
||||
}
|
||||
opts.onProgress?.(i, results[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, states.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection check for the Settings tab: one yes/no question, timed. The first request after
|
||||
* Unsloth starts also loads the model (10-20 s), so callers should say that a slow first test is
|
||||
* normal.
|
||||
*/
|
||||
export async function testDecisionConnection(opts = {}) {
|
||||
const started = performance.now();
|
||||
const answers = await decide("A cat is sitting on a sofa.", {
|
||||
test: noul("Is there an animal in the text?"),
|
||||
}, opts);
|
||||
return { ms: Math.round(performance.now() - started), yes: answers.test?.noul };
|
||||
}
|
||||
|
||||
// ---- Answer helpers ----
|
||||
// Threshold on `probabilities` (or noul's value), not `confidence` — per Unsloth's docs, confidence
|
||||
// only says how peaked the distribution is and its formula differs from Jev's.
|
||||
|
||||
/** True when the yes-probability of a noul answer reaches `threshold`. */
|
||||
export function isYes(answer, threshold = getDecisionSettings().threshold) {
|
||||
return typeof answer?.noul === "number" && answer.noul >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most likely option of a choice answer. `confident` is false below `threshold` — callers should
|
||||
* then show the value as a suggestion for the user to confirm rather than apply it automatically.
|
||||
*/
|
||||
export function pickChoice(answer, threshold = getDecisionSettings().threshold) {
|
||||
const value = answer?.choice ?? null;
|
||||
const probability = value != null ? (answer.probabilities?.[value] ?? 0) : 0;
|
||||
return { value, probability, confident: value != null && probability >= threshold };
|
||||
}
|
||||
|
||||
/**
|
||||
* Most likely level of a score answer: { level (index from 0), label, value (expected score,
|
||||
* fractional), probability, confident }.
|
||||
*/
|
||||
export function pickScore(answer, threshold = getDecisionSettings().threshold) {
|
||||
const probs = answer?.probabilities || {};
|
||||
let level = null, probability = 0;
|
||||
for (const [k, p] of Object.entries(probs)) {
|
||||
if (p > probability) { level = Number(k); probability = p; }
|
||||
}
|
||||
return {
|
||||
level,
|
||||
label: level != null ? (answer.legend?.[String(level)] ?? null) : null,
|
||||
value: typeof answer?.score === "number" ? answer.score : null,
|
||||
probability,
|
||||
confident: level != null && probability >= threshold,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Gallery Tab - セマンティック検索 (Gemini Embedding 2)
|
||||
* - 検索ボックスのSemantic Searchトグル(ONでEnter実行の意味検索)
|
||||
* - 詳細パネルの「類似画像」トグル/更新ボタン
|
||||
* - インデックス作成モーダル(Semantic⚙)
|
||||
* 一覧の差し替えは gallery-tab.js の loadImages() が getSemanticMode() / fetchSemanticImages() を呼んで行う。
|
||||
*/
|
||||
|
||||
import { openModal, closeModal, showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
|
||||
const API = {
|
||||
status: (folder, recursive) => `/wfm/gallery/semantic/status?folder=${encodeURIComponent(folder)}&recursive=${recursive}`,
|
||||
index: "/wfm/gallery/semantic/index",
|
||||
cancel: "/wfm/gallery/semantic/cancel",
|
||||
search: "/wfm/gallery/semantic/search",
|
||||
similar: "/wfm/gallery/semantic/similar",
|
||||
clear: "/wfm/gallery/semantic/clear",
|
||||
folders: (root) => `/wfm/gallery/semantic/folders?root=${encodeURIComponent(root)}`,
|
||||
test: "/wfm/gallery/semantic/test",
|
||||
};
|
||||
|
||||
const LS_ON = "wfm_gallery_semantic_on";
|
||||
const LS_DIM = "wfm_gallery_semantic_dim";
|
||||
const LS_MODEL = "wfm_gallery_semantic_model";
|
||||
const LS_SOFT = "wfm_gallery_semantic_soft";
|
||||
const GEMINI = "gemini-embedding-2";
|
||||
const LOCAL = "embeddinggemma-2";
|
||||
const LS_SUB = "wfm_gallery_semantic_recursive";
|
||||
const LS_TAB = "wfm_gallery_semantic_tab";
|
||||
const LS_TH = "wfm_gallery_semantic_th";
|
||||
const LIMITS = [50, 100, 200, 500, 1000];
|
||||
|
||||
const sem = {
|
||||
on: false, // Semantic Searchトグル
|
||||
anchor: "", // 類似画像表示中の基準画像パス(空=類似表示OFF)
|
||||
anchorName: "",
|
||||
indexModel: GEMINI, // 現在のインデックスを作ったモデル(しきい値はモデルごとに保存する)
|
||||
};
|
||||
|
||||
let ctx = null; // { getSearch, getFolder, getSelected, reload, focusSearch }
|
||||
|
||||
function lsGet(key, fallback) {
|
||||
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
|
||||
}
|
||||
function lsSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch { /* 保存できなくても動作に影響しない */ }
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
let data = {};
|
||||
try { data = await res.json(); } catch { /* 本文なし */ }
|
||||
if (!res.ok) {
|
||||
throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { code: data.code || "" });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** APIエラーコードをi18nメッセージにする */
|
||||
export function semanticErrorMessage(e) {
|
||||
const known = ["no_api_key", "api_key_invalid", "index_empty", "dim_mismatch", "already_running",
|
||||
"folder_not_allowed", "unsupported_type", "network_error", "api_error", "local_error"];
|
||||
if (e?.code && known.includes(e.code)) {
|
||||
return t(`sem_err_${e.code}`, e.message);
|
||||
}
|
||||
return t("errorWithMsg", e?.message || String(e));
|
||||
}
|
||||
|
||||
// ── gallery-tab.js から使う公開API ────────────────────────────
|
||||
|
||||
/** 現在の一覧モード: {kind:"similar", path} | {kind:"text", query} | null(通常) */
|
||||
export function getSemanticMode() {
|
||||
if (sem.anchor) return { kind: "similar", path: sem.anchor, name: sem.anchorName };
|
||||
const q = (ctx?.getSearch() || "").trim();
|
||||
if (sem.on && q) return { kind: "text", query: q };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSemanticOn() {
|
||||
return sem.on;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表示条件(類似度のしきい値・最大件数)。スコアの水準はモデルで違うため、モデルごとに保存する。
|
||||
* similar: 類似画像検索の最小スコア / text: 文章検索の最小スコア(0=絞り込みなし)
|
||||
*/
|
||||
function loadThresholds(model) {
|
||||
let v = {};
|
||||
try { v = JSON.parse(lsGet(LS_TH, "{}"))[model] || {}; } catch { /* 壊れた値は既定に戻す */ }
|
||||
const num = x => { const n = parseFloat(x); return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0; };
|
||||
return { similar: num(v.similar), text: num(v.text), limit: LIMITS.includes(v.limit) ? v.limit : 200 };
|
||||
}
|
||||
|
||||
function saveThresholds(model, th) {
|
||||
let all = {};
|
||||
try { all = JSON.parse(lsGet(LS_TH, "{}")) || {}; } catch { all = {}; }
|
||||
all[model] = th;
|
||||
lsSet(LS_TH, JSON.stringify(all));
|
||||
}
|
||||
|
||||
function minScoreFor(mode) {
|
||||
const th = loadThresholds(sem.indexModel);
|
||||
return mode.kind === "similar" ? th.similar : th.text;
|
||||
}
|
||||
|
||||
/** モードに応じてサーバーから結果(スコア降順・score付き)を取得する */
|
||||
export async function fetchSemanticImages(mode, folder) {
|
||||
const th = loadThresholds(sem.indexModel);
|
||||
const common = { folder, recursive: true, limit: th.limit, min_score: minScoreFor(mode) };
|
||||
const data = mode.kind === "similar"
|
||||
? await postJson(API.similar, { path: mode.path, ...common })
|
||||
: await postJson(API.search, { query: mode.query, ...common });
|
||||
return data.images || [];
|
||||
}
|
||||
|
||||
/** 2行目の結果件数表示(意味検索/類似表示のときだけ文言を入れる。通常は空) */
|
||||
export function setSemanticCountText(text) {
|
||||
const el = document.getElementById("wfm-gallery-sem-count");
|
||||
if (el) el.textContent = text || "";
|
||||
}
|
||||
|
||||
/** 一覧件数ラベル */
|
||||
export function semanticCountLabel(mode, count) {
|
||||
const min = minScoreFor(mode);
|
||||
return mode.kind === "similar"
|
||||
? t("semCountSimilar", count, mode.name, min)
|
||||
: t("semCountText", count, min);
|
||||
}
|
||||
|
||||
let _dotRoot = "";
|
||||
let _dotSeq = 0;
|
||||
|
||||
/**
|
||||
* フォルダツリーの枚数バッジ横に、直下の画像が全件インデックス済みのフォルダへドットを付ける。
|
||||
* ツリー再構築後・インデックス作成後に呼ぶ(DOMはその場で更新し、ツリーは作り直さない)。
|
||||
*/
|
||||
export async function refreshSemanticDots(root) {
|
||||
if (root) _dotRoot = root;
|
||||
if (!_dotRoot) return;
|
||||
const seq = ++_dotSeq;
|
||||
let folders;
|
||||
try {
|
||||
const res = await fetch(API.folders(_dotRoot));
|
||||
if (!res.ok) return;
|
||||
folders = (await res.json()).folders || {};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (seq !== _dotSeq) return; // より新しい呼び出しがある
|
||||
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
|
||||
const label = item.querySelector(".wfm-gallery-tree-label");
|
||||
const badge = label?.querySelector(".wfm-gallery-tree-count");
|
||||
label?.querySelector(".wfm-gallery-tree-sem-dot")?.remove();
|
||||
const st = folders[(item.dataset.path || "").replace(/\\/g, "/")];
|
||||
if (badge && st && st.indexable > 0 && st.indexed >= st.indexable) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "wfm-gallery-tree-sem-dot";
|
||||
dot.title = t("semDotTitle");
|
||||
badge.after(dot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** フォルダ切替・フィルタクリア時に類似表示を解除する(再読込は呼び出し側) */
|
||||
export function exitSimilarMode() {
|
||||
sem.anchor = "";
|
||||
sem.anchorName = "";
|
||||
updateSimilarButtons();
|
||||
}
|
||||
|
||||
// ── トグル/類似ボタン ───────────────────────────────────────
|
||||
|
||||
function updateToggleUi() {
|
||||
const btn = document.getElementById("wfm-gallery-semantic-toggle");
|
||||
btn?.classList.toggle("active", sem.on);
|
||||
// ON のときだけ2行目(Similar / 更新 / 設定 / 件数)を表示する
|
||||
const row = document.getElementById("wfm-gallery-sem-row");
|
||||
if (row) row.style.display = sem.on ? "flex" : "none";
|
||||
const input = document.getElementById("wfm-gallery-search");
|
||||
if (input) input.placeholder = sem.on ? t("semSearchPlaceholder") : "Search images...";
|
||||
}
|
||||
|
||||
function updateSimilarButtons() {
|
||||
document.getElementById("wfm-gallery-similar-btn")?.classList.toggle("active", !!sem.anchor);
|
||||
}
|
||||
|
||||
async function toggleSimilar() {
|
||||
if (sem.anchor) {
|
||||
exitSimilarMode();
|
||||
ctx.reload();
|
||||
return;
|
||||
}
|
||||
await applySimilar();
|
||||
}
|
||||
|
||||
async function applySimilar() {
|
||||
const img = ctx.getSelected();
|
||||
if (!img) {
|
||||
showToast(t("gallerySelectImageFirst"), "error");
|
||||
return;
|
||||
}
|
||||
sem.anchor = img.path;
|
||||
sem.anchorName = img.filename;
|
||||
updateSimilarButtons();
|
||||
ctx.reload();
|
||||
}
|
||||
|
||||
// ── インデックス作成モーダル ─────────────────────────────────
|
||||
|
||||
function modalHtml() {
|
||||
const tab = ["index", "model", "search"].includes(lsGet(LS_TAB, "index")) ? lsGet(LS_TAB, "index") : "index";
|
||||
const pane = (id, body) => `<div class="wfm-sem-pane" data-sem-pane="${id}" style="${tab === id ? "" : "display:none;"}">${body}</div>`;
|
||||
const tabBtn = (id, label) =>
|
||||
`<button type="button" class="wfm-sem-tab-btn${tab === id ? " active" : ""}" data-sem-tab="${id}">${escapeHtml(label)}</button>`;
|
||||
const indexPane = `
|
||||
<div class="wfm-sem-status" id="wfm-sem-status">${escapeHtml(t("loading"))}</div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semScope"))}</b>
|
||||
<span id="wfm-sem-folder" class="wfm-sem-folder"></span></div>
|
||||
<label class="wfm-sem-row"><input type="checkbox" id="wfm-sem-recursive" ${lsGet(LS_SUB, "1") === "1" ? "checked" : ""}>
|
||||
${escapeHtml(t("semIncludeSub"))}</label>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semMode"))}</b>
|
||||
<label><input type="radio" name="wfm-sem-mode" value="missing" checked> ${escapeHtml(t("semModeMissing"))}</label>
|
||||
<label><input type="radio" name="wfm-sem-mode" value="all"> ${escapeHtml(t("semModeAll"))}</label></div>
|
||||
<div class="wfm-sem-progress" id="wfm-sem-progress" style="display:none;">
|
||||
<div class="wfm-sem-bar"><div class="wfm-sem-bar-fill" id="wfm-sem-bar-fill"></div></div>
|
||||
<div id="wfm-sem-progress-text" class="wfm-sem-hint"></div>
|
||||
</div>
|
||||
<div class="wfm-sem-actions">
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-sem-start">${escapeHtml(t("semStart"))}</button>
|
||||
<button class="wfm-btn" id="wfm-sem-cancel" disabled>${escapeHtml(t("semCancelJob"))}</button>
|
||||
<button class="wfm-btn wfm-sem-danger" id="wfm-sem-clear">${escapeHtml(t("semClear"))}</button>
|
||||
</div>`;
|
||||
const modelPane = `
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semModel"))}</b>
|
||||
<label><input type="radio" name="wfm-sem-model" value="${GEMINI}"> ${escapeHtml(t("semModelGemini"))}</label>
|
||||
<label><input type="radio" name="wfm-sem-model" value="${LOCAL}"> ${escapeHtml(t("semModelLocal"))}</label></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semDimension"))}</b>
|
||||
<select id="wfm-sem-dim" class="wfm-select"></select>
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semDimHint"))}</span></div>
|
||||
<div id="wfm-sem-local-opts" class="wfm-sem-local-opts" style="display:none;">
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semDevice"))}</b>
|
||||
<select id="wfm-sem-device" class="wfm-select">
|
||||
<option value="auto">${escapeHtml(t("semDeviceAuto"))}</option>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="cuda">GPU (CUDA)</option>
|
||||
</select>
|
||||
<span id="wfm-sem-device-info" class="wfm-sem-hint"></span></div>
|
||||
<div class="wfm-sem-row" id="wfm-sem-threads-row"><b>${escapeHtml(t("semThreads"))}</b>
|
||||
<select id="wfm-sem-threads" class="wfm-select"></select>
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semThreadsHint"))}</span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semSoft"))}</b>
|
||||
<select id="wfm-sem-soft" class="wfm-select">
|
||||
<option value="280">${escapeHtml(t("semSoftHigh"))}</option>
|
||||
<option value="140">${escapeHtml(t("semSoftStd"))}</option>
|
||||
<option value="70">${escapeHtml(t("semSoftLight"))}</option>
|
||||
</select>
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semSoftHint"))}</span></div>
|
||||
</div>
|
||||
<div class="wfm-sem-actions">
|
||||
<button class="wfm-btn" id="wfm-sem-test">${escapeHtml(t("semTest"))}</button>
|
||||
</div>
|
||||
<p class="wfm-sem-hint">${escapeHtml(t("semCostNote"))}</p>`;
|
||||
const searchPane = `
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThTarget"))}</b>
|
||||
<span id="wfm-sem-th-model" class="wfm-sem-folder"></span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThSimilar"))}</b>
|
||||
<input type="number" id="wfm-sem-th-similar" class="wfm-input wfm-sem-num" min="0" max="1" step="0.01" placeholder="0">
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semThSimilarHint"))}</span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThText"))}</b>
|
||||
<input type="number" id="wfm-sem-th-text" class="wfm-input wfm-sem-num" min="0" max="1" step="0.01" placeholder="0">
|
||||
<span class="wfm-sem-hint">${escapeHtml(t("semThTextHint"))}</span></div>
|
||||
<div class="wfm-sem-row"><b>${escapeHtml(t("semThLimit"))}</b>
|
||||
<select id="wfm-sem-th-limit" class="wfm-select">${LIMITS.map(n => `<option value="${n}">${n}</option>`).join("")}</select></div>
|
||||
<p class="wfm-sem-hint">${escapeHtml(t("semThNote"))}</p>`;
|
||||
return `
|
||||
<div class="wfm-sem-modal">
|
||||
<div class="wfm-sem-tabs">${tabBtn("index", t("semTabIndex"))}${tabBtn("model", t("semTabModel"))}${tabBtn("search", t("semTabSearch"))}</div>
|
||||
${pane("index", indexPane)}${pane("model", modelPane)}${pane("search", searchPane)}
|
||||
<div class="wfm-sem-actions wfm-sem-footer">
|
||||
<button class="wfm-btn" id="wfm-sem-help" title="${escapeHtml(t("semHelpTitle"))}">?</button>
|
||||
<button class="wfm-btn" id="wfm-sem-close">${escapeHtml(t("close"))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function modalAlive() {
|
||||
const overlay = document.getElementById("wfm-modal-overlay");
|
||||
return !!document.getElementById("wfm-sem-status") && overlay && overlay.style.display !== "none";
|
||||
}
|
||||
|
||||
let _pollTimer = null;
|
||||
let _backends = {};
|
||||
let _sawRunning = false;
|
||||
|
||||
function selectedModel() {
|
||||
return document.querySelector('input[name="wfm-sem-model"]:checked')?.value || GEMINI;
|
||||
}
|
||||
|
||||
/** ローカルモデルの実行設定(デバイス/スレッド/画像精度)の初期値をサーバー保存値から設定する */
|
||||
function initLocalOpts(st) {
|
||||
const lo = st.local || {};
|
||||
const devSel = document.getElementById("wfm-sem-device");
|
||||
devSel.value = (lo.options && lo.options.device) || "auto";
|
||||
const cores = lo.devices?.cpu_count || 4;
|
||||
const cur = (lo.options && lo.options.threads) || lo.default_threads || Math.max(1, Math.floor(cores / 2));
|
||||
const set = new Set([1, 2, 4, 6, 8, 12, 16, 24, 32].filter(n => n <= cores).concat([cores, cur, lo.default_threads || 1]));
|
||||
document.getElementById("wfm-sem-threads").innerHTML = [...set].sort((a, b) => a - b)
|
||||
.map(n => `<option value="${n}">${n}${n === cores ? ` (${t("semThreadsAll")})` : ""}</option>`).join("");
|
||||
document.getElementById("wfm-sem-threads").value = String(cur);
|
||||
const soft = st.indexed_total && st.index_model === LOCAL ? st.index_soft : parseInt(lsGet(LS_SOFT, String(lo.default_soft || 280)), 10);
|
||||
document.getElementById("wfm-sem-soft").value = String([70, 140, 280].includes(soft) ? soft : 280);
|
||||
}
|
||||
|
||||
/** 検索タブ: しきい値の入力欄にインデックスのモデル用の保存値を入れる(入力中の値は上書きしない) */
|
||||
function fillThresholds(st) {
|
||||
const model = st.indexed_total ? st.index_model : selectedModel();
|
||||
const box = document.getElementById("wfm-sem-th-model");
|
||||
if (box) box.textContent = model + (st.indexed_total ? "" : ` ${t("semThNoIndex")}`);
|
||||
const pane = document.getElementById("wfm-sem-th-similar");
|
||||
if (!pane || pane.dataset.model === model) return;
|
||||
const th = loadThresholds(model);
|
||||
pane.value = th.similar || "";
|
||||
document.getElementById("wfm-sem-th-text").value = th.text || "";
|
||||
document.getElementById("wfm-sem-th-limit").value = String(th.limit);
|
||||
pane.dataset.model = model;
|
||||
}
|
||||
|
||||
/** 検索タブの入力を保存する。一覧が意味検索/類似表示中なら閉じるときに再読込する */
|
||||
let _thDirty = false;
|
||||
function saveThresholdInputs() {
|
||||
const model = document.getElementById("wfm-sem-th-similar")?.dataset.model;
|
||||
if (!model) return;
|
||||
const clamp = id => {
|
||||
const n = parseFloat(document.getElementById(id).value);
|
||||
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
||||
};
|
||||
saveThresholds(model, {
|
||||
similar: clamp("wfm-sem-th-similar"),
|
||||
text: clamp("wfm-sem-th-text"),
|
||||
limit: parseInt(document.getElementById("wfm-sem-th-limit").value, 10) || 200,
|
||||
});
|
||||
_thDirty = true;
|
||||
}
|
||||
|
||||
/** モーダルを閉じる共通処理 */
|
||||
function closeSemanticModal() {
|
||||
clearTimeout(_pollTimer);
|
||||
closeModal();
|
||||
refreshSemanticDots();
|
||||
if (_thDirty) {
|
||||
_thDirty = false;
|
||||
if (getSemanticMode()) ctx.reload(); // 新しいしきい値で一覧を取り直す
|
||||
}
|
||||
}
|
||||
|
||||
/** 検出した実行デバイスの表示(GPU版onnxruntimeが無いときの案内を含む) */
|
||||
function updateDeviceInfo(lo) {
|
||||
const dev = lo?.devices;
|
||||
if (!dev) return;
|
||||
const info = document.getElementById("wfm-sem-device-info");
|
||||
const cudaOpt = document.querySelector('#wfm-sem-device option[value="cuda"]');
|
||||
if (cudaOpt) cudaOpt.disabled = !dev.cuda;
|
||||
if (!dev.cuda && document.getElementById("wfm-sem-device").value === "cuda") {
|
||||
document.getElementById("wfm-sem-device").value = "auto";
|
||||
}
|
||||
if (info) info.textContent = dev.cuda ? t("semDeviceCudaOk", dev.ort_version) : t("semDeviceNoGpu", dev.ort_version);
|
||||
const dv = document.getElementById("wfm-sem-device").value;
|
||||
const useCpu = dv === "cpu" || (dv === "auto" && !dev.cuda);
|
||||
document.getElementById("wfm-sem-threads-row").style.display = useCpu ? "" : "none";
|
||||
}
|
||||
|
||||
/** 選択中モデルの次元選択肢を作り直す(preferredが選択肢にあればそれを選ぶ) */
|
||||
function fillDims(model, preferred) {
|
||||
const sel = document.getElementById("wfm-sem-dim");
|
||||
const info = _backends[model];
|
||||
if (!sel || !info) return;
|
||||
sel.innerHTML = info.dims.map(d => `<option value="${d}">${d}</option>`).join("");
|
||||
sel.value = String(info.dims.includes(preferred) ? preferred : info.default_dim);
|
||||
}
|
||||
|
||||
async function refreshModal() {
|
||||
if (!modalAlive()) return;
|
||||
const folder = ctx.getFolder();
|
||||
const recursive = document.getElementById("wfm-sem-recursive")?.checked ?? true;
|
||||
let st;
|
||||
try {
|
||||
const res = await fetch(API.status(folder, recursive));
|
||||
st = await res.json();
|
||||
if (!res.ok) throw new Error(st.error || `HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
document.getElementById("wfm-sem-status").textContent = t("errorWithMsg", e.message);
|
||||
return;
|
||||
}
|
||||
if (!modalAlive()) return;
|
||||
const job = st.job || {};
|
||||
_backends = st.backends || {};
|
||||
if (st.indexed_total) sem.indexModel = st.index_model;
|
||||
if (!document.getElementById("wfm-sem-dim").dataset.init) {
|
||||
// 初回のみ: 既存インデックスのモデル/次元、なければ前回の選択を初期値にする
|
||||
const initModel = st.indexed_total ? st.index_model : lsGet(LS_MODEL, GEMINI);
|
||||
const radio = document.querySelector(`input[name="wfm-sem-model"][value="${initModel}"]`)
|
||||
|| document.querySelector(`input[name="wfm-sem-model"][value="${GEMINI}"]`);
|
||||
radio.checked = true;
|
||||
fillDims(radio.value, st.indexed_total ? st.index_dim : parseInt(lsGet(LS_DIM, "0"), 10));
|
||||
initLocalOpts(st);
|
||||
document.getElementById("wfm-sem-dim").dataset.init = "1";
|
||||
}
|
||||
fillThresholds(st);
|
||||
const model = selectedModel();
|
||||
document.getElementById("wfm-sem-local-opts").style.display = model === LOCAL ? "" : "none";
|
||||
updateDeviceInfo(st.local);
|
||||
const ready = model === LOCAL ? true : !!st.has_key;
|
||||
const keyLine = model === LOCAL
|
||||
? (_backends[LOCAL]?.downloaded ? t("semLocalReady") : t("semLocalNeedDownload"))
|
||||
: (st.has_key ? t("semKeyOk") : t("semKeyMissing"));
|
||||
const idxLine = t("semIndexInfo", st.indexed_total, st.index_dim || "-");
|
||||
const scopeLine = st.scope_total != null ? t("semScopeInfo", st.scope_indexed, st.scope_total) : "";
|
||||
document.getElementById("wfm-sem-status").innerHTML =
|
||||
`<div class="${ready ? "wfm-sem-ok" : "wfm-sem-ng"}">${escapeHtml(keyLine)}</div>` +
|
||||
`<div>${escapeHtml(st.indexed_total ? st.index_model : "-")} / ${escapeHtml(idxLine)}</div>` +
|
||||
(scopeLine ? `<div>${escapeHtml(scopeLine)}</div>` : "");
|
||||
document.getElementById("wfm-sem-folder").textContent = folder || t("semNoFolder");
|
||||
|
||||
const prog = document.getElementById("wfm-sem-progress");
|
||||
const hasJob = job.running || job.finished;
|
||||
prog.style.display = hasJob ? "" : "none";
|
||||
if (hasJob) {
|
||||
const total = job.total || 0;
|
||||
const doneAll = (job.done || 0) + (job.failed || 0);
|
||||
document.getElementById("wfm-sem-bar-fill").style.width = total ? `${Math.min(100, (doneAll / total) * 100)}%` : "100%";
|
||||
let text = t("semProgress", job.done || 0, total, job.failed || 0);
|
||||
if (job.running && job.current) text += ` ${job.current}`;
|
||||
if (!job.running) {
|
||||
if (["no_api_key", "api_key_invalid", "local_error"].includes(job.error_code)) text += ` ${t(`sem_err_${job.error_code}`, job.error)}`;
|
||||
else text += ` ${job.cancelled ? t("semCancelled") : t("semFinished")}`;
|
||||
if (job.failed && job.error) text += ` (${job.error})`;
|
||||
}
|
||||
document.getElementById("wfm-sem-progress-text").textContent = text;
|
||||
}
|
||||
document.getElementById("wfm-sem-start").disabled = !!job.running || !ready;
|
||||
document.getElementById("wfm-sem-cancel").disabled = !job.running;
|
||||
document.getElementById("wfm-sem-clear").disabled = !!job.running;
|
||||
document.getElementById("wfm-sem-test").disabled = !ready;
|
||||
|
||||
clearTimeout(_pollTimer);
|
||||
if (job.running) {
|
||||
_pollTimer = setTimeout(refreshModal, 1000);
|
||||
_sawRunning = true;
|
||||
} else if (_sawRunning) {
|
||||
_sawRunning = false;
|
||||
refreshSemanticDots();
|
||||
}
|
||||
}
|
||||
|
||||
/** ヘルプタブのGallery > セマンティック検索の説明へ飛ぶ */
|
||||
function openSemanticHelp() {
|
||||
closeSemanticModal();
|
||||
document.querySelector('.wfm-tab[data-tab="help"]')?.click();
|
||||
document.querySelector('.wfm-help-nav-item[data-help-page="gallery"]')?.click();
|
||||
const target = document.getElementById("wfm-help-gallery-33");
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
target.classList.add("wfm-help-flash");
|
||||
setTimeout(() => target.classList.remove("wfm-help-flash"), 2500);
|
||||
}
|
||||
|
||||
function openSemanticModal() {
|
||||
if (!ctx.getFolder()) {
|
||||
showToast(t("semNoFolder"), "error");
|
||||
return;
|
||||
}
|
||||
openModal(t("semModalTitle"), modalHtml());
|
||||
const dimOf = () => parseInt(document.getElementById("wfm-sem-dim").value, 10) || 0;
|
||||
// ローカルモデル選択時のみ実行設定を送る(サーバー側で保存され、検索時のクエリ埋め込みにも使われる)
|
||||
const localOpts = () => selectedModel() === LOCAL ? {
|
||||
device: document.getElementById("wfm-sem-device").value,
|
||||
threads: parseInt(document.getElementById("wfm-sem-threads").value, 10) || 0,
|
||||
soft: parseInt(document.getElementById("wfm-sem-soft").value, 10) || 280,
|
||||
} : {};
|
||||
document.getElementById("wfm-sem-device").addEventListener("change", () => refreshModal());
|
||||
document.querySelectorAll('input[name="wfm-sem-model"]').forEach(r => r.addEventListener("change", () => {
|
||||
fillDims(selectedModel(), 0);
|
||||
refreshModal();
|
||||
}));
|
||||
|
||||
document.getElementById("wfm-sem-recursive").addEventListener("change", (e) => {
|
||||
lsSet(LS_SUB, e.target.checked ? "1" : "0");
|
||||
refreshModal();
|
||||
});
|
||||
document.getElementById("wfm-sem-close").addEventListener("click", closeSemanticModal);
|
||||
document.querySelectorAll(".wfm-sem-tab-btn").forEach(btn => btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".wfm-sem-tab-btn").forEach(b => b.classList.toggle("active", b === btn));
|
||||
document.querySelectorAll(".wfm-sem-pane").forEach(p => { p.style.display = p.dataset.semPane === btn.dataset.semTab ? "" : "none"; });
|
||||
lsSet(LS_TAB, btn.dataset.semTab);
|
||||
}));
|
||||
["wfm-sem-th-similar", "wfm-sem-th-text"].forEach(id => document.getElementById(id).addEventListener("change", saveThresholdInputs));
|
||||
document.getElementById("wfm-sem-th-limit").addEventListener("change", saveThresholdInputs);
|
||||
document.getElementById("wfm-sem-help").addEventListener("click", openSemanticHelp);
|
||||
document.getElementById("wfm-sem-start").addEventListener("click", async () => {
|
||||
const mode = document.querySelector('input[name="wfm-sem-mode"]:checked')?.value || "missing";
|
||||
if (mode === "all" && !confirm(t("semConfirmAll"))) return;
|
||||
lsSet(LS_DIM, String(dimOf()));
|
||||
lsSet(LS_MODEL, selectedModel());
|
||||
lsSet(LS_SOFT, document.getElementById("wfm-sem-soft").value);
|
||||
try {
|
||||
await postJson(API.index, {
|
||||
folder: ctx.getFolder(),
|
||||
recursive: document.getElementById("wfm-sem-recursive").checked,
|
||||
mode,
|
||||
model: selectedModel(),
|
||||
dim: dimOf(),
|
||||
...localOpts(),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(semanticErrorMessage(e), "error");
|
||||
}
|
||||
refreshModal();
|
||||
});
|
||||
document.getElementById("wfm-sem-cancel").addEventListener("click", async () => {
|
||||
await postJson(API.cancel, {}).catch(() => {});
|
||||
refreshModal();
|
||||
});
|
||||
document.getElementById("wfm-sem-test").addEventListener("click", async () => {
|
||||
try {
|
||||
const r = await postJson(API.test, { model: selectedModel(), dim: dimOf(), ...localOpts() });
|
||||
showToast(r.provider && r.provider !== "API" ? t("semTestOkProvider", r.dimensions, r.provider) : t("semTestOk", r.dimensions), "success");
|
||||
} catch (e) {
|
||||
showToast(semanticErrorMessage(e), "error");
|
||||
}
|
||||
});
|
||||
document.getElementById("wfm-sem-clear").addEventListener("click", async () => {
|
||||
if (!confirm(t("semConfirmClear"))) return;
|
||||
try {
|
||||
await postJson(API.clear, {});
|
||||
showToast(t("semCleared"), "success");
|
||||
refreshSemanticDots();
|
||||
} catch (e) {
|
||||
showToast(semanticErrorMessage(e), "error");
|
||||
}
|
||||
refreshModal();
|
||||
});
|
||||
refreshModal();
|
||||
}
|
||||
|
||||
// ── 初期化 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} context
|
||||
* getSearch(): 検索ボックスの文字列 / getFolder(): 選択中フォルダ / getSelected(): 選択中画像
|
||||
* reload(): 一覧再読込(loadImages)
|
||||
*/
|
||||
export function initSemantic(context) {
|
||||
ctx = context;
|
||||
sem.on = lsGet(LS_ON, "0") === "1";
|
||||
updateToggleUi();
|
||||
updateSimilarButtons();
|
||||
|
||||
document.getElementById("wfm-gallery-semantic-toggle")?.addEventListener("click", () => {
|
||||
sem.on = !sem.on;
|
||||
lsSet(LS_ON, sem.on ? "1" : "0");
|
||||
updateToggleUi();
|
||||
// OFFにしたら類似表示も解除する(2行目ごと消えるため)
|
||||
const wasSimilar = !!sem.anchor;
|
||||
if (!sem.on) exitSimilarMode();
|
||||
// 検索文字列があれば、モード切替後の方式で結果を取り直す
|
||||
if (wasSimilar && !sem.on || (ctx.getSearch() || "").trim()) ctx.reload();
|
||||
});
|
||||
fetch(API.status("", true)).then(r => r.json()).then(st => {
|
||||
if (st.indexed_total) sem.indexModel = st.index_model;
|
||||
}).catch(() => { /* 取得できなくても既定のモデル扱いで動作する */ });
|
||||
document.getElementById("wfm-gallery-semantic-settings-btn")?.addEventListener("click", openSemanticModal);
|
||||
document.getElementById("wfm-gallery-similar-btn")?.addEventListener("click", toggleSimilar);
|
||||
document.getElementById("wfm-gallery-similar-refresh-btn")?.addEventListener("click", applySimilar);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { loadFileIntoMetadataTab, extractPrompts, buildPromptItem } from "./metadata-tab.js";
|
||||
import { loadFileIntoMetadataTab, extractPrompts, extractPromptsFromParameters, buildPromptItem } from "./metadata-tab.js";
|
||||
import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { escapeHtml, setupSearchClearBtn, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
@@ -14,6 +14,8 @@ 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";
|
||||
import { initSemantic, refreshSemanticDots, getSemanticMode, isSemanticOn, fetchSemanticImages, semanticCountLabel, setSemanticCountText, semanticErrorMessage, exitSimilarMode } from "./gallery-semantic.js";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,9 +25,11 @@ const API = {
|
||||
imageMeta: (path) => `/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`,
|
||||
imageWorkflow: (path) => `/wfm/gallery/image/workflow?path=${encodeURIComponent(path)}`,
|
||||
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
|
||||
thumb: (path, w = 256) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
|
||||
// 音声の波形サムネイルは描画仕様を変えた際にブラウザのHTTPキャッシュ(24h)を回避するため版数を付ける
|
||||
thumb: (path, w = 256) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}${isAudioFile({ path }) ? `&v=${WAVE_THUMB_VERSION}` : ""}`,
|
||||
bulkFavorite: "/wfm/gallery/bulk/favorite",
|
||||
bulkGroup: "/wfm/gallery/bulk/group",
|
||||
convert: "/wfm/gallery/convert",
|
||||
saveImageMeta: `/wfm/gallery/image/meta`,
|
||||
toggleFavorite: `/wfm/gallery/image/favorite`,
|
||||
groups: `/wfm/gallery/groups`,
|
||||
@@ -42,15 +46,51 @@ 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__";
|
||||
|
||||
// 拡張子から動画ファイル(mp4)かどうかを判定する。
|
||||
const _RESERVED_GROUPS = [FEEDER_GROUP, VIDEO_GROUP, VTEMP_GROUP];
|
||||
function _isReservedGroup(name) {
|
||||
return _RESERVED_GROUPS.includes(name);
|
||||
}
|
||||
|
||||
// 拡張子から動画ファイル(mp4/webm)かどうかを判定する。
|
||||
// 一覧アイテムは path/filename、詳細メタ(get_image_metadata)は ext フィールドを持つため両対応。
|
||||
function isVideoFile(img) {
|
||||
const source = img?.ext || img?.filename || img?.path || "";
|
||||
return source.toLowerCase().endsWith(".mp4");
|
||||
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
|
||||
export function isVideoFile(img) {
|
||||
const source = (img?.ext || img?.filename || img?.path || "").toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some(ext => source.endsWith(ext));
|
||||
}
|
||||
|
||||
// 音声の波形サムネイルの版数。py/services/gallery_service.py の WAVE_THUMB_VERSION と同じ値にすること。
|
||||
const WAVE_THUMB_VERSION = "wave2";
|
||||
|
||||
// 音声ファイル(ComfyUIの音楽生成出力)。サムネイルはサーバー生成の波形画像、再生は<audio>。
|
||||
const AUDIO_EXTENSIONS = [".wav", ".mp3", ".ogg", ".m4a", ".flac", ".opus"];
|
||||
export function isAudioFile(img) {
|
||||
const source = (img?.ext || img?.filename || img?.path || "").toLowerCase();
|
||||
return AUDIO_EXTENSIONS.some(ext => source.endsWith(ext));
|
||||
}
|
||||
|
||||
// プレビュー/ライトボックス/比較用のメディア要素HTML。音声は波形画像+<audio controls>。
|
||||
function mediaElementHtml(img, { imgClass, videoAttrs = "controls", audioAttrs = "controls", title = "" }) {
|
||||
const src = API.serveImage(img.path);
|
||||
const titleAttr = title ? ` title="${title}"` : "";
|
||||
if (isVideoFile(img)) return `<video src="${src}" class="${imgClass}" ${videoAttrs}${titleAttr}></video>`;
|
||||
if (isAudioFile(img)) {
|
||||
return `<div class="wfm-gallery-audio-box">
|
||||
<img src="${API.thumb(img.path, 512)}" class="${imgClass} wfm-gallery-audio-wave" alt="${escapeHtml(img.filename)}"${titleAttr}>
|
||||
<audio src="${src}" class="wfm-gallery-audio-player" ${audioAttrs}></audio>
|
||||
</div>`;
|
||||
}
|
||||
return `<img src="${src}" class="${imgClass}" alt="${escapeHtml(img.filename)}"${titleAttr}>`;
|
||||
}
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Generate UIのImage入力スロットへ直接セットする(I2I連携)。
|
||||
@@ -221,6 +261,9 @@ window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, work
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
let _scrollObserver = null;
|
||||
let _loadImagesAbortController = null;
|
||||
let _searchDebounceTimer = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// ── 状態 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -260,6 +303,14 @@ function formatDate(mtime) {
|
||||
return new Date(mtime * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
// cc_nanobananaフォルダの画像はComfyUIワークフローを埋め込んでいないため、Promptタブに
|
||||
// 表示する情報が無い(Nanobananaタブのprompt欄と役割が重複する)。フォルダ名そのもので判定する
|
||||
// (history.jsonlに対応エントリが無い古い画像でも、フォルダが同じなら同様に隠す)。
|
||||
function isNanobananaFolderImage(img) {
|
||||
const parts = (img.path || "").split(/[\\/]/);
|
||||
return parts[parts.length - 2] === "cc_nanobanana";
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const total = Math.round(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
@@ -273,7 +324,7 @@ async function openImageInMetadataTab(img) {
|
||||
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);
|
||||
await loadFileIntoMetadataTab(file, img.path);
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
@@ -322,6 +373,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() {
|
||||
@@ -379,6 +454,7 @@ async function loadFolderTree() {
|
||||
state.folderTree = data;
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
refreshSemanticDots(state.outputRoot);
|
||||
|
||||
if (isFirstLoad) {
|
||||
// 初回のみ root を自動選択
|
||||
@@ -434,6 +510,7 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
state.selectedImages.clear();
|
||||
state.lastSelectionIndex = -1;
|
||||
updateBulkBar();
|
||||
exitSimilarMode();
|
||||
loadImages();
|
||||
// Delete Folder ボタン: rootは削除不可
|
||||
const delBtn = document.getElementById("wfm-gallery-folder-delete-btn");
|
||||
@@ -467,6 +544,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);
|
||||
}
|
||||
@@ -477,6 +564,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>`;
|
||||
|
||||
@@ -488,18 +585,51 @@ 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";
|
||||
}
|
||||
|
||||
// セマンティック検索/類似画像モード: サーバーがスコア順で返すためソート・各フィルタは適用しない
|
||||
const semMode = getSemanticMode();
|
||||
|
||||
try {
|
||||
const images = (await apiFetch(API.images(params))).images || [];
|
||||
if (semMode) {
|
||||
const images = await fetchSemanticImages(semMode, state.currentFolder);
|
||||
if (controller.signal.aborted) return;
|
||||
state.images = images;
|
||||
state.lastSelectionIndex = -1;
|
||||
document.getElementById("wfm-gallery-count").textContent = `${images.length} images`;
|
||||
setSemanticCountText(semanticCountLabel(semMode, images.length));
|
||||
renderImages();
|
||||
return;
|
||||
}
|
||||
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`;
|
||||
setSemanticCountText("");
|
||||
renderImages();
|
||||
updateTagFilter(state.images);
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") return;
|
||||
if (semMode) {
|
||||
state.images = [];
|
||||
document.getElementById("wfm-gallery-count").textContent = "";
|
||||
setSemanticCountText("");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${escapeHtml(semanticErrorMessage(e))}</p>`;
|
||||
showToast(semanticErrorMessage(e), "error");
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
} finally {
|
||||
if (_loadImagesAbortController === controller) {
|
||||
_loadImagesAbortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,13 +723,21 @@ function createThumbCard(img) {
|
||||
card.appendChild(imgEl);
|
||||
|
||||
// 動画ファイルは再生アイコンバッジを重ねて表示(サムネイル自体は先頭フレームのJPEG)
|
||||
if (isVideoFile(img)) {
|
||||
if (isVideoFile(img) || isAudioFile(img)) {
|
||||
const playBadge = document.createElement("div");
|
||||
playBadge.className = "wfm-gallery-thumb-video-badge";
|
||||
playBadge.textContent = "▶";
|
||||
playBadge.textContent = isAudioFile(img) ? "♪" : "▶";
|
||||
card.appendChild(playBadge);
|
||||
}
|
||||
|
||||
// セマンティック検索/類似画像の結果にはコサイン類似度を表示
|
||||
if (img.score != null) {
|
||||
const scoreBadge = document.createElement("div");
|
||||
scoreBadge.className = "wfm-gallery-thumb-score";
|
||||
scoreBadge.textContent = img.score.toFixed(3);
|
||||
card.appendChild(scoreBadge);
|
||||
}
|
||||
|
||||
// お気に入りトグルボタン(カード右上)
|
||||
const favBtn = document.createElement("button");
|
||||
favBtn.className = `wfm-gallery-thumb-fav-btn${img.favorite ? " active" : ""}`;
|
||||
@@ -721,7 +859,7 @@ function createTable(images) {
|
||||
tdSize.textContent = formatBytes(img.size);
|
||||
|
||||
const tdDate = document.createElement("td");
|
||||
tdDate.textContent = formatDate(img.mtime);
|
||||
tdDate.textContent = formatDate(img.mtime) + (img.score != null ? ` (${img.score.toFixed(3)})` : "");
|
||||
|
||||
const tdTags = document.createElement("td");
|
||||
tdTags.innerHTML = (img.tags || []).map(tag => `<span class="wfm-gallery-tag-badge">${escapeHtml(tag)}</span>`).join("");
|
||||
@@ -800,6 +938,12 @@ function updateBulkBar() {
|
||||
} else {
|
||||
bar.style.display = "none";
|
||||
}
|
||||
// Convert ボタンは音声/動画を含む選択時のみ表示
|
||||
const convertBtn = document.getElementById("wfm-gallery-bulk-convert");
|
||||
if (convertBtn) {
|
||||
convertBtn.style.display = count > 0 && [...state.selectedImages].some(p => isConvertible(imageByPath(p))) ? "" : "none";
|
||||
convertBtn.textContent = t("galleryBulkConvert");
|
||||
}
|
||||
// Compare ボタンは 2〜4 枚選択時のみ表示
|
||||
const compareBtn = document.getElementById("wfm-gallery-bulk-compare");
|
||||
if (compareBtn) {
|
||||
@@ -902,15 +1046,18 @@ async function loadImageDetail(img) {
|
||||
// プレビュー(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">`;
|
||||
const isAudio = isAudioFile(img);
|
||||
// 音声は波形の下にプレイヤーが要るため、固定高さの枠をやめて内容なりの高さにする
|
||||
preview.classList.toggle("wfm-gallery-detail-preview--audio", isAudio);
|
||||
preview.innerHTML = `
|
||||
<div class="wfm-gallery-preview-wrapper">
|
||||
${mediaHtml}
|
||||
${mediaElementHtml(img, { imgClass: "wfm-gallery-detail-img", title: "Double-click to enlarge" })}
|
||||
</div>
|
||||
`;
|
||||
preview.querySelector(isVideo ? "video" : "img").addEventListener("dblclick", () => openLightbox(img));
|
||||
const previewMediaEl = preview.querySelector(isVideo ? "video" : "img");
|
||||
previewMediaEl.addEventListener("dblclick", () => openLightbox(img));
|
||||
if (isVideo) applyStoredVideoVolume(previewMediaEl);
|
||||
if (isAudio) applyStoredVideoVolume(preview.querySelector("audio"));
|
||||
|
||||
// ファイル名
|
||||
document.getElementById("wfm-gallery-detail-filename").textContent = img.filename;
|
||||
@@ -926,6 +1073,8 @@ async function loadImageDetail(img) {
|
||||
// メモ
|
||||
document.getElementById("wfm-gallery-memo").value = img.memo || "";
|
||||
|
||||
updatePromptTabVisibility(isNanobananaFolderImage(img));
|
||||
|
||||
// 埋め込みメタデータとworkflow取得
|
||||
try {
|
||||
const [metaRes, wfRes] = await Promise.all([
|
||||
@@ -937,15 +1086,17 @@ async function loadImageDetail(img) {
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(metaRes.image_prompt);
|
||||
renderDimensionInfo(metaRes);
|
||||
renderNanobananaTab(metaRes.nanobanana);
|
||||
// Promptタブは prompt_workflow (API形式優先) を使う。トップレベルとサブグラフに
|
||||
// 独立した複数系統を持つワークフローでは workflow (UI形式) からの抽出だと
|
||||
// トップレベル系統しか拾えないことがあるため(Metadataタブと同じ優先順位に揃える)。
|
||||
renderPromptTab(wfRes.prompt_workflow ?? state.embeddedWorkflow);
|
||||
renderPromptTab(wfRes.prompt_workflow ?? state.embeddedWorkflow, metaRes.embedded);
|
||||
} catch (e) {
|
||||
renderWorkflowJson(null);
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(null);
|
||||
renderDimensionInfo(null);
|
||||
renderNanobananaTab(null);
|
||||
renderPromptTab(null);
|
||||
}
|
||||
|
||||
@@ -962,6 +1113,53 @@ function renderDimensionInfo(metaRes) {
|
||||
el.textContent = parts.length ? parts.join(" · ") : "";
|
||||
}
|
||||
|
||||
// cc_nanobananaフォルダの画像はComfyUIワークフローを埋め込んでいないためPromptタブに
|
||||
// 表示する情報が無い。隠す際、Promptタブが選択中だったらInfoタブに切り替える。
|
||||
function updatePromptTabVisibility(hide) {
|
||||
const promptTabBtn = document.querySelector('.wfm-gallery-detail-tab-btn[data-detail-tab="prompt"]');
|
||||
if (!promptTabBtn) return;
|
||||
promptTabBtn.style.display = hide ? "none" : "";
|
||||
if (hide && promptTabBtn.classList.contains("active")) {
|
||||
promptTabBtn.classList.remove("active");
|
||||
document.querySelector('.wfm-gallery-detail-tab-btn[data-detail-tab="info"]')?.click();
|
||||
}
|
||||
}
|
||||
|
||||
// cc_nanobananaフォルダの画像のみ、history.jsonlから対応エントリ(server側で検索済み)を
|
||||
// タブ内容に反映する。対応エントリが無ければタブ自体を隠す(他フォルダの画像を見た時も同様)。
|
||||
function renderNanobananaTab(entry) {
|
||||
const tabBtn = document.getElementById("wfm-gallery-detail-tab-nanobanana");
|
||||
if (!tabBtn) return;
|
||||
if (!entry) {
|
||||
tabBtn.style.display = "none";
|
||||
if (tabBtn.classList.contains("active")) {
|
||||
tabBtn.classList.remove("active");
|
||||
// Promptタブはcc_nanobananaフォルダでは非表示にしているため、代わりにInfoへ戻す
|
||||
document.querySelector('.wfm-gallery-detail-tab-btn[data-detail-tab="info"]')?.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
tabBtn.style.display = "";
|
||||
const set = (id, label, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value ? `${label}: ${value}` : "";
|
||||
};
|
||||
set("wfm-gallery-nb-engine", "Engine", entry.engine);
|
||||
set("wfm-gallery-nb-model", "Model", entry.model);
|
||||
const size = entry.image_size || (entry.width && entry.height ? `${entry.width}×${entry.height}` : "");
|
||||
set("wfm-gallery-nb-size", "Size", size);
|
||||
set("wfm-gallery-nb-seed", "Seed", entry.seed != null ? String(entry.seed) : "");
|
||||
set("wfm-gallery-nb-timestamp", "Date", entry.timestamp);
|
||||
const promptEl = document.getElementById("wfm-gallery-nb-prompt");
|
||||
if (promptEl) promptEl.value = entry.prompt || "";
|
||||
const negSection = document.getElementById("wfm-gallery-nb-negative-section");
|
||||
const negEl = document.getElementById("wfm-gallery-nb-negative");
|
||||
if (negSection && negEl) {
|
||||
if (entry.negative_prompt) { negEl.value = entry.negative_prompt; negSection.style.display = ""; }
|
||||
else { negEl.value = ""; negSection.style.display = "none"; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderImagePromptSection(imagePrompt) {
|
||||
const section = document.getElementById("wfm-gallery-image-prompt-section");
|
||||
const text = document.getElementById("wfm-gallery-image-prompt-text");
|
||||
@@ -1006,7 +1204,7 @@ function renderWorkflowJson(workflow) {
|
||||
// 複数候補があるケース(PromptStyler等で正負が複数生成される場合)に対応するため、
|
||||
// Metadataタブと同じ POS/NEGリスト → クリックで全文表示のUI(buildPromptItem)をそのまま
|
||||
// 再利用する。選択画像を切り替えるたびにマウス操作無しで読めるよう、先頭項目を自動選択する。
|
||||
function renderPromptTab(workflow) {
|
||||
function renderPromptTab(workflow, embedded = null) {
|
||||
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");
|
||||
@@ -1016,12 +1214,14 @@ function renderPromptTab(workflow) {
|
||||
fullArea.value = "";
|
||||
fullLabel.textContent = "";
|
||||
|
||||
if (!workflow) {
|
||||
// ワークフローが無い画像はA1111/Forge/Fooocusの parameters テキストから抽出する
|
||||
const extracted = workflow ? extractPrompts(workflow) : extractPromptsFromParameters(embedded);
|
||||
if (!extracted) {
|
||||
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 { positives, negatives, texts } = extracted;
|
||||
const allPrompts = [
|
||||
...positives.map(p => ({ type: "positive", text: p })),
|
||||
...negatives.map(p => ({ type: "negative", text: p })),
|
||||
@@ -1122,6 +1322,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 {
|
||||
@@ -1136,6 +1363,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() {
|
||||
// ツールバー: グループフィルタ
|
||||
@@ -1215,15 +1455,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>
|
||||
`;
|
||||
@@ -1302,15 +1543,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");
|
||||
@@ -1525,10 +1769,9 @@ function openMoveModal(paths) {
|
||||
overlay.className = "wfm-gallery-lightbox";
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-gallery-move-modal">
|
||||
<div class="wfm-gallery-move-modal-title">Move ${paths.length} image(s) to folder:</div>
|
||||
<select id="wfm-gallery-move-dest-sel" class="wfm-select wfm-gallery-move-dest-sel">
|
||||
${destinations.map(f => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.name)}</option>`).join("")}
|
||||
</select>
|
||||
<div class="wfm-gallery-move-modal-title">${escapeHtml(t("moveToFolderTitle", paths.length))}</div>
|
||||
<input type="text" id="wfm-gallery-move-filter" class="wfm-input wfm-gallery-move-filter" placeholder="${escapeHtml(t("galleryMoveFilterPlaceholder"))}" autocomplete="off">
|
||||
<select id="wfm-gallery-move-dest-sel" class="wfm-select wfm-gallery-move-dest-sel" size="8"></select>
|
||||
<div class="wfm-gallery-move-modal-footer">
|
||||
<button id="wfm-gallery-move-confirm" class="wfm-btn wfm-btn-primary">Move</button>
|
||||
<button id="wfm-gallery-move-cancel" class="wfm-btn">Cancel</button>
|
||||
@@ -1536,6 +1779,31 @@ function openMoveModal(paths) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const destSel = overlay.querySelector("#wfm-gallery-move-dest-sel");
|
||||
const filterInput = overlay.querySelector("#wfm-gallery-move-filter");
|
||||
const confirmBtn = overlay.querySelector("#wfm-gallery-move-confirm");
|
||||
// フォルダ名・パスの部分一致(大文字小文字無視、空白区切りはAND)で絞り込む
|
||||
const renderDestinations = () => {
|
||||
const words = filterInput.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const matched = destinations.filter(f => {
|
||||
const hay = `${f.name} ${f.abs_path}`.toLowerCase();
|
||||
return words.every(w => hay.includes(w));
|
||||
});
|
||||
destSel.innerHTML = matched
|
||||
.map(f => `<option value="${escapeHtml(f.abs_path)}" title="${escapeHtml(f.abs_path)}">${escapeHtml(f.name)}</option>`)
|
||||
.join("");
|
||||
if (matched.length > 0) destSel.selectedIndex = 0;
|
||||
confirmBtn.disabled = matched.length === 0;
|
||||
};
|
||||
filterInput.addEventListener("input", renderDestinations);
|
||||
filterInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); if (!confirmBtn.disabled) confirmBtn.click(); }
|
||||
else if (e.key === "ArrowDown") { e.preventDefault(); destSel.focus(); }
|
||||
});
|
||||
destSel.addEventListener("dblclick", () => { if (!confirmBtn.disabled) confirmBtn.click(); });
|
||||
renderDestinations();
|
||||
setTimeout(() => filterInput.focus(), 0); // 開いたらすぐ入力できるように
|
||||
|
||||
overlay.querySelector("#wfm-gallery-move-cancel").addEventListener("click", () => overlay.remove());
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
overlay.querySelector("#wfm-gallery-move-confirm").addEventListener("click", async () => {
|
||||
@@ -1548,6 +1816,146 @@ function openMoveModal(paths) {
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ── 変換(音声変換 / 動画からの音声抽出) ───────────────────────
|
||||
|
||||
const CONVERT_FORMATS = {
|
||||
flac: { bitrate: false },
|
||||
mp3: { bitrate: true, bitrates: [320, 256, 192, 128, 96], defaultBitrate: 192 },
|
||||
opus: { bitrate: true, bitrates: [192, 128, 96, 64, 32], defaultBitrate: 128 },
|
||||
m4a: { bitrate: true, bitrates: [256, 192, 160, 128, 96], defaultBitrate: 192 },
|
||||
wav: { bitrate: false },
|
||||
};
|
||||
|
||||
// パス→画像のMap。state.imagesは再代入/filterで新しい配列になるため、配列ごとにキャッシュする
|
||||
// (選択のたびに O(選択数 × 画像数) の線形探索をしないため)。
|
||||
const _imageByPathCache = new WeakMap();
|
||||
function imageByPath(path) {
|
||||
let map = _imageByPathCache.get(state.images);
|
||||
if (!map) {
|
||||
map = new Map(state.images.map(i => [i.path, i]));
|
||||
_imageByPathCache.set(state.images, map);
|
||||
}
|
||||
return map.get(path);
|
||||
}
|
||||
|
||||
function isConvertible(img) {
|
||||
return !!img && (isAudioFile(img) || isVideoFile(img));
|
||||
}
|
||||
|
||||
function convertTargets(paths) {
|
||||
return paths.map(imageByPath).filter(isConvertible);
|
||||
}
|
||||
|
||||
function openConvertModal(paths) {
|
||||
const targets = convertTargets(paths);
|
||||
if (targets.length === 0) {
|
||||
showToast(t("convertNoTarget"), "info");
|
||||
return;
|
||||
}
|
||||
const hasVideo = targets.some(isVideoFile);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "wfm-gallery-lightbox";
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-gallery-move-modal wfm-gallery-convert-modal">
|
||||
<div class="wfm-gallery-move-modal-title">${escapeHtml(t("convertTitle", targets.length))}</div>
|
||||
${hasVideo ? `<div class="wfm-gallery-convert-note">${escapeHtml(t("convertVideoNote"))}</div>` : ""}
|
||||
<label class="wfm-gallery-convert-row">${escapeHtml(t("convertFormat"))}
|
||||
<select id="wfm-convert-format" class="wfm-select">
|
||||
${Object.keys(CONVERT_FORMATS).map(f => `<option value="${f}">${f.toUpperCase()}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wfm-gallery-convert-row" id="wfm-convert-bitrate-row">${escapeHtml(t("convertBitrate"))}
|
||||
<select id="wfm-convert-bitrate" class="wfm-select"></select>
|
||||
</label>
|
||||
<label class="wfm-gallery-convert-row">${escapeHtml(t("convertSampleRate"))}
|
||||
<select id="wfm-convert-rate" class="wfm-select">
|
||||
<option value="0">${escapeHtml(t("convertKeep"))}</option>
|
||||
<option value="44100">44100 Hz</option>
|
||||
<option value="48000">48000 Hz</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wfm-gallery-convert-row">${escapeHtml(t("convertChannels"))}
|
||||
<select id="wfm-convert-channels" class="wfm-select">
|
||||
<option value="keep">${escapeHtml(t("convertKeep"))}</option>
|
||||
<option value="mono">${escapeHtml(t("convertMono"))}</option>
|
||||
<option value="stereo">${escapeHtml(t("convertStereo"))}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wfm-gallery-convert-check"><input type="checkbox" id="wfm-convert-inherit" checked> ${escapeHtml(t("convertInherit"))}</label>
|
||||
<label class="wfm-gallery-convert-check"><input type="checkbox" id="wfm-convert-copymeta" checked> ${escapeHtml(t("convertCopyMeta"))}</label>
|
||||
<label class="wfm-gallery-convert-check"><input type="checkbox" id="wfm-convert-delete"> ${escapeHtml(t("convertDeleteOriginal"))}</label>
|
||||
<div class="wfm-gallery-convert-status" id="wfm-convert-status"></div>
|
||||
<div class="wfm-gallery-move-modal-footer">
|
||||
<button id="wfm-convert-run" class="wfm-btn wfm-btn-primary">${escapeHtml(t("convertRun"))}</button>
|
||||
<button id="wfm-convert-cancel" class="wfm-btn">${escapeHtml(t("convertCancel"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const q = (id) => overlay.querySelector(id);
|
||||
const fmtSel = q("#wfm-convert-format");
|
||||
const brSel = q("#wfm-convert-bitrate");
|
||||
const syncFormat = () => {
|
||||
const cfg = CONVERT_FORMATS[fmtSel.value];
|
||||
q("#wfm-convert-bitrate-row").style.display = cfg.bitrate ? "" : "none";
|
||||
if (cfg.bitrate) {
|
||||
brSel.innerHTML = cfg.bitrates
|
||||
.map(b => `<option value="${b}"${b === cfg.defaultBitrate ? " selected" : ""}>${b} kbps</option>`).join("");
|
||||
}
|
||||
// タグを持てない形式ではタグ引き継ぎ指定が無効であることを見せる
|
||||
q("#wfm-convert-inherit").disabled = fmtSel.value === "wav";
|
||||
};
|
||||
fmtSel.addEventListener("change", syncFormat);
|
||||
syncFormat();
|
||||
|
||||
let running = false;
|
||||
q("#wfm-convert-cancel").addEventListener("click", () => { if (!running) overlay.remove(); });
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay && !running) overlay.remove(); });
|
||||
q("#wfm-convert-run").addEventListener("click", async () => {
|
||||
const deleteOriginal = q("#wfm-convert-delete").checked;
|
||||
if (deleteOriginal && !window.confirm(t("convertConfirmDelete", targets.length))) return;
|
||||
running = true;
|
||||
q("#wfm-convert-run").disabled = true;
|
||||
q("#wfm-convert-cancel").disabled = true;
|
||||
const opts = {
|
||||
format: fmtSel.value,
|
||||
bitrate: CONVERT_FORMATS[fmtSel.value].bitrate ? Number(brSel.value) : 0,
|
||||
sample_rate: Number(q("#wfm-convert-rate").value),
|
||||
channels: q("#wfm-convert-channels").value,
|
||||
inherit_metadata: q("#wfm-convert-inherit").checked,
|
||||
copy_gallery_meta: q("#wfm-convert-copymeta").checked,
|
||||
delete_original: deleteOriginal,
|
||||
};
|
||||
let ok = 0, fail = 0, tagsSkipped = false;
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
q("#wfm-convert-status").textContent = t("convertProgress", i + 1, targets.length);
|
||||
try {
|
||||
const res = await fetch(API.convert, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: targets[i].path, ...opts }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
ok++;
|
||||
if (data.tags_skipped) tagsSkipped = true;
|
||||
if (data.deleted) state.selectedImages.delete(targets[i].path);
|
||||
} catch (e) {
|
||||
fail++;
|
||||
console.warn("[Gallery] convert failed:", targets[i].filename, e);
|
||||
}
|
||||
}
|
||||
overlay.remove();
|
||||
showToast(t("convertDone", ok, fail), fail ? "error" : "success");
|
||||
if (tagsSkipped && opts.inherit_metadata) showToast(t("convertTagsSkipped"), "info");
|
||||
await loadImages();
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ── 一括操作 ─────────────────────────────────────────────────
|
||||
|
||||
async function bulkAddToGroup(groupName) {
|
||||
@@ -1631,9 +2039,9 @@ 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)}">`;
|
||||
const mediaHtml = mediaElementHtml(img, {
|
||||
imgClass: "wfm-gallery-lightbox-img", videoAttrs: "controls autoplay", audioAttrs: "controls autoplay",
|
||||
});
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-gallery-lightbox-inner">
|
||||
${mediaHtml}
|
||||
@@ -1647,6 +2055,8 @@ function openLightbox(img) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
if (isVideoFile(img)) applyStoredVideoVolume(overlay.querySelector("video"));
|
||||
if (isAudioFile(img)) applyStoredVideoVolume(overlay.querySelector("audio"));
|
||||
}
|
||||
|
||||
// ── 画像比較ライトボックス ─────────────────────────────────────
|
||||
@@ -1659,9 +2069,11 @@ function openCompare(paths) {
|
||||
overlay.className = "wfm-gallery-lightbox wfm-lightbox-compare";
|
||||
|
||||
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">`;
|
||||
const mediaHtml = isAudioFile(img)
|
||||
? mediaElementHtml(img, { imgClass: "wfm-lightbox-compare-img" })
|
||||
: 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">
|
||||
${mediaHtml}
|
||||
@@ -1682,6 +2094,7 @@ function openCompare(paths) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelectorAll("video, audio").forEach(applyStoredVideoVolume);
|
||||
}
|
||||
|
||||
// ── outputパス取得 ────────────────────────────────────────────
|
||||
@@ -1761,17 +2174,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 タブへ送信
|
||||
@@ -1835,6 +2263,37 @@ function bindEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
// 選択動画をComfyUIキャンバス上で選択中のノード(ネイティブLoad Video)のfileウィジェットへ送信。
|
||||
// Send to LI nodeの動画版(window.opener.wfmSendVideoToSelectedNode、node_sets_menu.js側で定義)。
|
||||
// /upload/imageは拡張子を問わず任意バイナリをinputフォルダへ保存するエンドポイントなので、
|
||||
// 動画ファイルもそのままアップロードできる。
|
||||
document.getElementById("wfm-gallery-send-lv-node-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedImage) {
|
||||
showToast(t("gallerySelectImageFirst"), "info");
|
||||
return;
|
||||
}
|
||||
if (!isVideoFile(state.selectedImage)) {
|
||||
showToast(t("sendToLvNodeNotVideo"), "info");
|
||||
return;
|
||||
}
|
||||
if (!window.opener || typeof window.opener.wfmSendVideoToSelectedNode !== "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-video.mp4", { type: blob.type || "video/mp4" });
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
const filename = result.subfolder ? `${result.subfolder}/${result.name}` : result.name;
|
||||
window.opener.wfmSendVideoToSelectedNode(filename);
|
||||
showToast(t("sendToLvNodeSuccess", 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");
|
||||
@@ -1853,6 +2312,7 @@ function bindEvents() {
|
||||
return;
|
||||
}
|
||||
const url = API.serveImage(state.selectedImage.path);
|
||||
const isVideo = isVideoFile(state.selectedImage);
|
||||
|
||||
// I2I送信元がImageタブだった場合はそちらへ画像を読み込ませる(_ccI2ITargetModeはComic Creater側が
|
||||
// 「I2Iへ送る」ボタン押下時にセットするフラグ。undefined/'layout'なら従来通りコマ/オーバーレイへ挿入)
|
||||
@@ -1863,6 +2323,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") {
|
||||
@@ -1880,6 +2345,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") {
|
||||
@@ -1897,12 +2382,24 @@ function bindEvents() {
|
||||
});
|
||||
}
|
||||
|
||||
// 検索
|
||||
// 検索(連続キー入力のたびにリクエストを飛ばすと、50枚程度のフォルダでも
|
||||
// ファイルI/Oの重いサーバー処理が積み重なって読み込みが遅延するためdebounceする)
|
||||
document.getElementById("wfm-gallery-search")?.addEventListener("input", (e) => {
|
||||
state.search = e.target.value;
|
||||
loadImages();
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
// Semantic Search ON中は入力のたびにAPIを呼ばず、Enterで実行する(クエリ埋め込みにAPI呼び出しが要るため)。
|
||||
// 全文消去した時だけ通常一覧へ即復帰する。
|
||||
if (isSemanticOn() && state.search.trim()) return;
|
||||
_searchDebounceTimer = setTimeout(loadImages, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
document.getElementById("wfm-gallery-search")?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && isSemanticOn() && !e.isComposing) {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
loadImages();
|
||||
}
|
||||
});
|
||||
setupSearchClearBtn("wfm-gallery-search", "wfm-gallery-search-clear-btn", () => {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
@@ -2056,6 +2553,41 @@ function bindEvents() {
|
||||
exportSelectedImagesToZip([...state.selectedImages]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-gallery-bulk-convert")?.addEventListener("click", () => {
|
||||
if (state.selectedImages.size === 0) return;
|
||||
openConvertModal([...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 => !isVideoFile({ path: p }) && !isAudioFile({ path: 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;
|
||||
@@ -2150,6 +2682,7 @@ function bindEvents() {
|
||||
if (galleryClearBtn) {
|
||||
galleryClearBtn.textContent = t("clearFilters");
|
||||
galleryClearBtn.addEventListener("click", () => {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
state.favoriteOnly = false;
|
||||
state.tagFilter = "";
|
||||
@@ -2162,7 +2695,15 @@ function bindEvents() {
|
||||
if (groupFilter) groupFilter.value = "";
|
||||
const favBtn = document.getElementById("wfm-gallery-fav-btn");
|
||||
if (favBtn) favBtn.classList.remove("active");
|
||||
exitSimilarMode();
|
||||
loadImages();
|
||||
});
|
||||
}
|
||||
|
||||
initSemantic({
|
||||
getSearch: () => state.search,
|
||||
getFolder: () => state.currentFolder,
|
||||
getSelected: () => state.selectedImage,
|
||||
reload: loadImages,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,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 {
|
||||
@@ -2284,10 +2291,11 @@ export async function initGenerateTab() {
|
||||
});
|
||||
searchOverlay.innerHTML = html + "\n";
|
||||
|
||||
// Scroll editor to current match
|
||||
// Scroll editor to current match. フォーカス/selectionRangeは意図的に設定しない
|
||||
// (テキストエリアにカーソルが当たった状態になり、検索直後のキー入力が選択範囲を
|
||||
// 上書きして不意にJSONを編集してしまうため)。ハイライトはsearchOverlayの<mark>
|
||||
// 表示のみで行い、編集はユーザーが明示的にエディタをクリックした場合に限る。
|
||||
const pos = matchPositions[currentMatchIndex];
|
||||
editor.focus();
|
||||
editor.setSelectionRange(pos, pos + term.length);
|
||||
const lineNum = (text.slice(0, pos).match(/\n/g) || []).length;
|
||||
const lineH = parseFloat(getComputedStyle(editor).lineHeight) || 18;
|
||||
editor.scrollTop = Math.max(0, lineH * lineNum - editor.clientHeight / 2);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DrawTool } from "./image-edit/DrawTool.js";
|
||||
import { TextTool, TEXT_FONTS } from "./image-edit/TextTool.js";
|
||||
import { SelectTool } from "./image-edit/SelectTool.js";
|
||||
import { ShapeTool } from "./image-edit/ShapeTool.js";
|
||||
import { FillTool } from "./image-edit/FillTool.js";
|
||||
import { MaskTool } from "./image-edit/MaskTool.js";
|
||||
import { MaskColorTool, MaskAlphaTool, MaskTextTool, MaskVectorTool, MaskShapeTool, MASK_TEXT_FONTS } from "./image-edit/MaskEditorOneTools.js";
|
||||
import { GmicIntegration } from "./image-edit/GmicIntegration.js";
|
||||
@@ -28,6 +29,7 @@ const TOOL_DEFS = [
|
||||
{ id: "draw", icon: "✏", label: "Draw", ready: true },
|
||||
{ id: "text", icon: "T", label: "Text", ready: true },
|
||||
{ id: "shape", icon: "□", label: "Shape", ready: true },
|
||||
{ id: "fill", icon: "🪣", label: "Fill", ready: true },
|
||||
{ id: "mask", icon: "🎭", label: "Mask", ready: true },
|
||||
{ id: "blur", icon: "≈", label: "Blur", ready: true },
|
||||
{ id: "filter", icon: "★", label: "Filter", ready: true },
|
||||
@@ -63,6 +65,7 @@ class ImageEditTab {
|
||||
this._editingTextLayer = null;
|
||||
this._initialized = false;
|
||||
this._shapeTool = new ShapeTool();
|
||||
this._fillTool = new FillTool();
|
||||
// Blur ツール
|
||||
this._blurTool = new BlurTool({
|
||||
getLayerManager: () => this._layerMgr,
|
||||
@@ -241,6 +244,11 @@ class ImageEditTab {
|
||||
if (this._activeTool === "text") this._textTool?.deactivate();
|
||||
if (this._activeTool === "select") this._selectTool?.deactivate();
|
||||
if (this._activeTool === "shape") this._shapeTool?.deactivate();
|
||||
if (this._activeTool === "fill") {
|
||||
this._fillTool?.deactivate();
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.style.cursor = "";
|
||||
}
|
||||
if (this._activeTool === "mask") this._deactivateMaskSubtool();
|
||||
if (this._activeTool === "filter") {
|
||||
this._gmic.abort();
|
||||
@@ -248,8 +256,8 @@ class ImageEditTab {
|
||||
if (this._activeTool === "blur") {
|
||||
this._blurTool.deactivate();
|
||||
}
|
||||
// Draw/Mask/Inpaint以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw" && toolId !== "inpaint") {
|
||||
// Draw/Mask/Fill/Inpaint以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw" && toolId !== "fill" && toolId !== "inpaint") {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
if (pane) pane.style.display = "none";
|
||||
}
|
||||
@@ -282,6 +290,11 @@ class ImageEditTab {
|
||||
this._shapeTool.setCanvas(overlayCanvas);
|
||||
this._shapeTool.activate();
|
||||
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
|
||||
} else if (this._activeTool === "fill" && this._fillTool) {
|
||||
const activeLayer = this._layerMgr?.activeLayer;
|
||||
if (activeLayer) this._fillTool.setCanvas(activeLayer.canvas);
|
||||
this._fillTool.activate();
|
||||
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
|
||||
} else if (this._activeTool === "mask") {
|
||||
this._initMaskEditorOneTools();
|
||||
this._activateMaskSubtool();
|
||||
@@ -344,6 +357,10 @@ class ImageEditTab {
|
||||
el.innerHTML = "";
|
||||
this._renderDrawProps();
|
||||
|
||||
} else if (toolId === "fill" && this._fillTool) {
|
||||
el.innerHTML = "";
|
||||
this._renderFillProps();
|
||||
|
||||
} else if (toolId === "text" && this._textTool) {
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
@@ -968,6 +985,271 @@ class ImageEditTab {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fill Tool (bucket fill) props ─────────────────────────────────────
|
||||
|
||||
_renderFillProps() {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
const body = document.getElementById("ie-props-body");
|
||||
const title = document.getElementById("ie-props-title");
|
||||
if (!pane || !body) return;
|
||||
pane.style.display = "flex";
|
||||
if (title) title.textContent = "Fill";
|
||||
|
||||
const t = this._fillTool;
|
||||
const isGradient = t.fillMode === "gradient";
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label>Mode</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${!isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-solid" style="flex:1;">Solid</button>
|
||||
<button class="wfm-btn wfm-btn-sm${isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-gradient" style="flex:1;">Gradient</button>
|
||||
</div>
|
||||
</div>
|
||||
${!isGradient ? `
|
||||
<div class="ie-props-row">
|
||||
<label>Color</label>
|
||||
<input type="color" id="ie-fill-color" value="${t.color}"
|
||||
style="width:36px;height:24px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;flex-shrink:0;">
|
||||
</div>` : `
|
||||
<div class="ie-props-row">
|
||||
<label>Shape</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${t.gradientShape === "linear" ? " ie-opt-active" : ""}" id="ie-fill-shape-linear" style="flex:1;">Linear</button>
|
||||
<button class="wfm-btn wfm-btn-sm${t.gradientShape === "radial" ? " ie-opt-active" : ""}" id="ie-fill-shape-radial" style="flex:1;">Radial</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row" style="align-items:flex-start;">
|
||||
<label>Ramp</label>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;flex:1;min-width:0;">
|
||||
<canvas id="ie-fill-ramp" width="130" height="24" style="display:block;width:130px;height:24px;border:1px solid var(--wfm-border);border-radius:3px;cursor:pointer;"></canvas>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<input type="color" id="ie-fill-stop-color" value="${t.gradientStops[t.selectedStopIdx]?.color ?? "#ffffff"}"
|
||||
style="width:28px;height:22px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-fill-stop-add" title="Add stop">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-fill-stop-remove" title="Remove stop">-</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row" style="align-items:flex-start;">
|
||||
<label>Direction</label>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<canvas id="ie-fill-dir" width="70" height="70" style="width:70px;height:70px;border:1px solid var(--wfm-border);border-radius:3px;cursor:crosshair;"></canvas>
|
||||
<span id="ie-fill-strength-lbl" style="font-size:11px;color:var(--wfm-text-secondary);">${t.gradientStrength.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="ie-props-row">
|
||||
<label>Tolerance</label>
|
||||
<input type="range" id="ie-fill-tolerance" min="0" max="255" value="${t.tolerance}">
|
||||
<span id="ie-fill-tolerance-lbl">${t.tolerance}</span>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Opacity</label>
|
||||
<input type="range" id="ie-fill-opacity" min="1" max="100" value="${Math.round(t.opacity * 100)}">
|
||||
<span id="ie-fill-opacity-lbl">${Math.round(t.opacity * 100)}%</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("ie-fill-mode-solid")?.addEventListener("click", () => {
|
||||
t.fillMode = "solid";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-mode-gradient")?.addEventListener("click", () => {
|
||||
t.fillMode = "gradient";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-color")?.addEventListener("input", e => { t.color = e.target.value; });
|
||||
document.getElementById("ie-fill-tolerance")?.addEventListener("input", e => {
|
||||
t.tolerance = parseInt(e.target.value);
|
||||
document.getElementById("ie-fill-tolerance-lbl").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-fill-opacity")?.addEventListener("input", e => {
|
||||
t.opacity = parseInt(e.target.value) / 100;
|
||||
document.getElementById("ie-fill-opacity-lbl").textContent = e.target.value + "%";
|
||||
});
|
||||
|
||||
if (isGradient) {
|
||||
document.getElementById("ie-fill-shape-linear")?.addEventListener("click", () => {
|
||||
t.gradientShape = "linear";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-shape-radial")?.addEventListener("click", () => {
|
||||
t.gradientShape = "radial";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-color")?.addEventListener("input", e => {
|
||||
t.gradientStops[t.selectedStopIdx].color = e.target.value;
|
||||
this._drawFillGradientRamp();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-add")?.addEventListener("click", () => {
|
||||
t.addStop();
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-remove")?.addEventListener("click", () => {
|
||||
t.removeStop();
|
||||
this._renderFillProps();
|
||||
});
|
||||
this._setupFillGradientRamp();
|
||||
this._setupFillGradientDir();
|
||||
}
|
||||
}
|
||||
|
||||
/** カラーランプ(複数ストップの線形プレビュー)の描画とドラッグ操作。particle_widget.jsのカラーランプUIを移植。 */
|
||||
_drawFillGradientRamp() {
|
||||
const canvas = document.getElementById("ie-fill-ramp");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const t = this._fillTool;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const barH = h - 10; // 下部にハンドル用の余白を確保
|
||||
const grad = ctx.createLinearGradient(0, 0, w, 0);
|
||||
[...t.gradientStops].sort((a, b) => a.pos - b.pos).forEach(s => grad.addColorStop(s.pos, s.color));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, barH);
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.strokeRect(0.5, 0.5, w - 1, barH - 1);
|
||||
|
||||
t.gradientStops.forEach((s, i) => {
|
||||
// ハンドル(三角形)がcanvas端で切れて見えなくならないようクランプする
|
||||
const x = Math.max(5, Math.min(w - 5, s.pos * w));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, h);
|
||||
ctx.lineTo(x - 5, barH);
|
||||
ctx.lineTo(x + 5, barH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = i === t.selectedStopIdx ? "#0077ff" : "#999";
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
_setupFillGradientRamp() {
|
||||
const canvas = document.getElementById("ie-fill-ramp");
|
||||
if (!canvas) return;
|
||||
this._drawFillGradientRamp();
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
const t = this._fillTool;
|
||||
const hitTestStop = mx => {
|
||||
let best = -1, bestDist = 9;
|
||||
t.gradientStops.forEach((s, i) => {
|
||||
const d = Math.abs(s.pos * canvas.width - mx);
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
});
|
||||
return best;
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", e => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||
const idx = hitTestStop(mx);
|
||||
if (idx < 0) return;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
t.selectStop(idx);
|
||||
// ドラッグ中onMove/onUpが参照し続けるcanvas要素が入れ替わらないよう、
|
||||
// DOM全体を再構築する_renderFillProps()は使わず、選択状態の見た目だけを部分更新する
|
||||
this._drawFillGradientRamp();
|
||||
const colorInput = document.getElementById("ie-fill-stop-color");
|
||||
if (colorInput) colorInput.value = t.gradientStops[idx].color;
|
||||
|
||||
const onMove = ev => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const x = (ev.clientX - r.left) * (canvas.width / r.width);
|
||||
t.gradientStops[t.selectedStopIdx].pos = Math.max(0, Math.min(1, x / canvas.width));
|
||||
this._drawFillGradientRamp();
|
||||
};
|
||||
const onUp = () => {
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
/** グラデーション方向(linear)/半径(radial)を指定するミニコンパスUI。particle_widget.jsの矢印方向ハンドルを移植。 */
|
||||
_drawFillGradientDir() {
|
||||
const canvas = document.getElementById("ie-fill-dir");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
|
||||
const maxR = Math.min(w, h) / 2 - 6;
|
||||
const t = this._fillTool;
|
||||
const distFrac = (Math.max(0.2, Math.min(3.0, t.gradientStrength)) - 0.2) / 2.8;
|
||||
const dist = 6 + distFrac * (maxR - 6);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = "#888";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 4, cy); ctx.lineTo(cx + 4, cy);
|
||||
ctx.moveTo(cx, cy - 4); ctx.lineTo(cx, cy + 4);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.strokeStyle = "#0077ff";
|
||||
if (t.gradientShape === "radial") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dist, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
this._fillDirHandlePos = { x: cx + dist, y: cy };
|
||||
} else {
|
||||
const rad = t.gradientAngleDeg * Math.PI / 180;
|
||||
const ex = cx + Math.cos(rad) * dist, ey = cy + Math.sin(rad) * dist;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy); ctx.lineTo(ex, ey);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
this._fillDirHandlePos = { x: ex, y: ey };
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(this._fillDirHandlePos.x, this._fillDirHandlePos.y, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ffcc00";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
_setupFillGradientDir() {
|
||||
const canvas = document.getElementById("ie-fill-dir");
|
||||
if (!canvas) return;
|
||||
this._drawFillGradientDir();
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
const t = this._fillTool;
|
||||
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
|
||||
const maxR = Math.min(w, h) / 2 - 6;
|
||||
|
||||
const applyDrag = (mx, my) => {
|
||||
const dx = mx - cx, dy = my - cy;
|
||||
const dist = Math.max(6, Math.min(maxR, Math.hypot(dx, dy)));
|
||||
t.gradientStrength = 0.2 + ((dist - 6) / (maxR - 6)) * 2.8;
|
||||
if (t.gradientShape === "linear") t.gradientAngleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
this._drawFillGradientDir();
|
||||
const lbl = document.getElementById("ie-fill-strength-lbl");
|
||||
if (lbl) lbl.textContent = t.gradientStrength.toFixed(1);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", e => {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
applyDrag((e.clientX - rect.left) * (w / rect.width), (e.clientY - rect.top) * (h / rect.height));
|
||||
const onMove = ev => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
applyDrag((ev.clientX - r.left) * (w / r.width), (ev.clientY - r.top) * (h / r.height));
|
||||
};
|
||||
const onUp = () => {
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mask Editor One 追加ツール: 初期化・切り替え ──────────────────────────
|
||||
|
||||
_initMaskEditorOneTools() {
|
||||
@@ -1107,11 +1389,19 @@ class ImageEditTab {
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
document.getElementById("ie-open-psd-input")?.addEventListener("change", e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) this._loadPsdFile(file);
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
document.getElementById("ie-new-btn")?.addEventListener("click", () => this._newCanvas());
|
||||
document.getElementById("ie-close-btn")?.addEventListener("click", () => this._closeDocument());
|
||||
document.getElementById("ie-undo-btn")?.addEventListener("click", () => this._undo());
|
||||
document.getElementById("ie-redo-btn")?.addEventListener("click", () => this._redo());
|
||||
document.getElementById("ie-save-btn")?.addEventListener("click", () => this._fileExport.savePng());
|
||||
document.getElementById("ie-save-gallery-btn")?.addEventListener("click", () => this._fileExport.saveToGallery());
|
||||
document.getElementById("ie-save-psd-btn")?.addEventListener("click", () => this._fileExport.saveAsPsd());
|
||||
document.getElementById("ie-send-workflow-btn")?.addEventListener("click", () => this._fileExport.sendToWorkflow());
|
||||
document.getElementById("ie-zoom-fit")?.addEventListener("click", () => this._fitToView());
|
||||
document.getElementById("ie-zoom-100")?.addEventListener("click", () => {
|
||||
@@ -1140,7 +1430,7 @@ class ImageEditTab {
|
||||
this._setZoom(this._zoom * (e.deltaY > 0 ? 0.9 : 1.1));
|
||||
}, { passive: false });
|
||||
|
||||
wrap.addEventListener("mousedown", e => {
|
||||
wrap.addEventListener("pointerdown", e => {
|
||||
if (e.button === 1 || (e.button === 0 && this._spaceDown)) {
|
||||
e.preventDefault();
|
||||
this._panning = true;
|
||||
@@ -1159,7 +1449,7 @@ class ImageEditTab {
|
||||
if (!this._layerMgr || !drawCanvas) return;
|
||||
this._onToolMouseDown(e, drawCanvas);
|
||||
});
|
||||
window.addEventListener("mousemove", e => {
|
||||
window.addEventListener("pointermove", e => {
|
||||
if (this._panning) {
|
||||
this._panOffset.x = e.clientX - this._panStart.x;
|
||||
this._panOffset.y = e.clientY - this._panStart.y;
|
||||
@@ -1178,7 +1468,7 @@ class ImageEditTab {
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener("mouseup", e => {
|
||||
window.addEventListener("pointerup", e => {
|
||||
if (this._panning && (e.button === 1 || e.button === 0)) {
|
||||
this._panning = false;
|
||||
wrap.style.cursor = this._spaceDown ? "grab" : "";
|
||||
@@ -1191,22 +1481,24 @@ class ImageEditTab {
|
||||
}
|
||||
});
|
||||
|
||||
// draw / text 用 mousedown
|
||||
// draw / text 用 pointerdown(ペンタブレット筆圧対応のため Pointer Events を使用)
|
||||
const drawCanvas = document.getElementById("ie-canvas-draw");
|
||||
if (drawCanvas) {
|
||||
drawCanvas.addEventListener("mousedown", e => this._onToolMouseDown(e, drawCanvas));
|
||||
drawCanvas.addEventListener("mousemove", e => this._onToolMouseMove(e, drawCanvas));
|
||||
drawCanvas.addEventListener("mouseup", e => this._onToolMouseUp(e));
|
||||
drawCanvas.addEventListener("mouseleave", () => this._onToolMouseLeave());
|
||||
drawCanvas.style.touchAction = "none";
|
||||
drawCanvas.addEventListener("pointerdown", e => this._onToolMouseDown(e, drawCanvas));
|
||||
drawCanvas.addEventListener("pointermove", e => this._onToolMouseMove(e, drawCanvas));
|
||||
drawCanvas.addEventListener("pointerup", e => this._onToolMouseUp(e));
|
||||
drawCanvas.addEventListener("pointerleave", () => this._onToolMouseLeave());
|
||||
}
|
||||
|
||||
// select 用 mousedown(overlayCanvas)
|
||||
// select 用 pointerdown(overlayCanvas)
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) {
|
||||
overlay.addEventListener("mousedown", e => this._onToolMouseDown(e, overlay));
|
||||
overlay.addEventListener("mousemove", e => this._onToolMouseMove(e, overlay));
|
||||
overlay.addEventListener("mouseup", e => this._onToolMouseUp(e));
|
||||
overlay.addEventListener("mouseleave", () => this._onToolMouseLeave());
|
||||
overlay.style.touchAction = "none";
|
||||
overlay.addEventListener("pointerdown", e => this._onToolMouseDown(e, overlay));
|
||||
overlay.addEventListener("pointermove", e => this._onToolMouseMove(e, overlay));
|
||||
overlay.addEventListener("pointerup", e => this._onToolMouseUp(e));
|
||||
overlay.addEventListener("pointerleave", () => this._onToolMouseLeave());
|
||||
// テキストオブジェクトのダブルクリックで再編集
|
||||
overlay.addEventListener("dblclick", e => this._onOverlayDblClick(e, overlay));
|
||||
}
|
||||
@@ -1214,16 +1506,27 @@ class ImageEditTab {
|
||||
|
||||
_onToolMouseDown(e, refCanvas) {
|
||||
if (!this._layerMgr || e.button !== 0 || this._spaceDown) return;
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
// PointerEvent.pressure: pen -> actual pressure, mouse -> 0.5 while a button is held.
|
||||
const pressure = e.pressure || 0.5;
|
||||
|
||||
if (this._activeTool === "draw" && this._drawTool) {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer) return;
|
||||
this._saveUndo();
|
||||
this._drawTool.setCanvas(activeLayer.canvas);
|
||||
this._drawTool.onMouseDown(pos.x, pos.y);
|
||||
this._drawTool.onMouseDown(pos.x, pos.y, pressure);
|
||||
this._updateCompositeView();
|
||||
|
||||
} else if (this._activeTool === "fill" && this._fillTool) {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer) return;
|
||||
this._saveUndo();
|
||||
this._fillTool.setCanvas(activeLayer.canvas);
|
||||
this._fillTool.onMouseDown(pos.x, pos.y);
|
||||
this._updateCompositeView();
|
||||
this._refreshLayerList();
|
||||
|
||||
} else if (this._activeTool === "mask") {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer || activeLayer.type !== "mask") {
|
||||
@@ -1234,7 +1537,7 @@ class ImageEditTab {
|
||||
if (sub === "paint" && this._maskTool) {
|
||||
this._saveUndo();
|
||||
this._maskTool.setCanvas(activeLayer.canvas);
|
||||
this._maskTool.onMouseDown(pos.x, pos.y);
|
||||
this._maskTool.onMouseDown(pos.x, pos.y, pressure);
|
||||
this._updateCompositeView();
|
||||
} else if (sub === "color" && this._maskColorTool) {
|
||||
this._saveUndo();
|
||||
@@ -1279,15 +1582,16 @@ class ImageEditTab {
|
||||
|
||||
_onToolMouseMove(e, refCanvas) {
|
||||
if (!this._layerMgr) return;
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pressure = e.pressure || 0.5;
|
||||
if (this._activeTool === "draw") {
|
||||
this._drawTool?.onMouseMove(pos.x, pos.y);
|
||||
this._drawTool?.onMouseMove(pos.x, pos.y, pressure);
|
||||
if (this._drawTool?._drawing) this._updateCompositeView();
|
||||
}
|
||||
if (this._activeTool === "mask") {
|
||||
const sub = this._maskSubtool;
|
||||
if (sub === "paint") {
|
||||
this._maskTool?.onMouseMove(pos.x, pos.y);
|
||||
this._maskTool?.onMouseMove(pos.x, pos.y, pressure);
|
||||
if (this._maskTool?._drawing) this._updateCompositeView();
|
||||
} else if (sub === "vector") {
|
||||
this._maskVectorTool?.onMouseMove(pos.x, pos.y);
|
||||
@@ -1401,6 +1705,102 @@ class ImageEditTab {
|
||||
showToast(`Loaded: ${img.width}×${img.height}`, "success");
|
||||
}
|
||||
|
||||
// PSDファイルをアップロードし、レイヤーを分解して新規ドキュメントとして読み込む。
|
||||
async _loadPsdFile(file) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/import-psd-layers", { method: "POST", body: form });
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
data = await r.json();
|
||||
} catch (err) {
|
||||
showToast(`PSD import failed: ${err.message}`, "error");
|
||||
return;
|
||||
}
|
||||
await this.loadPsdLayersData(data, file.name.replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
|
||||
/** バックエンドの import-psd-layers / psd-layers が返すレイヤー配列(dataURL+メタデータ)を
|
||||
* 新規ドキュメントとして読み込む。グループ・調整レイヤー等はバックエンド側で既にスキップ
|
||||
* 済みのフラットな画像レイヤー配列が渡される想定(ギャラリーからの直接送信でも使う共通経路)。
|
||||
*/
|
||||
async loadPsdLayersData(data, baseName = "psd-image") {
|
||||
const { width, height, layers } = data || {};
|
||||
if (!width || !height || !layers?.length) {
|
||||
showToast("No importable layers found in this PSD", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
this._canvasW = width;
|
||||
this._canvasH = height;
|
||||
this._baseName = baseName;
|
||||
this._initCanvases();
|
||||
|
||||
// レイヤーはバックエンド側で先頭=最前面の順に並べ替え済み
|
||||
const loaded = await Promise.all(layers.map(ld => new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const layer = new Layer(ld.name || "Layer", "image", ld.width, ld.height);
|
||||
layer.x = ld.x;
|
||||
layer.y = ld.y;
|
||||
layer.displayW = ld.width;
|
||||
layer.displayH = ld.height;
|
||||
layer.opacity = ld.opacity;
|
||||
layer.blendMode = ld.blendMode;
|
||||
layer.visible = ld.visible;
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
resolve(layer);
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = ld.imageData;
|
||||
})));
|
||||
|
||||
this._layerMgr.layers = loaded.filter(Boolean);
|
||||
this._layerMgr.activeIndex = 0;
|
||||
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._setActiveTool("select");
|
||||
this._selectTool?.setLayer(this._layerMgr.activeLayer);
|
||||
this._refreshLayerList();
|
||||
this._updateCompositeView();
|
||||
this._fitToView();
|
||||
|
||||
document.getElementById("ie-placeholder").style.display = "none";
|
||||
showToast(`PSD imported: ${this._layerMgr.layers.length} layer(s)`, "success");
|
||||
}
|
||||
|
||||
// 現在のドキュメントを閉じ、未保存の変更を破棄してプレースホルダー表示に戻す
|
||||
_closeDocument() {
|
||||
if (!this._layerMgr) return;
|
||||
if (!confirm("Close the current document? Unsaved changes will be lost.")) return;
|
||||
|
||||
this._layerMgr = null;
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._canvasW = 512;
|
||||
this._canvasH = 512;
|
||||
this._baseName = "image";
|
||||
this._zoom = 1.0;
|
||||
this._panOffset = { x: 0, y: 0 };
|
||||
|
||||
const drawCanvas = document.getElementById("ie-canvas-draw");
|
||||
const overlayCanvas = document.getElementById("ie-canvas-overlay");
|
||||
drawCanvas?.getContext("2d").clearRect(0, 0, drawCanvas.width, drawCanvas.height);
|
||||
overlayCanvas?.getContext("2d").clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
||||
|
||||
const layerList = document.getElementById("ie-layer-list");
|
||||
if (layerList) layerList.innerHTML = "";
|
||||
|
||||
const placeholder = document.getElementById("ie-placeholder");
|
||||
if (placeholder) placeholder.style.display = "";
|
||||
}
|
||||
|
||||
_newCanvas() {
|
||||
const current = `${this._canvasW || 512}x${this._canvasH || 512}`;
|
||||
const input = prompt("Canvas size (WxH):", current);
|
||||
@@ -1539,6 +1939,8 @@ class ImageEditTab {
|
||||
this._refreshLayerList();
|
||||
});
|
||||
|
||||
this._fillTool = new FillTool();
|
||||
|
||||
this._compositeMode = false;
|
||||
}
|
||||
|
||||
@@ -1750,6 +2152,45 @@ class ImageEditTab {
|
||||
|
||||
// ── レイヤーパネル ────────────────────────────
|
||||
|
||||
// 指定レイヤーをアクティブ化し、種別・現在のツールに応じてキャンバス/ツールを同期する
|
||||
// (レイヤーリストのクリック選択・複製直後のアクティブ化などで共用)
|
||||
_activateLayerById(id) {
|
||||
if (!this._layerMgr) return;
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._layerMgr.setActive(id);
|
||||
const layer = this._layerMgr.activeLayer;
|
||||
if (layer?.type === "mask") {
|
||||
if (this._activeTool !== "mask") {
|
||||
this._setActiveTool("mask");
|
||||
} else {
|
||||
// すでにマスクツール選択中 → canvas だけ切り替え
|
||||
this._maskTool?.setCanvas(layer.canvas);
|
||||
this._maskTool?.activate();
|
||||
}
|
||||
// operation に合わせて MaskTool の mode を同期
|
||||
if (this._maskTool) {
|
||||
this._maskTool.mode = layer.operation === "subtract" ? "erase" : "paint";
|
||||
this._renderToolOptions("mask");
|
||||
}
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "draw" && layer) {
|
||||
this._drawTool?.setCanvas(layer.canvas);
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "select" && layer) {
|
||||
this._selectTool?.setLayer(layer);
|
||||
this._updateCompositeView();
|
||||
} else {
|
||||
this._updateCompositeView();
|
||||
}
|
||||
this._refreshLayerList();
|
||||
if (layer) {
|
||||
const sl = document.getElementById("ie-layer-opacity");
|
||||
const lb = document.getElementById("ie-layer-opacity-label");
|
||||
if (sl) sl.value = Math.round(layer.opacity * 100);
|
||||
if (lb) lb.textContent = Math.round(layer.opacity * 100) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
_setupLayerPanel() {
|
||||
document.getElementById("ie-add-layer-btn")?.addEventListener("click", () => {
|
||||
if (!this._layerMgr) return;
|
||||
@@ -1764,6 +2205,24 @@ class ImageEditTab {
|
||||
this._activateCurrentTool();
|
||||
});
|
||||
|
||||
document.getElementById("ie-dup-layer-btn")?.addEventListener("click", async () => {
|
||||
if (!this._layerMgr) return;
|
||||
const active = this._layerMgr.activeLayer;
|
||||
if (!active) return;
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._saveUndo();
|
||||
|
||||
const json = active.toJSON();
|
||||
json.name = `${active.name} copy`;
|
||||
const dup = await Layer.fromJSON(json);
|
||||
dup.id = crypto.randomUUID();
|
||||
|
||||
const idx = this._layerMgr.layers.indexOf(active);
|
||||
this._layerMgr.layers.splice(idx, 0, dup);
|
||||
|
||||
this._activateLayerById(dup.id);
|
||||
});
|
||||
|
||||
document.getElementById("ie-add-mask-btn")?.addEventListener("click", () => {
|
||||
if (!this._layerMgr) { showToast("Open an image first", "info"); return; }
|
||||
this._syncActiveLayerFromCanvas();
|
||||
@@ -1873,39 +2332,7 @@ class ImageEditTab {
|
||||
if (sel?.id === id) this._selectTool?.setLayer(sel);
|
||||
this._refreshLayerList();
|
||||
} else if (action === "select") {
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._layerMgr.setActive(id);
|
||||
const layer = this._layerMgr.activeLayer;
|
||||
if (layer?.type === "mask") {
|
||||
if (this._activeTool !== "mask") {
|
||||
this._setActiveTool("mask");
|
||||
} else {
|
||||
// すでにマスクツール選択中 → canvas だけ切り替え
|
||||
this._maskTool?.setCanvas(layer.canvas);
|
||||
this._maskTool?.activate();
|
||||
}
|
||||
// operation に合わせて MaskTool の mode を同期
|
||||
if (this._maskTool) {
|
||||
this._maskTool.mode = layer.operation === "subtract" ? "erase" : "paint";
|
||||
this._renderToolOptions("mask");
|
||||
}
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "draw" && layer) {
|
||||
this._drawTool?.setCanvas(layer.canvas);
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "select" && layer) {
|
||||
this._selectTool?.setLayer(layer);
|
||||
this._updateCompositeView();
|
||||
} else {
|
||||
this._updateCompositeView();
|
||||
}
|
||||
this._refreshLayerList();
|
||||
if (layer) {
|
||||
const sl = document.getElementById("ie-layer-opacity");
|
||||
const lb = document.getElementById("ie-layer-opacity-label");
|
||||
if (sl) sl.value = Math.round(layer.opacity * 100);
|
||||
if (lb) lb.textContent = Math.round(layer.opacity * 100) + "%";
|
||||
}
|
||||
this._activateLayerById(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1962,6 +2389,60 @@ class ImageEditTab {
|
||||
}
|
||||
}
|
||||
|
||||
/** ギャラリーなど外部から複数画像URLをまとめてレイヤーとして新規ドキュメントに読み込む
|
||||
* @param {Array<{url: string, name?: string}>} items 先頭=最背面, 末尾=最前面として重ねる
|
||||
*/
|
||||
async loadLayersFromUrls(items) {
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
let imgs;
|
||||
try {
|
||||
imgs = await Promise.all(items.map(({ url }) => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load: ${url}`));
|
||||
img.src = url;
|
||||
})));
|
||||
} catch (err) {
|
||||
showToast(err.message || "Failed to load images", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const w = Math.max(...imgs.map(i => i.width));
|
||||
const h = Math.max(...imgs.map(i => i.height));
|
||||
|
||||
this._canvasW = w;
|
||||
this._canvasH = h;
|
||||
this._baseName = "gallery-layers";
|
||||
this._initCanvases();
|
||||
|
||||
const layers = imgs.map((img, i) => {
|
||||
const fit = fitToCanvas(img.width, img.height, w, h);
|
||||
const layer = new Layer(items[i].name || `Layer ${i + 1}`, "image", img.width, img.height);
|
||||
layer.displayW = fit.w;
|
||||
layer.displayH = fit.h;
|
||||
layer.x = Math.round((w - fit.w) / 2);
|
||||
layer.y = Math.round((h - fit.h) / 2);
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
return layer;
|
||||
});
|
||||
|
||||
// items は先頭=最背面の順で渡される想定のため、Layer配列規約(先頭=最前面)に合わせて反転する
|
||||
this._layerMgr.layers = layers.reverse();
|
||||
this._layerMgr.activeIndex = 0;
|
||||
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._setActiveTool("select");
|
||||
this._selectTool?.setLayer(this._layerMgr.activeLayer);
|
||||
this._refreshLayerList();
|
||||
this._updateCompositeView();
|
||||
this._fitToView();
|
||||
|
||||
document.getElementById("ie-placeholder").style.display = "none";
|
||||
showToast(`Loaded ${layers.length} image(s) as layers`, "success");
|
||||
}
|
||||
|
||||
// ── キーボードショートカット ──────────────────
|
||||
|
||||
_setupKeyboard() {
|
||||
@@ -1983,6 +2464,7 @@ class ImageEditTab {
|
||||
if (e.key === "b") this._setActiveTool("draw");
|
||||
if (e.key === "t") this._setActiveTool("text");
|
||||
if (e.key === "s") this._setActiveTool("shape");
|
||||
if (e.key === "g") this._setActiveTool("fill");
|
||||
// Delete/Backspaceで選択オブジェクト削除
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && this._activeTool === "select") {
|
||||
const layer = this._selectTool?.getSelectedLayer();
|
||||
|
||||
@@ -20,6 +20,11 @@ export class DrawTool {
|
||||
this.sizeJitterAmount = 0.5;
|
||||
this.rotationJitter = false;
|
||||
|
||||
// Pen pressure (0–1, from PointerEvent.pressure; mouse reports 0.5).
|
||||
// pressure=0.5 maps to a 1.0x multiplier so mouse behavior is unchanged.
|
||||
this.pressureEnabled = true;
|
||||
this._lastPressure = 0.5;
|
||||
|
||||
// Image brush from ABR library (null = default circle brush)
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
@@ -80,29 +85,31 @@ export class DrawTool {
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
onMouseDown(x, y, pressure = 0.5) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._paintToStroke(x, y, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
this._paint(x, y, pressure);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
onMouseMove(x, y, pressure = 0.5) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
this._paintLine(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
@@ -117,6 +124,20 @@ export class DrawTool {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Pressure response ──────────────────────────────────────────────
|
||||
|
||||
_effectiveSize(pressure) {
|
||||
if (!this.pressureEnabled) return this.brushSize;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(1, this.brushSize * (0.3 + p * 1.4)); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
_effectiveOpacity(pressure) {
|
||||
if (!this.pressureEnabled) return this.opacity;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(0, Math.min(1, this.opacity * (0.4 + p * 1.2))); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
@@ -155,54 +176,48 @@ export class DrawTool {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
_paintToStroke(x, y, pressure = 0.5) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
let size = this._effectiveSize(pressure);
|
||||
if (this.sizeJitter) {
|
||||
size = Math.max(1, size * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
|
||||
if (this.rotationJitter) {
|
||||
const savedAngle = this.angle;
|
||||
this.angle = Math.random() * 360;
|
||||
this._paintImageBrush(x, y, size);
|
||||
this.angle = savedAngle;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
this._paintImageBrush(x, y, size);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
_paintLineToStroke(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const step = Math.max(1, this.brushSize * this.spacing);
|
||||
const steps = Math.ceil(dist / step);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
_getStamp(size) {
|
||||
size = Math.max(1, Math.round(size));
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampSize === size &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampColor === this.color
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
@@ -225,11 +240,12 @@ export class DrawTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
_paint(x, y, pressure = 0.5) {
|
||||
const size = this._effectiveSize(pressure);
|
||||
const stamp = this._getStamp(size);
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = this.opacity;
|
||||
this.ctx.globalAlpha = this._effectiveOpacity(pressure);
|
||||
if (this.mode === "erase") {
|
||||
this.ctx.globalCompositeOperation = "destination-out";
|
||||
}
|
||||
@@ -237,13 +253,13 @@ export class DrawTool {
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
_paintLine(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const spacing = Math.max(1, this.brushSize * 0.2);
|
||||
const steps = Math.max(1, Math.ceil(dist / spacing));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,8 +335,8 @@ export class DrawTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
_paintImageBrush(x, y, size = this.brushSize) {
|
||||
const stamp = this._getImageStamp(Math.round(size));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -63,6 +63,92 @@ export class FileExport {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用レイヤー配列を構築: 各レイヤーを個別のcanvas(ドキュメント全体サイズ)に
|
||||
// 回転・反転・拡縮のみベイクして描画する。opacity/blendModeはピクセルに焼き込まず
|
||||
// PSDレイヤー属性として渡すためメタデータのまま残す。
|
||||
// maskApply=true のクリッピングマスクは対象レイヤーと合成して1枚にベイクする
|
||||
// (PSDの本物のレイヤーマスクとしては書き出さない簡略化)。
|
||||
_buildLayerExportList(layerMgr, w, h) {
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const exportLayers = [];
|
||||
// 背面→前面の順(PSD側で後から追加したレイヤーほど上に重なるため、この順で送る)
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (maskedIndices.has(i)) continue; // マスク側の処理でまとめて扱う
|
||||
|
||||
if (layer.type === "mask") {
|
||||
if (!(layer.maskApply && layer.visible && i + 1 < layers.length)) continue;
|
||||
const target = layers[i + 1];
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
this._cb.renderMaskedLayer(canvas.getContext("2d"), canvas, layer, target, false);
|
||||
exportLayers.push({
|
||||
name: target.name || "Layer",
|
||||
visible: target.visible,
|
||||
opacity: 1,
|
||||
blendMode: "source-over",
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.save();
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
exportLayers.push({
|
||||
name: layer.name || "Layer",
|
||||
visible: layer.visible,
|
||||
opacity: layer.opacity,
|
||||
blendMode: layer.blendMode,
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
}
|
||||
return exportLayers;
|
||||
}
|
||||
|
||||
async saveAsPsd() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const exportLayers = this._buildLayerExportList(layerMgr, w, h);
|
||||
if (exportLayers.length === 0) { showToast("No layers to export", "error"); return; }
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/export-psd-layers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width: w, height: h, layers: exportLayers }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + ".psd";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("PSD saved", "success");
|
||||
} catch (err) {
|
||||
showToast(`PSD save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
savePng() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Image Edit Tab - Fill Tool (bucket fill)
|
||||
* Flood-fills a contiguous same-color region on the active layer canvas,
|
||||
* either with a solid color or a linear/radial gradient.
|
||||
* Gradient ramp math (evalGradient / hex<->rgb) is ported from
|
||||
* comfyUI-particle-pixijs's particle_engine.js.
|
||||
*/
|
||||
|
||||
function _hexToRgb01(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || "#000000");
|
||||
if (!m) return { r: 0, g: 0, b: 0 };
|
||||
return { r: parseInt(m[1], 16) / 255, g: parseInt(m[2], 16) / 255, b: parseInt(m[3], 16) / 255 };
|
||||
}
|
||||
|
||||
function _rgb01ToHex({ r, g, b }) {
|
||||
const h = v => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0");
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
function _lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
export class FillTool {
|
||||
constructor() {
|
||||
// fill settings
|
||||
this.tolerance = 32; // 0-255
|
||||
this.fillMode = "solid"; // "solid" | "gradient"
|
||||
this.color = "#ff0000"; // solid fill color
|
||||
this.opacity = 1.0;
|
||||
|
||||
// gradient settings
|
||||
this.gradientShape = "linear"; // "linear" | "radial"
|
||||
this.gradientAngleDeg = 0; // linear direction
|
||||
this.gradientStrength = 1.0; // 0.2-3.0, length/radius multiplier
|
||||
this.gradientStops = [
|
||||
{ pos: 0.0, color: "#ffffff" },
|
||||
{ pos: 1.0, color: "#888888" },
|
||||
];
|
||||
this.selectedStopIdx = 0;
|
||||
|
||||
// internal state
|
||||
this._active = false;
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas ? canvas.getContext("2d") : null;
|
||||
}
|
||||
|
||||
onChange(cb) { this._onChange = cb; }
|
||||
|
||||
activate() { this._active = true; }
|
||||
deactivate() { this._active = false; }
|
||||
|
||||
// ── color ramp evaluation (ported from particle_engine.js evalGradient) ──
|
||||
|
||||
static evalGradient(stops, t) {
|
||||
if (!stops || stops.length === 0) return { r: 0, g: 0, b: 0 };
|
||||
const sorted = [...stops].sort((a, b) => a.pos - b.pos);
|
||||
if (t <= sorted[0].pos) return _hexToRgb01(sorted[0].color);
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (t >= last.pos) return _hexToRgb01(last.color);
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i], b = sorted[i + 1];
|
||||
if (t >= a.pos && t <= b.pos) {
|
||||
const span = b.pos - a.pos;
|
||||
const localT = span > 0 ? (t - a.pos) / span : 0;
|
||||
const ca = _hexToRgb01(a.color), cb = _hexToRgb01(b.color);
|
||||
return {
|
||||
r: _lerp(ca.r, cb.r, localT),
|
||||
g: _lerp(ca.g, cb.g, localT),
|
||||
b: _lerp(ca.b, cb.b, localT),
|
||||
};
|
||||
}
|
||||
}
|
||||
return _hexToRgb01(last.color);
|
||||
}
|
||||
|
||||
/** Preview swatch color (hex) of the ramp at position t. */
|
||||
evalGradientHex(t) {
|
||||
return _rgb01ToHex(FillTool.evalGradient(this.gradientStops, t));
|
||||
}
|
||||
|
||||
// ── stop editing helpers (ported from particle_widget.js addStop/removeStop/selectStop) ──
|
||||
|
||||
addStop() {
|
||||
const sorted = [...this.gradientStops].sort((a, b) => a.pos - b.pos);
|
||||
let bestGapStart = 0, bestGapSize = 0;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].pos - sorted[i].pos;
|
||||
if (gap > bestGapSize) { bestGapSize = gap; bestGapStart = sorted[i].pos; }
|
||||
}
|
||||
const pos = sorted.length < 2 ? 0.5 : bestGapStart + bestGapSize / 2;
|
||||
const color = _rgb01ToHex(FillTool.evalGradient(this.gradientStops, pos));
|
||||
this.gradientStops.push({ pos, color });
|
||||
this.selectedStopIdx = this.gradientStops.length - 1;
|
||||
}
|
||||
|
||||
removeStop(idx = this.selectedStopIdx) {
|
||||
if (this.gradientStops.length <= 1) return;
|
||||
this.gradientStops.splice(idx, 1);
|
||||
this.selectedStopIdx = Math.max(0, Math.min(this.selectedStopIdx, this.gradientStops.length - 1));
|
||||
}
|
||||
|
||||
selectStop(idx) {
|
||||
this.selectedStopIdx = idx;
|
||||
}
|
||||
|
||||
// ── flood fill ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flood-fills the contiguous region matching the pixel at (x, y).
|
||||
* (x, y) are canvas-pixel coordinates (already converted via DrawTool.getCanvasPos).
|
||||
*/
|
||||
onMouseDown(x, y) {
|
||||
if (!this._active || !this._ctx) return;
|
||||
const w = this._canvas.width, h = this._canvas.height;
|
||||
const px = Math.floor(x), py = Math.floor(y);
|
||||
if (px < 0 || py < 0 || px >= w || py >= h) return;
|
||||
|
||||
const imgData = this._ctx.getImageData(0, 0, w, h);
|
||||
const data = imgData.data;
|
||||
|
||||
const mask = this._buildFloodMask(data, w, h, px, py, this.tolerance);
|
||||
if (!mask) return;
|
||||
|
||||
if (this.fillMode === "gradient") {
|
||||
this._fillMaskWithGradient(data, w, h, mask.pixels, mask.bbox);
|
||||
} else {
|
||||
this._fillMaskWithSolid(data, mask.pixels, this.color, this.opacity);
|
||||
}
|
||||
|
||||
this._ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
/** Scanline stack-based flood fill. Returns { pixels: Uint8Array(w*h), bbox } or null. */
|
||||
_buildFloodMask(data, w, h, startX, startY, tolerance) {
|
||||
const idx0 = (startY * w + startX) * 4;
|
||||
const tr = data[idx0], tg = data[idx0 + 1], tb = data[idx0 + 2], ta = data[idx0 + 3];
|
||||
const tol2 = tolerance * tolerance;
|
||||
|
||||
const matches = (i) => {
|
||||
const dr = data[i] - tr, dg = data[i + 1] - tg, db = data[i + 2] - tb, da = data[i + 3] - ta;
|
||||
return (dr * dr + dg * dg + db * db + da * da) <= tol2 * 4;
|
||||
};
|
||||
|
||||
const pixels = new Uint8Array(w * h);
|
||||
const stack = [[startX, startY]];
|
||||
let minX = startX, maxX = startX, minY = startY, maxY = startY;
|
||||
|
||||
while (stack.length) {
|
||||
let [x, y] = stack.pop();
|
||||
while (x >= 0 && matches((y * w + x) * 4)) x--;
|
||||
x++;
|
||||
let spanUp = false, spanDown = false;
|
||||
while (x < w && matches((y * w + x) * 4) && !pixels[y * w + x]) {
|
||||
pixels[y * w + x] = 1;
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
|
||||
if (y > 0) {
|
||||
const above = matches((( y - 1) * w + x) * 4) && !pixels[(y - 1) * w + x];
|
||||
if (above && !spanUp) { stack.push([x, y - 1]); spanUp = true; }
|
||||
else if (!above) spanUp = false;
|
||||
}
|
||||
if (y < h - 1) {
|
||||
const below = matches(((y + 1) * w + x) * 4) && !pixels[(y + 1) * w + x];
|
||||
if (below && !spanDown) { stack.push([x, y + 1]); spanDown = true; }
|
||||
else if (!below) spanDown = false;
|
||||
}
|
||||
x++;
|
||||
}
|
||||
}
|
||||
return { pixels, bbox: { minX, minY, maxX, maxY } };
|
||||
}
|
||||
|
||||
_fillMaskWithSolid(data, pixels, colorHex, opacity) {
|
||||
const { r, g, b } = _hexToRgb01(colorHex);
|
||||
const cr = r * 255, cg = g * 255, cb = b * 255;
|
||||
const alpha = Math.max(0, Math.min(1, opacity));
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
if (!pixels[i]) continue;
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], cr, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], cg, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], cb, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable with any {pixels, bbox} mask (flood fill today; rect/ellipse selections in the future). */
|
||||
_fillMaskWithGradient(data, w, h, pixels, bbox) {
|
||||
const cx = (bbox.minX + bbox.maxX) / 2, cy = (bbox.minY + bbox.maxY) / 2;
|
||||
const diagW = bbox.maxX - bbox.minX, diagH = bbox.maxY - bbox.minY;
|
||||
const diag = Math.max(1, Math.hypot(diagW, diagH));
|
||||
const strength = Math.max(0.2, Math.min(3.0, this.gradientStrength));
|
||||
const alpha = Math.max(0, Math.min(1, this.opacity));
|
||||
|
||||
const angleRad = this.gradientAngleDeg * Math.PI / 180;
|
||||
const dirX = Math.cos(angleRad), dirY = Math.sin(angleRad);
|
||||
const halfLen = (diag / 2) * strength;
|
||||
const radius = (diag / 2) * strength;
|
||||
|
||||
for (let y = bbox.minY; y <= bbox.maxY; y++) {
|
||||
for (let x = bbox.minX; x <= bbox.maxX; x++) {
|
||||
const i = y * w + x;
|
||||
if (!pixels[i]) continue;
|
||||
const dx = x - cx, dy = y - cy;
|
||||
let t;
|
||||
if (this.gradientShape === "radial") {
|
||||
t = Math.hypot(dx, dy) / radius;
|
||||
} else {
|
||||
t = (dx * dirX + dy * dirY) / (halfLen * 2) + 0.5;
|
||||
}
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const c = FillTool.evalGradient(this.gradientStops, t);
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], c.r * 255, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], c.g * 255, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], c.b * 255, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export class MaskTool {
|
||||
this.sizeJitterAmount = 0.5; // 0–1: fraction of brushSize that can be removed
|
||||
this.rotationJitter = false;
|
||||
|
||||
// Pen pressure (0–1, from PointerEvent.pressure; mouse reports 0.5).
|
||||
// pressure=0.5 maps to a 1.0x multiplier so mouse behavior is unchanged.
|
||||
this.pressureEnabled = true;
|
||||
this._lastPressure = 0.5;
|
||||
|
||||
this._drawing = false;
|
||||
this._lastX = 0;
|
||||
this._lastY = 0;
|
||||
@@ -80,29 +85,31 @@ export class MaskTool {
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
onMouseDown(x, y, pressure = 0.5) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._paintToStroke(x, y, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
this._paint(x, y, pressure);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
onMouseMove(x, y, pressure = 0.5) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
this._paintLine(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
@@ -117,6 +124,14 @@ export class MaskTool {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Pressure response ──────────────────────────────────────────────
|
||||
|
||||
_effectiveSize(pressure) {
|
||||
if (!this.pressureEnabled) return this.brushSize;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(1, this.brushSize * (0.3 + p * 1.4)); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
@@ -149,54 +164,48 @@ export class MaskTool {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
_paintToStroke(x, y, pressure = 0.5) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
let size = this._effectiveSize(pressure);
|
||||
if (this.sizeJitter) {
|
||||
size = Math.max(1, size * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
|
||||
if (this.rotationJitter) {
|
||||
const savedAngle = this.angle;
|
||||
this.angle = Math.random() * 360;
|
||||
this._paintImageBrush(x, y, size);
|
||||
this.angle = savedAngle;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
this._paintImageBrush(x, y, size);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
_paintLineToStroke(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const step = Math.max(1, this.brushSize * this.spacing);
|
||||
const steps = Math.ceil(dist / step);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
_getStamp(size) {
|
||||
size = Math.max(1, Math.round(size));
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampSize === size &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampMode === this.mode
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
@@ -222,8 +231,9 @@ export class MaskTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
_paint(x, y, pressure = 0.5) {
|
||||
const size = this._effectiveSize(pressure);
|
||||
const stamp = this._getStamp(size);
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
@@ -233,13 +243,13 @@ export class MaskTool {
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
_paintLine(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const spacing = Math.max(1, this.brushSize * 0.2);
|
||||
const steps = Math.max(1, Math.ceil(dist / spacing));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,8 +317,8 @@ export class MaskTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
_paintImageBrush(x, y, size = this.brushSize) {
|
||||
const stamp = this._getImageStamp(Math.round(size));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -249,6 +249,7 @@ function extractLoRAs(wf) {
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "LoraLoader") add(n.inputs?.lora_name, n.inputs?.strength_model, n.inputs?.strength_clip);
|
||||
else if (ct === "LoraLoaderModelOnly") add(n.inputs?.lora_name, n.inputs?.strength, 1.0);
|
||||
else if (ct === "ImageMetadataLoRALoader") { for (let i = 1; i <= 3; i++) add(n.inputs?.[`lora_${i}`], n.inputs?.[`strength_model_${i}`], n.inputs?.[`strength_clip_${i}`]); }
|
||||
else if (ct === "Lora Loader (LoraManager)") {
|
||||
const lorasData = n.inputs?.loras;
|
||||
const list = lorasData?.__value__ ?? (Array.isArray(lorasData) ? lorasData : null);
|
||||
@@ -259,10 +260,61 @@ function extractLoRAs(wf) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// LiveChatStream(ComfyUI-LiveChatStream)のRETURN_NAMES順 = INPUT_TYPESの対応ウィジェット名
|
||||
// (positive→prompt_text, negative→negative_text, response→response_text, ...)。nodes.pyの
|
||||
// INPUT_TYPES/RETURN_TYPESコメント通り、ウィジェット・出力スロットとも末尾追加のみで位置互換。
|
||||
const LIVE_CHAT_STREAM_OUTPUT_KEYS = ["prompt_text", "negative_text", "response_text", "chat_p_text", "chat_n_text", "thinking_text"];
|
||||
|
||||
function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("TextEncode") || ct.includes("TextEncoderSD"); }
|
||||
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
|
||||
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
|
||||
|
||||
// サンプラーノードの設定値(seed/steps/cfg/sampler_name/scheduler/denoise)をAPI形式の
|
||||
// 名前付きinputsから読む。ノードタイプごとにwidgets_valuesの並びが異なる(KSampler/
|
||||
// KSamplerAdvanced等)ため、UI形式(LiteGraph)はconvertUiToApi()を経由した後に呼ぶ想定。
|
||||
const SAMPLER_SETTING_KEYS = ["seed", "noise_seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"];
|
||||
function extractSamplerSettings(apiWf) {
|
||||
if (!apiWf || typeof apiWf !== "object" || Array.isArray(apiWf.nodes)) return null;
|
||||
const pick = (inputs, keys) => {
|
||||
const out = {};
|
||||
for (const key of keys) {
|
||||
const v = inputs?.[key];
|
||||
if (v !== undefined && !Array.isArray(v)) out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
// Flux/SD3系はRandomNoise+BasicScheduler+KSamplerSelect+CFGGuider等に設定が分散するため、
|
||||
// 補助ノードから不足分を補う。
|
||||
const AUX = {
|
||||
RandomNoise: ["noise_seed"],
|
||||
BasicScheduler: ["steps", "scheduler", "denoise"],
|
||||
KSamplerSelect: ["sampler_name"],
|
||||
CFGGuider: ["cfg"],
|
||||
DualCFGGuider: ["cfg"],
|
||||
};
|
||||
// 設定キーを最も多く持つサンプラーを主とし、同数なら後ろのノード(hires-fix等の最終パス)を優先する
|
||||
let main = null, mainCount = 0;
|
||||
const aux = {};
|
||||
for (const [id, n] of Object.entries(apiWf)) {
|
||||
if (!n || typeof n !== "object") continue;
|
||||
const ct = n.class_type ?? "";
|
||||
const inputs = n.inputs ?? {};
|
||||
if (AUX[ct]) {
|
||||
const got = pick(inputs, AUX[ct]);
|
||||
for (const [k, v] of Object.entries(got)) if (!(k in aux)) aux[k] = v;
|
||||
continue;
|
||||
}
|
||||
if (!isSamplerNode(ct)) continue;
|
||||
const settings = pick(inputs, SAMPLER_SETTING_KEYS);
|
||||
const count = Object.keys(settings).length;
|
||||
if (count > 0 && count >= mainCount) {
|
||||
main = settings; mainCount = count;
|
||||
}
|
||||
}
|
||||
const merged = { ...aux, ...(main ?? {}) };
|
||||
return Object.keys(merged).length > 0 ? merged : null;
|
||||
}
|
||||
|
||||
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
|
||||
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
|
||||
function resolveEditPlusText(mode, textEdit, text1, text2) {
|
||||
@@ -359,18 +411,16 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(type)) continue;
|
||||
const text = n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") {
|
||||
textMap.set(n.id, text);
|
||||
} else if (Array.isArray(n.inputs)) {
|
||||
const textInput = n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt");
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
// A text widget converted to a wired input keeps its last typed value in widgets_values[0]
|
||||
// (stale — never sent to the backend), so the linked source must win over it.
|
||||
const textInput = Array.isArray(n.inputs)
|
||||
? n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt")
|
||||
: null;
|
||||
const linkedText = textInput?.link != null && linkOrigin.has(textInput.link)
|
||||
? resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, linkOrigin.get(textInput.link), linkSlot.get(textInput.link) ?? 0)
|
||||
: null;
|
||||
const text = linkedText || n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") textMap.set(n.id, text);
|
||||
}
|
||||
const pos = new Set(), neg = new Set();
|
||||
let foundSampler = false;
|
||||
@@ -381,11 +431,13 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role slots.
|
||||
let inputsToScan = n.inputs;
|
||||
let viaBasicGuider = false;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
viaBasicGuider = guiderNode?.type === "BasicGuider";
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
@@ -393,7 +445,9 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
// BasicGuider (Flux/Ming Image etc., no negative): its single "conditioning" input is positive.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive")
|
||||
|| (viaBasicGuider && name === "conditioning");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
@@ -480,6 +534,15 @@ function resolveLinkedText(wf, srcId, slot, depth = 0) {
|
||||
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// LiveChatStream (ComfyUI-LiveChatStream) — 出力(positive/negative/response/chat_p/
|
||||
// chat_n/thinking)は同名+"_text"の非表示ウィジェットの値をそのまま返すだけのノード。
|
||||
// 下の汎用キー探索には引っかからない(prompt_text等は"prompt"等と一致しない)ため個別対応する。
|
||||
// response(LLM応答全文)は数千〜数万文字になり得るが、文字列を返すだけなので問題なく解決できる。
|
||||
if (ct === "LiveChatStream") {
|
||||
const key = LIVE_CHAT_STREAM_OUTPUT_KEYS[slot];
|
||||
const v = key ? src.inputs?.[key] : undefined;
|
||||
return (typeof v === "string" && v) ? v : null;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
|
||||
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
|
||||
// リテラルへ解決できた方を採用する。
|
||||
@@ -562,14 +625,18 @@ function extractPromptsAPI(wf) {
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
let viaBasicGuider = false;
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
viaBasicGuider = guiderNode?.class_type === "BasicGuider";
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
// BasicGuider (Flux/Ming Image etc., no negative): its single "conditioning" input is positive.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive")
|
||||
|| (viaBasicGuider && key === "conditioning");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
@@ -659,7 +726,7 @@ function extractPromptsLiteGraph(wf) {
|
||||
}
|
||||
|
||||
// ── SD/Fooocus prompt extraction ──────────────────────────────
|
||||
function parseSDAParameters(raw) {
|
||||
export function parseSDAParameters(raw) {
|
||||
const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const stepsMatch = text.match(/\nSteps:\s+\d/);
|
||||
if (!stepsMatch) return null;
|
||||
@@ -677,13 +744,27 @@ function parseSDAParameters(raw) {
|
||||
while ((m = re.exec(paramsLine)) !== null) params[m[1].trim()] = m[2].trim().replace(/^"|"$/g, "");
|
||||
return { positive, negative, params };
|
||||
}
|
||||
function parseFooocusMetadata(raw) {
|
||||
export function parseFooocusMetadata(raw) {
|
||||
let obj; try { obj = JSON.parse(raw); } catch { return null; }
|
||||
if (!obj?.base_model) return null;
|
||||
const toArray = v => !v ? [] : Array.isArray(v) ? v.filter(Boolean) : [String(v)];
|
||||
return { checkpoint: obj.base_model, vae: (obj.vae && obj.vae !== "Default") ? obj.vae : null, positives: toArray(obj.full_prompt ?? obj.prompt), negatives: toArray(obj.full_negative_prompt ?? obj.negative_prompt) };
|
||||
}
|
||||
|
||||
// ComfyUIワークフローを持たない画像(A1111/Forge/Fooocus)の埋め込みテキストからプロンプトを取り出す。
|
||||
// embedded: PNG tEXt/iTXtのキー→文字列マップ。取り出せなければnull。
|
||||
export function extractPromptsFromParameters(embedded) {
|
||||
const raw = embedded?.parameters;
|
||||
if (!raw || typeof raw !== "string") return null;
|
||||
if (embedded.fooocus_scheme === "fooocus") {
|
||||
const f = parseFooocusMetadata(raw);
|
||||
return f ? { positives: f.positives, negatives: f.negatives, texts: [] } : null;
|
||||
}
|
||||
const p = parseSDAParameters(raw);
|
||||
if (!p) return null;
|
||||
return { positives: p.positive ? [p.positive] : [], negatives: p.negative ? [p.negative] : [], texts: [] };
|
||||
}
|
||||
|
||||
// ── Master extractAllMetadata ─────────────────────────────────
|
||||
export async function extractAllMetadata(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
@@ -693,17 +774,21 @@ export async function extractAllMetadata(file) {
|
||||
|
||||
async function fromWorkflow(originalWf, source) {
|
||||
let wf = originalWf;
|
||||
let apiWf = Array.isArray(wf?.nodes) ? null : wf;
|
||||
// サブグラフを持つワークフローは、外側のサブグラフノードに実際に設定された値
|
||||
// (ユーザーが見ている値)が内部ノードのwidgets_valuesへ反映されていないことがある
|
||||
// (テンプレートの古いデフォルト値のまま)。convertUiToApi() はこの注入も含めて
|
||||
// 正しく解決するため、そちらを経由してから抽出する。通常のワークフローには影響しない。
|
||||
if (Array.isArray(wf?.nodes) && wf.definitions?.subgraphs?.length > 0) {
|
||||
if (Array.isArray(wf?.nodes)) {
|
||||
try {
|
||||
const apiWf = await comfyWorkflow.convertUiToApi(wf);
|
||||
if (apiWf && Object.keys(apiWf).length > 0) wf = apiWf;
|
||||
const converted = await comfyWorkflow.convertUiToApi(wf, { readOnly: true });
|
||||
if (converted && Object.keys(converted).length > 0) {
|
||||
apiWf = converted;
|
||||
if (wf.definitions?.subgraphs?.length > 0) wf = converted;
|
||||
}
|
||||
} catch { /* 変換失敗時は元のUI形式のまま抽出(既存ロジックにフォールバック) */ }
|
||||
}
|
||||
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
|
||||
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), samplerSettings: extractSamplerSettings(apiWf), ...extractPrompts(wf) };
|
||||
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)。
|
||||
// API変換後は nodes 情報が失われるため、常に元のUI形式ワークフローから抽出する。
|
||||
const mdm = extractMarkdownNoteModels(originalWf);
|
||||
@@ -816,11 +901,13 @@ function renderSection(sectionEl, listEl, items, buildFn) {
|
||||
// ── External API ──────────────────────────────────────────────
|
||||
let _externalHandleFile = null;
|
||||
|
||||
export async function loadFileIntoMetadataTab(file) {
|
||||
// path: Gallery上のサーバーファイルパス(任意)。渡された場合、/wfm/gallery/image/meta から
|
||||
// 幅・高さやnanobanana履歴(cc_nanobananaフォルダの画像のみ)を補って表示する。
|
||||
export async function loadFileIntoMetadataTab(file, path) {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="metadata"]')?.click();
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
if (_externalHandleFile) await _externalHandleFile(file);
|
||||
if (_externalHandleFile) await _externalHandleFile(file, path);
|
||||
}
|
||||
|
||||
// ── Tab initialization ────────────────────────────────────────
|
||||
@@ -847,6 +934,26 @@ export function initMetadataTab() {
|
||||
const promptFull = document.getElementById("wfm-meta-prompt-full");
|
||||
const promptFullLabel = document.getElementById("wfm-meta-prompt-full-label");
|
||||
|
||||
// Settings (画像サイズ・KSampler設定値) / Nanobanana履歴
|
||||
const settingsSection = document.getElementById("wfm-meta-settings-section");
|
||||
const setSize = document.getElementById("wfm-meta-set-size");
|
||||
const setSeed = document.getElementById("wfm-meta-set-seed");
|
||||
const setSteps = document.getElementById("wfm-meta-set-steps");
|
||||
const setCfg = document.getElementById("wfm-meta-set-cfg");
|
||||
const setSampler = document.getElementById("wfm-meta-set-sampler");
|
||||
const setScheduler = document.getElementById("wfm-meta-set-scheduler");
|
||||
const setDenoise = document.getElementById("wfm-meta-set-denoise");
|
||||
|
||||
const nbSection = document.getElementById("wfm-meta-nb-section");
|
||||
const nbEngine = document.getElementById("wfm-meta-nb-engine");
|
||||
const nbModel = document.getElementById("wfm-meta-nb-model");
|
||||
const nbSize = document.getElementById("wfm-meta-nb-size");
|
||||
const nbSeed = document.getElementById("wfm-meta-nb-seed");
|
||||
const nbTimestamp = document.getElementById("wfm-meta-nb-timestamp");
|
||||
const nbPrompt = document.getElementById("wfm-meta-nb-prompt");
|
||||
const nbNegSection = document.getElementById("wfm-meta-nb-negative-section");
|
||||
const nbNegative = document.getElementById("wfm-meta-nb-negative");
|
||||
|
||||
if (!dropZone) return;
|
||||
|
||||
// Apply i18n to section titles
|
||||
@@ -857,6 +964,8 @@ export function initMetadataTab() {
|
||||
"wfm-meta-te-section": "metaSectionTe",
|
||||
"wfm-meta-lora-section": "metaSectionLora",
|
||||
"wfm-meta-prompt-section": "metaSectionPrompt",
|
||||
"wfm-meta-settings-section": "metaSectionSettings",
|
||||
"wfm-meta-nb-section": "metaSectionNanobanana",
|
||||
};
|
||||
for (const [id, key] of Object.entries(titleMap)) {
|
||||
const title = document.querySelector(`#${id} .wfm-meta-section-title`);
|
||||
@@ -886,6 +995,7 @@ export function initMetadataTab() {
|
||||
"wfm-help-metadata-4": "helpMetadata4",
|
||||
"wfm-help-metadata-5": "helpMetadata5",
|
||||
"wfm-help-metadata-6": "helpMetadata6",
|
||||
"wfm-help-metadata-7": "helpMetadata7",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIds)) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -897,10 +1007,70 @@ export function initMetadataTab() {
|
||||
[ckptSection, vaeSection, diffSection, teSection, loraSection, promptSection].forEach(s => { if (s) s.classList.add("wfm-meta-section-empty"); });
|
||||
if (promptFull) promptFull.value = "";
|
||||
if (promptFullLabel) promptFullLabel.textContent = "";
|
||||
renderSettings(null, null);
|
||||
renderNanobanana(null);
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
// ── Settings (画像サイズ・KSampler設定) ─────────────────────
|
||||
function setRow(el, label, value) {
|
||||
if (!el) return;
|
||||
el.textContent = (value !== undefined && value !== null && value !== "") ? `${label}: ${value}` : "";
|
||||
}
|
||||
|
||||
function renderSettings(samplerSettings, dims) {
|
||||
setRow(setSize, t("metaSettingsSize") || "Size", dims?.width && dims?.height ? `${dims.width}×${dims.height}` : "");
|
||||
const s = samplerSettings ?? {};
|
||||
setRow(setSeed, t("metaSettingsSeed") || "Seed", s.seed ?? s.noise_seed);
|
||||
setRow(setSteps, t("metaSettingsSteps") || "Steps", s.steps);
|
||||
setRow(setCfg, t("metaSettingsCfg") || "CFG", s.cfg);
|
||||
setRow(setSampler, t("metaSettingsSampler") || "Sampler", s.sampler_name);
|
||||
setRow(setScheduler, t("metaSettingsScheduler") || "Scheduler", s.scheduler);
|
||||
setRow(setDenoise, t("metaSettingsDenoise") || "Denoise", s.denoise);
|
||||
const hasAny = (dims?.width && dims?.height) || Object.keys(s).length > 0;
|
||||
if (settingsSection) settingsSection.style.display = hasAny ? "" : "none";
|
||||
}
|
||||
|
||||
// ── Nanobanana履歴(cc_nanobananaフォルダの画像のみ、Galleryから開いた場合に表示) ──
|
||||
function renderNanobanana(entry) {
|
||||
if (!nbSection) return;
|
||||
if (!entry) {
|
||||
nbSection.style.display = "none";
|
||||
return;
|
||||
}
|
||||
nbSection.style.display = "";
|
||||
setRow(nbEngine, t("metaNbEngine") || "Engine", entry.engine);
|
||||
setRow(nbModel, t("metaNbModel") || "Model", entry.model);
|
||||
const size = entry.image_size || (entry.width && entry.height ? `${entry.width}×${entry.height}` : "");
|
||||
setRow(nbSize, t("metaNbSize") || "Size", size);
|
||||
setRow(nbSeed, t("metaNbSeed") || "Seed", entry.seed != null ? String(entry.seed) : "");
|
||||
setRow(nbTimestamp, t("metaNbTimestamp") || "Date", entry.timestamp);
|
||||
if (nbPrompt) nbPrompt.value = entry.prompt || "";
|
||||
if (nbNegSection && nbNegative) {
|
||||
if (entry.negative_prompt) { nbNegative.value = entry.negative_prompt; nbNegSection.style.display = ""; }
|
||||
else { nbNegative.value = ""; nbNegSection.style.display = "none"; }
|
||||
}
|
||||
}
|
||||
|
||||
// path指定時、サーバーの /wfm/gallery/image/meta から幅・高さとnanobanana履歴を取得する
|
||||
// (Galleryタブから「Metadata」ボタンで開いた場合のみ渡される。ドラッグ&ドロップされた
|
||||
// 任意ファイルはブラウザ上のFileオブジェクトのみでサーバーパスを持たないため対象外)。
|
||||
async function fetchServerMeta(path) {
|
||||
if (!path) return null;
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.error ? null : json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 連続で画像を開いたとき、先に始めた処理が後から描画して結果が混ざるのを防ぐ
|
||||
let _loadSeq = 0;
|
||||
async function handleFile(file, path) {
|
||||
if (!file) return;
|
||||
const seq = ++_loadSeq;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
fileInfo.textContent = t("metaFileTooLarge");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
@@ -911,32 +1081,60 @@ export function initMetadataTab() {
|
||||
fileInfo.style.color = "var(--wfm-text-secondary)";
|
||||
clearAll();
|
||||
|
||||
const serverMeta = await fetchServerMeta(path);
|
||||
if (seq !== _loadSeq) return;
|
||||
|
||||
// Show preview for images
|
||||
const isImage = file.type.startsWith("image/") || file.name.toLowerCase().match(/\.(png|webp|jpg|jpeg)$/);
|
||||
let naturalDims = null;
|
||||
if (isImage) {
|
||||
const url = URL.createObjectURL(file);
|
||||
previewImg.src = url;
|
||||
previewImg.style.display = "block";
|
||||
dropLabel.style.display = "none";
|
||||
previewImg.onload = () => URL.revokeObjectURL(url);
|
||||
await new Promise(resolve => {
|
||||
previewImg.onload = () => {
|
||||
naturalDims = { width: previewImg.naturalWidth, height: previewImg.naturalHeight };
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
};
|
||||
previewImg.onerror = resolve;
|
||||
});
|
||||
} else {
|
||||
previewImg.style.display = "none";
|
||||
dropLabel.style.display = "flex";
|
||||
}
|
||||
|
||||
if (seq !== _loadSeq) return;
|
||||
const dims = (serverMeta?.width && serverMeta?.height) ? serverMeta : naturalDims;
|
||||
renderNanobanana(serverMeta?.nanobanana ?? null);
|
||||
|
||||
let meta;
|
||||
try {
|
||||
meta = await extractAllMetadata(file);
|
||||
} catch (err) {
|
||||
if (seq !== _loadSeq) return;
|
||||
console.error("[MetadataTab]", err);
|
||||
fileInfo.textContent = t("metaParseError");
|
||||
fileInfo.style.color = "var(--wfm-danger)";
|
||||
renderSettings(null, dims);
|
||||
return;
|
||||
}
|
||||
|
||||
if (seq !== _loadSeq) return;
|
||||
if (!meta) {
|
||||
fileInfo.textContent = t("metaNoMetadata");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
// nanobanana(Gemini API経由)の画像はComfyUIワークフローを埋め込んでいないため
|
||||
// extractAllMetadataはnullを返す。その場合でもnanobanana履歴があれば
|
||||
// 「メタデータ無し」エラーにせず、画像サイズのみSettingsに表示する。
|
||||
if (serverMeta?.nanobanana) {
|
||||
const sizeKB = (file.size / 1024).toFixed(1);
|
||||
fileInfo.textContent = `${file.name} (${sizeKB} KB · Nanobanana)`;
|
||||
fileInfo.style.color = "var(--wfm-text-secondary)";
|
||||
} else {
|
||||
fileInfo.textContent = t("metaNoMetadata");
|
||||
fileInfo.style.color = "var(--wfm-warning)";
|
||||
}
|
||||
renderSettings(null, dims);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -950,6 +1148,7 @@ export function initMetadataTab() {
|
||||
renderSection(diffSection, diffList, meta.diffusionModels, n => buildModelItem(n));
|
||||
renderSection(teSection, teList, meta.textEncoders, n => buildModelItem(n));
|
||||
renderSection(loraSection, loraList, meta.loras, l => buildLoRAItem(l));
|
||||
renderSettings(meta.samplerSettings, dims);
|
||||
|
||||
// Prompts
|
||||
promptList.innerHTML = "";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { openBadgeEditModal, renderBadgeFilter, setGridChangeCallback } from "./
|
||||
import { isModelDisabled, renderTagFilter, renderGroupFilter, renderDirFilter } from "./models/filters.js";
|
||||
import { renderModelGrid, clearBatchGroup, clearStackGroup } from "./models/grid-view.js";
|
||||
import { toggleSelectMode, renderBulkActionBar, fetchSubdirs } from "./models/selection-bulk.js";
|
||||
import { fetchPreviewKeys } from "./models/helpers.js";
|
||||
import { closeSidePanel, renderSideCivitai, renderSideInfo, batchFetchCivitai, fetchCivitaiCache } from "./models/detail-panel.js";
|
||||
|
||||
export { openBadgeEditModal };
|
||||
@@ -302,7 +303,7 @@ async function loadModelsForCurrentType() {
|
||||
if (!fetchFn) throw new Error("Unknown model type: " + type);
|
||||
|
||||
const [models, disabledList, groups] = await Promise.all([
|
||||
fetchFn(), fetchDisabledModels(type), fetchModelGroups(),
|
||||
fetchFn(), fetchDisabledModels(type), fetchModelGroups(), fetchPreviewKeys(type),
|
||||
]);
|
||||
const disabledSet = new Set(Array.isArray(disabledList) ? disabledList : []);
|
||||
state.disabledModels[type] = disabledSet;
|
||||
@@ -550,6 +551,16 @@ export function initModelsTab() {
|
||||
document.getElementById("wfm-models-civitai-batch-btn")?.addEventListener("click", () => {
|
||||
batchFetchCivitai();
|
||||
});
|
||||
// Skip models already known to be missing on CivitAI (persisted per browser)
|
||||
const skipNfEl = document.getElementById("wfm-models-civitai-skip-nf");
|
||||
const skipNfLabel = document.getElementById("wfm-models-civitai-skip-nf-label");
|
||||
if (skipNfLabel) skipNfLabel.textContent = t("civitaiSkipNotFound");
|
||||
if (skipNfEl) {
|
||||
skipNfEl.checked = localStorage.getItem("wfm_models_civitai_skip_nf") === "1";
|
||||
skipNfEl.addEventListener("change", () => {
|
||||
localStorage.setItem("wfm_models_civitai_skip_nf", skipNfEl.checked ? "1" : "0");
|
||||
});
|
||||
}
|
||||
|
||||
// Side panel tab switching
|
||||
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((btn) => {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Models Tab - Auto badges from CivitAI base model info
|
||||
*
|
||||
* CivitAI's `baseModel` is already a categorical string, so labels are derived
|
||||
* with a deterministic rule table (variants such as "SDXL 1.0" / "SDXL Turbo"
|
||||
* collapse into one family badge). Unknown strings fall back to the raw value.
|
||||
* Apply semantics: model badges are appended (existing badges are kept);
|
||||
* a label already in the palette gets its color overwritten (optional).
|
||||
*/
|
||||
|
||||
import { openModal, closeModal, showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { getCurrentModels } from "./filters.js";
|
||||
import { getBadgePalette, saveBadgePalette, renderBadgeFilter } from "./badges.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import { saveModelMetadata } from "../models-tab.js";
|
||||
|
||||
// [pattern, label, color] — first match wins, so specific families come first.
|
||||
const FAMILY_RULES = [
|
||||
[/^pony/i, "Pony", "#d946ef"],
|
||||
[/^illustrious/i, "Illustrious", "#f59e0b"],
|
||||
[/^noobai/i, "NoobAI", "#14b8a6"],
|
||||
[/^sdxl|^sd ?xl/i, "SDXL", "#3b82f6"],
|
||||
[/^sd ?1\.[45]/i, "SD1.5", "#22c55e"],
|
||||
[/^sd ?2/i, "SD2", "#84cc16"],
|
||||
[/^sd ?3/i, "SD3", "#06b6d4"],
|
||||
[/^flux\.?1|^flux$/i, "Flux.1", "#ef4444"],
|
||||
[/^flux\.?2/i, "Flux.2", "#dc2626"],
|
||||
[/^qwen/i, "Qwen", "#8b5cf6"],
|
||||
[/^wan/i, "Wan", "#0ea5e9"],
|
||||
[/^z-?image/i, "Z-Image", "#ec4899"],
|
||||
[/^hidream/i, "HiDream", "#f97316"],
|
||||
[/^hunyuan/i, "Hunyuan", "#6366f1"],
|
||||
[/^chroma/i, "Chroma", "#a855f7"],
|
||||
[/^lumina/i, "Lumina", "#10b981"],
|
||||
[/^auraflow/i, "AuraFlow", "#64748b"],
|
||||
[/^kolors/i, "Kolors", "#e11d48"],
|
||||
];
|
||||
const SKIP_VALUES = new Set(["", "other", "unknown"]);
|
||||
const FALLBACK_COLORS = ["#0891b2", "#7c3aed", "#be185d", "#b45309", "#4d7c0f", "#475569"];
|
||||
|
||||
export function baseModelToBadge(baseModel) {
|
||||
const raw = (baseModel || "").trim();
|
||||
if (SKIP_VALUES.has(raw.toLowerCase())) return null;
|
||||
for (const [re, label, color] of FAMILY_RULES) {
|
||||
if (re.test(raw)) return { label, color };
|
||||
}
|
||||
let h = 0;
|
||||
for (const ch of raw) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return { label: raw, color: FALLBACK_COLORS[h % FALLBACK_COLORS.length] };
|
||||
}
|
||||
|
||||
/** Scan current-type models → { assignments: [{model, label}], labels: Map(label → {color, count}) } */
|
||||
function buildPlan() {
|
||||
const assignments = [];
|
||||
const labels = new Map();
|
||||
let noInfo = 0;
|
||||
for (const m of getCurrentModels()) {
|
||||
const sha = state.modelMetadata[m]?.sha256;
|
||||
const civ = sha && state.civitaiCache[sha];
|
||||
const badge = civ ? baseModelToBadge(civ.baseModel) : null;
|
||||
if (!badge) { noInfo++; continue; }
|
||||
assignments.push({ model: m, label: badge.label });
|
||||
const entry = labels.get(badge.label) || { color: badge.color, count: 0 };
|
||||
entry.count++;
|
||||
labels.set(badge.label, entry);
|
||||
}
|
||||
return { assignments, labels, noInfo };
|
||||
}
|
||||
|
||||
export function openAutoBadgeModal(onDone = null) {
|
||||
const { assignments, labels, noInfo } = buildPlan();
|
||||
if (assignments.length === 0) {
|
||||
showToast(t("autoBadgeNoData"), "warning");
|
||||
return;
|
||||
}
|
||||
const palette = getBadgePalette();
|
||||
const rows = [...labels.entries()].sort((a, b) => b[1].count - a[1].count).map(([label, info]) => {
|
||||
const exists = label in palette;
|
||||
return `<div class="wfm-badge-color-row">
|
||||
<span class="wfm-badge wfm-badge-model" style="background:${info.color};color:#fff;">${escapeHtml(label)}</span>
|
||||
<span style="font-size:12px;">${info.count}</span>
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">${exists ? t("autoBadgeExisting") : t("autoBadgeNew")}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
openModal(t("autoBadgeTitle"), `
|
||||
<div style="min-width:340px;">
|
||||
<p style="font-size:12px;color:var(--wfm-text-secondary);margin:0 0 8px;">${t("autoBadgeDesc")}</p>
|
||||
${rows}
|
||||
<p style="font-size:11px;color:var(--wfm-text-secondary);">${t("autoBadgeNoInfo")}: ${noInfo}</p>
|
||||
<label style="font-size:12px;display:block;margin:8px 0;">
|
||||
<input type="checkbox" id="wfm-auto-badge-overwrite" checked> ${t("autoBadgeOverwrite")}
|
||||
</label>
|
||||
<label style="font-size:12px;display:block;margin:8px 0;">
|
||||
<input type="checkbox" id="wfm-auto-badge-skip"> ${t("autoBadgeSkipExisting")}
|
||||
</label>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-auto-badge-apply">${t("autoBadgeApply")} (${assignments.length})</button>
|
||||
</div>`);
|
||||
|
||||
const hasBadges = (m) => (state.modelMetadata[m]?.badges || []).length > 0;
|
||||
const targets = () => document.getElementById("wfm-auto-badge-skip")?.checked
|
||||
? assignments.filter(({ model }) => !hasBadges(model)) : assignments;
|
||||
document.getElementById("wfm-auto-badge-skip")?.addEventListener("change", () => {
|
||||
const btn = document.getElementById("wfm-auto-badge-apply");
|
||||
if (btn) btn.textContent = `${t("autoBadgeApply")} (${targets().length})`;
|
||||
});
|
||||
|
||||
document.getElementById("wfm-auto-badge-apply")?.addEventListener("click", async (e) => {
|
||||
e.target.disabled = true;
|
||||
const overwrite = document.getElementById("wfm-auto-badge-overwrite")?.checked;
|
||||
const target = targets();
|
||||
const pal = getBadgePalette();
|
||||
const usedLabels = new Set(target.map((a) => a.label));
|
||||
for (const [label, info] of labels) {
|
||||
if (!usedLabels.has(label)) continue;
|
||||
if (!(label in pal) || overwrite) pal[label] = info.color;
|
||||
}
|
||||
saveBadgePalette(pal);
|
||||
|
||||
const todo = target.filter(({ model, label }) => !(state.modelMetadata[model]?.badges || []).includes(label));
|
||||
for (let i = 0; i < todo.length; i += 8) {
|
||||
await Promise.all(todo.slice(i, i + 8).map(({ model, label }) => {
|
||||
const badges = [...(state.modelMetadata[model]?.badges || []), label];
|
||||
return saveModelMetadata(model, { badges });
|
||||
}));
|
||||
}
|
||||
renderBadgeFilter();
|
||||
renderModelGrid();
|
||||
showToast(`${todo.length} ${t("autoBadgeDone")}`, "success");
|
||||
closeModal();
|
||||
if (onDone) onDone();
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { openModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml, readJsonStorage } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { openAutoBadgeModal } from "./auto-badge.js";
|
||||
|
||||
// Grid re-render is owned by models-tab.js (grid rendering hasn't been split
|
||||
// out yet); registered once from initModelsTab() to avoid a circular import.
|
||||
@@ -61,6 +62,9 @@ export function openBadgeEditModal(onPaletteChange = null) {
|
||||
<input type="color" id="wfm-badge-new-color" value="#6366f1" style="width:36px;height:28px;padding:1px;border-radius:4px;cursor:pointer;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-badge-add-btn">${t("badgeAdd")}</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-badge-auto-btn" title="${t("autoBadgeDesc")}">✨ ${t("autoBadgeTitle")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(t("badgeManage"), html);
|
||||
@@ -93,6 +97,10 @@ function bindBadgeModalEvents(onPaletteChange = null) {
|
||||
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
|
||||
document.getElementById("wfm-badge-auto-btn")?.addEventListener("click", () => {
|
||||
openAutoBadgeModal(onPaletteChange);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-badge-add-btn")?.addEventListener("click", () => {
|
||||
const labelInput = document.getElementById("wfm-badge-new-label");
|
||||
const colorInput = document.getElementById("wfm-badge-new-color");
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
import { showToast, openModal, closeModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { escapeHtml, sanitizeHtml, safeHttpUrl } from "../util.js";
|
||||
import { state, RESERVED_GROUPS, GENUI_TYPE_MAP } from "./state.js";
|
||||
import { getCurrentModels, renderTagFilter, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette, modelBadgesHtml, openBadgeEditModal } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl } from "./helpers.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl, markHasPreview, fetchPreviewKeys } from "./helpers.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import {
|
||||
applyToGenUI, applyEmbeddingToPrompt, saveModelMetadata, toggleGroupEnable,
|
||||
@@ -200,6 +200,7 @@ export function openDetailModal(modelName) {
|
||||
const res = await fetch("/api/wfm/models/change-preview", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
markHasPreview(modelName);
|
||||
// Reload preview in modal
|
||||
const newUrl = previewUrl(modelName) + "&t=" + Date.now();
|
||||
const mImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
@@ -308,12 +309,6 @@ export function renderSideInfo(modelName) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsFilePath")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<span id="wfm-models-side-filepath" class="wfm-model-filepath" title="${t("modelsCopyPath")}" style="cursor:pointer;word-break:break-all;font-size:0.85em;color:#aaa;">${t("modelsLoading")}...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsTags")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
@@ -348,33 +343,6 @@ export function renderSideInfo(modelName) {
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and display file path
|
||||
fetch(`/api/wfm/models/filepath?type=${encodeURIComponent(state.activeModelType)}&name=${encodeURIComponent(modelName)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl && data.path) {
|
||||
fpEl.textContent = data.path;
|
||||
fpEl.title = t("modelsCopyPath");
|
||||
} else if (fpEl) {
|
||||
fpEl.textContent = modelName;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) fpEl.textContent = modelName;
|
||||
});
|
||||
|
||||
// Copy file path on click
|
||||
el.querySelector("#wfm-models-side-filepath")?.addEventListener("click", () => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) {
|
||||
navigator.clipboard.writeText(fpEl.textContent).then(() => {
|
||||
showToast(t("modelsCopiedPath"), "success");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save button
|
||||
el.querySelector("#wfm-models-side-save-btn")?.addEventListener("click", () => {
|
||||
const tagsInput = document.getElementById("wfm-models-side-tags");
|
||||
@@ -564,6 +532,15 @@ export function renderSideGroup(modelName) {
|
||||
|
||||
// ── Side Panel: CivitAI Tab ───────────────────────────────
|
||||
|
||||
// ハッシュ未計算を示す赤字の行(CivitAIタブ)。更新/取得ボタンでハッシュ計算して取得する
|
||||
function hashMissingRowHtml() {
|
||||
return `
|
||||
<div style="display:flex;align-items:center;justify-content:center;font-size:12px;margin-bottom:10px;">
|
||||
<span style="color:var(--wfm-text-secondary);margin-right:8px;">${t("civitaiHashLabel")}</span>
|
||||
<span id="wfm-civitai-hash-missing" style="color:#e53935;font-weight:600;">${t("civitaiHashNotCalculated")}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderSideCivitai(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-civitai");
|
||||
if (!el) return;
|
||||
@@ -593,6 +570,7 @@ export function renderSideCivitai(modelName) {
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
${hashMissingRowHtml()}
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiFetchDesc")}
|
||||
</p>
|
||||
@@ -629,6 +607,7 @@ export async function fetchCivitaiForModel(modelName, el) {
|
||||
if (!state.modelMetadata[modelName]) state.modelMetadata[modelName] = {};
|
||||
state.modelMetadata[modelName].sha256 = data.sha256;
|
||||
}
|
||||
if (data.preview_saved) markHasPreview(modelName);
|
||||
renderCivitaiInfo(el, data.civitai, modelName);
|
||||
showToast(t("civitaiFound"), "success");
|
||||
// Always refresh preview: local file if saved, otherwise civitai image as fallback
|
||||
@@ -640,7 +619,7 @@ export async function fetchCivitaiForModel(modelName, el) {
|
||||
if (data.preview_saved) {
|
||||
sideImg.src = previewUrl(modelName) + "&t=" + Date.now();
|
||||
} else {
|
||||
const civitaiImg = data.civitai.images && data.civitai.images[0];
|
||||
const civitaiImg = safeHttpUrl(data.civitai.images && data.civitai.images[0]);
|
||||
if (civitaiImg) sideImg.src = civitaiImg;
|
||||
}
|
||||
sideImg.style.display = "";
|
||||
@@ -669,7 +648,7 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
? `https://${civitaiHost}/models/${info.modelId}?modelVersionId=${info.versionId}`
|
||||
: info.versionId
|
||||
? `https://${civitaiHost}/model-versions/${info.versionId}`
|
||||
: (info.modelUrl || "#");
|
||||
: safeHttpUrl(info.modelUrl, "#");
|
||||
|
||||
// Hash: BLAKE3 優先、なければ SHA256
|
||||
const fileHashes = info.fileHashes || {};
|
||||
@@ -700,7 +679,11 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
<code class="wfm-hash-value" data-hash="${escapeHtml(hashFull)}"
|
||||
style="background:var(--wfm-bg-secondary);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 84px);"
|
||||
title="${t("civitaiCopyHash")}">${escapeHtml(hashType)}: ${escapeHtml(hashFull.substring(0, 16).toUpperCase())}…</code>
|
||||
</div>` : "";
|
||||
</div>` : `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiHashLabel")}</span>
|
||||
<span style="color:#e53935;font-weight:600;">${t("civitaiHashNotCalculated")}</span>
|
||||
</div>`;
|
||||
|
||||
const detailSection = (typeRow || baseModelRow || hashRow)
|
||||
? `<div style="margin-bottom:10px;">${typeRow}${baseModelRow}${hashRow}</div>` : "";
|
||||
@@ -714,7 +697,7 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
).join(" ");
|
||||
|
||||
// Sample images — all go to Sample pane
|
||||
const images = info.images || [];
|
||||
const images = (info.images || []).filter((u) => safeHttpUrl(u));
|
||||
const sampleImagesHtml = images.map((url) =>
|
||||
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${t("civitaiOpenImage")}"><img src="${escapeHtml(url)}" style="width:100%;border-radius:4px;margin-bottom:6px;cursor:pointer;display:block;" loading="lazy" /></a>`
|
||||
).join("");
|
||||
@@ -744,7 +727,7 @@ export function renderCivitaiInfo(el, info, modelName) {
|
||||
${detailSection}
|
||||
${tagsHtml ? `<div style="margin-bottom:8px;">${tagsHtml}</div>` : ""}
|
||||
${trainedWordsHtml ? `<div style="margin-bottom:10px;"><div style="font-weight:600;font-size:12px;margin-bottom:4px;">${t("civitaiTriggerWords")}</div>${trainedWordsHtml}</div>` : ""}
|
||||
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${info.description}</div>` : ""}
|
||||
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${sanitizeHtml(info.description)}</div>` : ""}
|
||||
<div style="margin-top:10px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-refresh-btn">${t("civitaiRefresh")}</button>
|
||||
</div>
|
||||
@@ -815,10 +798,13 @@ export async function batchFetchCivitai() {
|
||||
}
|
||||
|
||||
// Filter out models that already have CivitAI data
|
||||
// Optionally also skip models previously recorded as "not found on CivitAI"
|
||||
const skipNotFound = document.getElementById("wfm-models-civitai-skip-nf")?.checked;
|
||||
const meta = state.modelMetadata;
|
||||
const uncached = models.filter((m) => {
|
||||
const sha = meta[m]?.sha256;
|
||||
return !sha || !state.civitaiCache[sha];
|
||||
if (sha && state.civitaiCache[sha]) return false;
|
||||
return !(skipNotFound && meta[m]?.civitaiNotFound);
|
||||
});
|
||||
|
||||
if (uncached.length === 0) {
|
||||
@@ -860,15 +846,16 @@ export async function batchFetchCivitai() {
|
||||
const pct = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
|
||||
const statusText = data.status === "hashing" ? t("civitaiHashing2")
|
||||
: data.status === "fetching" ? t("civitaiFetching")
|
||||
: data.status === "previews" ? t("civitaiSavingPreviews")
|
||||
: data.status === "cached" ? "✓"
|
||||
: data.status === "found" ? "✓"
|
||||
: data.status === "not_found" ? "—"
|
||||
: data.status === "not_found" || data.status === "needs_hash" ? "—"
|
||||
: "";
|
||||
if (progressEl) progressEl.textContent = `${pct}% (${data.current}/${data.total}) ${statusText}`;
|
||||
} else if (eventType === "done") {
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
const previewNote = data.preview_saved > 0 ? ` (+${data.preview_saved} preview)` : "";
|
||||
showToast(t("civitaiBatchDone", data.found, data.not_found) + previewNote, "success");
|
||||
showToast(t("civitaiBatchDone", data.found, data.not_found, data.needs_hash || 0) + previewNote, "success");
|
||||
// Reload caches
|
||||
const [newMeta, newCache] = await Promise.all([fetchModelMetadata(), fetchCivitaiCache()]);
|
||||
// Apply sha256 hashes from batch result directly in case fetchModelMetadata
|
||||
@@ -879,6 +866,7 @@ export async function batchFetchCivitai() {
|
||||
if (!newMeta[modelName].sha256) newMeta[modelName].sha256 = sha256;
|
||||
}
|
||||
}
|
||||
if (data.preview_saved > 0) await fetchPreviewKeys(state.activeModelType);
|
||||
state.modelMetadata = newMeta;
|
||||
state.civitaiCache = newCache;
|
||||
renderModelGrid();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { state } from "./state.js";
|
||||
import { safeHttpUrl } from "../util.js";
|
||||
|
||||
export function parseModelPath(fullName) {
|
||||
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
|
||||
@@ -30,8 +31,35 @@ export function previewUrl(modelName, modelType) {
|
||||
* (aiohttp add_get does not auto-handle HEAD method).
|
||||
* Falls back to CivitAI cached image if no local preview exists.
|
||||
*/
|
||||
/** モデル名 → previewKeys のキー(`サブフォルダ/拡張子なし名`、小文字・/区切り) */
|
||||
export function previewKey(modelName) {
|
||||
const n = modelName.replace(/\\/g, "/");
|
||||
const dot = n.lastIndexOf(".");
|
||||
const slash = n.lastIndexOf("/");
|
||||
return (dot > slash ? n.substring(0, dot) : n).toLowerCase();
|
||||
}
|
||||
|
||||
/** プレビュー画像を持つモデルのキー一覧をサーバから取得してstateに保存。失敗時は保存せず従来動作に戻る */
|
||||
export async function fetchPreviewKeys(modelType) {
|
||||
const type = modelType || state.activeModelType;
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/models/preview-keys?type=${encodeURIComponent(type)}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.keys)) state.previewKeys[type] = new Set(data.keys);
|
||||
} catch { /* 従来動作(都度リクエスト)にフォールバック */ }
|
||||
}
|
||||
|
||||
/** 新しくプレビューが作られた/変更されたモデルを登録する */
|
||||
export function markHasPreview(modelName, modelType) {
|
||||
const set = state.previewKeys[modelType || state.activeModelType];
|
||||
if (set) set.add(previewKey(modelName));
|
||||
}
|
||||
|
||||
export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
const url = previewUrl(modelName, modelType);
|
||||
const keys = state.previewKeys[modelType || state.activeModelType];
|
||||
const noLocalPreview = !!keys && !keys.has(previewKey(modelName));
|
||||
imgEl.onload = () => {
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
@@ -41,7 +69,7 @@ export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiImg = civitai && civitai.images && civitai.images[0];
|
||||
const civitaiImg = safeHttpUrl(civitai && civitai.images && civitai.images[0]);
|
||||
if (civitaiImg) {
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
@@ -55,5 +83,6 @@ export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
}
|
||||
};
|
||||
if (noLocalPreview) { imgEl.onerror(); return; }
|
||||
imgEl.src = url;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const state = {
|
||||
allModelGroups: {}, // { type: { groupName: [models] } } — all types
|
||||
civitaiCache: {},
|
||||
disabledModels: {}, // { type: Set<modelName> }
|
||||
previewKeys: {}, // { type: Set<"subdir/stem"(小文字)> } プレビュー画像を持つモデル。未取得の型は従来どおり都度リクエスト
|
||||
subdirs: [],
|
||||
selectMode: false,
|
||||
selectedModels: new Set(),
|
||||
@@ -48,14 +49,31 @@ export const state = {
|
||||
};
|
||||
|
||||
|
||||
// 設定「モデルフォルダ」で追加されたフォルダのモデル(ComfyUIのobject_infoには出ない)
|
||||
async function fetchExtraModels(type) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/models/files?type=${encodeURIComponent(type)}&extra_only=1`);
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
const withExtra = (type, fn) => async () => {
|
||||
const [base, extra] = await Promise.all([fn(), fetchExtraModels(type)]);
|
||||
return extra.length ? [...new Set([...base, ...extra])] : base;
|
||||
};
|
||||
|
||||
export const FETCH_MAP = {
|
||||
checkpoint: () => comfyUI.fetchCheckpoints(),
|
||||
lora: () => comfyUI.fetchLoras(),
|
||||
vae: () => comfyUI.fetchVaes(),
|
||||
controlnet: () => comfyUI.fetchControlNets(),
|
||||
unet: () => comfyUI.fetchDiffusionModels(),
|
||||
textencoder: () => comfyUI.fetchTextEncoders(),
|
||||
hypernetwork: () => comfyUI.fetchHypernetworks(),
|
||||
checkpoint: withExtra("checkpoint", () => comfyUI.fetchCheckpoints()),
|
||||
lora: withExtra("lora", () => comfyUI.fetchLoras()),
|
||||
vae: withExtra("vae", () => comfyUI.fetchVaes()),
|
||||
controlnet: withExtra("controlnet", () => comfyUI.fetchControlNets()),
|
||||
unet: withExtra("unet", () => comfyUI.fetchDiffusionModels()),
|
||||
textencoder: withExtra("textencoder", () => comfyUI.fetchTextEncoders()),
|
||||
hypernetwork: withExtra("hypernetwork", () => comfyUI.fetchHypernetworks()),
|
||||
embedding: () => comfyUI.fetchEmbeddings(),
|
||||
};
|
||||
|
||||
|
||||
@@ -28,8 +28,35 @@ const state = {
|
||||
currentPage: 0,
|
||||
selectedNodes: new Set(),
|
||||
lastSelectionIndex: -1,
|
||||
renderToken: 0,
|
||||
};
|
||||
|
||||
// 数千件のカード/行を一度にDOMへ作ると描画が固まるため、先頭だけ同期で作り
|
||||
// 残りはフレームごとに分割して追加する。新しい描画が始まったら古い分は打ち切る。
|
||||
const RENDER_FIRST_CHUNK = 120;
|
||||
const RENDER_CHUNK = 250;
|
||||
function appendChunked(items, parent, build) {
|
||||
const token = ++state.renderToken;
|
||||
let i = 0;
|
||||
const step = (size) => {
|
||||
if (token !== state.renderToken) return;
|
||||
const frag = document.createDocumentFragment();
|
||||
const end = Math.min(i + size, items.length);
|
||||
for (; i < end; i++) frag.appendChild(build(items[i]));
|
||||
parent.appendChild(frag);
|
||||
if (i < items.length) requestAnimationFrame(() => step(RENDER_CHUNK));
|
||||
};
|
||||
step(RENDER_FIRST_CHUNK);
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function extractPackageName(pythonModule) {
|
||||
@@ -56,6 +83,40 @@ function categoryBadgeHtml(cat) {
|
||||
|
||||
// ── API ───────────────────────────────────────────────────
|
||||
|
||||
// 軽量一覧(入力定義なし)。/object_info より桁違いに速い。失敗時は null。
|
||||
async function fetchLightNodes() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/light");
|
||||
if (!res.ok) return null;
|
||||
const list = await res.json();
|
||||
if (!Array.isArray(list)) return null;
|
||||
return list.map(info => ({
|
||||
name: info.name,
|
||||
display_name: info.display_name || info.name,
|
||||
description: info.description || "",
|
||||
category: info.category || "uncategorized",
|
||||
python_module: info.python_module || "",
|
||||
package: extractPackageName(info.python_module),
|
||||
input: null, // /object_info で後から補完
|
||||
input_order: {},
|
||||
output: info.output || [],
|
||||
output_name: info.output_name || [],
|
||||
output_node: info.output_node || false,
|
||||
search_aliases: info.search_aliases || [],
|
||||
deprecated: info.deprecated || false,
|
||||
experimental: info.experimental || false,
|
||||
}));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// 入力定義などを full node (object_info 由来) から既存ノードへ反映する
|
||||
function mergeFullNode(target, full) {
|
||||
target.input = full.input;
|
||||
target.input_order = full.input_order;
|
||||
target.output = full.output;
|
||||
target.output_name = full.output_name;
|
||||
}
|
||||
|
||||
async function fetchAllNodes() {
|
||||
const data = await comfyUI.fetchAllObjectInfo();
|
||||
return Object.entries(data).map(([name, info]) => ({
|
||||
@@ -76,6 +137,15 @@ async function fetchAllNodes() {
|
||||
}));
|
||||
}
|
||||
|
||||
// 検索用の小文字化済み文字列(タグ以外)を1回だけ作る。キー入力ごとの再計算を避ける。
|
||||
function searchBase(node) {
|
||||
if (node._searchBase === undefined) {
|
||||
node._searchBase = [node.name, node.display_name, node.description, ...(node.search_aliases || [])]
|
||||
.join(" ").toLowerCase();
|
||||
}
|
||||
return node._searchBase;
|
||||
}
|
||||
|
||||
async function fetchNodeMetadata() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata");
|
||||
@@ -189,13 +259,10 @@ function filterNodes() {
|
||||
|
||||
if (state.searchText) {
|
||||
const q = state.searchText.toLowerCase();
|
||||
const meta = state.nodeMetadata[node.name];
|
||||
const searchable = [
|
||||
node.name, node.display_name, node.description,
|
||||
...(node.search_aliases || []),
|
||||
...(meta?.tags || []),
|
||||
].join(" ").toLowerCase();
|
||||
if (!searchable.includes(q)) return false;
|
||||
if (!searchBase(node).includes(q)) {
|
||||
const tags = state.nodeMetadata[node.name]?.tags;
|
||||
if (!tags || !tags.join(" ").toLowerCase().includes(q)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -255,6 +322,7 @@ function renderNodeGrid() {
|
||||
if (countEl) countEl.textContent = `${filtered.length} / ${state.allNodes.length}`;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
state.renderToken++; // 実行中の分割描画を止める(止めないと古いカードが追加され続ける)
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
|
||||
return;
|
||||
}
|
||||
@@ -265,7 +333,7 @@ function renderNodeGrid() {
|
||||
}
|
||||
|
||||
grid.innerHTML = "";
|
||||
filtered.forEach(node => grid.appendChild(createNodeCard(node)));
|
||||
appendChunked(filtered, grid, createNodeCard);
|
||||
}
|
||||
|
||||
function createNodeCard(node) {
|
||||
@@ -341,7 +409,7 @@ function renderNodeTableView(grid, filtered) {
|
||||
<th>In</th><th>Out</th>
|
||||
</tr></thead>`;
|
||||
const tbody = document.createElement("tbody");
|
||||
filtered.forEach(node => {
|
||||
const buildRow = (node) => {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "wfm-nodes-table-row";
|
||||
@@ -352,15 +420,16 @@ function renderNodeTableView(grid, filtered) {
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
tr.classList.add("multi-selected");
|
||||
}
|
||||
const inputCount = Object.keys(node.input.required || {}).length +
|
||||
Object.keys(node.input.optional || {}).length;
|
||||
const inputCount = node.input
|
||||
? Object.keys(node.input.required || {}).length + Object.keys(node.input.optional || {}).length
|
||||
: "\u2026";
|
||||
const outputCount = (node.output || []).length;
|
||||
tr.innerHTML = `
|
||||
<td><button class="${meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn"}">${meta.favorite ? "\u2605" : "\u2606"}</button></td>
|
||||
<td title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</td>
|
||||
<td>${packageBadgeHtml(node.package)}</td>
|
||||
<td>${escapeHtml(node.category)}</td>
|
||||
<td>${inputCount}</td><td>${outputCount}</td>`;
|
||||
<td class="wfm-nodes-in-count">${inputCount}</td><td>${outputCount}</td>`;
|
||||
tr.querySelector(".wfm-fav-btn").addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
@@ -390,10 +459,11 @@ function renderNodeTableView(grid, filtered) {
|
||||
showNodeSidePanel(node);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
return tr;
|
||||
};
|
||||
table.appendChild(tbody);
|
||||
grid.appendChild(table);
|
||||
appendChunked(filtered, tbody, buildRow);
|
||||
}
|
||||
|
||||
// ── Favorite ──────────────────────────────────────────────
|
||||
@@ -526,6 +596,19 @@ function showNodeSidePanel(node) {
|
||||
renderSideDetails(node);
|
||||
renderSideIO(node);
|
||||
renderSideGroups(node);
|
||||
|
||||
// 入力定義が未取得なら、そのノード分だけ先に取得して差し替える
|
||||
if (!node.input) {
|
||||
comfyUI.fetchObjectInfo(node.name).then(res => {
|
||||
const full = res?.[node.name];
|
||||
if (!full) return;
|
||||
node.input = full.input || {};
|
||||
node.input_order = full.input_order || {};
|
||||
node.output = full.output || node.output;
|
||||
node.output_name = full.output_name || node.output_name;
|
||||
if (state.selectedNode === node) renderSideIO(node);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderSideDetails(node) {
|
||||
@@ -591,8 +674,8 @@ function renderSideIO(node) {
|
||||
const el = document.getElementById("wfm-nodes-side-io");
|
||||
if (!el) return;
|
||||
|
||||
const required = node.input.required || {};
|
||||
const optional = node.input.optional || {};
|
||||
const required = node.input?.required || {};
|
||||
const optional = node.input?.optional || {};
|
||||
|
||||
let html = `<h4 style="margin:0 0 8px;">${t("nodesInputs")}</h4>`;
|
||||
html += `<table class="wfm-node-io-table"><thead><tr><th>Name</th><th>Type</th><th>Details</th></tr></thead><tbody>`;
|
||||
@@ -1128,6 +1211,30 @@ function showEditSetModal(set) {
|
||||
|
||||
// ── Data Loading ──────────────────────────────────────────
|
||||
|
||||
async function loadFullNodeDefinitions(nodes) {
|
||||
try {
|
||||
const full = await fetchAllNodes();
|
||||
// 取得中に再読み込みされていたら破棄
|
||||
if (state.allNodes !== nodes) return;
|
||||
const byName = new Map(full.map(n => [n.name, n]));
|
||||
for (const node of nodes) {
|
||||
const f = byName.get(node.name);
|
||||
if (f) mergeFullNode(node, f);
|
||||
}
|
||||
// テーブル表示のIn列を埋める
|
||||
document.querySelectorAll("#wfm-nodes-grid tr.wfm-nodes-table-row").forEach(tr => {
|
||||
const node = byName.get(tr.dataset.nodeName);
|
||||
const cell = tr.querySelector(".wfm-nodes-in-count");
|
||||
if (node && cell) {
|
||||
cell.textContent = Object.keys(node.input.required || {}).length + Object.keys(node.input.optional || {}).length;
|
||||
}
|
||||
});
|
||||
if (state.selectedNode) renderSideIO(state.selectedNode);
|
||||
} catch (err) {
|
||||
console.warn("Failed to load full node definitions:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodesData() {
|
||||
const placeholder = document.getElementById("wfm-nodes-placeholder");
|
||||
if (placeholder) placeholder.textContent = t("loading");
|
||||
@@ -1138,11 +1245,13 @@ async function loadNodesData() {
|
||||
return;
|
||||
}
|
||||
|
||||
const [nodes, metadata, sets] = await Promise.all([
|
||||
fetchAllNodes(),
|
||||
// 一覧は軽量APIで先に表示し、入力定義を含む /object_info は裏で取得する
|
||||
const [lightNodes, metadata, sets] = await Promise.all([
|
||||
fetchLightNodes(),
|
||||
fetchNodeMetadata(),
|
||||
fetchNodeSets(),
|
||||
]);
|
||||
const nodes = lightNodes ?? await fetchAllNodes();
|
||||
|
||||
state.allNodes = nodes;
|
||||
state.nodeMetadata = metadata;
|
||||
@@ -1152,6 +1261,8 @@ async function loadNodesData() {
|
||||
|
||||
renderFilters();
|
||||
renderNodeGrid();
|
||||
|
||||
if (lightNodes) loadFullNodeDefinitions(nodes);
|
||||
} catch (err) {
|
||||
console.error("Failed to load nodes:", err);
|
||||
if (placeholder) placeholder.textContent = t("nodesConnectToComfyUI");
|
||||
@@ -1189,10 +1300,11 @@ export function initNodesTab() {
|
||||
// Search
|
||||
const searchInput = document.getElementById("wfm-nodes-search");
|
||||
if (searchInput) {
|
||||
const rerender = debounce(() => renderNodeGrid(), 150);
|
||||
searchInput.addEventListener("input", () => {
|
||||
state.searchText = searchInput.value;
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
setupSearchClearBtn("wfm-nodes-search", "wfm-nodes-search-clear-btn", () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Prompt Tab - AI Assistant (Ollama / LM Studio chat, translate, apply to GenerateUI)
|
||||
* 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 { getAiBackendDefaultUrl } from "./util.js";
|
||||
import { unslothProxy } from "./util.js";
|
||||
import { openAiBackendSettingsModal, getAiBackendConfig } from "./ai-settings-modal.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -17,64 +18,73 @@ const ollamaState = {
|
||||
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
|
||||
function loadPromptAiSettings() {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
}
|
||||
|
||||
// バックエンド変更検知用: モデル一覧を取得した時点のバックエンド名を記録
|
||||
let _promptAiBackendForModels = null;
|
||||
|
||||
export function getPromptAiConfig() {
|
||||
const s = loadPromptAiSettings();
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return { backend, url };
|
||||
return getAiBackendConfig(PROMPT_AI_KEY);
|
||||
}
|
||||
|
||||
export async function fetchAiModels() {
|
||||
const { backend, url } = getPromptAiConfig();
|
||||
// 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") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ((await res.json()).data || []).map(m => m.id);
|
||||
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 { backend, url } = getPromptAiConfig();
|
||||
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({ model, messages, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, messages, stream: false }, backend, cfg)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
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): convert Ollama-style messages (with .images) to OpenAI content arrays
|
||||
const openAiMessages = messages.map(msg => {
|
||||
if (msg.images?.length) {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: [
|
||||
{ type: "text", text: msg.content },
|
||||
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { role: msg.role, content: msg.content };
|
||||
});
|
||||
// LM Studio / Lemonade (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages: openAiMessages, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, messages: _toOpenAiMessages(messages), stream: false }, backend, cfg)),
|
||||
});
|
||||
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 cfg.thinkingMode ? text : _stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,37 +202,30 @@ function updateAttachmentDisplay() {
|
||||
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");
|
||||
const modelSelect = document.getElementById("wfm-ollama-model");
|
||||
if (!input || !modelSelect) return;
|
||||
if (!input) return;
|
||||
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
// バックエンドが変更されていたらモデル一覧を自動更新
|
||||
const { backend } = getPromptAiConfig();
|
||||
if (backend !== _promptAiBackendForModels) {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = `Switching to ${backend}...`; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
modelSelect.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) { status.textContent = `${models.length} models [${backend}]`; status.className = "wfm-ollama-status connected"; }
|
||||
} catch {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
}
|
||||
|
||||
const model = modelSelect.value;
|
||||
const { model } = getPromptAiConfig();
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
return;
|
||||
@@ -259,13 +262,12 @@ async function sendMessage() {
|
||||
|
||||
async function sendTranslate(direction) {
|
||||
const input = document.getElementById("wfm-ollama-input");
|
||||
const modelSelect = document.getElementById("wfm-ollama-model");
|
||||
if (!input || !modelSelect) return;
|
||||
if (!input) return;
|
||||
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const model = modelSelect.value;
|
||||
const { model } = getPromptAiConfig();
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
return;
|
||||
@@ -318,48 +320,11 @@ function applyToGenerateUI(text) {
|
||||
// ============================================
|
||||
|
||||
export function initAiChatUI() {
|
||||
// AI model refresh
|
||||
async function refreshModels() {
|
||||
const select = document.getElementById("wfm-ollama-model");
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
const { backend } = getPromptAiConfig();
|
||||
try {
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
if (select) {
|
||||
select.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
}
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) {
|
||||
status.textContent = `${models.length} models [${backend}]`;
|
||||
status.className = "wfm-ollama-status connected";
|
||||
}
|
||||
} catch {
|
||||
if (status) {
|
||||
status.textContent = t("failedConnect");
|
||||
status.className = "wfm-ollama-status error";
|
||||
}
|
||||
}
|
||||
}
|
||||
_updateStatusLabel();
|
||||
|
||||
// Auto-load models on init
|
||||
refreshModels();
|
||||
|
||||
document.getElementById("wfm-ollama-refresh-btn")?.addEventListener("click", refreshModels);
|
||||
|
||||
// Test connection
|
||||
document.getElementById("wfm-ollama-test-btn")?.addEventListener("click", async () => {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = "Testing..."; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
await fetchAiModels();
|
||||
if (status) { status.textContent = t("connectedCheck"); status.className = "wfm-ollama-status connected"; }
|
||||
await refreshModels();
|
||||
} catch {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
// Settings modal
|
||||
document.getElementById("wfm-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(PROMPT_AI_KEY, t("settings"), _updateStatusLabel);
|
||||
});
|
||||
|
||||
// Send message
|
||||
|
||||
@@ -6,7 +6,9 @@ import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
|
||||
|
||||
import { getSettings, readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
|
||||
import { DECISION_BACKENDS, getDecisionSettings, saveDecisionSettings, testDecisionConnection, listDecisionModels } from "./decision-client.js";
|
||||
import { initVramSettings } from "./vram-prepare.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
@@ -336,6 +338,34 @@ export function applyTextareaFontSize(size) {
|
||||
el.textContent = `${TEXTAREA_FONT_SIZE_TARGETS} { font-size: ${px}px !important; }`;
|
||||
}
|
||||
|
||||
// Video playback volume — shared by every <video> player in the app (Video tab preview,
|
||||
// Gallery detail/lightbox/compare). The browser default for a freshly created <video> element
|
||||
// is always 100%, and Gallery's players are recreated from scratch each time a different video
|
||||
// is opened, so without this the volume silently jumps back to max on every video switch.
|
||||
const VIDEO_VOLUME_DEFAULT = 1;
|
||||
|
||||
export function getVideoVolume() {
|
||||
const v = loadLocalSettings().videoVolume;
|
||||
return typeof v === "number" && v >= 0 && v <= 1 ? v : VIDEO_VOLUME_DEFAULT;
|
||||
}
|
||||
|
||||
function setVideoVolume(volume) {
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
saveLocalSettings({ videoVolume: clamped });
|
||||
return clamped;
|
||||
}
|
||||
|
||||
// Applies the saved volume to a <video> element, and keeps it in sync with any further change
|
||||
// the user makes via that element's own native controls — so adjusting the volume on any video
|
||||
// player anywhere in the app is remembered for the next video opened anywhere else.
|
||||
export function applyStoredVideoVolume(videoEl) {
|
||||
if (!videoEl) return;
|
||||
videoEl.volume = getVideoVolume();
|
||||
if (videoEl.dataset.wfmVolumeWired) return;
|
||||
videoEl.dataset.wfmVolumeWired = "1";
|
||||
videoEl.addEventListener("volumechange", () => setVideoVolume(videoEl.volume));
|
||||
}
|
||||
|
||||
// JSON highlight color settings
|
||||
const JSON_COLOR_STYLE_ID = "wfm-json-color-style";
|
||||
|
||||
@@ -378,7 +408,7 @@ export function applyModelTabActiveColor(color) {
|
||||
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active { color: ${val}; }`;
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active,\n.wfm-video-plan-content label.wfm-model-label-active { color: ${val}; }`;
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
@@ -427,6 +457,7 @@ export async function initSettingsTab() {
|
||||
|
||||
const uiLang = getLang();
|
||||
const summaryLang = getSummaryLang();
|
||||
const decisionSettings = getDecisionSettings();
|
||||
|
||||
container.innerHTML = `
|
||||
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
|
||||
@@ -575,6 +606,24 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Video Volume -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("videoVolumeLabel") || "Video Volume"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>${t("videoVolumeLabel") || "Video Volume"}</span>
|
||||
<span id="wfm-video-volume-val" style="font-size:12px;color:var(--wfm-text-secondary);">${Math.round(getVideoVolume() * 100)}%</span>
|
||||
</label>
|
||||
<input type="range" class="wfm-range" id="wfm-video-volume"
|
||||
min="0" max="100" step="1"
|
||||
value="${Math.round(getVideoVolume() * 100)}"
|
||||
style="width:100%;margin-top:4px;">
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("videoVolumeHint") || "Applies to: Video tab preview, Gallery detail/lightbox/compare video players"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- RAW JSON Colors -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("jsonColorsSection") || "RAW JSON Colors"}</summary>
|
||||
@@ -628,6 +677,38 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Models Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("modelsDir")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("modelsDirLabel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-models-dir"
|
||||
value="${serverSettings.models_dir || ""}"
|
||||
placeholder="C:\\Users\\...\\StabilityMatrix\\Models">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-models-dir-apply">${t("workflowsDirApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-models-dir-reset">${t("workflowsDirDefault")}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("modelsDirHint")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Windows Event Loop (stability) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("selectorLoop")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-settings-selector-loop" ${serverSettings.windows_selector_event_loop ? "checked" : ""}>
|
||||
<span>${t("selectorLoopLabel")}</span>
|
||||
</label>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("selectorLoopHint")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Gallery Output Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
|
||||
@@ -691,44 +772,6 @@ export async function initSettingsTab() {
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-default-ckpt-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- AI Assistant Settings (Prompt Tab) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("aiSettingsBackend")}</label>
|
||||
<div class="wfm-ai-backend-row">
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="ollama"> Ollama
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaUrl")}</label>
|
||||
<input type="text" class="wfm-input" id="wfm-settings-prompt-ai-url"
|
||||
placeholder="http://localhost:11434">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaDefaultModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-prompt-ai-model" style="flex:1;">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-refresh">${t("refresh")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-prompt-ai-save">${t("saveOllama")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-test">${t("testConnection")}</button>
|
||||
<span id="wfm-settings-prompt-ai-status" style="font-size:12px;line-height:28px;"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- CivitAI Settings -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("civitaiApiKeySetting")}</summary>
|
||||
@@ -795,6 +838,73 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Decision Model (Laya) — independent of the AI TOOL / Tagger LLM/VLM backends -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("decisionSection")}</summary>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-bottom:8px;">
|
||||
${t("decisionHint")}
|
||||
</small>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionBackend")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-decision-backend">
|
||||
${Object.entries(DECISION_BACKENDS).map(([b, def]) => `<option value="${b}" ${decisionSettings.backend === b ? "selected" : ""}>${def.label}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionUrl")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-decision-url"
|
||||
value="${decisionSettings.baseUrl}"
|
||||
placeholder="${DECISION_BACKENDS[decisionSettings.backend].defaultUrl}">
|
||||
<button class="wfm-btn" id="wfm-settings-decision-test">${t("test")}</button>
|
||||
</div>
|
||||
<div id="wfm-settings-decision-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-decision-model" style="flex:1;">
|
||||
<option value="${escapeHtml(decisionSettings.model)}" selected>${escapeHtml(decisionSettings.model)}</option>
|
||||
</select>
|
||||
<button class="wfm-btn" id="wfm-settings-decision-refresh" title="${t("decisionRefreshModels")}">↻</button>
|
||||
</div>
|
||||
<div id="wfm-settings-decision-model-hint" style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;"></div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("decisionThreshold")}</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-decision-threshold"
|
||||
value="${decisionSettings.threshold}" min="0.5" max="0.99" step="0.05" style="width:100px;">
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("decisionThresholdHint")}
|
||||
</small>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-decision-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- VRAM Management (Ollama) — unload Ollama models before every generation (vram-prepare.js) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("vramSection")}</summary>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-bottom:8px;">
|
||||
${t("vramHint")}
|
||||
</small>
|
||||
<div class="wfm-form-group">
|
||||
<label for="wfm-settings-vram-mode">${t("vramModeLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-vram-mode">
|
||||
<option value="off">${t("vramModeOff")}</option>
|
||||
<option value="auto">${t("vramModeAuto")}</option>
|
||||
<option value="all">${t("vramModeAll")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label for="wfm-settings-vram-target">${t("vramTargetLabel")}</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-vram-target" min="0" max="64" step="0.5" value="8" style="width:100px;">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-vram-unload">${t("vramUnloadNow")}</button>
|
||||
<span id="wfm-settings-vram-status" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- G'MIC Integration -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">G'MIC-Qt Integration</summary>
|
||||
@@ -834,6 +944,10 @@ export async function initSettingsTab() {
|
||||
<input type="checkbox" id="wfm-settings-backup-include-wildcard">
|
||||
${t("fullBackupIncludeWildcard")}
|
||||
</label>
|
||||
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
|
||||
<input type="checkbox" id="wfm-settings-backup-include-video-media">
|
||||
${t("fullBackupIncludeVideoMedia")}
|
||||
</label>
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">${t("fullBackupExternalHint")}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
@@ -1114,6 +1228,48 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Windows selector event loop (needs ComfyUI restart) ---
|
||||
document.getElementById("wfm-settings-selector-loop")?.addEventListener("change", async (e) => {
|
||||
const enabled = !!e.target.checked;
|
||||
try {
|
||||
await saveServerSettings({ windows_selector_event_loop: enabled });
|
||||
serverSettings.windows_selector_event_loop = enabled;
|
||||
showToast(t("selectorLoopSaved"), "success");
|
||||
} catch (err) {
|
||||
e.target.checked = !enabled;
|
||||
showToast(`${t("saveError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Models dir handlers ---
|
||||
const saveModelsDir = async (value) => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/models-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ models_dir: value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById("wfm-settings-models-dir");
|
||||
if (input) input.value = data.saved || "";
|
||||
showToast(t("modelsDirChanged"), "success");
|
||||
window.dispatchEvent(new CustomEvent("wfm-models-dir-changed"));
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
document.getElementById("wfm-settings-models-dir-apply")?.addEventListener("click", () => {
|
||||
saveModelsDir(document.getElementById("wfm-settings-models-dir").value.trim());
|
||||
});
|
||||
document.getElementById("wfm-settings-models-dir-reset")?.addEventListener("click", () => {
|
||||
document.getElementById("wfm-settings-models-dir").value = "";
|
||||
saveModelsDir("");
|
||||
});
|
||||
|
||||
// --- Gallery output dir handlers ---
|
||||
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
@@ -1158,92 +1314,6 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- AI Assistant (Prompt Tab) settings ---
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
const loadPromptAiCfg = () => {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
};
|
||||
const savePromptAiCfg = (patch) => {
|
||||
const data = { ...loadPromptAiCfg(), ...patch };
|
||||
localStorage.setItem(PROMPT_AI_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Restore saved values into UI
|
||||
const promptAiSaved = loadPromptAiCfg();
|
||||
const promptAiBackendSaved = promptAiSaved.backend || "ollama";
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.checked = r.value === promptAiBackendSaved;
|
||||
});
|
||||
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
|
||||
if (promptAiUrlEl) {
|
||||
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
|
||||
promptAiUrlEl.placeholder = getAiBackendDefaultUrl(promptAiBackendSaved);
|
||||
}
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
|
||||
});
|
||||
});
|
||||
|
||||
// Load models directly from the selected backend
|
||||
async function loadPromptAiModels() {
|
||||
const select = document.getElementById("wfm-settings-prompt-ai-model");
|
||||
if (!select) return;
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
try {
|
||||
let models = [];
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
models = (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
models = ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
const saved = loadPromptAiCfg().model || "";
|
||||
select.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === saved ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
} catch {
|
||||
select.innerHTML = `<option value="">${t("failedLoadModels")}</option>`;
|
||||
}
|
||||
}
|
||||
await loadPromptAiModels();
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-refresh")?.addEventListener("click", loadPromptAiModels);
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
|
||||
try {
|
||||
let count = 0;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
count = ((await res.json()).models || []).length;
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
count = ((await res.json()).data || []).length;
|
||||
}
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaConnected")} (${count} models)`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
await loadPromptAiModels();
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaFailed")}: ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-save")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = promptAiUrlEl?.value.trim() || "";
|
||||
const model = document.getElementById("wfm-settings-prompt-ai-model")?.value || "";
|
||||
savePromptAiCfg({ backend, backendUrl: url, model });
|
||||
showToast(t("ollamaSaved"), "success");
|
||||
});
|
||||
|
||||
// CivitAI host select
|
||||
document.getElementById("wfm-settings-civitai-host")?.addEventListener("change", async (e) => {
|
||||
const host = e.target.value;
|
||||
@@ -1315,6 +1385,83 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Decision Model (Laya) ---
|
||||
const readDecisionForm = () => {
|
||||
const baseUrl = document.getElementById("wfm-settings-decision-url")?.value.trim() || "";
|
||||
const threshold = parseFloat(document.getElementById("wfm-settings-decision-threshold")?.value);
|
||||
let urlOk = false;
|
||||
try { urlOk = ["http:", "https:"].includes(new URL(baseUrl).protocol); } catch {}
|
||||
if (!urlOk) throw new Error(t("decisionInvalidUrl"));
|
||||
if (!(threshold >= 0.5 && threshold <= 0.99)) throw new Error(t("decisionInvalidThreshold"));
|
||||
return {
|
||||
backend: document.getElementById("wfm-settings-decision-backend")?.value || "unsloth",
|
||||
baseUrl,
|
||||
model: document.getElementById("wfm-settings-decision-model")?.value || "",
|
||||
threshold,
|
||||
};
|
||||
};
|
||||
// Unsloth: fixed Laya names. Ollama: installed models whose capabilities include "decision";
|
||||
// none installed (or Ollama unreachable) → keep the preferred name selectable and show why.
|
||||
const refreshDecisionModels = async (preferred) => {
|
||||
const backend = document.getElementById("wfm-settings-decision-backend")?.value || "unsloth";
|
||||
const baseUrl = document.getElementById("wfm-settings-decision-url")?.value.trim() || "";
|
||||
const select = document.getElementById("wfm-settings-decision-model");
|
||||
const hint = document.getElementById("wfm-settings-decision-model-hint");
|
||||
if (!select) return;
|
||||
const def = DECISION_BACKENDS[backend];
|
||||
const current = preferred ?? select.value;
|
||||
let models = [];
|
||||
let hintText = "";
|
||||
try {
|
||||
({ models } = await listDecisionModels(backend, baseUrl));
|
||||
if (backend === "ollama" && models.length === 0) hintText = t("decisionNoOllamaModels");
|
||||
} catch (err) {
|
||||
hintText = `${t("failedConnect")}: ${err.message}`;
|
||||
}
|
||||
const list = models.length ? [...models] : [...def.models];
|
||||
if (current && !list.includes(current) && (backend === "ollama") === !/^laya/.test(current)) list.unshift(current);
|
||||
const selected = list.includes(current) ? current : (list.includes(def.defaultModel) ? def.defaultModel : list[0]);
|
||||
select.innerHTML = list.map((m) => `<option value="${escapeHtml(m)}" ${m === selected ? "selected" : ""}>${m === "laya" ? t("decisionModelLaya") : escapeHtml(m)}</option>`).join("");
|
||||
if (hint) hint.textContent = hintText;
|
||||
};
|
||||
refreshDecisionModels(decisionSettings.model);
|
||||
initVramSettings();
|
||||
document.getElementById("wfm-settings-decision-refresh")?.addEventListener("click", () => refreshDecisionModels());
|
||||
document.getElementById("wfm-settings-decision-backend")?.addEventListener("change", (e) => {
|
||||
const urlInput = document.getElementById("wfm-settings-decision-url");
|
||||
const def = DECISION_BACKENDS[e.target.value]?.defaultUrl;
|
||||
if (urlInput && def) { urlInput.value = def; urlInput.placeholder = def; }
|
||||
refreshDecisionModels(DECISION_BACKENDS[e.target.value]?.defaultModel);
|
||||
});
|
||||
document.getElementById("wfm-settings-decision-save")?.addEventListener("click", () => {
|
||||
try {
|
||||
saveDecisionSettings(readDecisionForm());
|
||||
showToast(t("settingsSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
});
|
||||
// Tests the values currently in the form (saved or not), same as the Eagle test button.
|
||||
document.getElementById("wfm-settings-decision-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-decision-status");
|
||||
let form;
|
||||
try { form = readDecisionForm(); } catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = t("decisionTesting");
|
||||
statusEl.style.color = "var(--wfm-text-secondary)";
|
||||
try {
|
||||
const { ms, yes } = await testDecisionConnection({ backend: form.backend, baseUrl: form.baseUrl, model: form.model });
|
||||
statusEl.textContent = `${t("connectedCheck")} ${t("decisionTestOk").replace("{ms}", ms).replace("{yes}", typeof yes === "number" ? yes.toFixed(3) : "?")}`;
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("failedConnect")}: ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
}
|
||||
});
|
||||
|
||||
// Test Eagle URL (via server proxy to avoid CORS)
|
||||
document.getElementById("wfm-settings-test-eagle")?.addEventListener("click", async () => {
|
||||
const urlInput = document.getElementById("wfm-settings-eagle-url");
|
||||
@@ -1361,6 +1508,16 @@ export async function initSettingsTab() {
|
||||
applyTextareaFontSize(px);
|
||||
});
|
||||
|
||||
// --- Video volume slider (auto-saves immediately, no "Save" click needed — a dragged
|
||||
// slider that silently didn't persist would defeat the whole point of this setting) ---
|
||||
document.getElementById("wfm-video-volume")?.addEventListener("input", (e) => {
|
||||
const pct = parseInt(e.target.value) || 0;
|
||||
const valEl = document.getElementById("wfm-video-volume-val");
|
||||
if (valEl) valEl.textContent = `${pct}%`;
|
||||
const volume = setVideoVolume(pct / 100);
|
||||
document.querySelectorAll("video").forEach((v) => { v.volume = volume; });
|
||||
});
|
||||
|
||||
// --- JSON highlight color pickers ---
|
||||
document.getElementById("wfm-json-color-grid")?.addEventListener("input", (e) => {
|
||||
if (e.target.type !== "color") return;
|
||||
@@ -1559,7 +1716,8 @@ export async function initSettingsTab() {
|
||||
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}`);
|
||||
const includeVideoMedia = document.getElementById("wfm-settings-backup-include-video-media")?.checked ? "1" : "0";
|
||||
const res = await fetch(`/api/wfm/settings/export-full?include_workflows=${includeWorkflows}&include_wildcard=${includeWildcard}&include_video_media=${includeVideoMedia}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1590,7 +1748,13 @@ export async function initSettingsTab() {
|
||||
});
|
||||
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")}`;
|
||||
let msg = `${t("importSuccess")}: ${result.extracted?.length ?? 0} ${t("fullBackupFilesUnit")}`;
|
||||
const restoredMedia = result.media_restored?.length ?? 0;
|
||||
const existingMedia = result.media_existing?.length ?? 0;
|
||||
if (restoredMedia || existingMedia) {
|
||||
msg += ` / ${t("fullBackupMediaResult").replace("{restored}", restoredMedia).replace("{existing}", existingMedia)}`;
|
||||
}
|
||||
statusEl.textContent = msg;
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("importError") + ": " + e.message;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Tagger Tab
|
||||
* Handles WD Tagger / DeepDanbooru + Ollama VLM single/batch tagging.
|
||||
* Handles WD Tagger / DeepDanbooru + VLM (Ollama/LM Studio/Lemonade/Unsloth) single/batch tagging.
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { openAiBackendSettingsModal, getAiBackendConfig } from "./ai-settings-modal.js";
|
||||
|
||||
const TAGGER_AI_KEY = "wfm_tagger_ai_settings";
|
||||
function getTaggerAiConfig() {
|
||||
return getAiBackendConfig(TAGGER_AI_KEY);
|
||||
}
|
||||
|
||||
// ── 状態 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,7 +38,7 @@ export function initTaggerTab() {
|
||||
_setupBatchActions();
|
||||
_setupDbActions();
|
||||
_loadModels();
|
||||
_loadSettings();
|
||||
_updateVlmBackendIndicators();
|
||||
}
|
||||
|
||||
/** GalleryタブからTaggerタブへ画像を渡す */
|
||||
@@ -74,8 +80,6 @@ function _applyI18n() {
|
||||
"wfm-tagger-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-generate-btn": "taggerGenerateTags",
|
||||
"wfm-tagger-ollama-header-label": "taggerOllamaEnable",
|
||||
"wfm-tagger-ollama-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-ollama-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-ollama-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-ollama-max-label": "taggerOllamaMaxTags",
|
||||
"wfm-tagger-result-header": "taggerResult",
|
||||
@@ -87,8 +91,7 @@ function _applyI18n() {
|
||||
"wfm-tagger-batch-folder-header": "taggerBatchFolder",
|
||||
"wfm-tagger-batch-threshold-label": "taggerThreshold",
|
||||
"wfm-tagger-batch-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-batch-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-batch-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-batch-ollama-header-label": "taggerOllamaEnable",
|
||||
"wfm-tagger-batch-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-batch-output-header": "taggerBatchOutput",
|
||||
"wfm-tagger-batch-save-db-label": "taggerSaveToDB",
|
||||
@@ -107,12 +110,18 @@ function _applyI18n() {
|
||||
"wfm-tagger-db-edit-vlm-label": "taggerDbVlm",
|
||||
"wfm-tagger-db-save-btn": "taggerDbSave",
|
||||
"wfm-tagger-db-delete-btn": "taggerDbDelete",
|
||||
"wfm-tagger-db-delete-selected-btn": "taggerDbDelete"
|
||||
};
|
||||
for (const [id, key] of Object.entries(map)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
["wfm-tagger-ollama-settings-btn", "wfm-tagger-batch-ollama-settings-btn"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = t("settings");
|
||||
});
|
||||
|
||||
// サブタブボタン
|
||||
const subtabKeys = ["taggerSingleTab", "taggerBatchTab", "taggerDbTab"];
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach((btn, i) => {
|
||||
@@ -168,6 +177,24 @@ function _setupOllamaToggle() {
|
||||
});
|
||||
wrap.style.display = chk.checked ? "" : "none";
|
||||
}
|
||||
|
||||
document.getElementById("wfm-tagger-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(TAGGER_AI_KEY, t("settings"), _updateVlmBackendIndicators);
|
||||
});
|
||||
document.getElementById("wfm-tagger-batch-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(TAGGER_AI_KEY, t("settings"), _updateVlmBackendIndicators);
|
||||
});
|
||||
}
|
||||
|
||||
// ── VLMバックエンド表示 (Taggerタブ独自のAI設定を参照) ────────────
|
||||
|
||||
function _updateVlmBackendIndicators() {
|
||||
const { backend, model } = getTaggerAiConfig();
|
||||
const label = `${backend}: ${model || t("taggerNoModel")}`;
|
||||
const singleEl = document.getElementById("wfm-tagger-ollama-backend-value");
|
||||
if (singleEl) singleEl.textContent = label;
|
||||
const batchEl = document.getElementById("wfm-tagger-batch-backend-value");
|
||||
if (batchEl) batchEl.textContent = label;
|
||||
}
|
||||
|
||||
// ── モデル一覧ロード ─────────────────────────────────────────────
|
||||
@@ -188,61 +215,6 @@ async function _loadModels() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ollamaモデル一覧ロード ───────────────────────────────────────
|
||||
|
||||
async function _loadOllamaModels(urlId, selId) {
|
||||
const urlEl = document.getElementById(urlId);
|
||||
const selEl = document.getElementById(selId);
|
||||
if (!urlEl || !selEl) return;
|
||||
const api = urlEl.value || "http://127.0.0.1:11434";
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/ollama/models?api_url=${encodeURIComponent(api)}`);
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
if (models.length === 0) {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
} else {
|
||||
selEl.innerHTML = models.map(m => `<option value="${m}">${m}</option>`).join("");
|
||||
}
|
||||
} catch {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 設定の読み込み/保存 ─────────────────────────────────────────
|
||||
|
||||
async function _loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/settings");
|
||||
const data = await res.json();
|
||||
if (data.ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-ollama-url");
|
||||
if (el) el.value = data.ollama_url;
|
||||
}
|
||||
if (data.batch_ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-batch-ollama-url");
|
||||
if (el) el.value = data.batch_ollama_url;
|
||||
}
|
||||
} catch {
|
||||
// 初回は無視
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveSettings() {
|
||||
try {
|
||||
await fetch("/wfm/tagger/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ollama_url: document.getElementById("wfm-tagger-ollama-url")?.value || "",
|
||||
batch_ollama_url: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "",
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// サイレントに失敗
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single サブタブ ─────────────────────────────────────────────
|
||||
|
||||
function _setupSingleActions() {
|
||||
@@ -279,11 +251,6 @@ function _setupSingleActions() {
|
||||
// タグ生成
|
||||
document.getElementById("wfm-tagger-generate-btn")?.addEventListener("click", _generateTags);
|
||||
|
||||
// Ollamaリフレッシュ
|
||||
document.getElementById("wfm-tagger-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-ollama-url", "wfm-tagger-ollama-model");
|
||||
});
|
||||
|
||||
// 結果アクション
|
||||
document.getElementById("wfm-tagger-send-genui-btn")?.addEventListener("click", _sendToGenUI);
|
||||
document.getElementById("wfm-tagger-send-prompt-btn")?.addEventListener("click", _sendToPrompt);
|
||||
@@ -321,8 +288,7 @@ async function _generateTags() {
|
||||
const threshold = parseFloat(document.getElementById("wfm-tagger-threshold")?.value || "0.35");
|
||||
const charThreshold = parseFloat(document.getElementById("wfm-tagger-char-threshold")?.value || "0.85");
|
||||
const useOllama = document.getElementById("wfm-tagger-ollama-enable")?.checked || false;
|
||||
const ollamaUrl = document.getElementById("wfm-tagger-ollama-url")?.value || "http://127.0.0.1:11434";
|
||||
const ollamaModel = document.getElementById("wfm-tagger-ollama-model")?.value || "";
|
||||
const { backend: vlmBackend, url: vlmUrl, model: ollamaModel, thinkingMode, maxTokens } = getTaggerAiConfig();
|
||||
const ollamaPrompt = document.getElementById("wfm-tagger-ollama-prompt")?.value || "";
|
||||
const maxTags = parseInt(document.getElementById("wfm-tagger-ollama-max-tags")?.value || "40");
|
||||
|
||||
@@ -344,12 +310,13 @@ async function _generateTags() {
|
||||
}
|
||||
|
||||
if (useOllama && ollamaModel) {
|
||||
const res2 = await fetch("/wfm/tagger/ollama/predict", {
|
||||
const res2 = await fetch("/wfm/tagger/vlm/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
image_b64: state.imageB64, api_url: ollamaUrl,
|
||||
image_b64: state.imageB64, backend: vlmBackend, api_url: vlmUrl,
|
||||
model: ollamaModel, prompt: ollamaPrompt, max_tags: maxTags,
|
||||
thinking_mode: thinkingMode, max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
@@ -361,7 +328,6 @@ async function _generateTags() {
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
document.getElementById("wfm-tagger-result").value = tags;
|
||||
showToast(t("taggerDone"), "success");
|
||||
_saveSettings();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
} finally {
|
||||
@@ -453,10 +419,6 @@ async function _saveToDb() {
|
||||
// ── Batch サブタブ ───────────────────────────────────────────────
|
||||
|
||||
function _setupBatchActions() {
|
||||
document.getElementById("wfm-tagger-batch-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-batch-ollama-url", "wfm-tagger-batch-ollama-model");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-tagger-batch-start-btn")?.addEventListener("click", _batchStart);
|
||||
document.getElementById("wfm-tagger-batch-stop-btn")?.addEventListener("click", _batchStop);
|
||||
}
|
||||
@@ -465,15 +427,19 @@ async function _batchStart() {
|
||||
const folder = document.getElementById("wfm-tagger-batch-folder")?.value || "";
|
||||
if (!folder) { showToast(t("taggerBatchFolder"), "warning"); return; }
|
||||
|
||||
const { backend: vlmBackend, url: vlmUrl, model: vlmModel, thinkingMode, maxTokens } = getTaggerAiConfig();
|
||||
const body = {
|
||||
folder,
|
||||
model: document.getElementById("wfm-tagger-batch-model")?.value || "",
|
||||
threshold: parseFloat(document.getElementById("wfm-tagger-batch-threshold")?.value || "0.35"),
|
||||
char_threshold: parseFloat(document.getElementById("wfm-tagger-batch-char-threshold")?.value || "0.85"),
|
||||
use_ollama: document.getElementById("wfm-tagger-batch-ollama-enable")?.checked || false,
|
||||
ollama_api: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "http://127.0.0.1:11434",
|
||||
ollama_model: document.getElementById("wfm-tagger-batch-ollama-model")?.value || "",
|
||||
vlm_backend: vlmBackend,
|
||||
vlm_api_url: vlmUrl,
|
||||
ollama_model: vlmModel,
|
||||
ollama_prompt: document.getElementById("wfm-tagger-batch-ollama-prompt")?.value || "",
|
||||
thinking_mode: thinkingMode,
|
||||
max_tokens: maxTokens,
|
||||
save_db: document.getElementById("wfm-tagger-batch-save-db")?.checked ?? true,
|
||||
write_file: document.getElementById("wfm-tagger-batch-write-file")?.checked || false,
|
||||
write_txt: document.getElementById("wfm-tagger-batch-write-txt")?.checked || false,
|
||||
@@ -549,6 +515,8 @@ function _setupDbActions() {
|
||||
});
|
||||
document.getElementById("wfm-tagger-db-save-btn")?.addEventListener("click", _dbSave);
|
||||
document.getElementById("wfm-tagger-db-delete-btn")?.addEventListener("click", _dbDelete);
|
||||
document.getElementById("wfm-tagger-db-select").addEventListener("click", _dbToggleSelection);
|
||||
document.getElementById("wfm-tagger-db-delete-selected-btn").addEventListener("click", _dbDeleteSelected);
|
||||
}
|
||||
|
||||
async function _dbLoad() {
|
||||
@@ -576,12 +544,20 @@ async function _dbSearch() {
|
||||
function _renderDbTable(rows) {
|
||||
const tbody = document.getElementById("wfm-tagger-db-tbody");
|
||||
if (!tbody) return;
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(row => `
|
||||
<tr data-id="${row.id}" title="${_esc(row.all_tags)}">
|
||||
<td class="wfm-tagger-db-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-id="${row.id}"
|
||||
>
|
||||
</td>
|
||||
<td>${row.id}</td>
|
||||
<td>${_esc(row.filename)}</td>
|
||||
<td>${_esc(_truncate(row.interrogator_tags, 60))}</td>
|
||||
@@ -591,9 +567,13 @@ function _renderDbTable(rows) {
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(tr => {
|
||||
tr.addEventListener("click", () => {
|
||||
tr.addEventListener("click", e => {
|
||||
// チェックボックスをクリックした場合は行選択を発生させない
|
||||
if (e.target.closest(".wfm-tagger-db-select")) return;
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(r => r.classList.remove("selected"));
|
||||
tr.classList.add("selected");
|
||||
|
||||
const row = rows.find(r => r.id === parseInt(tr.dataset.id));
|
||||
if (row) _openDbEdit(row);
|
||||
});
|
||||
@@ -655,6 +635,55 @@ function _dbExport() {
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function _dbDeleteSelected() {
|
||||
const ids = _getSelectedDbIds();
|
||||
|
||||
if (!ids.length) {
|
||||
showToast(t("taggerDbNoSelection"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(t("taggerDbDeleteConfirm", ids.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/db", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
showToast(t("taggerDbDeletedCount", data.count), "success");
|
||||
_dbLoad();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _getSelectedDbIds() {
|
||||
return [...document.querySelectorAll(".wfm-tagger-db-select input:checked")]
|
||||
.map(el => parseInt(el.dataset.id))
|
||||
.filter(Number.isInteger);
|
||||
}
|
||||
|
||||
function _dbToggleSelection(e) {
|
||||
const checked = e.target.checked;
|
||||
const checkboxes = document.querySelectorAll(
|
||||
'#wfm-tagger-db-tbody input[type="checkbox"]'
|
||||
);
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
// ── ユーティリティ ───────────────────────────────────────────────
|
||||
|
||||
function _esc(str) {
|
||||
|
||||
@@ -82,6 +82,57 @@ export function escapeHtml(s) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// http(s) 以外(javascript: / data: など)を拒否して安全なURLだけ返す。不正なら fallback
|
||||
export function safeHttpUrl(url, fallback = "") {
|
||||
// 絶対URLのhttp(s)のみ許可(相対URLだとComfyUI自身のURLへリクエストを送らせられるため不可)
|
||||
const s = String(url ?? "").trim();
|
||||
if (!/^https?:\/\/[^\s]+$/i.test(s)) return fallback;
|
||||
try {
|
||||
new URL(s);
|
||||
return s;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// 外部由来のHTML(CivitAIの説明文など)を許可リスト方式で無害化して返す。
|
||||
// スクリプト・イベント属性・style・javascript:/data: URL・iframe等は除去する。
|
||||
const _SAN_DROP = new Set(["script", "style", "iframe", "frame", "frameset", "object", "embed", "applet",
|
||||
"link", "meta", "base", "form", "input", "button", "textarea", "select", "svg", "math", "template",
|
||||
"noscript", "audio", "video", "source", "track", "canvas", "title", "head"]);
|
||||
const _SAN_KEEP = new Set(["p", "br", "hr", "strong", "b", "em", "i", "u", "s", "del", "ins", "mark", "small",
|
||||
"sub", "sup", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "code", "pre", "kbd",
|
||||
"span", "div", "a", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "details", "summary"]);
|
||||
|
||||
export function sanitizeHtml(html) {
|
||||
if (html == null || html === "") return "";
|
||||
const doc = new DOMParser().parseFromString(String(html), "text/html"); // 不活性ドキュメント(スクリプト非実行)
|
||||
const walk = (node) => {
|
||||
let out = "";
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === 3) { // text
|
||||
out += escapeHtml(child.nodeValue);
|
||||
} else if (child.nodeType === 1) { // element
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (_SAN_DROP.has(tag)) continue;
|
||||
if (!_SAN_KEEP.has(tag)) { out += walk(child); continue; } // 未知タグは中身だけ残す
|
||||
if (tag === "br" || tag === "hr") { out += `<${tag}>`; continue; }
|
||||
if (tag === "a") {
|
||||
const href = safeHttpUrl(child.getAttribute("href") || "", "");
|
||||
out += `<a${href ? ` href="${escapeHtml(href)}"` : ""} target="_blank" rel="noopener noreferrer">${walk(child)}</a>`;
|
||||
} else if (tag === "img") {
|
||||
const src = safeHttpUrl(child.getAttribute("src") || "", "");
|
||||
if (src) out += `<img src="${escapeHtml(src)}" alt="${escapeHtml(child.getAttribute("alt") || "")}" loading="lazy" style="max-width:100%;">`;
|
||||
} else {
|
||||
out += `<${tag}>${walk(child)}</${tag}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return walk(doc.body);
|
||||
}
|
||||
|
||||
// localStorage からJSONを安全に読む(不正JSON・未設定時はfallback)
|
||||
export function readJsonStorage(key, fallback = {}) {
|
||||
try {
|
||||
@@ -109,6 +160,52 @@ export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
// Unslothはローカルでも常にAPIキーが必要なため、フロントから直接叩かず
|
||||
// サーバー側プロキシ(/api/wfm/unsloth/proxy)経由でキーを付与して中継する
|
||||
// (see py/routes/unsloth_routes.py)
|
||||
export async function unslothProxy(baseUrl, path, method, payload) {
|
||||
const res = await fetch("/api/wfm/unsloth/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, path, method, payload }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ローカルVLM/LLMバックエンドからモデルをVRAM/メモリからアンロードする。
|
||||
// 公式APIが存在しない場合(Unsloth)は "UNSUPPORTED" エラーを投げる。
|
||||
export async function unloadAiModel(url, backend, model) {
|
||||
if (!model) throw new Error("No model selected");
|
||||
if (backend === "ollama") {
|
||||
// keep_alive:0 + 空プロンプトが公式に文書化されたアンロード方法
|
||||
// (専用エンドポイントは存在しない) https://docs.ollama.com/api
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt: "", keep_alive: 0 }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else if (backend === "lmstudio") {
|
||||
const res = await fetch(`${url}/api/v1/models/unload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ instance_id: model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else if (backend === "lemonade") {
|
||||
const res = await fetch(`${url}/api/v1/unload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else {
|
||||
throw new Error("UNSUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Video Tab - Asset subtab: browses the videos a Plan run has generated.
|
||||
*
|
||||
* Deliberately thin — it reuses Gallery's existing backend (groups, tags/memo
|
||||
* sidecar, thumbnailing) exactly like feeder-tab.js's own Gallery mode does,
|
||||
* rather than re-implementing folder browsing, bulk selection, etc. Anything
|
||||
* beyond basic browsing/tagging (move, export, delete, multi-select) is left
|
||||
* to the real Gallery tab via the "Open in Gallery" button.
|
||||
*/
|
||||
|
||||
import { VIDEO_GROUP, VTEMP_GROUP, ensureVideoGroup, isVideoFile, isAudioFile } from "./gallery-tab.js";
|
||||
import { setSourcePreview } from "./video-preview.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { addClipFromFile, setBgmFromFile, addPipFromFile, addSoundFromFile } from "./video-edit-tab.js";
|
||||
import { setBlockImageFromFile } from "./video-plan-tab.js";
|
||||
|
||||
// Sentinel for the "All Video Assets" option — not a real backend group, since
|
||||
// Gallery's group filter only ever matches one group at a time. Selecting it
|
||||
// fetches VIDEO_GROUP and VTEMP_GROUP separately and merges them (see _loadImages).
|
||||
const ALL_GROUPS_VALUE = "__video_asset_all__";
|
||||
|
||||
// Display names are now identical to the underlying group names (minus the
|
||||
// "__...__" wrapping) — no more per-plan "VideoPlan:<name>" sub-groups, so the
|
||||
// dropdown is always exactly these three fixed entries.
|
||||
const _GROUPS = [ALL_GROUPS_VALUE, VIDEO_GROUP, VTEMP_GROUP];
|
||||
|
||||
const _s = {
|
||||
outputDir: "",
|
||||
group: ALL_GROUPS_VALUE,
|
||||
images: [],
|
||||
selectedPath: null,
|
||||
loaded: false, // becomes true once the Asset subtab has been shown at least once
|
||||
searchQuery: "",
|
||||
viewMode: "grid", // "grid" | "table"
|
||||
kindFilter: "all", // "all" | "video" | "image" | "audio"
|
||||
};
|
||||
|
||||
function _formatDate(mtime) {
|
||||
return new Date(mtime * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function _filteredImages() {
|
||||
const q = _s.searchQuery.trim().toLowerCase();
|
||||
let list = _s.images;
|
||||
if (_s.kindFilter === "video") list = list.filter((img) => isVideoFile(img));
|
||||
else if (_s.kindFilter === "audio") list = list.filter((img) => isAudioFile(img));
|
||||
else if (_s.kindFilter === "image") list = list.filter((img) => !isVideoFile(img) && !isAudioFile(img));
|
||||
if (q) list = list.filter((img) => img.filename.toLowerCase().includes(q));
|
||||
return list;
|
||||
}
|
||||
|
||||
async function _fetchOutputDir() {
|
||||
if (_s.outputDir) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_s.outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
function _groupLabel(g) {
|
||||
if (g === ALL_GROUPS_VALUE) return "All Video Assets";
|
||||
if (g === VIDEO_GROUP) return "Video Assets";
|
||||
if (g === VTEMP_GROUP) return "Video Temp";
|
||||
return g;
|
||||
}
|
||||
|
||||
function _renderGroupSelect() {
|
||||
const sel = document.getElementById("wfm-video-asset-group");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = _GROUPS
|
||||
.map((g) => `<option value="${g}"${g === _s.group ? " selected" : ""}>${_groupLabel(g)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function _fetchGroupImages(group) {
|
||||
const params = new URLSearchParams({ folder: _s.outputDir, group, recursive: "true", sort: "date_desc" });
|
||||
const res = await fetch(`/wfm/gallery/images?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
return data.images || [];
|
||||
}
|
||||
|
||||
async function _loadImages() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const statusEl = document.getElementById("wfm-video-asset-status");
|
||||
if (!grid) return;
|
||||
await _fetchOutputDir();
|
||||
if (!_s.outputDir) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No output folder configured</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = `<div class="wfm-placeholder">Loading...</div>`;
|
||||
try {
|
||||
let images;
|
||||
if (_s.group === ALL_GROUPS_VALUE) {
|
||||
// "All Video Assets" = the union of the curated __Video Assets__
|
||||
// group and __Video Temp__ (auto-populated by every plan run,
|
||||
// organized into __Video Assets__ by the user on their own
|
||||
// schedule) — not a single backend group filter, so fetch both
|
||||
// and merge.
|
||||
const [curated, temp] = await Promise.all([
|
||||
_fetchGroupImages(VIDEO_GROUP),
|
||||
_fetchGroupImages(VTEMP_GROUP),
|
||||
]);
|
||||
const byPath = new Map();
|
||||
for (const img of [...curated, ...temp]) byPath.set(img.path, img);
|
||||
images = Array.from(byPath.values()).sort((a, b) => b.mtime - a.mtime);
|
||||
} else {
|
||||
images = await _fetchGroupImages(_s.group);
|
||||
}
|
||||
_s.images = images;
|
||||
_renderList();
|
||||
if (statusEl) statusEl.textContent = `${_s.images.length} video(s)`;
|
||||
} catch (err) {
|
||||
_s.images = [];
|
||||
grid.innerHTML = `<div class="wfm-placeholder" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Renders whichever of grid/table is active for the current viewMode — called
|
||||
// on load, on search input, and after switching view modes.
|
||||
function _renderList() {
|
||||
if (_s.viewMode === "table") _renderTable();
|
||||
else _renderGrid();
|
||||
}
|
||||
|
||||
function _renderGrid() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
if (!grid) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No videos yet</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
for (const img of images) grid.appendChild(_makeCard(img));
|
||||
}
|
||||
|
||||
function _renderTable() {
|
||||
const body = document.getElementById("wfm-video-asset-table-body");
|
||||
if (!body) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
body.innerHTML = `<tr><td colspan="3" class="wfm-placeholder">No videos yet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
for (const img of images) body.appendChild(_makeRow(img));
|
||||
}
|
||||
|
||||
function _makeRow(img) {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "wfm-video-asset-row" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
row.dataset.path = img.path;
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.textContent = img.filename;
|
||||
nameCell.title = img.filename;
|
||||
|
||||
const dateCell = document.createElement("td");
|
||||
dateCell.textContent = _formatDate(img.mtime);
|
||||
|
||||
const tagsCell = document.createElement("td");
|
||||
tagsCell.textContent = (img.tags || []).join(", ");
|
||||
|
||||
row.append(nameCell, dateCell, tagsCell);
|
||||
row.addEventListener("click", () => _selectImage(img));
|
||||
return row;
|
||||
}
|
||||
|
||||
function _makeCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-video-asset-card" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
card.dataset.path = img.path;
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "wfm-video-asset-card-img";
|
||||
thumb.loading = "lazy";
|
||||
thumb.src = `/wfm/gallery/image/thumb?path=${encodeURIComponent(img.path)}&w=200`;
|
||||
thumb.onerror = () => { thumb.style.display = "none"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-video-asset-card-name";
|
||||
name.textContent = img.filename;
|
||||
name.title = img.filename;
|
||||
|
||||
card.append(thumb, name);
|
||||
card.addEventListener("click", () => _selectImage(img));
|
||||
return card;
|
||||
}
|
||||
|
||||
function _selectImage(img) {
|
||||
_s.selectedPath = img.path;
|
||||
document.querySelectorAll(".wfm-video-asset-card, .wfm-video-asset-row").forEach((c) => {
|
||||
c.classList.toggle("selected", c.dataset.path === img.path);
|
||||
});
|
||||
_renderDetail(img);
|
||||
_loadIntoSourcePreview(img);
|
||||
}
|
||||
|
||||
// Feeds the selected asset's media into the center panel's Asset/Source preview
|
||||
// pane (see video-preview.js) so it's visible without switching tabs, and so the
|
||||
// Frame/GIF property tools can operate on it just like a Video Source drop.
|
||||
// Fetched as a Blob and wrapped as a "local" source rather than trying to map
|
||||
// Gallery's arbitrary absolute path onto ComfyUI's filename/subfolder/type
|
||||
// triple — that keeps the GIF tool's existing upload-on-first-use path working
|
||||
// unchanged for both cases. Still/animated images (this group isn't exclusively
|
||||
// videos — anything tagged into it shows up here) preview via the pane's <img>
|
||||
// companion element instead of trying to play them as a <video>.
|
||||
async function _loadIntoSourcePreview(img) {
|
||||
if (isAudioFile(img)) return; // 音声は映像プレビュー対象外(詳細パネルの<audio>で試聴)
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const isVideo = isVideoFile(img);
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideo ? "video/mp4" : "image/png") });
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file }, isVideo ? "video" : "image");
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] failed to load preview:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDetail(img) {
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (!panel) return;
|
||||
// Filename/memo are untrusted (filesystem names, user-entered text) — the
|
||||
// markup skeleton is static, and those two values are assigned afterward
|
||||
// via textContent/.value, never interpolated into the HTML string itself.
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-video-asset-name" id="wfm-video-asset-name"></div>
|
||||
${isAudioFile(img) ? `<audio id="wfm-video-asset-audio" class="wfm-gallery-audio-player" controls src="/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}" style="width:100%;margin-bottom:8px;"></audio>` : ""}
|
||||
<label>Tags</label>
|
||||
<div class="wfm-video-asset-tags" id="wfm-video-asset-tags"></div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<input type="text" id="wfm-video-asset-tag-input" class="wfm-input" placeholder="Add tag" style="flex:1;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-tag-add">Add</button>
|
||||
</div>
|
||||
<label style="margin-top:10px;">Memo</label>
|
||||
<textarea id="wfm-video-asset-memo" class="wfm-textarea" rows="3"></textarea>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-memo-save" style="margin-top:6px;">Save Memo</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-send-to-edit" style="width:100%;margin-top:12px;display:${isAudioFile(img) ? "none" : "block"};">${t("videoEditSendToEdit")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-add-overlay" style="width:100%;margin-top:6px;display:${isAudioFile(img) ? "none" : "block"};">${t("videoAssetAddAsOverlay")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-bgm" style="width:100%;margin-top:${isAudioFile(img) ? "12" : "6"}px;display:${isVideoFile(img) || isAudioFile(img) ? "block" : "none"};">${t("videoAssetSetAsBgm")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-add-sound" style="width:100%;margin-top:6px;display:${isAudioFile(img) ? "block" : "none"};">${t("videoAssetAddAsSound")}</button>
|
||||
<div id="wfm-video-asset-set-plan-image-row" style="display:${isVideoFile(img) || isAudioFile(img) ? "none" : "flex"};gap:6px;margin-top:6px;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-first" style="flex:1;">${t("videoAssetSetAsFirst")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-last" style="flex:1;">${t("videoAssetSetAsLast")}</button>
|
||||
</div>
|
||||
<button type="button" class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-video-asset-open-gallery" style="width:100%;margin-top:6px;">Open in Gallery</button>
|
||||
`;
|
||||
|
||||
const nameEl = document.getElementById("wfm-video-asset-name");
|
||||
if (nameEl) { nameEl.textContent = img.filename; nameEl.title = img.filename; }
|
||||
const memoEl = document.getElementById("wfm-video-asset-memo");
|
||||
if (memoEl) memoEl.value = img.memo || "";
|
||||
|
||||
_renderTags(img);
|
||||
|
||||
panel.querySelector("#wfm-video-asset-tag-add")?.addEventListener("click", async () => {
|
||||
const input = panel.querySelector("#wfm-video-asset-tag-input");
|
||||
const tag = input?.value.trim();
|
||||
if (!tag) return;
|
||||
const tags = Array.from(new Set([...(img.tags || []), tag]));
|
||||
await _saveMeta(img.path, { tags });
|
||||
img.tags = tags;
|
||||
input.value = "";
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-memo-save")?.addEventListener("click", async () => {
|
||||
const memo = panel.querySelector("#wfm-video-asset-memo")?.value || "";
|
||||
await _saveMeta(img.path, { memo });
|
||||
img.memo = memo;
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-send-to-edit")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideoFile(img) ? "video/mp4" : "image/png") });
|
||||
addClipFromFile(file, img.filename);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoEditClipSent", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Overlay clip (picture-in-picture): works for both videos and images, and
|
||||
// jumps to the Edit subtab's Overlay track so the new clip is visible.
|
||||
panel.querySelector("#wfm-video-asset-add-overlay")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideoFile(img) ? "video/mp4" : "image/png") });
|
||||
if (await addPipFromFile(file, img.filename)) {
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
document.getElementById("wfm-video-edit-track-pip")?.click();
|
||||
showToast(t("videoAssetAddAsOverlayDone", img.filename), "success");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// BGM source: an audio asset directly, or a video asset's own audio track
|
||||
// (LoadAudio decodes audio from video files).
|
||||
panel.querySelector("#wfm-video-asset-set-bgm")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isAudioFile(img) ? "audio/mpeg" : "video/mp4") });
|
||||
if (await setBgmFromFile(file, img.filename)) {
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoAssetSetAsBgmDone", img.filename), "success");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Timeline sound (sound effect / voice): placed at the selected clip's start on the Audio track.
|
||||
panel.querySelector("#wfm-video-asset-add-sound")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "audio/mpeg" });
|
||||
if (await addSoundFromFile(file, img.filename)) {
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
document.getElementById("wfm-video-edit-track-audio")?.click();
|
||||
showToast(t("videoAssetAddAsSoundDone", img.filename), "success");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const setAsPlanImage = async (which) => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
await setBlockImageFromFile(which, file);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="plan"]')?.click();
|
||||
showToast(t(which === "first" ? "videoAssetSetAsFirstDone" : "videoAssetSetAsLastDone", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
};
|
||||
panel.querySelector("#wfm-video-asset-set-first")?.addEventListener("click", () => setAsPlanImage("first"));
|
||||
panel.querySelector("#wfm-video-asset-set-last")?.addEventListener("click", () => setAsPlanImage("last"));
|
||||
|
||||
panel.querySelector("#wfm-video-asset-open-gallery")?.addEventListener("click", () => {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
const filterSel = document.getElementById("wfm-gallery-group-filter");
|
||||
if (filterSel) {
|
||||
// ALL_GROUPS_VALUE is a virtual union with no Gallery-side equivalent
|
||||
// (Gallery's own filter only ever matches one real group) — fall back
|
||||
// to the curated group, the closer of the two to "everything worth keeping".
|
||||
filterSel.value = _s.group === ALL_GROUPS_VALUE ? VIDEO_GROUP : _s.group;
|
||||
filterSel.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _renderTags(img) {
|
||||
const container = document.getElementById("wfm-video-asset-tags");
|
||||
if (!container) return;
|
||||
const tags = img.tags || [];
|
||||
container.innerHTML = "";
|
||||
if (tags.length === 0) {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "wfm-placeholder";
|
||||
placeholder.textContent = "No tags";
|
||||
container.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
// Built via DOM APIs (not innerHTML) since tag text is user-entered —
|
||||
// textContent keeps it inert regardless of what characters it contains.
|
||||
for (const tag of tags) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "wfm-video-asset-tag";
|
||||
pill.append(document.createTextNode(`${tag} `));
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", async () => {
|
||||
const newTags = tags.filter((t2) => t2 !== tag);
|
||||
await _saveMeta(img.path, { tags: newTags });
|
||||
img.tags = newTags;
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
pill.appendChild(removeBtn);
|
||||
container.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveMeta(path, data) {
|
||||
try {
|
||||
await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, ...data }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] saveMeta failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Called whenever the Asset subtab becomes active (see video-tab.js's subtab
|
||||
// toggle) — always refetches rather than caching, so a Plan run's newly
|
||||
// generated videos show up as soon as the user switches over to look.
|
||||
export async function refreshVideoAssetTab() {
|
||||
if (!_s.loaded) {
|
||||
_renderGroupSelect();
|
||||
_s.loaded = true;
|
||||
}
|
||||
await _loadImages();
|
||||
}
|
||||
|
||||
function _setViewMode(mode) {
|
||||
_s.viewMode = mode;
|
||||
document.getElementById("wfm-video-asset-view-grid")?.classList.toggle("active", mode === "grid");
|
||||
document.getElementById("wfm-video-asset-view-table")?.classList.toggle("active", mode === "table");
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const tableWrap = document.getElementById("wfm-video-asset-table-wrap");
|
||||
if (grid) grid.style.display = mode === "grid" ? "" : "none";
|
||||
if (tableWrap) tableWrap.style.display = mode === "table" ? "" : "none";
|
||||
_renderList();
|
||||
}
|
||||
|
||||
export function initVideoAssetTab() {
|
||||
document.getElementById("wfm-video-asset-group")?.addEventListener("change", (e) => {
|
||||
_s.group = e.target.value;
|
||||
_s.selectedPath = null;
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (panel) panel.innerHTML = `<span class="wfm-placeholder">Select a video</span>`;
|
||||
_loadImages();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-refresh")?.addEventListener("click", () => _loadImages());
|
||||
document.getElementById("wfm-video-asset-kind")?.addEventListener("change", (e) => {
|
||||
_s.kindFilter = e.target.value;
|
||||
_renderList();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-search")?.addEventListener("input", (e) => {
|
||||
_s.searchQuery = e.target.value;
|
||||
_renderList();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-view-grid")?.addEventListener("click", () => _setViewMode("grid"));
|
||||
document.getElementById("wfm-video-asset-view-table")?.addEventListener("click", () => _setViewMode("table"));
|
||||
|
||||
// Neither reserved group is ever auto-created by a batch run itself (see
|
||||
// video-plan-tab.js's _ensureVideoAssetGroups, which only ensures
|
||||
// __Video Temp__ right before a run starts), so a fresh install or a group
|
||||
// deleted via Gallery's Manage Groups would otherwise never come back on
|
||||
// its own — add_to_group() happily records membership on individual images
|
||||
// without ever re-registering the group itself. ensureVideoGroup() ensures
|
||||
// both __Video Assets__ and __Video Temp__ every time the Asset subtab inits.
|
||||
ensureVideoGroup();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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: { frame: "wfm-video-source-preview-frame", video: "wfm-video-source-preview-video", img: "wfm-video-source-preview-img", placeholder: "wfm-video-source-preview-placeholder" },
|
||||
result: { frame: "wfm-video-preview-frame", 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);
|
||||
}
|
||||
|
||||
// The frame/<video>/<img> elements of one pane ("source" | "result") — used by
|
||||
// video-edit-tab.js to position its text-overlay preview layer over whichever
|
||||
// media element is currently showing in that pane.
|
||||
export function getPreviewPaneElements(pane) {
|
||||
const ids = _PANES[pane];
|
||||
if (!ids) return null;
|
||||
return {
|
||||
frame: document.getElementById(ids.frame),
|
||||
video: document.getElementById(ids.video),
|
||||
img: document.getElementById(ids.img),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Video Tab - Sidebar "Project" subtab (next to Asset): browses saved Video
|
||||
* Plan files (ws_videoplan_*.json in video_plan/) OR saved Video Edit
|
||||
* projects (ws_videoeditproj_*.json in video_edit_project/), depending on
|
||||
* which center subtab (Plan/Edit) is currently active — video-tab.js passes
|
||||
* that mode in on every refresh (Asset stays on whichever of the two was
|
||||
* last active, since it has no saved-list concept of its own).
|
||||
*
|
||||
* Deliberately thin, mirroring video-asset-tab.js's own scope — clicking an
|
||||
* item hands off to video-plan-tab.js's openSavedVideoPlan() or
|
||||
* video-edit-tab.js's openSavedVideoEditProject() rather than
|
||||
* re-implementing plan/project loading here.
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { openSavedVideoPlan } from "./video-plan-tab.js";
|
||||
import { openSavedVideoEditProject } from "./video-edit-tab.js";
|
||||
|
||||
// Per-mode config: which endpoints to hit, which loader/toast strings to use,
|
||||
// and how to read each item's count field — keeps _renderList/_makeItem/etc.
|
||||
// mode-agnostic instead of branching throughout.
|
||||
const _MODES = {
|
||||
plan: {
|
||||
listUrl: "/api/wfm/video/plans",
|
||||
deleteUrl: "/api/wfm/video/plans/delete",
|
||||
open: (filename) => openSavedVideoPlan(filename),
|
||||
countOf: (item) => item.block_count ?? 0,
|
||||
countLabel: (n) => t("videoProjectBlockCount", n),
|
||||
hintKey: "videoProjectHint",
|
||||
emptyKey: "videoProjectEmpty",
|
||||
deleteConfirmKey: "videoProjectDeleteConfirm",
|
||||
deletedKey: "videoProjectDeleted",
|
||||
deleteFailedKey: "videoProjectDeleteFailed",
|
||||
loadListFailedKey: "videoProjectLoadListFailed",
|
||||
},
|
||||
edit: {
|
||||
listUrl: "/api/wfm/video/edit/projects",
|
||||
deleteUrl: "/api/wfm/video/edit/projects/delete",
|
||||
open: (filename) => openSavedVideoEditProject(filename),
|
||||
countOf: (item) => item.clip_count ?? 0,
|
||||
countLabel: (n) => t("videoEditProjectClipCount", n),
|
||||
hintKey: "videoEditProjectHint",
|
||||
emptyKey: "videoEditProjectEmpty",
|
||||
deleteConfirmKey: "videoEditProjectDeleteConfirm",
|
||||
deletedKey: "videoEditProjectDeleted",
|
||||
deleteFailedKey: "videoEditProjectDeleteFailed",
|
||||
loadListFailedKey: "videoEditProjectLoadListFailed",
|
||||
},
|
||||
};
|
||||
|
||||
const _s = {
|
||||
mode: "plan",
|
||||
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;
|
||||
const cfg = _MODES[_s.mode];
|
||||
if (_s.plans.length === 0) {
|
||||
list.innerHTML = `<span class="wfm-placeholder">${t(cfg.emptyKey)}</span>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const plan of _s.plans) list.appendChild(_makeItem(plan));
|
||||
}
|
||||
|
||||
function _makeItem(plan) {
|
||||
const cfg = _MODES[_s.mode];
|
||||
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 countLabel = cfg.countLabel(cfg.countOf(plan));
|
||||
const updated = _formatUpdatedAt(plan.updated_at);
|
||||
meta.textContent = updated ? `${countLabel} · ${updated}` : countLabel;
|
||||
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 _MODES[_s.mode].open(plan.filename);
|
||||
}
|
||||
|
||||
async function _deletePlan(plan) {
|
||||
const cfg = _MODES[_s.mode];
|
||||
if (!window.confirm(t(cfg.deleteConfirmKey, plan.name || plan.filename))) return;
|
||||
try {
|
||||
const res = await fetch(cfg.deleteUrl, {
|
||||
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(cfg.deletedKey), "success");
|
||||
await refreshVideoProjectTab(_s.mode);
|
||||
} catch (err) {
|
||||
showToast(`${t(cfg.deleteFailedKey)}: ${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/project saved
|
||||
// elsewhere (Plan pane's Save/Save As, Edit's Save/Save As) shows up as soon
|
||||
// as the user switches over to look. `mode` ("plan"|"edit") is video-tab.js's
|
||||
// record of which center subtab is currently active; defaults to "plan" so
|
||||
// direct calls (e.g. from initVideoProjectTab) keep the original behavior.
|
||||
export async function refreshVideoProjectTab(mode = "plan") {
|
||||
const list = document.getElementById("wfm-video-project-list");
|
||||
if (!list) return;
|
||||
_s.mode = mode === "edit" ? "edit" : "plan";
|
||||
_s.activeFilename = null;
|
||||
const cfg = _MODES[_s.mode];
|
||||
const hintEl = document.getElementById("wfm-video-project-hint");
|
||||
if (hintEl) hintEl.textContent = t(cfg.hintKey);
|
||||
try {
|
||||
const res = await fetch(cfg.listUrl);
|
||||
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(cfg.loadListFailedKey))}: ${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
_s.loaded = true;
|
||||
}
|
||||
|
||||
export function initVideoProjectTab() {
|
||||
document.getElementById("wfm-video-project-refresh")?.addEventListener("click", () => refreshVideoProjectTab());
|
||||
}
|
||||
@@ -1,187 +1,100 @@
|
||||
/**
|
||||
* Video Tab - MiniMax H3 (Image to Video) 専用の動画生成UI
|
||||
* GenerateUIタブとは完全に独立。UI形式ワークフローJSONを直接編集し、
|
||||
* comfyWorkflow.convertUiToApi() でAPI形式に変換して実行する。
|
||||
* 状態は「テンプレートJSON+フォーム値」のみ保持し、生成の都度
|
||||
* structuredClone() したコピーに書き込む(複数回生成をまたいだID不整合を防ぐ)。
|
||||
* 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 { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { escapeHtml } from "./util.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";
|
||||
|
||||
const DEFAULT_WORKFLOW_FILENAME = "minimax_test.json";
|
||||
|
||||
const state = {
|
||||
templateWorkflow: null,
|
||||
templateFilename: null,
|
||||
firstFrameFilename: null,
|
||||
lastFrameFilename: null,
|
||||
generating: false,
|
||||
// Whatever's currently shown in the center preview frame — a just-generated
|
||||
// result, or a video dropped/picked in the Video Source panel. Used by the
|
||||
// Frame/GIF property tabs so they work on any video, not just generated ones.
|
||||
// { kind: "output"|"input", filename, subfolder, type } for anything already on
|
||||
// the server, or { kind: "local", file } for a picked file not yet uploaded.
|
||||
previewSource: null,
|
||||
};
|
||||
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.
|
||||
// ============================================
|
||||
|
||||
async function getRawWorkflow(filename) {
|
||||
const res = await fetch(`/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
// Which of the center Plan/Edit subtabs was last active — the sidebar
|
||||
// Project panel uses this to decide whether to list saved Video Plans or
|
||||
// saved Video Edit projects (Asset has no saved-list concept, so selecting
|
||||
// it leaves this unchanged rather than resetting to "plan").
|
||||
let _centerSubtabMode = "plan";
|
||||
|
||||
function _isProjectPanelOpen() {
|
||||
const panel = document.getElementById("wfm-video-subtab-project");
|
||||
return !!panel && panel.style.display !== "none";
|
||||
}
|
||||
|
||||
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();
|
||||
if (target === "plan" || target === "edit") {
|
||||
_centerSubtabMode = target;
|
||||
// Project panel may already be open (e.g. switching Plan<->Edit
|
||||
// while it's pinned) — keep its list in sync with the new mode.
|
||||
if (_isProjectPanelOpen()) refreshVideoProjectTab(_centerSubtabMode);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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(_centerSubtabMode);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MiniMax H3ノードの動的特定(ノードIDをハードコードしない)
|
||||
// ============================================
|
||||
|
||||
// ノードのinputs[]のうちwidgetキーを持つものだけを抽出した並び順で、名前からインデックスを引く。
|
||||
// widgets_values配列はこの並び順と1:1対応する(link接続されていてもwidgetキーを持つ入力は
|
||||
// widgets_valuesにスロットを持つ「legacy full」形式のため)。
|
||||
function _widgetIndex(node, name) {
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.findIndex((inp) => inp.name === name);
|
||||
}
|
||||
|
||||
function locateMiniMaxNodes(workflow) {
|
||||
if (!Array.isArray(workflow?.nodes) || !Array.isArray(workflow?.links)) return null;
|
||||
|
||||
const sgDef = (workflow.definitions?.subgraphs || [])
|
||||
.find((sg) => (sg.nodes || []).some((n) => n.type === "MiniMaxH3ImageToVideo"));
|
||||
if (!sgDef) return null;
|
||||
|
||||
const instanceNode = workflow.nodes.find((n) => n.type === sgDef.id);
|
||||
if (!instanceNode || !Array.isArray(instanceNode.widgets_values)) return null;
|
||||
|
||||
const firstFrameSlot = instanceNode.inputs.findIndex((inp) => inp.name === "first_frame");
|
||||
const lastFrameSlot = instanceNode.inputs.findIndex((inp) => inp.name === "last_frame");
|
||||
const widthSlot = instanceNode.inputs.findIndex((inp) => inp.name === "width");
|
||||
if (firstFrameSlot === -1 || lastFrameSlot === -1 || widthSlot === -1) return null;
|
||||
|
||||
const findLinkedNode = (targetSlot) => {
|
||||
const link = workflow.links.find((l) => l[3] === instanceNode.id && l[4] === targetSlot);
|
||||
if (!link) return null;
|
||||
return workflow.nodes.find((n) => n.id === link[1]) || null;
|
||||
};
|
||||
|
||||
// first_frameはoptional入力のため、LoadImageノードが接続されていないワークフロー
|
||||
// (テキストのみで動作するT2V構成など)もサポート対象とする。未接続の場合は
|
||||
// loadImageNode: null のまま返し、必要ならrunGeneration側で動的にノードを注入する。
|
||||
const rawLoadImageNode = findLinkedNode(firstFrameSlot);
|
||||
const loadImageNode = (rawLoadImageNode && rawLoadImageNode.type === "LoadImage") ? rawLoadImageNode : null;
|
||||
const resolutionNode = findLinkedNode(widthSlot);
|
||||
if (!resolutionNode || resolutionNode.type !== "ResolutionSelector") return null;
|
||||
|
||||
const promptIdx = _widgetIndex(instanceNode, "prompt");
|
||||
const durationIdx = _widgetIndex(instanceNode, "value_1");
|
||||
if (promptIdx === -1 || durationIdx === -1) return null;
|
||||
|
||||
return {
|
||||
sgDef, instanceNode, loadImageNode, resolutionNode,
|
||||
firstFrameSlot, lastFrameSlot, promptIdx, durationIdx,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ワークフロー読み込み
|
||||
// ============================================
|
||||
|
||||
function _showFramePreview(which, filename) {
|
||||
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
|
||||
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
|
||||
if (!previewImg || !wrap || !filename) return;
|
||||
previewImg.src = `${comfyUI.baseUrl}/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||||
wrap.style.display = "";
|
||||
}
|
||||
|
||||
export async function loadWorkflowIntoVideoEditor(workflow, filename) {
|
||||
const nodes = locateMiniMaxNodes(workflow);
|
||||
if (!nodes) {
|
||||
showToast(t("videoUnsupportedWorkflow"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
state.templateWorkflow = workflow;
|
||||
state.templateFilename = filename || "";
|
||||
state.firstFrameFilename = null;
|
||||
state.lastFrameFilename = null;
|
||||
|
||||
const nameEl = document.getElementById("wfm-video-wf-name");
|
||||
if (nameEl) nameEl.textContent = filename || "Loaded Workflow";
|
||||
|
||||
const { instanceNode, resolutionNode, loadImageNode, promptIdx, durationIdx } = nodes;
|
||||
|
||||
const promptEl = document.getElementById("wfm-video-prompt");
|
||||
if (promptEl) promptEl.value = instanceNode.widgets_values[promptIdx] ?? "";
|
||||
|
||||
const durationEl = document.getElementById("wfm-video-duration");
|
||||
if (durationEl) durationEl.value = instanceNode.widgets_values[durationIdx] ?? 2;
|
||||
|
||||
const arIdx = _widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = _widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = _widgetIndex(resolutionNode, "multiple");
|
||||
const aspectEl = document.getElementById("wfm-video-aspect-ratio");
|
||||
if (aspectEl && arIdx !== -1 && resolutionNode.widgets_values[arIdx] != null) {
|
||||
aspectEl.value = resolutionNode.widgets_values[arIdx];
|
||||
}
|
||||
const mpEl = document.getElementById("wfm-video-megapixels");
|
||||
if (mpEl && mpIdx !== -1) mpEl.value = resolutionNode.widgets_values[mpIdx] ?? mpEl.value;
|
||||
const multEl = document.getElementById("wfm-video-multiple");
|
||||
if (multEl && multIdx !== -1) multEl.value = resolutionNode.widgets_values[multIdx] ?? multEl.value;
|
||||
|
||||
const fnIdx = _widgetIndex(loadImageNode, "image");
|
||||
if (fnIdx !== -1 && loadImageNode.widgets_values[fnIdx]) {
|
||||
state.firstFrameFilename = loadImageNode.widgets_values[fnIdx];
|
||||
_showFramePreview("first", state.firstFrameFilename);
|
||||
}
|
||||
|
||||
showToast(t("videoWorkflowLoaded", filename || ""), "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadDefaultWorkflow() {
|
||||
try {
|
||||
const wf = await getRawWorkflow(DEFAULT_WORKFLOW_FILENAME);
|
||||
await loadWorkflowIntoVideoEditor(wf, DEFAULT_WORKFLOW_FILENAME);
|
||||
} catch {
|
||||
// 初回自動ロード失敗は静かに無視する(環境によってファイルが存在しないこともある)。
|
||||
// Workflowタブの「Load in Video」ボタンから明示的に読み込める。
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Aspect Ratio 選択肢(object_infoから動的取得、ハードコードしない)
|
||||
// ============================================
|
||||
|
||||
async function _populateAspectRatioOptions() {
|
||||
const select = document.getElementById("wfm-video-aspect-ratio");
|
||||
if (!select) return;
|
||||
try {
|
||||
const info = await comfyUI.fetchObjectInfo("ResolutionSelector");
|
||||
const inputDef = info?.ResolutionSelector?.input?.required?.aspect_ratio;
|
||||
if (!inputDef) return;
|
||||
const first = inputDef[0];
|
||||
let values = null;
|
||||
if (Array.isArray(first)) values = first;
|
||||
else if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) values = inputDef[1].values;
|
||||
else if (typeof first === "string" && Array.isArray(inputDef[1]?.options)) values = inputDef[1].options;
|
||||
if (!values?.length) return;
|
||||
select.innerHTML = values.map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
|
||||
} catch {
|
||||
// ResolutionSelectorが未導入の環境などは選択肢が空のまま(手入力不可のselectなので実質何もできないが、
|
||||
// Videoタブ自体の初期化は継続させる)。
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// first_frame / last_frame アップロード
|
||||
// first_frame / last_frame アップロード(Video Sourceパネル・Frame/GIFタブと共有する
|
||||
// 汎用ドロップゾーン配線ヘルパー。video-plan-tab.jsのブロック画像アップロードとは別物)
|
||||
// ============================================
|
||||
|
||||
function _wireDropZone(dropZoneId, fileInputId, onFile) {
|
||||
@@ -200,279 +113,6 @@ function _wireDropZone(dropZoneId, fileInputId, onFile) {
|
||||
});
|
||||
}
|
||||
|
||||
async function _handleFrameUpload(which, file) {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const statusEl = document.getElementById(`wfm-video-${which}-frame-status`);
|
||||
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
|
||||
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
|
||||
if (previewImg) previewImg.src = URL.createObjectURL(file);
|
||||
if (wrap) wrap.style.display = "";
|
||||
if (statusEl) statusEl.textContent = "Uploading...";
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (which === "first") state.firstFrameFilename = result.name;
|
||||
else state.lastFrameFilename = result.name;
|
||||
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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _clearFrame(which) {
|
||||
if (which === "first") state.firstFrameFilename = null;
|
||||
else state.lastFrameFilename = null;
|
||||
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
|
||||
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
|
||||
const statusEl = document.getElementById(`wfm-video-${which}-frame-status`);
|
||||
const fileInput = document.getElementById(`wfm-video-${which}-frame-file`);
|
||||
if (wrap) wrap.style.display = "none";
|
||||
if (previewImg) previewImg.src = "";
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
if (fileInput) fileInput.value = "";
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// フォーム値の読み取り・ワークフローへの反映
|
||||
// ============================================
|
||||
|
||||
function _readFormValues() {
|
||||
return {
|
||||
prompt: document.getElementById("wfm-video-prompt")?.value || "",
|
||||
aspectRatio: document.getElementById("wfm-video-aspect-ratio")?.value || "",
|
||||
megapixels: Number(document.getElementById("wfm-video-megapixels")?.value) || 0.2,
|
||||
multiple: Number(document.getElementById("wfm-video-multiple")?.value) || 32,
|
||||
duration: Number(document.getElementById("wfm-video-duration")?.value) || 2,
|
||||
};
|
||||
}
|
||||
|
||||
function _applyFormToWorkflow(nodes, form) {
|
||||
const { instanceNode, resolutionNode, loadImageNode, promptIdx, durationIdx } = nodes;
|
||||
|
||||
instanceNode.widgets_values[promptIdx] = form.prompt;
|
||||
instanceNode.widgets_values[durationIdx] = form.duration;
|
||||
|
||||
const arIdx = _widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = _widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = _widgetIndex(resolutionNode, "multiple");
|
||||
if (arIdx !== -1) resolutionNode.widgets_values[arIdx] = form.aspectRatio;
|
||||
if (mpIdx !== -1) resolutionNode.widgets_values[mpIdx] = form.megapixels;
|
||||
if (multIdx !== -1) resolutionNode.widgets_values[multIdx] = form.multiple;
|
||||
|
||||
// first_frameは既存のLoadImageノードが接続されている場合のみここで書き換える。
|
||||
// 接続が無い場合(loadImageNode: null)は、first_frameが新規指定された時だけ
|
||||
// _injectFrameNode()でノードごと注入する(未指定ならバイパス=未接続のまま実行)。
|
||||
if (loadImageNode) {
|
||||
const fnIdx = _widgetIndex(loadImageNode, "image");
|
||||
if (fnIdx !== -1) loadImageNode.widgets_values[fnIdx] = state.firstFrameFilename;
|
||||
}
|
||||
}
|
||||
|
||||
// first_frame/last_frameが未接続のワークフローに対して、画像が指定された時だけ
|
||||
// LoadImageノードを新規注入する共通ヘルパー。ノードID/リンクIDはclone内の実際の
|
||||
// 最大値+1で採番するため、重複ID/リンクID不整合は起きない
|
||||
// (CLAUDE.md記載の既知の落とし穴への対策)。未指定時は何もしない
|
||||
// (テンプレート由来のlink:nullのまま=クリーンな状態を毎回cloneするので残留の心配がない=バイパス扱い)。
|
||||
function _injectFrameNode(clone, instanceNode, targetSlot, filename) {
|
||||
const maxNodeId = Math.max(clone.last_node_id || 0, ...clone.nodes.map((n) => n.id));
|
||||
const maxLinkId = Math.max(clone.last_link_id || 0, ...clone.links.map((l) => l[0]));
|
||||
const newNodeId = maxNodeId + 1;
|
||||
const newLinkId = maxLinkId + 1;
|
||||
|
||||
clone.nodes.push({
|
||||
id: newNodeId,
|
||||
type: "LoadImage",
|
||||
pos: [(instanceNode.pos?.[0] || 0) - 260, (instanceNode.pos?.[1] || 0) + 40],
|
||||
size: [280, 314],
|
||||
flags: {},
|
||||
order: clone.nodes.length,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ localized_name: "image", name: "image", type: "COMBO", widget: { name: "image" }, link: null },
|
||||
{ localized_name: "choose file to upload", name: "upload", type: "IMAGEUPLOAD", widget: { name: "upload" }, link: null },
|
||||
],
|
||||
outputs: [
|
||||
{ localized_name: "IMAGE", name: "IMAGE", type: "IMAGE", links: [newLinkId] },
|
||||
{ localized_name: "MASK", name: "MASK", type: "MASK", links: null },
|
||||
],
|
||||
properties: { "Node name for S&R": "LoadImage" },
|
||||
widgets_values: [filename, "image"],
|
||||
});
|
||||
|
||||
clone.links.push([newLinkId, newNodeId, 0, instanceNode.id, targetSlot, "IMAGE"]);
|
||||
const targetInput = instanceNode.inputs[targetSlot];
|
||||
if (targetInput) targetInput.link = newLinkId;
|
||||
|
||||
clone.last_node_id = newNodeId;
|
||||
clone.last_link_id = newLinkId;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 実行
|
||||
// ============================================
|
||||
|
||||
async function runGeneration() {
|
||||
if (state.generating) return;
|
||||
if (!state.templateWorkflow) {
|
||||
showToast(t("videoNoWorkflowLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = structuredClone(state.templateWorkflow);
|
||||
const nodes = locateMiniMaxNodes(clone);
|
||||
if (!nodes) {
|
||||
showToast(t("videoUnsupportedWorkflow"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
_applyFormToWorkflow(nodes, _readFormValues());
|
||||
// first_frame: 既存のLoadImageノードが無いワークフロー(T2V構成等)で、
|
||||
// ユーザーが新たに画像を指定した場合のみノードを注入する。未指定ならバイパス(未接続のまま実行)。
|
||||
if (!nodes.loadImageNode && state.firstFrameFilename) {
|
||||
_injectFrameNode(clone, nodes.instanceNode, nodes.firstFrameSlot, state.firstFrameFilename);
|
||||
}
|
||||
if (state.lastFrameFilename) {
|
||||
_injectFrameNode(clone, nodes.instanceNode, nodes.lastFrameSlot, state.lastFrameFilename);
|
||||
}
|
||||
|
||||
const genBtn = document.getElementById("wfm-video-generate-btn");
|
||||
const progressBar = document.getElementById("wfm-video-progress-bar");
|
||||
const progressText = document.getElementById("wfm-video-progress-text");
|
||||
|
||||
state.generating = true;
|
||||
if (genBtn) genBtn.disabled = true;
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = "Converting workflow...";
|
||||
|
||||
try {
|
||||
const apiWorkflow = await comfyWorkflow.convertUiToApi(clone);
|
||||
|
||||
const seedValue = Number(document.getElementById("wfm-video-noise-seed")?.value) || 0;
|
||||
const seedMode = document.getElementById("wfm-video-seed-randomize")?.checked ? "random" : "fixed";
|
||||
|
||||
const { images, seed } = await comfyUI.generate(apiWorkflow, {
|
||||
seedMode,
|
||||
seedValue,
|
||||
timeoutMs: 30 * 60 * 1000, // 動画生成は静止画より長時間になりうる
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
});
|
||||
|
||||
const seedEl = document.getElementById("wfm-video-noise-seed");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
const outputMedia = images.filter((img) => img.type !== "temp");
|
||||
if (progressText) progressText.textContent = t("videoDoneCount", outputMedia.length);
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
_renderResult(outputMedia);
|
||||
await _saveGeneratedVideoMeta(outputMedia, apiWorkflow);
|
||||
showToast(t("videoGenerateSuccess"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
if (progressText) progressText.textContent = "Error";
|
||||
} finally {
|
||||
state.generating = false;
|
||||
if (genBtn) genBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 結果表示
|
||||
// ============================================
|
||||
|
||||
// Sets (or clears) the center preview frame's video and records where it came from —
|
||||
// `source` is what the Frame/GIF property tabs act on (see state.previewSource above).
|
||||
// Revokes the previous blob: URL (if any) so picking a series of local files doesn't
|
||||
// leak memory.
|
||||
let _previewObjectUrl = null;
|
||||
|
||||
function _setPreviewMedia(url, source) {
|
||||
state.previewSource = source;
|
||||
const video = document.getElementById("wfm-video-preview-video");
|
||||
const placeholder = document.getElementById("wfm-video-preview-placeholder");
|
||||
if (!video || !placeholder) return;
|
||||
|
||||
if (_previewObjectUrl) {
|
||||
URL.revokeObjectURL(_previewObjectUrl);
|
||||
_previewObjectUrl = null;
|
||||
}
|
||||
if (url && url.startsWith("blob:")) _previewObjectUrl = url;
|
||||
|
||||
if (url) {
|
||||
video.src = url;
|
||||
video.style.display = "block";
|
||||
placeholder.style.display = "none";
|
||||
} else {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.style.display = "none";
|
||||
placeholder.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _renderResult(images) {
|
||||
// MiniMax H3 always emits a single VIDEO output — but guard generically (prefer
|
||||
// an mp4 if present) in case a future video workflow's SaveVideo/PreviewVideo
|
||||
// ordering differs.
|
||||
const media = (images || []).find((img) => img.filename.toLowerCase().endsWith(".mp4")) || images?.[0];
|
||||
if (!media) {
|
||||
_setPreviewMedia(null, null);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ filename: media.filename, subfolder: media.subfolder || "", type: media.type || "output" });
|
||||
const src = `${comfyUI.baseUrl}/view?${params}`;
|
||||
_setPreviewMedia(src, {
|
||||
kind: media.type === "input" ? "input" : "output",
|
||||
filename: media.filename,
|
||||
subfolder: media.subfolder || "",
|
||||
type: media.type || "output",
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Gallery連携(生成結果のワークフローをサイドカーメタとして保存)
|
||||
// ============================================
|
||||
|
||||
let _outputDir = "";
|
||||
|
||||
async function _fetchOutputDir() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function _saveGeneratedVideoMeta(images, workflow) {
|
||||
if (!_outputDir) await _fetchOutputDir();
|
||||
if (!_outputDir) return;
|
||||
for (const img of images) {
|
||||
if (img.type !== "output") continue;
|
||||
const parts = [_outputDir];
|
||||
if (img.subfolder) parts.push(img.subfolder);
|
||||
parts.push(img.filename);
|
||||
const path = parts.join("/");
|
||||
try {
|
||||
await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, workflow }),
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// i18nの適用(app.jsのapplyI18nToHtml()と同じ「個別要素にt()を適用」方式)
|
||||
// ============================================
|
||||
@@ -482,29 +122,44 @@ function _applyVideoI18n() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
};
|
||||
setText("wfm-video-title-label", "videoTitle");
|
||||
setText("wfm-video-first-frame-label", "videoFirstFrame");
|
||||
setText("wfm-video-last-frame-label", "videoLastFrame");
|
||||
setText("wfm-video-first-frame-drop-label", "videoDropOrClick");
|
||||
setText("wfm-video-last-frame-drop-label", "videoDropOrClick");
|
||||
setText("wfm-video-first-frame-clear", "clear");
|
||||
setText("wfm-video-last-frame-clear", "clear");
|
||||
setText("wfm-video-prompt-label", "videoPrompt");
|
||||
setText("wfm-video-aspect-ratio-label", "videoAspectRatio");
|
||||
setText("wfm-video-megapixels-label", "videoMegapixels");
|
||||
setText("wfm-video-multiple-label", "videoMultiple");
|
||||
setText("wfm-video-duration-label", "videoDuration");
|
||||
setText("wfm-video-noise-seed-label", "videoNoiseSeed");
|
||||
setText("wfm-video-randomize-label", "videoRandomizeSeed");
|
||||
setText("wfm-video-generate-btn", "videoGenerate");
|
||||
setText("wfm-video-edit-placeholder-label", "videoEditPlaceholder");
|
||||
const setTitle = (id, key) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = t(key);
|
||||
};
|
||||
setText("wfm-video-edit-export-btn", "videoEditExportBtn");
|
||||
setText("wfm-video-edit-track-video", "videoEditTrackVideo");
|
||||
setText("wfm-video-edit-track-pip", "videoEditTrackPip");
|
||||
setText("wfm-video-edit-pip-title", "videoEditPipTitle");
|
||||
setText("wfm-video-edit-pip-btn", "videoEditPipAdd");
|
||||
setText("wfm-video-edit-track-text", "videoEditTrackText");
|
||||
setText("wfm-video-edit-track-audio", "videoEditTrackAudio");
|
||||
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");
|
||||
setTitle("wfm-video-edit-undo-btn", "videoEditUndo");
|
||||
setTitle("wfm-video-edit-redo-btn", "videoEditRedo");
|
||||
setText("wfm-video-edit-audio-title", "videoEditAudioTitle");
|
||||
setText("wfm-video-edit-keep-audio-label", "videoEditKeepAudio");
|
||||
setText("wfm-video-edit-orig-vol-label", "videoEditOriginalVolume");
|
||||
setText("wfm-video-edit-bgm-btn", "videoEditBgmSelect");
|
||||
setTitle("wfm-video-edit-bgm-clear", "videoEditBgmRemove");
|
||||
setText("wfm-video-edit-bgm-vol-label", "videoEditBgmVolume");
|
||||
setText("wfm-video-edit-bgm-offset-label", "videoEditBgmOffset");
|
||||
setText("wfm-video-edit-bgm-hint", "videoEditBgmHint");
|
||||
setText("wfm-video-edit-sound-btn", "videoEditSoundAdd");
|
||||
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-preview-placeholder", "videoNoVideo");
|
||||
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");
|
||||
@@ -517,19 +172,47 @@ function _applyVideoI18n() {
|
||||
|
||||
// ============================================
|
||||
// Video Source panel — loads any local video into the preview frame, independent of
|
||||
// generation, so Frame/GIF tools work without running MiniMax H3 first.
|
||||
// generation, so Frame/GIF tools work without running the loaded video workflow first.
|
||||
// ============================================
|
||||
|
||||
function _wireVideoSourcePanel() {
|
||||
_wireDropZone("wfm-video-source-drop", "wfm-video-source-file", (file) => {
|
||||
if (!file.type.startsWith("video/")) return;
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
const isImage = file.type.startsWith("image/");
|
||||
if (!isVideo && !isImage) return;
|
||||
const statusEl = document.getElementById("wfm-video-source-status");
|
||||
_setPreviewMedia(URL.createObjectURL(file), { kind: "local", file });
|
||||
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", () => {
|
||||
_setPreviewMedia(null, null);
|
||||
setSourcePreview(null, null);
|
||||
const statusEl = document.getElementById("wfm-video-source-status");
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
const fileInput = document.getElementById("wfm-video-source-file");
|
||||
@@ -561,6 +244,7 @@ function _initPropTabs() {
|
||||
// ============================================
|
||||
|
||||
let _capturedFrameBlob = null;
|
||||
let _frameOutputDir = "";
|
||||
|
||||
function _blobToDataUrl(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -571,6 +255,44 @@ function _blobToDataUrl(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");
|
||||
@@ -579,7 +301,7 @@ function _initFrameTab() {
|
||||
const statusEl = document.getElementById("wfm-video-frame-status");
|
||||
|
||||
captureBtn?.addEventListener("click", () => {
|
||||
const video = document.getElementById("wfm-video-preview-video");
|
||||
const video = getActivePreviewVideoElement();
|
||||
if (!video || video.style.display === "none" || !video.videoWidth) {
|
||||
showToast(t("videoNoSourceLoaded"), "error");
|
||||
return;
|
||||
@@ -617,6 +339,9 @@ function _initFrameTab() {
|
||||
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");
|
||||
@@ -641,7 +366,7 @@ function _initGifTab() {
|
||||
const statusEl = document.getElementById("wfm-video-gif-status");
|
||||
|
||||
convertBtn?.addEventListener("click", async () => {
|
||||
if (!state.previewSource) {
|
||||
if (!getActivePreviewSource()) {
|
||||
showToast(t("videoNoSourceLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
@@ -651,12 +376,12 @@ function _initGifTab() {
|
||||
if (progressText) progressText.textContent = "Uploading...";
|
||||
|
||||
try {
|
||||
let ref = state.previewSource;
|
||||
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.
|
||||
state.previewSource = ref;
|
||||
updateActivePreviewSourceRef(ref);
|
||||
}
|
||||
|
||||
const start = Number(document.getElementById("wfm-video-gif-start")?.value) || 0;
|
||||
@@ -707,19 +432,20 @@ function _initGifTab() {
|
||||
|
||||
export function initVideoTab() {
|
||||
_applyVideoI18n();
|
||||
_populateAspectRatioOptions();
|
||||
|
||||
_wireDropZone("wfm-video-first-frame-drop", "wfm-video-first-frame-file", (file) => _handleFrameUpload("first", file));
|
||||
_wireDropZone("wfm-video-last-frame-drop", "wfm-video-last-frame-file", (file) => _handleFrameUpload("last", file));
|
||||
|
||||
document.getElementById("wfm-video-first-frame-clear")?.addEventListener("click", () => _clearFrame("first"));
|
||||
document.getElementById("wfm-video-last-frame-clear")?.addEventListener("click", () => _clearFrame("last"));
|
||||
document.getElementById("wfm-video-generate-btn")?.addEventListener("click", runGeneration);
|
||||
_initCenterSubtabToggle();
|
||||
_initSidebarSubtabToggle();
|
||||
initVideoPlanTab();
|
||||
initVideoAssetTab();
|
||||
initVideoProjectTab();
|
||||
initVideoEditTab();
|
||||
|
||||
_wireVideoSourcePanel();
|
||||
_initPropTabs();
|
||||
_initFrameTab();
|
||||
_initGifTab();
|
||||
|
||||
loadDefaultWorkflow();
|
||||
// 保存済みの音量を適用し、以降ユーザーがネイティブコントロールで変更した音量も
|
||||
// 自動保存する(要素自体は永続的なので初期化時に一度呼べば十分)。両方のプレビュー
|
||||
// ペイン(Asset/Source用・生成結果用)の<video>要素に適用する。
|
||||
getAllPreviewVideoElements().forEach((video) => applyStoredVideoVolume(video));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Video workflow helpers — model-family detection and node manipulation for
|
||||
* MiniMax H3 / LTX-2.5 (Text・Image・First&Last-Frame to Video) / Wan2.2 (Text
|
||||
* to Video) workflows. Pure functions operating on UI-format workflow JSON,
|
||||
* with no DOM/state dependency, so both the Plan tab's per-block batch loop
|
||||
* and (previously) the single-shot form can share the exact same logic.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 動画モデルノードの動的特定(ノードIDをハードコードしない)
|
||||
// サブグラフの内部ノード型(Wan2.2のみサブグラフ名)で対象ワークフローの種類を判定し、
|
||||
// 以降はサブグラフが公開する入力のlabel(無ければname)で意味的にスロットを特定する。
|
||||
// これによりLTX-2.5の3バリアント間で公開入力のname(text/value/value_1...)が揺れて
|
||||
// いても、label(prompt/duration/width...)は共通しているため同じロジックで処理できる。
|
||||
// ============================================
|
||||
|
||||
// ノードのinputs[]のうちwidgetキーを持つものだけを抽出した並び順で、指定した入力定義の
|
||||
// インデックスを引く。widgets_values配列はこの並び順と1:1対応する(link接続されていても
|
||||
// widgetキーを持つ入力はwidgets_valuesにスロットを持つ「legacy full」形式のため)。
|
||||
function _widgetIndexOf(node, inputDef) {
|
||||
if (!inputDef) return -1;
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.indexOf(inputDef);
|
||||
}
|
||||
|
||||
// 上と同じ並び順ルールで、名前から直接インデックスを引く版(ResolutionSelectorや
|
||||
// LoadImageなど、モデルファミリー間で入力名が揺れないノードに使う)。
|
||||
export function widgetIndex(node, name) {
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.findIndex((inp) => inp.name === name);
|
||||
}
|
||||
|
||||
// サブグラフが公開する入力は、labelがあればlabel、無ければnameが意味的な役割を表す
|
||||
// (例: MiniMax H3の"width"はlabel無しでname自体が"width"、LTX-2.5の"value_2"はlabelが
|
||||
// "width")。
|
||||
function _findInputByLabel(node, label) {
|
||||
return (node?.inputs || []).find((inp) => (inp.label || inp.name) === label) || null;
|
||||
}
|
||||
|
||||
// noise_seedはLTX-2.5のText to Videoバリアントだけlabel="seed"を持つ(他は無label)ため、
|
||||
// 意味的ラベルではなくname自体(全バリアントで"noise_seed"に統一)で検索する。
|
||||
function _findInputByName(node, name) {
|
||||
return (node?.inputs || []).find((inp) => inp.name === name) || null;
|
||||
}
|
||||
|
||||
function _detectVideoSubgraph(workflow) {
|
||||
if (!Array.isArray(workflow?.nodes) || !Array.isArray(workflow?.links)) return null;
|
||||
const subgraphs = workflow.definitions?.subgraphs || [];
|
||||
|
||||
const minimax = subgraphs.find((sg) => (sg.nodes || []).some((n) => n.type === "MiniMaxH3ImageToVideo"));
|
||||
if (minimax) return { family: "minimax", sgDef: minimax };
|
||||
|
||||
// LTX-2.5のText/Image/First&Last-Frame to Videoはいずれも内部にLTXV*系ノードを持つ
|
||||
// サブグラフとして配布される。バリアントごとにサブグラフの公開入力nameが異なるため、
|
||||
// 内部ノード型のプレフィックスで検出する。
|
||||
const ltx = subgraphs.find((sg) => (sg.nodes || []).some((n) => typeof n.type === "string" && n.type.startsWith("LTXV")));
|
||||
if (ltx) return { family: "ltx25", sgDef: ltx };
|
||||
|
||||
// Wan2.2はUNETLoader/KSamplerAdvanced/EmptyHunyuanLatentVideoなど汎用ノードのみで
|
||||
// 構成されており、MiniMax H3/LTX-2.5のような固有ノード型による検出ができない。
|
||||
// そのためサブグラフ自体の名前(公式テンプレート由来の "...(Wan2.2)" 表記)で検出する。
|
||||
const wan22 = subgraphs.find((sg) => /wan\s*2\.2/i.test(sg.name || ""));
|
||||
if (wan22) return { family: "wan22", sgDef: wan22 };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function locateVideoModelNodes(workflow) {
|
||||
const detected = _detectVideoSubgraph(workflow);
|
||||
if (!detected) return null;
|
||||
const { family, sgDef } = detected;
|
||||
|
||||
const instanceNode = workflow.nodes.find((n) => n.type === sgDef.id);
|
||||
if (!instanceNode || !Array.isArray(instanceNode.widgets_values)) return null;
|
||||
|
||||
// Wan2.2の公開入力はプロンプトが label 無しの name="text" のまま(MiniMax H3の"prompt"や
|
||||
// LTX-2.5の label="prompt" と異なる)ため、"prompt" が見つからない場合は "text" にフォール
|
||||
// バックする。
|
||||
const promptInput = _findInputByLabel(instanceNode, "prompt") || _findInputByLabel(instanceNode, "text");
|
||||
const durationInput = _findInputByLabel(instanceNode, "duration");
|
||||
const widthInput = _findInputByLabel(instanceNode, "width");
|
||||
if (!promptInput || !durationInput || !widthInput) return null;
|
||||
|
||||
const promptIdx = _widgetIndexOf(instanceNode, promptInput);
|
||||
const durationIdx = _widgetIndexOf(instanceNode, durationInput);
|
||||
if (promptIdx === -1 || durationIdx === -1) return null;
|
||||
|
||||
const widthSlot = instanceNode.inputs.indexOf(widthInput);
|
||||
|
||||
// ワークフローにより有無・意味が異なる任意項目。見つからなければ全て-1を返し、
|
||||
// 呼び出し側(UI)はその項目を無効化・非ハイライトにする。
|
||||
const heightInput = _findInputByLabel(instanceNode, "height");
|
||||
const noiseSeedInput = _findInputByName(instanceNode, "noise_seed");
|
||||
// MiniMax H3は"turbo_mode"、Wan2.2は"enable_turbo_mode" — 同じ意味のトグルをUI上は
|
||||
// 1つのフィールドとして兼用するため、どちらか見つかった方を使う。
|
||||
const turboModeInput = _findInputByLabel(instanceNode, "turbo_mode") || _findInputByLabel(instanceNode, "enable_turbo_mode");
|
||||
const turboStrengthInput = _findInputByLabel(instanceNode, "turbo_model_strength");
|
||||
const turboStepsInput = _findInputByLabel(instanceNode, "turbo_steps");
|
||||
const promptEnhanceInput = _findInputByLabel(instanceNode, "prompt_enhance");
|
||||
const noiseSeedIdx = _widgetIndexOf(instanceNode, noiseSeedInput);
|
||||
const turboModeIdx = _widgetIndexOf(instanceNode, turboModeInput);
|
||||
const turboStrengthIdx = _widgetIndexOf(instanceNode, turboStrengthInput);
|
||||
const turboStepsIdx = _widgetIndexOf(instanceNode, turboStepsInput);
|
||||
const promptEnhanceIdx = _widgetIndexOf(instanceNode, promptEnhanceInput);
|
||||
|
||||
const findLinkedNode = (targetSlot) => {
|
||||
if (targetSlot === -1) return null;
|
||||
const link = workflow.links.find((l) => l[3] === instanceNode.id && l[4] === targetSlot);
|
||||
if (!link) return null;
|
||||
return workflow.nodes.find((n) => n.id === link[1]) || null;
|
||||
};
|
||||
|
||||
// 一部のLTX-2.5バリアント(First & Last Frame to Video等)はResolutionSelectorを介さず、
|
||||
// width/heightをサブグラフの直接ウィジェット値として持つ。その場合はresolutionNode: null
|
||||
// を返し、以降の処理ではaspect_ratio等の操作をスキップする(テンプレートのwidth/height値が
|
||||
// そのまま使われる)。
|
||||
const rawResolutionNode = findLinkedNode(widthSlot);
|
||||
const resolutionNode = (rawResolutionNode && rawResolutionNode.type === "ResolutionSelector") ? rawResolutionNode : null;
|
||||
|
||||
// first_frame/last_frameはoptional入力のため、対応するLoadImageノードが接続されて
|
||||
// いないワークフロー(テキストのみで動作するT2V構成など)もサポート対象とする。
|
||||
// 未接続の場合はnullのまま返し、必要ならapplyBlockToWorkflow側で動的にノードを注入する。
|
||||
const firstFrameInput = _findInputByLabel(instanceNode, "first_frame");
|
||||
const lastFrameInput = _findInputByLabel(instanceNode, "last_frame");
|
||||
const firstFrameSlot = firstFrameInput ? instanceNode.inputs.indexOf(firstFrameInput) : -1;
|
||||
const lastFrameSlot = lastFrameInput ? instanceNode.inputs.indexOf(lastFrameInput) : -1;
|
||||
|
||||
const rawLoadImageNode = findLinkedNode(firstFrameSlot);
|
||||
const loadImageNode = (rawLoadImageNode && rawLoadImageNode.type === "LoadImage") ? rawLoadImageNode : null;
|
||||
const rawLastLoadImageNode = findLinkedNode(lastFrameSlot);
|
||||
const lastLoadImageNode = (rawLastLoadImageNode && rawLastLoadImageNode.type === "LoadImage") ? rawLastLoadImageNode : null;
|
||||
|
||||
// width/heightはResolutionSelectorがあればその出力に接続されており(widthSlotの
|
||||
// link経由)、その場合は直接ウィジェット値を書き換えても実行時にリンク元の出力で
|
||||
// 上書きされ意味がない。resolutionNodeがnullのワークフロー(LTX-2.5 First&Last-Frame,
|
||||
// Wan2.2全種)でのみ、instanceNode自身のwidth/heightウィジェットを直接編集できる。
|
||||
const widthIdx = resolutionNode ? -1 : _widgetIndexOf(instanceNode, widthInput);
|
||||
const heightIdx = resolutionNode ? -1 : _widgetIndexOf(instanceNode, heightInput);
|
||||
|
||||
return {
|
||||
family, sgDef, instanceNode, loadImageNode, lastLoadImageNode, resolutionNode,
|
||||
firstFrameSlot, lastFrameSlot, promptIdx, durationIdx,
|
||||
widthIdx, heightIdx, noiseSeedIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
};
|
||||
}
|
||||
|
||||
// Reads the current values a freshly-loaded template workflow carries, so a UI
|
||||
// (the Plan tab's first block, previously the single-shot form) can seed its
|
||||
// fields from whatever the workflow file already had.
|
||||
export function readTemplateDefaults(nodes) {
|
||||
const {
|
||||
instanceNode, resolutionNode, loadImageNode, lastLoadImageNode, promptIdx, durationIdx,
|
||||
widthIdx, heightIdx, noiseSeedIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
} = nodes;
|
||||
const arIdx = widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = widgetIndex(resolutionNode, "multiple");
|
||||
const fnIdx = widgetIndex(loadImageNode, "image");
|
||||
const lastFnIdx = widgetIndex(lastLoadImageNode, "image");
|
||||
return {
|
||||
prompt: instanceNode.widgets_values[promptIdx] ?? "",
|
||||
duration: instanceNode.widgets_values[durationIdx] ?? 2,
|
||||
hasResolution: !!resolutionNode,
|
||||
aspectRatio: (resolutionNode && arIdx !== -1) ? resolutionNode.widgets_values[arIdx] : null,
|
||||
megapixels: (resolutionNode && mpIdx !== -1) ? resolutionNode.widgets_values[mpIdx] : null,
|
||||
multiple: (resolutionNode && multIdx !== -1) ? resolutionNode.widgets_values[multIdx] : null,
|
||||
firstImageFilename: (loadImageNode && fnIdx !== -1) ? loadImageNode.widgets_values[fnIdx] : null,
|
||||
lastImageFilename: (lastLoadImageNode && lastFnIdx !== -1) ? lastLoadImageNode.widgets_values[lastFnIdx] : null,
|
||||
// width/heightは resolutionNode がある場合は widthIdx/heightIdx が -1 になる
|
||||
// (locateVideoModelNodes参照) ため、常にnullが返り直接入力欄は無効化される。
|
||||
hasDirectResolution: widthIdx !== -1 && heightIdx !== -1,
|
||||
width: widthIdx !== -1 ? instanceNode.widgets_values[widthIdx] : null,
|
||||
height: heightIdx !== -1 ? instanceNode.widgets_values[heightIdx] : null,
|
||||
hasNoiseSeed: noiseSeedIdx !== -1,
|
||||
noiseSeed: noiseSeedIdx !== -1 ? instanceNode.widgets_values[noiseSeedIdx] : null,
|
||||
hasTurboMode: turboModeIdx !== -1,
|
||||
turboMode: turboModeIdx !== -1 ? instanceNode.widgets_values[turboModeIdx] : null,
|
||||
hasTurboStrength: turboStrengthIdx !== -1,
|
||||
turboStrength: turboStrengthIdx !== -1 ? instanceNode.widgets_values[turboStrengthIdx] : null,
|
||||
hasTurboSteps: turboStepsIdx !== -1,
|
||||
turboSteps: turboStepsIdx !== -1 ? instanceNode.widgets_values[turboStepsIdx] : null,
|
||||
hasPromptEnhance: promptEnhanceIdx !== -1,
|
||||
promptEnhance: promptEnhanceIdx !== -1 ? instanceNode.widgets_values[promptEnhanceIdx] : null,
|
||||
};
|
||||
}
|
||||
|
||||
// first_frame/last_frameが未接続のワークフローに対して、画像が指定された時だけ
|
||||
// LoadImageノードを新規注入する共通ヘルパー。ノードID/リンクIDはclone内の実際の
|
||||
// 最大値+1で採番するため、重複ID/リンクID不整合は起きない
|
||||
// (CLAUDE.md記載の既知の落とし穴への対策)。
|
||||
export function injectFrameNode(clone, instanceNode, targetSlot, filename) {
|
||||
const maxNodeId = Math.max(clone.last_node_id || 0, ...clone.nodes.map((n) => n.id));
|
||||
const maxLinkId = Math.max(clone.last_link_id || 0, ...clone.links.map((l) => l[0]));
|
||||
const newNodeId = maxNodeId + 1;
|
||||
const newLinkId = maxLinkId + 1;
|
||||
|
||||
clone.nodes.push({
|
||||
id: newNodeId,
|
||||
type: "LoadImage",
|
||||
pos: [(instanceNode.pos?.[0] || 0) - 260, (instanceNode.pos?.[1] || 0) + 40],
|
||||
size: [280, 314],
|
||||
flags: {},
|
||||
order: clone.nodes.length,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ localized_name: "image", name: "image", type: "COMBO", widget: { name: "image" }, link: null },
|
||||
{ localized_name: "choose file to upload", name: "upload", type: "IMAGEUPLOAD", widget: { name: "upload" }, link: null },
|
||||
],
|
||||
outputs: [
|
||||
{ localized_name: "IMAGE", name: "IMAGE", type: "IMAGE", links: [newLinkId] },
|
||||
{ localized_name: "MASK", name: "MASK", type: "MASK", links: null },
|
||||
],
|
||||
properties: { "Node name for S&R": "LoadImage" },
|
||||
widgets_values: [filename, "image"],
|
||||
});
|
||||
|
||||
clone.links.push([newLinkId, newNodeId, 0, instanceNode.id, targetSlot, "IMAGE"]);
|
||||
const targetInput = instanceNode.inputs[targetSlot];
|
||||
if (targetInput) targetInput.link = newLinkId;
|
||||
|
||||
clone.last_node_id = newNodeId;
|
||||
clone.last_link_id = newLinkId;
|
||||
}
|
||||
|
||||
// Writes one block's (or the old single-shot form's) values onto a workflow
|
||||
// clone already located via locateVideoModelNodes(clone) — `nodes`' node
|
||||
// references point into `clone` itself. First/last frame images are only
|
||||
// touched when a filename is actually given: an existing LoadImage node gets
|
||||
// its widget overwritten, a missing one gets injected on demand, and leaving
|
||||
// both unset keeps the slot disconnected (bypassed) exactly like the
|
||||
// template's own clean state.
|
||||
export function applyBlockToWorkflow(clone, nodes, params) {
|
||||
const {
|
||||
instanceNode, resolutionNode, loadImageNode, lastLoadImageNode, promptIdx, durationIdx, firstFrameSlot, lastFrameSlot,
|
||||
widthIdx, heightIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
} = nodes;
|
||||
|
||||
instanceNode.widgets_values[promptIdx] = params.prompt || "";
|
||||
instanceNode.widgets_values[durationIdx] = params.duration;
|
||||
|
||||
const arIdx = widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = widgetIndex(resolutionNode, "multiple");
|
||||
if (arIdx !== -1) resolutionNode.widgets_values[arIdx] = params.aspectRatio;
|
||||
if (mpIdx !== -1) resolutionNode.widgets_values[mpIdx] = params.megapixels;
|
||||
if (multIdx !== -1) resolutionNode.widgets_values[multIdx] = params.multiple;
|
||||
|
||||
// widthIdx/heightIdxはresolutionNodeがあるワークフローでは-1固定(locateVideoModelNodes
|
||||
// 参照)なので、そちらはaspect_ratio/megapixels/multiple経由の計算値のまま変更しない。
|
||||
if (widthIdx !== -1 && params.width != null) instanceNode.widgets_values[widthIdx] = params.width;
|
||||
if (heightIdx !== -1 && params.height != null) instanceNode.widgets_values[heightIdx] = params.height;
|
||||
// noise_seedはここでは書かない — comfyUI.generate()がAPI変換後のワークフロー全体から
|
||||
// "noise_seed"/"seed"キーを持つ全ノードを一括で上書きする(applySeedToWorkflow)ため、
|
||||
// ここで書いてもその後必ず上書きされる。Plan全体のシード管理は引き続きそちら任せでよい。
|
||||
if (turboModeIdx !== -1 && params.turboMode != null) instanceNode.widgets_values[turboModeIdx] = params.turboMode;
|
||||
if (turboStrengthIdx !== -1 && params.turboStrength != null) instanceNode.widgets_values[turboStrengthIdx] = params.turboStrength;
|
||||
if (turboStepsIdx !== -1 && params.turboSteps != null) instanceNode.widgets_values[turboStepsIdx] = params.turboSteps;
|
||||
if (promptEnhanceIdx !== -1 && params.promptEnhance != null) instanceNode.widgets_values[promptEnhanceIdx] = params.promptEnhance;
|
||||
|
||||
if (params.firstImageFilename) {
|
||||
if (loadImageNode) {
|
||||
const fnIdx = widgetIndex(loadImageNode, "image");
|
||||
if (fnIdx !== -1) loadImageNode.widgets_values[fnIdx] = params.firstImageFilename;
|
||||
} else if (firstFrameSlot !== -1) {
|
||||
injectFrameNode(clone, instanceNode, firstFrameSlot, params.firstImageFilename);
|
||||
}
|
||||
}
|
||||
if (params.lastImageFilename) {
|
||||
if (lastLoadImageNode) {
|
||||
const fnIdx = widgetIndex(lastLoadImageNode, "image");
|
||||
if (fnIdx !== -1) lastLoadImageNode.widgets_values[fnIdx] = params.lastImageFilename;
|
||||
} else if (lastFrameSlot !== -1) {
|
||||
injectFrameNode(clone, instanceNode, lastFrameSlot, params.lastImageFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* vram-prepare.js — unload Ollama models right before image generation to free VRAM.
|
||||
*
|
||||
* Ported from ComfyUI Comic Creator's vram-prepare.js. The work is done by the server
|
||||
* (py/routes/vram_routes.py, /api/wfm/vram/prepare), which judges free VRAM by nvidia-smi
|
||||
* rather than ComfyUI's figure (it may not react to Ollama loading/unloading models).
|
||||
*
|
||||
* - Settings (Settings tab "VRAM Management"): mode = 'off' | 'auto' | 'all', targetGb (auto's target)
|
||||
* - Ollama targets: the default 127.0.0.1:11434 (always added by the server) + the AI TOOL / Prompt /
|
||||
* Tagger AI settings and the Decision Model when their backend is Ollama
|
||||
* - comfyUI.queuePrompt() calls prepareVramForGeneration() before every queue; failures never block generation
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { getDecisionSettings } from "./decision-client.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_vram_settings";
|
||||
const DEFAULT_SETTINGS = Object.freeze({ mode: "off", targetGb: 8 });
|
||||
// AI backend settings that may point at Ollama (AI TOOL / sidepanel A tab, Prompt tab, Tagger tab)
|
||||
const AI_SETTINGS_KEYS = ["wfm_ai_settings", "wfm_prompt_ai_settings", "wfm_tagger_ai_settings"];
|
||||
|
||||
export function getVramSettings() {
|
||||
const saved = readJsonStorage(SETTINGS_KEY);
|
||||
const merged = { ...DEFAULT_SETTINGS, ...(saved && typeof saved === "object" ? saved : {}) };
|
||||
if (!["off", "auto", "all"].includes(merged.mode)) merged.mode = DEFAULT_SETTINGS.mode;
|
||||
const gb = Number(merged.targetGb);
|
||||
merged.targetGb = gb >= 0 && gb <= 64 ? gb : DEFAULT_SETTINGS.targetGb;
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function saveVramSettings(patch) {
|
||||
const next = { ...getVramSettings(), ...patch };
|
||||
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(next)); } catch { /* keep working even if it can't be saved */ }
|
||||
return next;
|
||||
}
|
||||
|
||||
// Configured Ollama URLs (duplicates and the default URL are merged server-side)
|
||||
function collectOllamaUrls() {
|
||||
const urls = [];
|
||||
for (const key of AI_SETTINGS_KEYS) {
|
||||
const s = readJsonStorage(key);
|
||||
if ((s.backend || "ollama") === "ollama") urls.push(s.backendUrl || getAiBackendDefaultUrl("ollama"));
|
||||
}
|
||||
const dec = getDecisionSettings();
|
||||
if (dec.backend === "ollama" && dec.baseUrl) urls.push(dec.baseUrl);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the server to free VRAM. mode: 'auto' | 'all'. extraUrls: e.g. an unsaved URL from a settings form.
|
||||
* Returns the server result (throws on failure).
|
||||
*/
|
||||
export async function requestVramPrepare(mode, targetGb = getVramSettings().targetGb, extraUrls = []) {
|
||||
const urls = [...new Set([...collectOllamaUrls(), ...extraUrls.filter(Boolean)])];
|
||||
const res = await fetch("/api/wfm/vram/prepare", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode, target_gb: targetGb, urls }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.status !== "ok") throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
const gb = (mb) => (mb / 1024).toFixed(1);
|
||||
|
||||
/** One-line summary of a result (Settings tab status and pre-generation toast) */
|
||||
export function describeVramResult(d) {
|
||||
if (d.unloaded.length) {
|
||||
let msg = t("vramUnloaded", d.unloaded.map((u) => u.name).join(", "), gb(d.free_before_mb), gb(d.free_after_mb));
|
||||
if (d.failed.length) msg += " / " + t("vramFailedModels", d.failed.join(", "));
|
||||
return msg;
|
||||
}
|
||||
if (d.failed.length) return t("vramFailedModels", d.failed.join(", "));
|
||||
if (d.unreachable?.length) return t("vramUnreachable", d.unreachable.map((u) => u.url).join(", "));
|
||||
if (d.mode === "auto") return d.reached ? t("vramEnough", gb(d.free_after_mb), d.target_gb) : t("vramShort", gb(d.free_after_mb), d.target_gb);
|
||||
return t("vramNothingLoaded", gb(d.free_after_mb));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Unload Model" button for the Ollama backend (AI TOOL tab / Prompt・Tagger settings modal):
|
||||
* unload every loaded Ollama model, decision models included — the same server step as
|
||||
* "Unload all now". url is the form's (possibly unsaved) Ollama URL. Shows the result as a toast.
|
||||
*/
|
||||
export async function unloadAllOllamaModels(url) {
|
||||
try {
|
||||
const d = await requestVramPrepare("all", undefined, [url]);
|
||||
showToast(describeVramResult(d), d.failed.length || (!d.unloaded.length && d.unreachable?.length) ? "error" : "success", 5000);
|
||||
} catch (e) {
|
||||
showToast(t("aiUnloadFailed") + (e.message || String(e)), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Settings tab "VRAM Management" section ----
|
||||
|
||||
/** Wire up the section rendered by settings-tab.js (re-run after every re-render). */
|
||||
export function initVramSettings() {
|
||||
const modeSel = document.getElementById("wfm-settings-vram-mode");
|
||||
const targetInput = document.getElementById("wfm-settings-vram-target");
|
||||
const unloadBtn = document.getElementById("wfm-settings-vram-unload");
|
||||
const statusEl = document.getElementById("wfm-settings-vram-status");
|
||||
if (!modeSel || !targetInput) return;
|
||||
const s = getVramSettings();
|
||||
modeSel.value = s.mode;
|
||||
targetInput.value = s.targetGb;
|
||||
targetInput.disabled = s.mode !== "auto";
|
||||
|
||||
const setStatus = (text, color) => {
|
||||
if (statusEl) { statusEl.textContent = text; statusEl.style.color = color || "var(--wfm-text-secondary)"; }
|
||||
};
|
||||
modeSel.addEventListener("change", () => {
|
||||
saveVramSettings({ mode: modeSel.value });
|
||||
targetInput.disabled = modeSel.value !== "auto";
|
||||
_lastShortNote = "";
|
||||
});
|
||||
targetInput.addEventListener("change", () => {
|
||||
const v = Math.min(Math.max(parseFloat(targetInput.value) || 0, 0), 64);
|
||||
targetInput.value = v;
|
||||
saveVramSettings({ targetGb: v });
|
||||
_lastShortNote = "";
|
||||
});
|
||||
unloadBtn?.addEventListener("click", async () => {
|
||||
unloadBtn.disabled = true;
|
||||
setStatus(t("vramUnloading"));
|
||||
try {
|
||||
const d = await requestVramPrepare("all");
|
||||
setStatus(describeVramResult(d), d.failed.length ? "var(--wfm-warning, #e0a040)" : "var(--wfm-success, #6c6)");
|
||||
} catch (e) {
|
||||
setStatus(t("vramPrepareFailed", e.message || String(e)), "var(--wfm-danger, #e66)");
|
||||
} finally {
|
||||
unloadBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _lastShortNote = "";
|
||||
|
||||
/**
|
||||
* Call right before queuing a generation. Does nothing when the mode is off; never throws.
|
||||
* Shows a toast only when something was unloaded, the target can't be reached, or it failed
|
||||
* (the same "can't reach target" note isn't repeated across a batch).
|
||||
*/
|
||||
export async function prepareVramForGeneration() {
|
||||
const settings = getVramSettings();
|
||||
if (settings.mode === "off") return null;
|
||||
try {
|
||||
const d = await requestVramPrepare(settings.mode, settings.targetGb);
|
||||
const short = d.mode === "auto" && !d.reached;
|
||||
if (d.unloaded.length || d.failed.length) {
|
||||
showToast("🧹 " + describeVramResult(d), d.failed.length > 0 || short ? "error" : "success", 6000);
|
||||
_lastShortNote = "";
|
||||
} else if (short) {
|
||||
const note = describeVramResult(d);
|
||||
if (note !== _lastShortNote) { _lastShortNote = note; showToast("⚠ " + note, "error", 6000); }
|
||||
}
|
||||
return d;
|
||||
} catch (e) {
|
||||
console.warn("[WFS] vram prepare failed:", e);
|
||||
showToast("⚠ " + t("vramPrepareFailed", e.message || String(e)), "error", 6000);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* workflow-compat-check.js — "GenerateUI compatibility check" for the Workflow tab detail modal.
|
||||
*
|
||||
* Two verdicts, each shown as a ✅ / ⚠ / ❌ badge whose hover tip lists the reasons:
|
||||
* - conversion: can the workflow be turned into the API prompt GenerateUI queues? Uses the same
|
||||
* convertUiToApi() as GenerateUI. ❌ = unsupported format / conversion error / node types ComfyUI
|
||||
* doesn't know (it would reject the prompt with missing_node_type); ⚠ = COMBO values not available
|
||||
* here (models, LoRAs, images…) that the conversion silently replaces with the first choice.
|
||||
* - operability: how much of the workflow can be run from GenerateUI's fields alone — the share of
|
||||
* nodes with settings that GenerateUI exposes per analyzeWorkflow() (✅ 80%+, ⚠ 50%+, ❌ below).
|
||||
* The Decision Model (laya) was tried for this rating but couldn't tell the cases apart (an
|
||||
* all-hidden workflow scored higher than an all-exposed one), so the rating is rule-based.
|
||||
* Nothing is persisted; results are cached in memory for the session only.
|
||||
*/
|
||||
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
const MAX_LIST = 8; // items listed per section in the tip
|
||||
const _cache = new Map(); // filename -> result (session only)
|
||||
|
||||
export function getCachedCompat(filename) {
|
||||
return _cache.get(filename) || null;
|
||||
}
|
||||
|
||||
const _list = (items) => {
|
||||
const shown = items.slice(0, MAX_LIST).map((s) => ` • ${s}`);
|
||||
if (items.length > MAX_LIST) shown.push(` • ${t("compatMore", items.length - MAX_LIST)}`);
|
||||
return shown;
|
||||
};
|
||||
|
||||
// API-format workflows skip convertUiToApi(), so check their COMBO values directly
|
||||
function _comboMismatches(api, objectInfo) {
|
||||
const out = [];
|
||||
for (const [id, node] of Object.entries(api)) {
|
||||
const def = objectInfo[node.class_type]?.input;
|
||||
if (!def) continue;
|
||||
const specs = { ...(def.required || {}), ...(def.optional || {}) };
|
||||
for (const [name, val] of Object.entries(node.inputs || {})) {
|
||||
if (Array.isArray(val)) continue;
|
||||
const choices = Array.isArray(specs[name]?.[0]) ? specs[name][0] : null;
|
||||
if (choices && choices.length > 0 && !choices.includes(val)) {
|
||||
out.push({ nodeId: id, type: node.class_type, title: node._meta?.title || node.class_type, input: name, original: val });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function _checkConversion(raw, filename) {
|
||||
const format = comfyWorkflow.detectFormat(raw, filename);
|
||||
if (format === "app") return { status: "error", lines: [t("appFormatNotSupported")] };
|
||||
if (format === "unknown") return { status: "error", lines: [t("unknownWorkflowFormat")] };
|
||||
|
||||
let api, subs = [], bypassed = [], muted = [];
|
||||
try {
|
||||
if (format === "ui") {
|
||||
api = await comfyWorkflow.convertUiToApi(structuredClone(raw));
|
||||
subs = comfyWorkflow.getLastComboSubstitutions();
|
||||
bypassed = comfyWorkflow.getLastBypassedNodes();
|
||||
muted = comfyWorkflow.getLastMutedNodes();
|
||||
} else {
|
||||
api = raw;
|
||||
}
|
||||
} catch (e) {
|
||||
return { status: "error", lines: [t("compatConvertFailed", e.message || String(e))] };
|
||||
}
|
||||
const objectInfo = await comfyWorkflow.getObjectInfo();
|
||||
if (format === "api") subs = _comboMismatches(api, objectInfo);
|
||||
|
||||
const ids = Object.keys(api || {});
|
||||
if (ids.length === 0) return { status: "error", lines: [t("compatNoNodes")] };
|
||||
|
||||
const hasObjectInfo = Object.keys(objectInfo).length > 0;
|
||||
const missingTypes = hasObjectInfo
|
||||
? [...new Set(ids.map((id) => api[id].class_type).filter((ct) => !objectInfo[ct]))]
|
||||
: [];
|
||||
|
||||
// API-format files are queued as-is (no conversion), so say so instead of "after conversion"
|
||||
const lines = [format === "api" ? t("compatNodeCountApi", ids.length) : t("compatNodeCount", ids.length)];
|
||||
if (bypassed.length || muted.length) lines.push(t("compatBypassMuted", bypassed.length, muted.length));
|
||||
if (!hasObjectInfo) lines.push(t("compatNoObjectInfo"));
|
||||
if (missingTypes.length) {
|
||||
lines.push(t("compatMissingNodes", missingTypes.length));
|
||||
lines.push(..._list(missingTypes));
|
||||
}
|
||||
if (subs.length) {
|
||||
// UI format: convertUiToApi() swaps them for the first choice. API format skips conversion,
|
||||
// so ComfyUI rejects them at validation ("Value not in list") unless fixed beforehand.
|
||||
lines.push(t(format === "api" ? "compatComboMissingApi" : "compatComboMissing", subs.length));
|
||||
lines.push(..._list(subs.map((s) => `${s.title}.${s.input}: ${s.original}`)));
|
||||
}
|
||||
const status = missingTypes.length ? "error" : (subs.length || !hasObjectInfo) ? "warn" : "ok";
|
||||
if (status === "ok") lines.push(t(format === "api" ? "compatApiOk" : "compatConvertOk"));
|
||||
return { status, lines, api, objectInfo };
|
||||
}
|
||||
|
||||
// Node IDs GenerateUI shows fields for (Settings uses only the first sampler / latent)
|
||||
function _exposedNodeIds(analysis) {
|
||||
const ids = new Set();
|
||||
const add = (item) => {
|
||||
if (!item) return;
|
||||
if (item.id != null) ids.add(String(item.id));
|
||||
for (const [k, v] of Object.entries(item)) {
|
||||
if (/NodeId$/.test(k) && v != null) ids.add(String(v));
|
||||
}
|
||||
};
|
||||
for (const [key, items] of Object.entries(analysis)) {
|
||||
if (key === "all_nodes" || !Array.isArray(items)) continue;
|
||||
if (key === "sampler_nodes" || key === "latent_nodes") add(items[0]);
|
||||
else items.forEach(add);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function _operabilityFacts(api, analysis) {
|
||||
const exposed = _exposedNodeIds(analysis);
|
||||
const settingsNodes = Object.entries(api).filter(([, n]) =>
|
||||
Object.values(n.inputs || {}).some((v) => !Array.isArray(v)));
|
||||
const hidden = settingsNodes.filter(([id]) => !exposed.has(id));
|
||||
const hiddenDesc = hidden.map(([, n]) => {
|
||||
const names = Object.entries(n.inputs || {}).filter(([, v]) => !Array.isArray(v)).map(([k]) => k);
|
||||
const title = n._meta?.title && n._meta.title !== n.class_type ? ` "${n._meta.title}"` : "";
|
||||
return `${n.class_type}${title} (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""})`;
|
||||
});
|
||||
const count = (k) => (analysis[k] || []).length;
|
||||
const positive = (analysis.prompt_nodes || []).filter((n) => n.role === "positive").length;
|
||||
const negative = (analysis.prompt_nodes || []).filter((n) => n.role === "negative").length;
|
||||
const coverage = settingsNodes.length ? (settingsNodes.length - hidden.length) / settingsNodes.length : 0;
|
||||
return {
|
||||
positive, negative,
|
||||
samplers: count("sampler_nodes"), latent: count("latent_nodes") + count("resolution_selector_nodes"),
|
||||
models: count("checkpoint_nodes") + count("diffusion_model_nodes") + count("text_encoder_nodes"),
|
||||
loras: count("lora_nodes"), loadImages: count("load_image_nodes"), saves: count("save_nodes"),
|
||||
settingsTotal: settingsNodes.length, hiddenDesc, coverage,
|
||||
};
|
||||
}
|
||||
|
||||
const _statusFromCoverage = (c) => (c >= 0.8 ? "ok" : c >= 0.5 ? "warn" : "error");
|
||||
|
||||
function _checkOperability(conv) {
|
||||
const analysis = comfyWorkflow.analyzeWorkflow(conv.api);
|
||||
const f = _operabilityFacts(conv.api, analysis);
|
||||
const pct = Math.round(f.coverage * 100);
|
||||
const lines = [
|
||||
t("compatScoreRule", pct),
|
||||
t("compatFactPrompts", f.positive, f.negative),
|
||||
t("compatFactSampler", f.samplers),
|
||||
t("compatFactCoverage", f.settingsTotal - f.hiddenDesc.length, f.settingsTotal, pct),
|
||||
];
|
||||
if (f.hiddenDesc.length) {
|
||||
lines.push(t("compatFactHidden"));
|
||||
lines.push(..._list(f.hiddenDesc));
|
||||
}
|
||||
return { status: _statusFromCoverage(f.coverage), label: `${pct}%`, lines };
|
||||
}
|
||||
|
||||
/** Run both checks for one workflow (raw JSON as stored). Never throws. */
|
||||
export async function checkWorkflowCompat(raw, filename) {
|
||||
let result;
|
||||
try {
|
||||
const conversion = await _checkConversion(raw, filename);
|
||||
const operability = conversion.status === "error"
|
||||
? { status: "skip", label: "—", lines: [t("compatOperabilitySkipped")] }
|
||||
: _checkOperability(conversion);
|
||||
result = {
|
||||
conversion: { status: conversion.status, lines: conversion.lines },
|
||||
operability,
|
||||
};
|
||||
} catch (e) {
|
||||
result = {
|
||||
conversion: { status: "error", lines: [t("compatConvertFailed", e.message || String(e))] },
|
||||
operability: { status: "skip", label: "—", lines: [t("compatOperabilitySkipped")] },
|
||||
};
|
||||
}
|
||||
_cache.set(filename, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const ICON = { ok: "✅", warn: "⚠", error: "❌", skip: "—" };
|
||||
|
||||
/** Badge HTML for one verdict: icon + label, reasons in the hover tip (data-tip, pre-line). */
|
||||
export function compatBadgeHtml(title, verdict, escapeHtml) {
|
||||
const label = verdict.label ? ` ${verdict.label}` : "";
|
||||
const tip = [title, ...verdict.lines].join("\n");
|
||||
return `<span class="wfm-compat-badge wfm-compat-${verdict.status}" tabindex="0" data-tip="${escapeHtml(tip)}">${ICON[verdict.status]} ${escapeHtml(title)}${escapeHtml(label)}</span>`;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { t, getSummaryPrompt } from "./i18n.js";
|
||||
import { highlightJSON } from "./json-highlight.js";
|
||||
import { openBadgeEditModal } from "./models-tab.js";
|
||||
import { escapeHtml, getSettings, readJsonStorage, setupSearchClearBtn } from "./util.js";
|
||||
import { checkWorkflowCompat, getCachedCompat, compatBadgeHtml } from "./workflow-compat-check.js";
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
@@ -31,6 +32,7 @@ const state = {
|
||||
groupFilter: "", // empty = all groups
|
||||
currentPage: 0,
|
||||
showBatchOnly: false,
|
||||
sortBy: localStorage.getItem("wfm_workflow_sort") || "date_desc",
|
||||
};
|
||||
|
||||
|
||||
@@ -449,6 +451,26 @@ function filterWorkflows() {
|
||||
});
|
||||
}
|
||||
|
||||
function sortWorkflows(list) {
|
||||
const sorted = [...list];
|
||||
switch (state.sortBy) {
|
||||
case "date_asc":
|
||||
sorted.sort((a, b) => a.mtime - b.mtime);
|
||||
break;
|
||||
case "name_asc":
|
||||
sorted.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
break;
|
||||
case "name_desc":
|
||||
sorted.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||
break;
|
||||
case "date_desc":
|
||||
default:
|
||||
sorted.sort((a, b) => b.mtime - a.mtime);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Grid Rendering (3 view modes)
|
||||
// ============================================
|
||||
@@ -459,7 +481,7 @@ function renderGrid() {
|
||||
|
||||
grid.className = `wfm-grid wfm-view-${state.viewMode}`;
|
||||
|
||||
const filtered = filterWorkflows();
|
||||
const filtered = sortWorkflows(filterWorkflows());
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML =
|
||||
@@ -602,6 +624,21 @@ async function toggleFavorite(wf, btnEl) {
|
||||
// Side Panel
|
||||
// ============================================
|
||||
|
||||
// Format badge (UI / API / App) — JSON tab, side panel compat row and detail modal share it
|
||||
function setFormatBadge(el, fmt) {
|
||||
if (!el) return;
|
||||
const formatLabels = { ui: t("uiFormat"), api: t("apiFormat"), app: t("appFormat") };
|
||||
el.textContent = fmt ? (formatLabels[fmt] || fmt) : "";
|
||||
el.className = "wfm-format-badge" + (fmt ? " wfm-format-badge--" + fmt : "");
|
||||
}
|
||||
|
||||
function renderCompatBadges(el, r) {
|
||||
if (!el) return;
|
||||
el.innerHTML = r
|
||||
? compatBadgeHtml(t("compatConversion"), r.conversion, escapeHtml) + compatBadgeHtml(t("compatOperability"), r.operability, escapeHtml)
|
||||
: "";
|
||||
}
|
||||
|
||||
async function showSidePanel(wf, cardEl) {
|
||||
const panel = document.getElementById("wfm-side-panel");
|
||||
const titleEl = document.getElementById("wfm-side-panel-title");
|
||||
@@ -634,23 +671,29 @@ async function showSidePanel(wf, cardEl) {
|
||||
renderSideGroup(wf);
|
||||
|
||||
const badgeEl = document.getElementById("wfm-json-format-badge");
|
||||
const sideFormatEl = document.getElementById("wfm-side-format-badge");
|
||||
const sideCompatEl = document.getElementById("wfm-side-compat-result");
|
||||
setFormatBadge(sideFormatEl, null);
|
||||
if (sideCompatEl) sideCompatEl.textContent = "";
|
||||
try {
|
||||
const data = await getRawWorkflow(wf.filename);
|
||||
if (badgeEl) {
|
||||
const fmt = comfyWorkflow.detectFormat(data, wf.filename);
|
||||
const formatLabels = { ui: t("uiFormat"), api: t("apiFormat"), app: t("appFormat") };
|
||||
badgeEl.textContent = formatLabels[fmt] || fmt;
|
||||
badgeEl.className = "wfm-format-badge wfm-format-badge--" + fmt;
|
||||
}
|
||||
const fmt = comfyWorkflow.detectFormat(data, wf.filename);
|
||||
setFormatBadge(badgeEl, fmt);
|
||||
setFormatBadge(sideFormatEl, fmt);
|
||||
const jsonStr = JSON.stringify(data, null, 2);
|
||||
contentEl.innerHTML = highlightJSON(jsonStr);
|
||||
contentEl.dataset.rawJson = jsonStr;
|
||||
// GenerateUI compatibility badges (results only). The check takes ~20 ms once /object_info
|
||||
// is cached, so it runs on every selection; skip the result if another card got selected.
|
||||
if (sideCompatEl) {
|
||||
sideCompatEl.textContent = t("compatChecking");
|
||||
const r = await checkWorkflowCompat(data, wf.filename);
|
||||
if (state.selectedWf === wf) renderCompatBadges(sideCompatEl, r);
|
||||
}
|
||||
} catch (err) {
|
||||
contentEl.textContent = "Error: " + err.message;
|
||||
if (badgeEl) {
|
||||
badgeEl.textContent = "";
|
||||
badgeEl.className = "wfm-format-badge";
|
||||
}
|
||||
setFormatBadge(badgeEl, null);
|
||||
if (sideCompatEl) sideCompatEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,6 +704,9 @@ function closeSidePanel() {
|
||||
state.selectedWf = null;
|
||||
const titleEl = document.getElementById("wfm-side-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
setFormatBadge(document.getElementById("wfm-side-format-badge"), null);
|
||||
const sideCompatEl = document.getElementById("wfm-side-compat-result");
|
||||
if (sideCompatEl) sideCompatEl.textContent = "";
|
||||
const listLoadBtn = document.getElementById("wfm-list-load-btn");
|
||||
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
|
||||
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
|
||||
@@ -939,6 +985,11 @@ function openDetailModal(wf) {
|
||||
|
||||
const thumbSrc = wf.thumbnail || "";
|
||||
const html = `
|
||||
<div class="wfm-compat-row">
|
||||
<span id="wfm-detail-format-badge" class="wfm-format-badge"></span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-compat-check" title="${t("compatCheckHint")}">${t("compatCheck")}</button>
|
||||
<span id="wfm-detail-compat-result" class="wfm-compat-result"></span>
|
||||
</div>
|
||||
<div class="wfm-modal-thumb-section">
|
||||
${thumbSrc
|
||||
? `<img src="${thumbSrc}" class="wfm-modal-thumb-img" />`
|
||||
@@ -1053,6 +1104,30 @@ function openDetailModal(wf) {
|
||||
});
|
||||
}
|
||||
|
||||
// GenerateUI compatibility check (on demand; results kept in memory for this session only)
|
||||
const renderCompat = (r) => renderCompatBadges(document.getElementById("wfm-detail-compat-result"), r);
|
||||
renderCompat(getCachedCompat(wf.filename));
|
||||
getRawWorkflow(wf.filename)
|
||||
.then((raw) => setFormatBadge(document.getElementById("wfm-detail-format-badge"), comfyWorkflow.detectFormat(raw, wf.filename)))
|
||||
.catch(() => {});
|
||||
// Warm the /object_info cache (large; ~10 s on first fetch) so the check button answers quickly
|
||||
comfyWorkflow.getObjectInfo();
|
||||
document.getElementById("wfm-detail-compat-check")?.addEventListener("click", async () => {
|
||||
const btn = document.getElementById("wfm-detail-compat-check");
|
||||
const el = document.getElementById("wfm-detail-compat-result");
|
||||
btn.disabled = true;
|
||||
if (el) el.textContent = t("compatChecking");
|
||||
try {
|
||||
const raw = await getRawWorkflow(wf.filename);
|
||||
const r = await checkWorkflowCompat(raw, wf.filename);
|
||||
renderCompat(r);
|
||||
} catch (err) {
|
||||
if (el) el.textContent = t("errorWithMsg", err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-save tags/memo/summary on blur
|
||||
["wfm-detail-tags", "wfm-detail-memo", "wfm-detail-summary"].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener("blur", () => saveModalMeta(wf));
|
||||
@@ -1365,6 +1440,16 @@ export function initWorkflowTab() {
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
// Toolbar: sort dropdown
|
||||
const sortSel = document.getElementById("wfm-sort");
|
||||
if (sortSel) sortSel.value = state.sortBy;
|
||||
sortSel?.addEventListener("change", (e) => {
|
||||
state.sortBy = e.target.value;
|
||||
localStorage.setItem("wfm_workflow_sort", state.sortBy);
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
// Toolbar: Load in GenerateUI button
|
||||
document.getElementById("wfm-list-load-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
@@ -1378,7 +1463,7 @@ export function initWorkflowTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar: Load in Video button (MiniMax H3専用。非対応ワークフローはloadWorkflowIntoVideoEditor内でエラートースト表示済み)
|
||||
// Toolbar: Load in Video button (MiniMax H3 / LTX-2.5 / Wan2.2対応。非対応ワークフローはloadWorkflowIntoVideoEditor内でエラートースト表示済み)
|
||||
document.getElementById("wfm-list-load-video-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
<select id="wfm-group-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Groups</option>
|
||||
</select>
|
||||
<select id="wfm-sort" class="wfm-select wfm-toolbar-select">
|
||||
<option value="date_desc">Date (Newest)</option>
|
||||
<option value="date_asc">Date (Oldest)</option>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
</select>
|
||||
<button id="wfm-list-load-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled title="Select a card first">Load in GenerateUI</button>
|
||||
<button id="wfm-list-load-video-btn" class="wfm-btn wfm-btn-sm" disabled title="Select a card first">Load in Video</button>
|
||||
<button id="wfm-list-open-comfyui-btn" class="wfm-btn wfm-btn-sm" disabled title="Select a card first">Send to Canvas</button>
|
||||
@@ -85,6 +91,10 @@
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-side-panel-title" class="wfm-side-panel-title"></span>
|
||||
</div>
|
||||
<div class="wfm-side-compat-row">
|
||||
<span id="wfm-side-format-badge" class="wfm-format-badge"></span>
|
||||
<span id="wfm-side-compat-result" class="wfm-compat-result wfm-compat-result--right"></span>
|
||||
</div>
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-side-tab-btn active" data-side-tab="thumb">Thumbnail</button>
|
||||
<button class="wfm-side-tab-btn" data-side-tab="json">JSON</button>
|
||||
@@ -241,6 +251,9 @@
|
||||
<span id="wfm-models-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-models-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button id="wfm-models-civitai-batch-btn" class="wfm-btn wfm-btn-sm" title="Fetch CivitAI info for all models">CivitAI</button>
|
||||
<label style="font-size:11px;color:var(--wfm-text-secondary);white-space:nowrap;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-models-civitai-skip-nf"> <span id="wfm-models-civitai-skip-nf-label">Skip not found</span>
|
||||
</label>
|
||||
<span id="wfm-models-civitai-progress" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
|
||||
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge settings">⚙ Badge</button>
|
||||
<button id="wfm-models-select-btn" class="wfm-btn wfm-btn-sm wfm-btn-success" title="Multi-select models">Select</button>
|
||||
@@ -951,12 +964,8 @@
|
||||
<div class="wfm-prompt-split-header">AI Assistant</div>
|
||||
<div class="wfm-ollama-assistant">
|
||||
<div class="wfm-ollama-controls">
|
||||
<select id="wfm-ollama-model" class="wfm-select" style="flex:1;min-width:120px;">
|
||||
<option value="">Loading models...</option>
|
||||
</select>
|
||||
<button id="wfm-ollama-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button id="wfm-ollama-test-btn" class="wfm-btn wfm-btn-sm">Test</button>
|
||||
<span id="wfm-ollama-status" class="wfm-ollama-status">Disconnected</span>
|
||||
<button id="wfm-ollama-settings-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;" title="Settings">⚙</button>
|
||||
</div>
|
||||
<div class="wfm-ollama-controls" style="margin-top:6px;">
|
||||
<button id="wfm-ollama-attach-btn" class="wfm-btn wfm-btn-sm">Attach Image</button>
|
||||
@@ -1388,8 +1397,9 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-wf-card-panel-title">Side Panel & Detail Modal</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-wf-5">Side panel: thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management</li>
|
||||
<li id="wfm-help-wf-5">Side panel: below the workflow name, the format badge (UI / API / App) and the GenerateUI Check result badges (checked automatically on selection, results only — hover for the reasons); tabs for thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management</li>
|
||||
<li id="wfm-help-wf-6">Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, send to canvas, load in GenerateUI, set as default, delete</li>
|
||||
<li id="wfm-help-wf-8">GenerateUI Check: in the detail modal, the row below the title shows the format badge (UI / API / App) at the left, then the GenerateUI Check button and two result badges; the side panel shows the same format and result badges automatically. Nothing is saved (results stay in memory until the page is reloaded); the check takes about 20 ms (the first one after opening the page waits for ComfyUI's node list, about 10 s). Hover a ✅ / ⚠ / ❌ badge for the reasons. Conversion: converts the workflow with the same logic as GenerateUI; ❌ for unsupported formats, conversion errors or node types not installed in this ComfyUI, ⚠ for values not available here (models, LoRAs, images…) — UI-format workflows have them silently replaced with the first choice, while API-format workflows are sent as-is and rejected by ComfyUI ("Value not in list") unless you pick another value in GenerateUI (a checkpoint is replaced if "Force default checkpoint on workflow load" is on). Operability: how much of the workflow GenerateUI's fields alone can operate — the share of nodes with settings that GenerateUI shows (✅ 80%+, ⚠ 50%+, ❌ below); the tip lists the prompt/sampler fields found and the nodes whose settings must be changed in the node graph</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1417,7 +1427,8 @@
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-28">Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)</li>
|
||||
<li id="wfm-help-gen-31">LTX-2.5 and Wan2.2 video workflows also work when loaded as regular workflows in this tab (not just the dedicated Video tab): Positive/Negative Prompt detection follows LTX-2.5's LTXVDualCFGGuider / LTXVConditioning nodes to find the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's EmptyHunyuanLatentVideo and LTX-2.5's EmptyLTXVLatentVideo nodes (with an added Length (frames) field) instead of showing "No EmptyLatentImage node found"; generating from these workflows also gets the same extended timeout as MiniMax H3 video generations</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself); the search bar highlights matches and scrolls to them without moving the cursor into the editor, so typing right after a search can't accidentally overwrite the JSON — click into the editor first to actually edit</li>
|
||||
<li id="wfm-help-gen-30">The <b>Save</b> button (right end of the subtab bar) is hidden while the <b>Batch</b> or <b>Lab</b> subtab is active, since those work on workflow copies / their own Plan files rather than the currently loaded workflow; it reappears on Input/Model/Settings/Feeder</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1571,7 +1582,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-prompt-card-ai-title">AI Assistant & Translation</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-prompt-1">AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)</li>
|
||||
<li id="wfm-help-prompt-1">AI assistant (Ollama / LM Studio / Lemonade / Unsloth): chat for prompt consultation, image attachment (vision models); click the ⚙ Settings button to choose backend, connection URL, model, Thinking mode, Max tokens, and unload the loaded model (with Ollama, every model loaded in Ollama on localhost is unloaded, decision models included — the same as Settings → VRAM Management) — settings are independent from the AI TOOL and Tagger tabs</li>
|
||||
<li id="wfm-help-prompt-2">Apply AI responses to GenerateUI prompts</li>
|
||||
<li id="wfm-help-prompt-3">Translation: JA↔EN, ZH↔EN</li>
|
||||
</ul>
|
||||
@@ -1636,8 +1647,9 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-metadata-card-extracted-title">Extracted Information</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-metadata-2">Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow</li>
|
||||
<li id="wfm-help-metadata-3">Right column: LoRA list (with strength values) and Prompt list (click to view full text below)</li>
|
||||
<li id="wfm-help-metadata-2">Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow, plus (since v0.7.14) the LoRA list and Prompt list that used to be in the right column</li>
|
||||
<li id="wfm-help-metadata-3">Right column (v0.7.14): <b>Settings</b> — image size (width×height) and, when the workflow has a KSampler-type node, its Seed / Steps / CFG / Sampler / Scheduler / Denoise values</li>
|
||||
<li id="wfm-help-metadata-7">Right column, <b>Nanobanana</b> section (v0.7.14): shown only for images from ComfyUI Comic Creator's Nanobanana tab opened via the Gallery tab's Metadata button (needs a matching entry in that folder's history.jsonl log) — engine, model, image size, seed, timestamp, and the prompt/negative prompt actually used; replaces what would otherwise be a "no metadata found" error, since these images have no embedded ComfyUI workflow</li>
|
||||
<li id="wfm-help-metadata-4">Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1658,7 +1670,7 @@
|
||||
<h3 id="wfm-help-settings-card-general-title">General</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-1">UI language and summary language</li>
|
||||
<li id="wfm-help-settings-2">Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)</li>
|
||||
<li id="wfm-help-settings-2">Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (15 Google Fonts including Japanese display fonts, plus the system default)</li>
|
||||
<li id="wfm-help-settings-9">All sections are collapsible accordion panels</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1666,22 +1678,29 @@
|
||||
<h3 id="wfm-help-settings-card-connections-title">Connections</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-4">ComfyUI connection URL and test</li>
|
||||
<li id="wfm-help-settings-5">Ollama URL, default model, and connection test</li>
|
||||
<li id="wfm-help-settings-5">AI Assistant settings have moved out of this tab — the Prompt tab, Tagger tab, and AI TOOL tab each have their own ⚙ Settings button (backend, URL, model, Thinking mode, Max tokens, unload model), saved independently</li>
|
||||
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</li>
|
||||
<li id="wfm-help-settings-22">G'MIC-Qt Integration: path to the G'MIC-Qt executable (gmic_qt.exe) used by the Image Edit tab's G'MIC filter (Edit with G'MIC); Save stores it — see the Image Edit help for installing G'MIC-Qt</li>
|
||||
<li id="wfm-help-settings-17">Decision Model: its own section, independent of the AI TOOL / Tagger LLM/VLM backends so it can be used alongside them — backend, URL, model and a default probability threshold (answers below it are treated as suggestions to confirm). Unsloth: Laya models (laya / laya-multilingual / laya-english / laya-typed-decisions); requires Settings → API → Decision API → Serve requests in Unsloth Desktop (otherwise HTTP 404 "The Decision API is off") and either UNSLOTH_API_KEY in .env or Keyless API access → "Chat and inference". Ollama (0.35+): lists installed models whose capabilities include "decision" (tev1, tev1:0.8b, nimble — ollama pull first; ↻ refreshes the list); no key needed. Test sends one yes/no question with the unsaved form values; the first request loads the model.</li>
|
||||
<li id="wfm-help-settings-25">VRAM Management (Ollama): right before every image generation from Workflow Studio, unload the models Ollama has loaded to free VRAM (off / only as much as needed to reach a target free VRAM / everything every time); free VRAM is measured with nvidia-smi; "Unload all now" unloads them immediately; the same "Unload all now" is also in the sidepanel A tab → Settings → VRAM (Ollama). Running a workflow from the ComfyUI canvas itself is not covered</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-settings-card-workflow-title">Workflow Defaults</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-3">Workflow data folder path</li>
|
||||
<li id="wfm-help-settings-23">Models Folder: add extra model root folders (e.g. Stability Matrix's Models) — their subfolders (StableDiffusion / Lora / VAE / ControlNet / TextEncoders / DiffusionModels / Embeddings / HyperNetworks, or ComfyUI names like checkpoints / loras) are listed in the Models tab in addition to ComfyUI's own folders; separate several roots with ; — Apply saves, Default clears</li>
|
||||
<li id="wfm-help-settings-19">Gallery Output Folder: the ComfyUI output folder the Gallery tab browses — leave blank to use ComfyUI's default output directory; Apply takes effect immediately, Default resets it, and the folder currently in use is shown below the field</li>
|
||||
<li id="wfm-help-settings-6">Default workflow setting</li>
|
||||
<li id="wfm-help-settings-10">Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (junction/symlink); existing files migrated automatically</li>
|
||||
<li id="wfm-help-settings-20">Default Checkpoint: when "Force default checkpoint on workflow load" is on, every checkpoint loader node's model is replaced with the chosen checkpoint whenever a workflow is loaded into GenerateUI (a toast shows when it's applied) — avoids errors or garbled images caused by a model name saved in the workflow that you don't have; Refresh reloads the checkpoint list, Save stores the setting</li>
|
||||
<li id="wfm-help-settings-10">Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (directory junction on Windows / symlink on other OS); existing files migrated automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-settings-card-editor-title">Editor Customization</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-11">Text Size: one slider (10–28 px) adjusts font size for all prompt and chat textareas at once — Generate UI positive/negative prompts, AI Assistant chat input, Preset positive/negative, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings</li>
|
||||
<li id="wfm-help-settings-18">Video Volume: one slider (0–100%) sets the playback volume of the Video tab preview and the Gallery detail / lightbox / compare video players; the level set here (or changed with any video's own controls) carries over to the next video opened anywhere in the app</li>
|
||||
<li id="wfm-help-settings-12">RAW JSON Colors: customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI — Default Text, Name/Scheduler, Title, Width/Height, Prompt/Text, Image/File; changes apply immediately on color pick; Reset Defaults restores the original color scheme</li>
|
||||
<li id="wfm-help-settings-14">GenerateUI Model Tab Highlight: pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it</li>
|
||||
</ul>
|
||||
@@ -1689,10 +1708,12 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-settings-card-misc-title">Data & Misc</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-8">Data Management: export core settings/metadata/prompts to JSON / import to restore</li>
|
||||
<li id="wfm-help-settings-15">Full Backup (ZIP): unlike the JSON export above, also includes folder-based data (AI Skills, Lab Plans, Style presets) and the Tagger database; Restore overwrites existing files — restart ComfyUI afterward</li>
|
||||
<li id="wfm-help-settings-16">Two opt-in checkboxes above the Full Backup buttons additionally include ComfyUI's default workflows folder and the Impact Pack's wildcard folder in the export — both export-only, never restored automatically; copy the ZIP's _external/ folder back by hand if you want them</li>
|
||||
<li id="wfm-help-settings-8">Data Management: export core settings, metadata, and prompts to a JSON file, or import to restore. After importing, restart ComfyUI.</li>
|
||||
<li id="wfm-help-settings-15">Full Backup (ZIP): unlike the JSON export above, also includes folder-based data (AI Skills, Lab Plans, Video Plans, Video Edit projects, Style presets) and the Tagger database — everything the JSON export can't represent. Restoring overwrites existing files; restart ComfyUI afterward.</li>
|
||||
<li id="wfm-help-settings-16">Two opt-in checkboxes above the Full Backup buttons additionally include ComfyUI's default workflows folder and the Impact Pack's wildcard folder in the export — both export-only, never restored automatically; copy the ZIP's _external/ folder back by hand if you want them. A third checkbox includes the clips, BGM, sounds and overlay clips referenced by saved Video Edit projects (projects only store references to files in ComfyUI's input folder) — unlike the other two, these are restored into the input folder, skipping any file that already exists there.</li>
|
||||
<li id="wfm-help-settings-13">CivitAI Host: choose between civitai.com (SFW only) and civitai.red (unrestricted) — determines which site opens when clicking a model link in the Models tab CivitAI panel</li>
|
||||
<li id="wfm-help-settings-21">CivitAI API Key: optional key for authenticated CivitAI access from the Models tab (get it at civitai.com → Account Settings → API Keys); stored in settings.json but left out of the JSON export and Full Backup, and the CIVITAI_API_KEY environment variable takes priority when set</li>
|
||||
<li id="wfm-help-settings-24">Windows Stability: "Use the Selector event loop on Windows" switches ComfyUI's asyncio loop from Proactor to Selector (applied at the next ComfyUI start). Turn it on if ComfyUI freezes entirely after switching Models lists that load many preview images; the trade-offs are no asyncio subprocess support and a connection limit of about 512. The same switch is available through the WFS_SELECTOR_LOOP=1 environment variable</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1763,16 +1784,17 @@
|
||||
<h3 id="wfm-help-gallery-card-folder-title">Folder & Selection</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gallery-5">Folder tree: click to load images, expand with ▶; "+ New" creates a subfolder inside the selected folder, "Del" deletes it with all contents</li>
|
||||
<li id="wfm-help-gallery-6">Multi-select with Ctrl+click — bulk bar: select group → 'Add to Group' / 'Remove from Group'; 'Favorite All' / 'Unfavorite All'; 'Compare' (2–4 images); 'Move To...'; '<b>Export</b>' (downloads all selected images as a ZIP file); 'Delete File'</li>
|
||||
<li id="wfm-help-gallery-6">Multi-select with Ctrl+click — bulk bar: select group → 'Add to Group' / 'Remove from Group'; 'Favorite All' / 'Unfavorite All'; 'Compare' (2–4 images); 'Move To...'; '<b>Export</b>' (downloads all selected images as a ZIP file); '<b>Create PSD</b>' (combines the selected images into one layered PSD file); '<b>Edit as Layers</b>' (opens the selected images in the Image Edit tab, each as its own layer); 'Delete File'</li>
|
||||
<li id="wfm-help-gallery-15"><b>F button</b> (cyan overlay, top-left of each thumbnail): toggle the image in/out of the <b>__Feeder__</b> group with a single click; active state stays visible (cyan) when the image is already in the group; use this to quickly queue images for the Feeder tab Gallery mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-gallery-card-detail-title">Detail Panel</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gallery-7">Detail panel — Info tab: filename, size, dimensions (image width×height) or video duration, date, tags (add/remove), memo; <b>download icon</b> (on image hover) to download the single image; "Move To..." moves the image to another folder, "Delete File" removes it permanently</li>
|
||||
<li id="wfm-help-gallery-7">Detail panel — Info tab: filename, size, dimensions (image width×height) or video duration, date, tags (add/remove), memo; <b>download icon</b> (on image hover) to download the single image; "Move To..." moves the image to another folder, "Delete File" removes it permanently. In the Move To dialog (also from the bulk bar), type in the filter box to narrow the folder list by name or path (space = AND); Enter moves to the first match.</li>
|
||||
<li id="wfm-help-gallery-8">Detail panel — JSON tab: displays workflow JSON from PNG embedded data (prompt / workflow keys) or from metadata saved by the Generate UI tab; 'Copy & Send Canvas' button copies to clipboard and stores the workflow for title-drag to canvas</li>
|
||||
<li id="wfm-help-gallery-9">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image; the <b>__Feeder__</b> group is reserved (shown with 🔒) and cannot be renamed or deleted — rename and delete buttons are disabled when it is selected</li>
|
||||
<li id="wfm-help-gallery-32">Detail panel — Prompt tab (selected by default): lists positive/negative prompts found in the embedded workflow; hidden for images in a <b>cc_nanobanana</b> folder, since those carry no embedded ComfyUI workflow — a <b>Nanobanana</b> tab (v0.7.14) appears there instead, shown only when that folder's <b>history.jsonl</b> generation log (written by ComfyUI Comic Creator's Nanobanana tab) has a matching entry for the selected image: engine, model, image size, seed, timestamp, and the prompt/negative prompt actually used</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1781,16 +1803,24 @@
|
||||
<li id="wfm-help-gallery-12"><b>Metadata</b> button (green, in the detail panel tab bar): opens the selected image in the Metadata tab for full embedded-data inspection; Alt+click on any image thumbnail has the same effect</li>
|
||||
<li id="wfm-help-gallery-13"><b>Load GenUI</b> button (blue, in the detail panel tab bar): loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab and switches to it; shows a warning toast if no workflow is embedded or the format is not supported</li>
|
||||
<li id="wfm-help-gallery-14"><b>Tagger</b> button (purple, in the detail panel action row): opens the selected image directly in the Tagger tab's Single mode for instant tagging</li>
|
||||
<li id="wfm-help-gallery-16"><b>Image Edit</b> button (toolbar, next to Refresh): sends the currently selected image to the Image Edit tab; if layers already exist the image is added as a new layer scaled to fit the canvas, otherwise it becomes Layer 1</li>
|
||||
<li id="wfm-help-gallery-16"><b>Image Edit</b> button (toolbar, next to Refresh): sends the currently selected image to the Image Edit tab; if layers already exist the image is added as a new layer scaled to fit the canvas, otherwise it becomes Layer 1; PSD files are fully decomposed into their original layers instead of being flattened into one image</li>
|
||||
<li id="wfm-help-gallery-17"><b>Send GenUI Image</b> button (toolbar, next to Image Edit): uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded first</li>
|
||||
<li id="wfm-help-gallery-27"><b>Send to LI node</b> button (toolbar, next to Send GenUI Image): uploads the selected image and writes its filename into the image widget of the currently selected node on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination as Image Edit tab's Send to LI node; requires Workflow Studio to have been opened from ComfyUI's top menu button</li>
|
||||
<li id="wfm-help-gallery-31"><b>Send to LV node</b> button (toolbar, next to Send to LI node): the video equivalent — uploads the selected video and writes its filename into the file widget of the currently selected node, which must be ComfyUI's native <b>Load Video</b> node; only enabled when the selected item is a video (mp4/webm); requires Workflow Studio to have been opened from ComfyUI's top menu button</li>
|
||||
<li id="wfm-help-gallery-28"><b>Create PSD</b> bulk button: combines all selected images into a single layered PSD file and downloads it — each image becomes its own layer (named after the file), positioned top-left on a canvas sized to the largest selected image; the last selected image ends up on top Convert... bulk button (shown when audio/video files are selected): converts audio files — or extracts the audio track of videos — to FLAC / MP3 / Opus / M4A / WAV (bitrate, sample rate, channels selectable) into the same folder as new files; prompt/workflow tags are carried over for formats that can hold them (WAV cannot), favorite/tags/memo/groups can be carried over, and the originals can optionally be deleted afterwards.</li>
|
||||
<li id="wfm-help-gallery-29"><b>Edit as Layers</b> bulk button: switches to the Image Edit tab and opens all selected images at once, each as an independent layer (video files in the selection are skipped); the last selected image ends up on top</li>
|
||||
<li id="wfm-help-gallery-33">Semantic Search (v0.8.2): the S button next to the search box turns semantic search on. When ON, the search box finds images by meaning using Gemini Embedding 2 or the local EmbeddingGemma 2 (e.g. 'sunset city street') - press Enter to search; results are ordered by similarity with a score badge. When OFF it is the normal filename/tag/prompt search. Turning S on also shows a second toolbar row with Similar, the update button, the settings button and the result count. The settings button (gear) opens a window to build/update/delete the index for the selected folder (optionally with subfolders); choose the model with the radio buttons (Gemini needs GEMINI_API_KEY in the plugin's .env + a ComfyUI restart; EmbeddingGemma 2 runs locally and downloads its model on first use). Switching model or vector size needs 'Rebuild all'. Searches cover the selected folder tree; sort and tag/group filters do not apply to semantic results</li>
|
||||
<li id="wfm-help-gallery-34">Similar toggle and update button (second toolbar row, shown while S is ON): Similar shows images similar to the selected one (an image not yet indexed is embedded on the fly). Selecting another image keeps the list as is; press the update button to show images similar to the newly selected one, or press Similar again to return to the normal list. Changing folder, Clear filters or turning S off also leaves similar mode</li>
|
||||
<li id="wfm-help-gallery-35">EmbeddingGemma 2 performance: by default it runs on the CPU with half of the cores (about 6 s per image at full precision). Image precision trades accuracy for speed (Standard is about 2.5x and Light about 5x faster; vectors differ, so changing it needs 'Rebuild all'); fewer CPU threads keep the PC lighter while generating images. To use an NVIDIA GPU, install onnxruntime-gpu into ComfyUI's Python (pip uninstall onnxruntime, then pip install onnxruntime-gpu; the CUDA runtime/cuDNN that ComfyUI's PyTorch uses is picked up) and restart ComfyUI: indexing then takes about 0.1 s per image (measured on an RTX 4070, about 45x faster than the CPU) and the results are identical to the CPU's. DirectML is not supported (its vision model fails to run). The model is released from memory when indexing finishes and reloaded on the next search</li>
|
||||
<li id="wfm-help-gallery-36">Modal tabs and the similarity threshold: the Semantic settings window has three tabs - Index (target folder, rebuild mode, progress, delete all), Model (Gemini / EmbeddingGemma 2, vector size, device, CPU threads, image precision, connection test) and Search. On the Search tab you can set a minimum score for similar-image results (e.g. 0.6 shows only images scoring 0.6 or higher), a minimum score for text search, and the maximum number of results. Scores differ by model, so each model keeps its own values; the list header shows the active threshold</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-gallery-card-formats-title">Formats & Auto-save</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gallery-10">Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the JSON tab</li>
|
||||
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF, MP4; workflow extraction reads ComfyUI prompt/workflow keys embedded in PNG metadata or in MP4 container tags (as written by SaveVideo, e.g. MiniMax H3 output)</li>
|
||||
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF, MP4, WebM, PSD, and audio (WAV, MP3, OGG, M4A, FLAC, Opus — waveform thumbnail, built-in player, prompt/workflow tags written by ComfyUI's SaveAudio nodes); workflow extraction reads ComfyUI prompt/workflow keys embedded in PNG metadata (including chunks appended after IEND by ComfyUI-Custom-Scripts), WebP EXIF, or MP4/WebM container tags (as written by SaveVideo, e.g. MiniMax H3 output); the Prompt tab also reads A1111 / Forge / Fooocus <code>parameters</code> text when no workflow is embedded. Audio waveform thumbnails are colored by format (MP3 blue, WAV green, FLAC amber, OGG/Opus purple, M4A rose) so formats can be told apart at a glance; the detail panel shows the waveform with the player below it.</li>
|
||||
<li id="wfm-help-gallery-30">PSD files: thumbnails and full-size previews show all layers merged together (hidden layers excluded); the underlying PSD file itself is left untouched — only the preview shown in Gallery is a flattened rendering</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1812,7 +1842,7 @@
|
||||
<h3 id="wfm-help-models-card-civitai-title">Side Panel & CivitAI</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-models-4">Side panel: opens to CivitAI tab by default when a model is selected; Info (file path, tags, memo), Groups management, CivitAI integration</li>
|
||||
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
|
||||
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type; the batch fetch reuses saved hashes, imports from existing .metadata.json sidecars and hashes the rest in parallel; 'Skip not found' skips models known to be missing on CivitAI</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1826,7 +1856,7 @@
|
||||
<h3 id="wfm-help-models-card-groups-title">Groups & Badges</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models; Checkpoint and LoRA tabs support B (Batch) and S (Stack) quick-assign buttons per model in both card and table views</li>
|
||||
<li id="wfm-help-models-9">User-defined badges: assign free-label badges with custom colors shared with the Workflow tab</li>
|
||||
<li id="wfm-help-models-9">User-defined badges: assign free-label badges with custom colors shared with the Workflow tab; 'Auto badges from CivitAI' in Manage Badges creates badges from CivitAI base models</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1848,7 +1878,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-sidepanel-1">Open from the Workflow Studio Library button in the ComfyUI toolbar</li>
|
||||
<li id="wfm-help-sidepanel-2">Workflows tab: All / ★ Favorites / Groups sub-tabs; ★ shown for favorites in All view</li>
|
||||
<li id="wfm-help-sidepanel-3">Nodes tab: All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs; Category and Package show a dropdown to filter nodes</li>
|
||||
<li id="wfm-help-sidepanel-3">Nodes tab: All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs; Category and Package show a dropdown to filter nodes Alt+click a node to add/remove it from Favorites.</li>
|
||||
<li id="wfm-help-sidepanel-11">Models tab: All / ★ Favorites / Groups / By Type sub-tabs</li>
|
||||
<li id="wfm-help-sidepanel-4">Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs</li>
|
||||
</ul>
|
||||
@@ -1867,7 +1897,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-sidepanel-card-info-title">Information (I) tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-sidepanel-13">I tab (Information): drop a ComfyUI PNG/WebP/MP4/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF and QuadrupleCLIPLoader node types; MP4 reads the workflow/prompt tags embedded by SaveVideo, including all-in-one video nodes like MiniMax H3 ImageToVideo</li>
|
||||
<li id="wfm-help-sidepanel-13">I tab (Information): drop a ComfyUI PNG/WebP/MP4/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF, QuadrupleCLIPLoader, Lora Loader (LoraManager), and ImageMetadataLoRALoader node types (API format included); MP4 reads the workflow/prompt tags embedded by SaveVideo, including all-in-one video nodes like MiniMax H3 ImageToVideo</li>
|
||||
<li id="wfm-help-sidepanel-14">Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center</li>
|
||||
<li id="wfm-help-sidepanel-15">Information tab — LoRA sub-tab: drag individual LoRA to place LoraLoader; "Multiple LORA" section at bottom places all LoRAs in a single Lora Loader (LoraManager) node; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy</li>
|
||||
</ul>
|
||||
@@ -1875,7 +1905,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-sidepanel-card-ai-title">AI TOOL tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage; the Settings sub-tab's VRAM (Ollama) → "Unload all now" unloads every model loaded in Ollama on localhost, decision models included</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1922,7 +1952,7 @@
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-settings-title">Settings pane</h3>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model; Unsloth always requires an API key even for local access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI, the key is read server-side and never sent to the browser; Unsloth also requires "Auto model switching (OpenAI API)" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a `think` flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</p>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model, and use the Unload Model button (next to Test connection) to free the loaded model from VRAM/memory — Ollama unloads every loaded Ollama model, decision models included (the same step as Settings → VRAM Management's "Unload all now"; the Prompt/Tagger settings button does the same), LM Studio uses /api/v1/models/unload, Lemonade uses /api/v1/unload; Unsloth has no unload API, so this shows an unsupported message for it; Unsloth needs either an API key or Keyless API access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI (the key is read server-side and never sent to the browser), or leave it unset and turn on Keyless API access → "Chat and inference" in Unsloth Desktop's Settings → API (requests are then sent without a key; note that setting also opens the API to your private LAN, and keep "Allow tools" off); Unsloth also requires "Auto model switching (OpenAI API)" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a `think` flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1934,7 +1964,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-tagger-card-overview-title">Overview & Setup</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-tagger-1">Automatic image tagging using WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and Ollama VLM — 3 sub-tabs: Single / Batch / DB</li>
|
||||
<li id="wfm-help-tagger-1">Automatic image tagging using WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and VLM (Ollama / LM Studio / Lemonade / Unsloth) — 3 sub-tabs: Single / Batch / DB</li>
|
||||
<li id="wfm-help-tagger-2">Model placement: put each model in its own subfolder under ComfyUI/models/tagger/<model-name>/; the folder must contain a .onnx file + selected_tags.csv (or .h5 + label file for DeepDanbooru)</li>
|
||||
<li id="wfm-help-tagger-9">Install dependencies into ComfyUI's embedded Python: python_embeded\python.exe -m pip install -r requirements.txt; for GPU ONNX inference install onnxruntime-gpu instead of onnxruntime; TensorFlow (for DeepDanbooru) is commented out by default due to size</li>
|
||||
</ul>
|
||||
@@ -1944,7 +1974,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-tagger-3">Single tab: drag & drop an image onto the preview area, or click Upload to pick a file; the Tagger button in the Gallery detail panel opens the selected image directly here</li>
|
||||
<li id="wfm-help-tagger-4">Threshold slider: minimum confidence score for general tags (default 0.35); Character Threshold: separate threshold for character: prefix tags (default 0.85)</li>
|
||||
<li id="wfm-help-tagger-5">Ollama VLM: enable the checkbox to run a vision model alongside WD Tagger; set API URL, select model (click ↻ to refresh), and customize the prompt; results from both models are merged</li>
|
||||
<li id="wfm-help-tagger-5">VLM Tagging: enable the checkbox to run a vision model alongside WD Tagger; click the ⚙ Settings button to choose backend (Ollama / LM Studio / Lemonade / Unsloth), URL, model, Thinking mode, Max tokens, and unload the model (with Ollama, every model loaded in Ollama on localhost is unloaded, decision models included) — independent from the Prompt tab's own AI settings; customize the prompt and Max Tags inline; results from both models are merged</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1956,7 +1986,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-tagger-card-batch-title">Batch & DB Tabs</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-tagger-7">Batch tab: enter a folder path, configure WD Tagger and Ollama settings; output options: Save to DB (default on), Write to File (EXIF/PNG metadata), Write .txt (creates <filename>.txt alongside each image); click Start Batch to process all images in the folder; progress bar and log are updated in real time; Stop cancels after the current image</li>
|
||||
<li id="wfm-help-tagger-7">Batch tab: enter a folder path, configure WD Tagger and enable VLM tagging (uses the same ⚙ Settings as the Single tab); output options: Save to DB (default on), Write to File (EXIF/PNG metadata), Write .txt (creates <filename>.txt alongside each image); click Start Batch to process all images in the folder; progress bar and log are updated in real time; Stop cancels after the current image</li>
|
||||
<li id="wfm-help-tagger-8">DB tab: searchable database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records as a CSV file</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1971,8 +2001,10 @@
|
||||
<h3 id="wfm-help-imageedit-card-overview-title">Overview</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-1">Layer-based image editor — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite</li>
|
||||
<li id="wfm-help-imageedit-2">Loading images: drag & drop onto the canvas, Upload button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas</li>
|
||||
<li id="wfm-help-imageedit-2">Loading images: drag & drop onto the canvas, <b>File Open</b> button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas</li>
|
||||
<li id="wfm-help-imageedit-3">New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers and automatically adds an empty Layer 1</li>
|
||||
<li id="wfm-help-imageedit-31"><b>Open PSD</b> button (next to File Open): loads a layered PSD file from disk and decomposes it into individual layers — position, size, visibility, opacity, and blend mode are restored for each layer; groups and non-raster layers (adjustment layers, etc.) are flattened into their rendered appearance</li>
|
||||
<li id="wfm-help-imageedit-32"><b>Close</b> button (next to Open PSD): closes the current document after a confirmation prompt, discarding all layers and unsaved edits and returning to the empty placeholder view</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1982,9 +2014,10 @@
|
||||
<li id="wfm-help-imageedit-5">Draw (B): freehand brush drawing onto the active draw layer; Tool Options: color, brush size, blend mode; Properties pane includes a MASK EDITOR ONE (COLOR) section for ABR image brushes — always visible, header text is green when brushes are available, gray when none are imported yet; Select is disabled until at least one brush exists</li>
|
||||
<li id="wfm-help-imageedit-6">Text (T): click on the canvas to place a text input overlay; configure font family, size, bold/italic, align, color in the Tool Options bar; click OK to place the text as an exact-size Text layer; double-click an existing text layer with the Select tool to re-edit</li>
|
||||
<li id="wfm-help-imageedit-7">Shape (S): draw geometric shapes onto the canvas; each committed shape becomes an independent draw layer; Tool Options: Shape type (Rect / Ellipse / Line / FreeLine), Rounded checkbox (Rect / Ellipse only), Fill color + None toggle (hidden for Line / FreeLine), Stroke color + width + None toggle (None hidden for Line / FreeLine where stroke is always active), Opacity slider, ↩ Undo button; drag to draw — a blue dashed outline previews the shape; release to commit</li>
|
||||
<li id="wfm-help-imageedit-7a2">Fill (G): bucket fill — click a contiguous same-color region on the active layer to flood-fill it with a solid color or a linear/radial gradient; Tool Options: Mode (Solid / Gradient), Color (solid) or Shape + Ramp (drag stops to reposition, click a stop then use the color swatch, +/- to add/remove) + Direction dial (drag to set angle/radius, gradient), Tolerance (color-match threshold), Opacity</li>
|
||||
<li id="wfm-help-imageedit-7b">Blur (≈): apply Gaussian blur or mosaic to the active layer — whole-layer or region-based (drag a rectangle); see the Blur Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7c">BG Remove (⬚): remove the background from the active layer using Lightweight (@imgly, runs in browser) or BiRefNet (requires Mask Editor One + birefnet.safetensors in ComfyUI/models/background_removal/); see the BG Remove Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7d">Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click "M" in the Layers header to add a mask layer; see the Mask Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7d">Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click "Mask" (second row of the Layers header) to add a mask layer; see the Mask Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7e">Inpaint (🩹): composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like VAEEncodeForInpaint); enter Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), and Denoise (applied to KSampler) in the properties panel, then click Run to upload and generate without leaving the Image Edit tab — the result is shown back in the properties panel when done; requires a Mask layer to be painted first and an inpaint-capable workflow loaded in GenerateUI; also works with workflows built around the Mask Editor One custom node (comfyui-mask-editor-one) instead of LoadImage — image/mask are pushed to it via its node_id-keyed server cache and layer_data widget; "Use dedicated workflow" checkbox: when off, the workflow currently loaded in GenerateUI is used (unchanged); when on, pick a saved workflow from the dropdown — it is loaded, edited, and generated entirely in the background without disturbing the workflow shown in GenerateUI</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1992,7 +2025,8 @@
|
||||
<h3 id="wfm-help-imageedit-layer-title">Layer Panel</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-8">Layers are listed from front (top) to back (bottom); click a layer row to make it active</li>
|
||||
<li id="wfm-help-imageedit-8b">"M" button in the Layers header: adds a new mask layer above the current layer and automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-8b">The Layers header has two rows: the top row has +, ⧉ (Duplicate), −, ↑, ↓; the "<b>Mask</b>" button sits alone on the second row — it adds a new mask layer above the current layer and automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-33">⧉ (Duplicate layer) button: copies the active layer — including its pixels, position, size, opacity, blend mode, and visibility — and inserts the copy directly above the original as the new active layer; works on any layer type (image, draw, text, mask)</li>
|
||||
<li id="wfm-help-imageedit-9">👁 / 🚫 button: toggle layer visibility</li>
|
||||
<li id="wfm-help-imageedit-10">🔒 / 🔓 button: toggle layer lock — a locked layer cannot be moved, resized, or rotated (orange bounding box is shown); Layer 1 is locked automatically when the first image is loaded to prevent accidental moves</li>
|
||||
<li id="wfm-help-imageedit-11">Opacity slider at the bottom adjusts the active layer's transparency</li>
|
||||
@@ -2024,7 +2058,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-imageedit-mask-title">Mask Tool (🎭)</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-mask-1">Add a mask layer: click the "M" button in the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically</li>
|
||||
<li id="wfm-help-imageedit-mask-1">Add a mask layer: click the "Mask" button on the second row of the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically</li>
|
||||
<li id="wfm-help-imageedit-mask-2">Clicking a mask layer in the layer list automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-mask-3">Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge; the overlay is only shown for the currently active mask layer — inactive mask layers stay hidden until selected</li>
|
||||
<li id="wfm-help-imageedit-mask-4">Properties pane — Paint mode: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); MASK EDITOR ONE section (always visible — header text is green when ABR brushes are available, gray when none are imported yet): Select button opens the ABR brush library (disabled until at least one brush exists) — choosing a brush replaces the default circle brush; ✕ clears the brush back to Circle; when an image brush is active, Hardness is disabled and additional controls appear: Spacing (stamp interval 5–100%), Angle (rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random rotation per stamp on/off)</li>
|
||||
@@ -2070,6 +2104,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-17">Save PNG: composites all visible layers and downloads the result as a PNG file</li>
|
||||
<li id="wfm-help-imageedit-18">Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root</li>
|
||||
<li id="wfm-help-imageedit-34">Save PSD: downloads a layered PSD file with every layer preserved separately — position, rotation, flip, and scale are baked into each layer's pixels (PSD layers cannot store a rotation angle), while opacity, blend mode, and visibility are kept as real, editable PSD layer attributes; clipping masks are merged into their target layer since PSD's native layer-mask mechanism is not used</li>
|
||||
<li id="wfm-help-imageedit-19b">Send to Workflow: uploads the composite PNG to ComfyUI's input folder via /upload/image and writes the resulting filename into the image widget of the currently selected node in the ComfyUI graph, falling back to the first LoadImage-type node found; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable</li>
|
||||
<li id="wfm-help-imageedit-20">Canvas navigation: scroll wheel to zoom, Space + drag to pan</li>
|
||||
<li id="wfm-help-imageedit-21">Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)</li>
|
||||
@@ -2082,6 +2117,7 @@
|
||||
<li id="wfm-help-imageedit-23">B: Draw tool</li>
|
||||
<li id="wfm-help-imageedit-24">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-25">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-25b">G: Fill tool</li>
|
||||
<li id="wfm-help-imageedit-26">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-27">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-28">Space + drag: Pan canvas</li>
|
||||
@@ -2093,16 +2129,21 @@
|
||||
|
||||
<div class="wfm-help-page" id="wfm-help-page-video">
|
||||
<h2 id="wfm-help-video-title">Video Tab</h2>
|
||||
<p id="wfm-help-video-desc">A dedicated video generation tab for the MiniMax H3 (Image to Video) workflow, independent of the GenerateUI tab.</p>
|
||||
<p id="wfm-help-video-desc">A dedicated video generation tab for MiniMax H3 (Image to Video), LTX-2.5 (Text / Image / First & Last Frame to Video), and Wan2.2 (Text to Video) workflows, independent of the GenerateUI tab. The center panel's Plan / Edit / Asset subtabs handle batch generation, clip editing, and browsing generated videos; the sidebar's Project subtab handles saved plans (while Plan is active) or saved Edit projects (while Edit is active).</p>
|
||||
<div class="wfm-help-card">
|
||||
<ul>
|
||||
<li id="wfm-help-video-1">Set first_frame (optional) / last_frame (optional) images, prompt, aspect_ratio / megapixels / multiple (resolution), duration (seconds), and noise_seed, then click Generate. Leaving first_frame empty runs the workflow text-only (bypassed), and workflows with no LoadImage node at all are also supported.</li>
|
||||
<li id="wfm-help-video-2">Model selection (UNet / CLIP / VAE / Audio VAE) is fixed to the loaded workflow's defaults and cannot be changed from this tab.</li>
|
||||
<li id="wfm-help-video-3">Use the "Load in Video" button in the Workflow tab's detail modal to load a workflow containing a MiniMax H3 Image-to-Video node. Unsupported workflows show an error instead.</li>
|
||||
<li id="wfm-help-video-4">Generated videos (mp4) can also be browsed and played back in the Gallery tab.</li>
|
||||
<li id="wfm-help-video-5">The Video Source panel next to the preview lets you drop or pick any local video (not just a generated one) to use the Frame/GIF tools below without running MiniMax H3 first; Clear resets it back to empty.</li>
|
||||
<li id="wfm-help-video-6">Frame tab (right panel): play/seek the preview video to the position you want, click Capture Frame to grab that frame client-side, then Save to Output to write it as a PNG into ComfyUI's output folder.</li>
|
||||
<li id="wfm-help-video-1">Plan subtab: the topbar's settings row holds Aspect Ratio / Megapixels / Multiple / Duration (seconds) / Noise Seed as this plan's base settings — Duration always reflects the currently selected block (each block keeps its own value), the rest are plan-wide. A second row below adds Width / Height / Turbo Mode / Strength / Steps / Prompt Enhance, each enabled only for workflows that actually support it (see helpVideo2). Split the timeline into blocks with the + Split / + Block / Delete buttons — each block has its own Prompt, Duration, and First/Last Image — First Image can be None, an explicit image, or "use previous block's last frame" (an explicit Last Image on the previous block always wins over extracting one from its generated video). Click Run to generate every block in order; Pause/Cancel work mid-run.</li>
|
||||
<li id="wfm-help-video-2">A plan with a single block behaves exactly like the old one-shot form. Model selection (UNet / CLIP / VAE / Audio VAE) is fixed to the loaded workflow's defaults. For workflows with no ResolutionSelector node (e.g. Wan2.2, LTX-2.5 First & Last Frame to Video), the Aspect Ratio / Megapixels / Multiple fields are disabled and the workflow's own Width / Height become directly editable instead. Turbo Mode / Strength / Steps only apply to MiniMax H3's turbo LoRA, and Prompt Enhance only to LTX-2.5 — both stay disabled for other model families. Noise Seed is disabled for the one workflow that doesn't expose it: Wan2.2 Text-to-Video.</li>
|
||||
<li id="wfm-help-video-3">Use the "Load in Video" button in the Workflow tab's detail modal to load a workflow containing a MiniMax H3 Image-to-Video node, an LTX-2.5 Text/Image/First&Last-Frame-to-Video node, or a Wan2.2 Text-to-Video node — this starts a fresh single-block plan seeded from the workflow's own current values. Unsupported workflows show an error instead.</li>
|
||||
<li id="wfm-help-video-4">Generated videos (mp4) automatically appear in the Asset subtab's Video Temp group, and can also be browsed and played back in the Gallery tab. Organizing keepers into Video Assets is entirely up to you, on your own schedule.</li>
|
||||
<li id="wfm-help-video-5">The center panel shows two previews: the left one shows either the video you select in the Asset subtab or whatever you drop/pick in the Video Source panel on its left (Clear resets it to empty), letting you use the Frame/GIF tools without running the loaded video workflow first; the right one always shows the most recently generated clip.</li>
|
||||
<li id="wfm-help-video-6">Frame tab (right panel): play/seek the preview video to the position you want, click Capture Frame to grab that frame client-side, then Save to Output to write it as a PNG into ComfyUI's output folder — check "Add to Video Assets group" first to also tag the saved frame into Gallery's Video Assets group.</li>
|
||||
<li id="wfm-help-video-7">GIF tab (right panel): set Start/End (seconds), FPS, and an optional Max Width, then Convert to GIF — the server decodes the video and encodes an animated GIF into ComfyUI's output folder.</li>
|
||||
<li id="wfm-help-video-8">Chaining: setting a block's First Image to "use previous block's last frame" continues smoothly from the previous clip — if that previous block has its own explicit Last Image, that image is used directly; otherwise the actual last frame of its generated video is extracted automatically.</li>
|
||||
<li id="wfm-help-video-9">Project tab (sidebar): lists saved Video Plans while the center Plan subtab is active, or saved Edit projects while the center Edit subtab is active (thumbnail/block or clip count, last updated) — click one to load it, or delete it with the × button; clicking the same tab again collapses the panel.</li>
|
||||
<li id="wfm-help-video-10">Highlight color: the Prompt / Duration / First Image / Last Image / Aspect Ratio / Megapixels / Multiple / Noise Seed / Width / Height / Turbo Mode / Strength / Steps / Prompt Enhance labels turn the same highlight color as the GenerateUI Model tab (customizable in the Settings tab) when the loaded workflow actually supports that field — e.g. for a Wan2.2 workflow (no ResolutionSelector node), the Aspect Ratio / Megapixels / Multiple labels stay unhighlighted while Width / Height light up instead.</li>
|
||||
<li id="wfm-help-video-11">Edit subtab: a lightweight clip timeline for trimming and concatenating video/image clips outside of generation. Add clips via the Video Source panel's "Add to Edit" button or the Asset subtab's "Send to Edit" button, then drag blocks to reorder (or use the ◀/▶ toolbar buttons), Duplicate/Delete the selected clip, and Clear to empty the timeline (this also resets the Audio section). The trim panel for the selected video clip shows a time ruler, a draggable playhead synced to the Source preview, and draggable in/out handles with time badges — image clips instead get a simple hold-duration field. ▶ Preview plays every ready clip back-to-back (trimmed to its in/out points) in the right-hand result preview; Export builds and runs the actual ComfyUI graph (trim → concatenate if more than one clip → save), tagging the result into the Video Temp group automatically. The Audio section of the export panel controls the soundtrack: "Keep original audio" (with its own volume in dB) keeps each clip's audio aligned to its segment even across multiple clips (image clips contribute silence), and "BGM..." lays one audio file over the whole video with its own volume and start offset (a longer BGM is cut to the video length). Each clip can also carry text overlays ("+ Add text" under the trim panel: text, start/end seconds within the clip, size as % of frame height, color, 9-position anchor, outline, background box); they are previewed live over the Source/Result panes and burned in on Export as a final pass. The BGM can be chosen with "BGM...", by dropping an audio/video file onto the Audio section, or with "Set as BGM" on a video in the Asset subtab (its audio track is used). Video clips can also be cropped: "Edit crop" shows a draggable/resizable rectangle over the Source preview (optionally locked to 16:9 / 9:16 / 1:1 / 4:3 / 3:4) — "Done" keeps it, "Cancel" restores the crop from before editing, and switching to another subtab/tab ends editing like Done; the export size becomes the first video clip's cropped size, and other clips of a different size are center-fit to it. ↶/↷ in the timeline toolbar (or Ctrl+Z / Ctrl+Y) undo and redo edits to clips, trims, crops, text and audio settings. The export panel's Save/Save As/Load buttons persist the timeline itself (clip order, trim points, crop, text overlays, audio/BGM settings, and which uploaded file each clip and the BGM refer to) as a JSON project file on the server. The project only stores references to those files in ComfyUI's input folder, so Load re-fetches each clip from its saved server reference and re-probes it — the original files must still exist (Settings → Full Backup can bundle them with "Include media used by Video Edit projects"). These saved projects also show up in the sidebar's Project tab whenever the Edit subtab is active (see the Project tab item).</li>
|
||||
<li id="wfm-help-video-12">Asset subtab (center panel, next to Plan/Edit): browses every video/image tagged into Video Assets or Video Temp (via the group dropdown, or "All Video Assets" for both). Search filters by filename; the ▦/☰ buttons switch between a thumbnail grid and a table (filename/updated/tags). Selecting an item shows its tags/memo, loads it into the center preview, and offers "Send to Edit" (queues it as a clip in the Edit subtab) plus, for images only, "Set as First Image"/"Set as Last Image" (uploads it straight into the currently-selected Plan block, switching to the Plan subtab). Audio files tagged into Video Assets also appear ("Audio Only" filter): the detail panel gets a player plus "Set as BGM" and "Add as sound" (places it on the Edit subtab's Audio track at the selected clip's start).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="wfm-help-jump-btn wfm-btn wfm-btn-sm" data-jump-tab="video">Go to Video Tab</button>
|
||||
@@ -2128,6 +2169,7 @@
|
||||
<li id="wfm-help-imageedit-tabkbd-2">B: Draw tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-3">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-4">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-4b">G: Fill tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-5">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-6">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-7">Space + drag: Pan canvas</li>
|
||||
@@ -2227,6 +2269,7 @@
|
||||
<input type="text" id="wfm-gallery-search" class="wfm-search-input" placeholder="Search images...">
|
||||
<button class="wfm-search-clear-btn" id="wfm-gallery-search-clear-btn" title="Clear search">✕</button>
|
||||
</div>
|
||||
<button id="wfm-gallery-semantic-toggle" class="wfm-btn wfm-btn-sm wfm-gallery-sem-toggle" title="Semantic Search: when ON, the search box finds images by meaning (press Enter to search) and a second row with Similar / settings appears.">S</button>
|
||||
<select id="wfm-gallery-tag-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
@@ -2250,8 +2293,16 @@
|
||||
<button id="wfm-gallery-send-image-edit-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to Image Edit tab">Image Edit</button>
|
||||
<button id="wfm-gallery-send-genui-image-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to GenerateUI Image input">Send GenUI Image</button>
|
||||
<button id="wfm-gallery-send-li-node-btn" class="wfm-btn wfm-btn-sm" title="Upload and write selected image's filename into the selected node's image widget (e.g. LoadImage)">Send to LI node</button>
|
||||
<button id="wfm-gallery-send-lv-node-btn" class="wfm-btn wfm-btn-sm" title="Upload and write selected video's filename into the selected node's file widget (native Load Video node)">Send to LV node</button>
|
||||
<button id="wfm-gallery-send-cc-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to ComfyUI Comic Creator's selected panel" style="display:none;">Send CC</button>
|
||||
</div>
|
||||
<!-- Semantic Search ON時の2行目: 類似画像 / 更新 / 設定 / 結果件数 -->
|
||||
<div id="wfm-gallery-sem-row" class="wfm-toolbar-row wfm-gallery-sem-row" style="display:none;">
|
||||
<button id="wfm-gallery-similar-btn" class="wfm-btn wfm-btn-sm wfm-sem-sim" title="Show images similar to the selected one (toggle)">Similar</button>
|
||||
<button id="wfm-gallery-similar-refresh-btn" class="wfm-btn wfm-btn-sm wfm-sem-sim" title="Update: show images similar to the currently selected image">↻</button>
|
||||
<button id="wfm-gallery-semantic-settings-btn" class="wfm-btn wfm-btn-sm" title="Semantic Search settings (index / model / search)">⚙</button>
|
||||
<span id="wfm-gallery-sem-count" class="wfm-gallery-sem-count"></span>
|
||||
</div>
|
||||
<!-- 複数選択時の一括操作バー -->
|
||||
<div id="wfm-gallery-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
|
||||
<span id="wfm-gallery-bulk-count" class="wfm-gallery-bulk-count"></span>
|
||||
@@ -2267,6 +2318,9 @@
|
||||
<button id="wfm-gallery-bulk-compare" class="wfm-btn wfm-btn-sm" style="display:none;"></button>
|
||||
<button id="wfm-gallery-bulk-move" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-export" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-psd" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-edit-layers" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-convert" class="wfm-btn wfm-btn-sm" style="display:none;"></button>
|
||||
<button id="wfm-gallery-bulk-delete" class="wfm-btn wfm-btn-sm wfm-btn-danger"></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2309,6 +2363,8 @@
|
||||
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="meta">JSON</button>
|
||||
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="group">Groups</button>
|
||||
<button class="wfm-gallery-detail-tab-btn active" data-detail-tab="prompt">Prompt</button>
|
||||
<!-- cc_nanobananaフォルダの画像でのみJSから表示する(他フォルダではdisplay:none) -->
|
||||
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="nanobanana" id="wfm-gallery-detail-tab-nanobanana" style="display:none;">Nanobanana</button>
|
||||
</div>
|
||||
<div class="wfm-gallery-detail-action-row">
|
||||
<button id="wfm-gallery-open-metadata-btn" class="wfm-gallery-action-btn wfm-gallery-action-btn-green" title="Open in Metadata tab (or Alt+click image)">Metadata</button>
|
||||
@@ -2382,6 +2438,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nanobanana タブ(cc_nanobananaフォルダの画像のみ。Comic Creatorのnanobananaタブが
|
||||
同フォルダに書くhistory.jsonlから、この画像に対応する生成履歴エントリを表示する) -->
|
||||
<div id="wfm-gallery-detail-nanobanana" class="wfm-gallery-detail-tab-content" style="display:none;">
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-nb-engine"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-nb-model"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-nb-size"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-nb-seed"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-nb-timestamp"></div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-section-title">Prompt</div>
|
||||
<textarea class="wfm-meta-prompt-full" id="wfm-gallery-nb-prompt" readonly rows="4"></textarea>
|
||||
</div>
|
||||
<div class="wfm-gallery-info-section" id="wfm-gallery-nb-negative-section" style="display:none;">
|
||||
<div class="wfm-gallery-section-title">Negative Prompt</div>
|
||||
<textarea class="wfm-meta-prompt-full" id="wfm-gallery-nb-negative" readonly rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2553,7 +2630,7 @@
|
||||
<input type="file" id="wfm-meta-file-input" accept="image/png,image/webp,video/mp4,.mp4,.json,application/json" style="display:none;">
|
||||
</div>
|
||||
|
||||
<!-- Col2: Model info -->
|
||||
<!-- Col2: Model info + LoRA + Prompt -->
|
||||
<div class="wfm-metadata-col2">
|
||||
<div class="wfm-meta-section" id="wfm-meta-ckpt-section">
|
||||
<div class="wfm-meta-section-title">Checkpoint</div>
|
||||
@@ -2571,10 +2648,6 @@
|
||||
<div class="wfm-meta-section-title">Text Encoder</div>
|
||||
<div class="wfm-meta-list" id="wfm-meta-te-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col3: LoRA + Prompt -->
|
||||
<div class="wfm-metadata-col3">
|
||||
<div class="wfm-meta-section" id="wfm-meta-lora-section">
|
||||
<div class="wfm-meta-section-title">LoRA</div>
|
||||
<div class="wfm-meta-list" id="wfm-meta-lora-list"></div>
|
||||
@@ -2595,6 +2668,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col3: Settings(画像サイズ・KSampler設定) + Nanobanana履歴 -->
|
||||
<div class="wfm-metadata-col3">
|
||||
<div class="wfm-meta-section" id="wfm-meta-settings-section">
|
||||
<div class="wfm-meta-section-title">Settings</div>
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-set-size"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-set-seed"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-set-steps"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-set-cfg"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-set-sampler"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-set-scheduler"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-set-denoise"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Comic Creatorのnanobananaタブ(history.jsonl)から来た画像のみ表示 -->
|
||||
<div class="wfm-meta-section" id="wfm-meta-nb-section">
|
||||
<div class="wfm-meta-section-title">Nanobanana</div>
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-nb-engine"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-nb-model"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-nb-size"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-nb-seed"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-meta-nb-timestamp"></div>
|
||||
</div>
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-section-title">Prompt</div>
|
||||
<textarea class="wfm-meta-prompt-full" id="wfm-meta-nb-prompt" readonly rows="4"></textarea>
|
||||
</div>
|
||||
<div class="wfm-gallery-info-section" id="wfm-meta-nb-negative-section" style="display:none;">
|
||||
<div class="wfm-gallery-section-title">Negative Prompt</div>
|
||||
<textarea class="wfm-meta-prompt-full" id="wfm-meta-nb-negative" readonly rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /Metadata ギャラリーパネル -->
|
||||
@@ -2609,6 +2717,7 @@
|
||||
<button class="ie-tool-btn" data-tool="draw" title="Draw (B)">✏</button>
|
||||
<button class="ie-tool-btn" data-tool="text" title="Text (T)">T</button>
|
||||
<button class="ie-tool-btn" data-tool="shape" title="Shape (S)">□</button>
|
||||
<button class="ie-tool-btn" data-tool="fill" title="Fill (G)">🪣</button>
|
||||
<button class="ie-tool-btn" data-tool="mask" title="Mask">🎭</button>
|
||||
<button class="ie-tool-btn" data-tool="blur" title="Blur">≈</button>
|
||||
<button class="ie-tool-btn" data-tool="filter" title="Filter">★</button>
|
||||
@@ -2621,15 +2730,21 @@
|
||||
<!-- Action bar -->
|
||||
<div class="ie-action-bar">
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;">
|
||||
Upload
|
||||
File Open
|
||||
<input type="file" id="ie-upload-input" accept="image/*" style="display:none;">
|
||||
</label>
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;" title="Open a layered PSD file">
|
||||
Open PSD
|
||||
<input type="file" id="ie-open-psd-input" accept=".psd" style="display:none;">
|
||||
</label>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-new-btn">New</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-close-btn" title="Close the current document">Close</button>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-undo-btn" title="Undo (Ctrl+Z)">↩</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-redo-btn" title="Redo (Ctrl+Y)">↪</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-btn">Save PNG</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-gallery-btn" title="Save to Gallery root folder">Save to Gallery</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-psd-btn" title="Save all layers as a layered PSD file">Save PSD</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-send-workflow-btn" title="Upload and write this image's filename into the selected node's image widget (e.g. LoadImage)">Send to LI node</button>
|
||||
</div>
|
||||
|
||||
@@ -2670,12 +2785,17 @@
|
||||
<!-- Right Panel (Layers) -->
|
||||
<div class="ie-right-panel">
|
||||
<div class="ie-layers-header">
|
||||
<span>Layers</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-layer-btn" title="Add layer">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-mask-btn" title="Add mask layer" style="font-weight:700;color:var(--wfm-accent,#4682e6);">M</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-del-layer-btn" title="Delete layer">−</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-up-btn" title="Move up">↑</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-down-btn" title="Move down">↓</button>
|
||||
<div class="ie-layers-header-row">
|
||||
<span>Layers</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-layer-btn" title="Add layer">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-dup-layer-btn" title="Duplicate layer">⧉</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-del-layer-btn" title="Delete layer">−</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-up-btn" title="Move up">↑</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-down-btn" title="Move down">↓</button>
|
||||
</div>
|
||||
<div class="ie-layers-header-row">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-mask-btn" title="Add mask layer" style="font-weight:700;color:var(--wfm-accent,#4682e6);">Mask</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-layer-list" id="ie-layer-list"></div>
|
||||
<div class="ie-opacity-row">
|
||||
@@ -2687,119 +2807,310 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Video Tab (MiniMax H3専用・GenerateUIタブとは独立) -->
|
||||
<!-- Video Tab (Plan[バッチ動画生成]/Asset[素材管理]/Edit[将来機能]の3サブタブ。
|
||||
中央のプレビュー+右のFrame/GIFプロパティパネルはサブタブ切替と独立した常時表示) -->
|
||||
<section id="wfm-tab-video" class="wfm-tab-content">
|
||||
<div class="wfm-video-layout">
|
||||
<div class="wfm-video-form-panel">
|
||||
<div class="wfm-video-header">
|
||||
<h3 id="wfm-video-title-label">Video (MiniMax H3)</h3>
|
||||
<span id="wfm-video-wf-name" class="wfm-video-wf-name">No workflow loaded</span>
|
||||
<div class="wfm-video-subtab-nav">
|
||||
<button type="button" class="wfm-video-subtab-btn" data-video-subtab="project" id="wfm-video-subtab-btn-project">Project</button>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-frame-slots">
|
||||
<div class="wfm-video-frame-slot">
|
||||
<label id="wfm-video-first-frame-label">First Frame (optional)</label>
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-video-first-frame-wrap" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-video-first-frame-preview">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-first-frame-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<span id="wfm-video-first-frame-drop-label">Drop image or click to select</span>
|
||||
<input type="file" accept="image/*" id="wfm-video-first-frame-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<span class="wfm-i2i-status" id="wfm-video-first-frame-status" style="flex:1;"></span>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-first-frame-clear">Clear</button>
|
||||
</div>
|
||||
<!-- Project: 保存済みVideo Plan一覧(クリックでPlanエディタへ読み込み) -->
|
||||
<div id="wfm-video-subtab-project" class="wfm-video-subtab-panel" data-video-subtab-panel="project" style="display:none;">
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<p class="wfm-video-prop-hint" id="wfm-video-project-hint" style="flex:1;margin:0;">Saved plans</p>
|
||||
<button type="button" id="wfm-video-project-refresh" class="wfm-btn wfm-btn-sm" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="wfm-video-frame-slot">
|
||||
<label id="wfm-video-last-frame-label">Last Frame (optional)</label>
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-video-last-frame-wrap" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-video-last-frame-preview">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-last-frame-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<span id="wfm-video-last-frame-drop-label">Drop image or click to select</span>
|
||||
<input type="file" accept="image/*" id="wfm-video-last-frame-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<span class="wfm-i2i-status" id="wfm-video-last-frame-status" style="flex:1;"></span>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-last-frame-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="wfm-video-prompt" id="wfm-video-prompt-label">Prompt</label>
|
||||
<textarea id="wfm-video-prompt" class="wfm-textarea" rows="6"></textarea>
|
||||
|
||||
<div class="wfm-video-resolution-row">
|
||||
<div>
|
||||
<label for="wfm-video-aspect-ratio" id="wfm-video-aspect-ratio-label">Aspect Ratio</label>
|
||||
<select id="wfm-video-aspect-ratio" class="wfm-input"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-megapixels" id="wfm-video-megapixels-label">Megapixels</label>
|
||||
<input type="number" id="wfm-video-megapixels" class="wfm-input" step="0.01" min="0.01" value="0.2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-multiple" id="wfm-video-multiple-label">Multiple</label>
|
||||
<input type="number" id="wfm-video-multiple" class="wfm-input" step="8" min="8" value="32">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-resolution-row">
|
||||
<div>
|
||||
<label for="wfm-video-duration" id="wfm-video-duration-label">Duration (seconds)</label>
|
||||
<input type="number" id="wfm-video-duration" class="wfm-input" step="0.5" min="0.5" value="2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-noise-seed" id="wfm-video-noise-seed-label">Noise Seed</label>
|
||||
<input type="number" id="wfm-video-noise-seed" class="wfm-input" value="0">
|
||||
</div>
|
||||
<div class="wfm-video-seed-randomize-wrap">
|
||||
<label><input type="checkbox" id="wfm-video-seed-randomize" checked> <span id="wfm-video-randomize-label">Randomize</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;">
|
||||
<button id="wfm-video-generate-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Generate</button>
|
||||
</div>
|
||||
<div class="wfm-gen-progress-container">
|
||||
<div class="wfm-gen-progress-track">
|
||||
<div id="wfm-video-progress-bar" class="wfm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span id="wfm-video-progress-text" class="wfm-gen-progress-text">Ready</span>
|
||||
<div class="wfm-video-project-list" id="wfm-video-project-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中央ペイン: プレビュー(生成結果 or ドロップされた任意の動画) + 編集(将来機能用プレースホルダー) -->
|
||||
<!-- 中央ペイン: プレビュー(常時表示) + Plan/Editタブ(バッチ生成編集・将来の動画編集機能)。
|
||||
左のAssetサブタブ切替とは独立して常に表示される -->
|
||||
<div class="wfm-video-center-panel">
|
||||
<div class="wfm-video-preview-row">
|
||||
<div class="wfm-video-preview-frame" id="wfm-video-preview-frame">
|
||||
<span class="wfm-placeholder" id="wfm-video-preview-placeholder">No video</span>
|
||||
<video id="wfm-video-preview-video" class="wfm-video-preview-el" controls style="display:none;"></video>
|
||||
</div>
|
||||
<div class="wfm-video-source-panel">
|
||||
<div class="wfm-video-pane-header" id="wfm-video-source-label">Video Source</div>
|
||||
<p class="wfm-video-prop-hint" id="wfm-video-source-hint">Load any video here to use Frame/GIF tools without generating one.</p>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-source-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<span id="wfm-video-source-drop-label">Drop video or click to select</span>
|
||||
<input type="file" accept="video/*" id="wfm-video-source-file" style="display:none;">
|
||||
<input type="file" accept="video/*,image/*" id="wfm-video-source-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-top:6px;">
|
||||
<span class="wfm-i2i-status" id="wfm-video-source-status" style="flex:1;"></span>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-source-add-to-edit">Add to Edit</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-source-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-preview-frame" id="wfm-video-source-preview-frame">
|
||||
<span class="wfm-placeholder" id="wfm-video-source-preview-placeholder">Asset / Source Preview</span>
|
||||
<video id="wfm-video-source-preview-video" class="wfm-video-preview-el" controls style="display:none;"></video>
|
||||
<img id="wfm-video-source-preview-img" class="wfm-video-preview-el" style="display:none;">
|
||||
</div>
|
||||
<div class="wfm-video-preview-frame" id="wfm-video-preview-frame">
|
||||
<span class="wfm-placeholder" id="wfm-video-preview-placeholder">Generated Video Preview</span>
|
||||
<video id="wfm-video-preview-video" class="wfm-video-preview-el" controls style="display:none;"></video>
|
||||
<img id="wfm-video-preview-img" class="wfm-video-preview-el" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-edit-panel">
|
||||
<span id="wfm-video-edit-placeholder-label" class="wfm-placeholder">Editing tools coming soon</span>
|
||||
|
||||
<div class="wfm-video-subtab-nav wfm-video-center-subtab-nav">
|
||||
<button type="button" class="wfm-video-subtab-btn active" data-video-subtab="plan">Plan</button>
|
||||
<button type="button" class="wfm-video-subtab-btn" data-video-subtab="edit">Edit</button>
|
||||
<button type="button" class="wfm-video-subtab-btn" data-video-subtab="asset" id="wfm-video-subtab-btn-asset">Asset</button>
|
||||
</div>
|
||||
|
||||
<!-- Asset: このPlanが生成した動画の素材管理(Galleryのグループ機能を再利用)。
|
||||
左=一覧(サムネイル/テーブル切替可) / 右=選択ファイルの詳細(タグ・メモ等) -->
|
||||
<div id="wfm-video-subtab-asset" class="wfm-video-subtab-panel wfm-video-asset-content" data-video-subtab-panel="asset" style="display:none;">
|
||||
<div class="wfm-video-asset-layout">
|
||||
<div class="wfm-video-asset-list-col">
|
||||
<div class="wfm-video-asset-toolbar">
|
||||
<select id="wfm-video-asset-group" class="wfm-input" style="flex:1;"></select>
|
||||
<select id="wfm-video-asset-kind" class="wfm-input" style="flex:0.5;">
|
||||
<option value="all">All Types</option>
|
||||
<option value="video">Video Only</option>
|
||||
<option value="image">Image Only</option>
|
||||
<option value="audio">Audio Only</option>
|
||||
</select>
|
||||
<input type="text" id="wfm-video-asset-search" class="wfm-input" placeholder="Search filename..." style="flex:1;">
|
||||
<button type="button" id="wfm-video-asset-view-grid" class="wfm-btn wfm-btn-sm wfm-video-asset-view-btn active" title="Thumbnail view">▦</button>
|
||||
<button type="button" id="wfm-video-asset-view-table" class="wfm-btn wfm-btn-sm wfm-video-asset-view-btn" title="Table view">☰</button>
|
||||
<button type="button" id="wfm-video-asset-refresh" class="wfm-btn wfm-btn-sm" title="Refresh">↻</button>
|
||||
</div>
|
||||
<span id="wfm-video-asset-status" class="wfm-i2i-status"></span>
|
||||
<div class="wfm-video-asset-grid" id="wfm-video-asset-grid"></div>
|
||||
<div class="wfm-video-asset-table-wrap" id="wfm-video-asset-table-wrap" style="display:none;">
|
||||
<table class="wfm-video-asset-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Updated</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="wfm-video-asset-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-asset-detail-col">
|
||||
<div class="wfm-video-asset-detail" id="wfm-video-asset-detail">
|
||||
<span class="wfm-placeholder">Select a video</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan: バッチ動画生成のタイムライン編集・実行(中央下部、Planタブ選択時のみ表示) -->
|
||||
<div id="wfm-video-plan-content" class="wfm-video-subtab-panel wfm-video-plan-content" data-video-subtab-panel="plan">
|
||||
<div class="wfm-video-plan-topbar">
|
||||
<div class="wfm-video-wf-row">
|
||||
<span id="wfm-video-wf-name" class="wfm-video-wf-name">No workflow loaded</span>
|
||||
<div class="wfm-lab-plan-workflow-row" id="wfm-video-plan-workflow-row" style="display:none;">
|
||||
<span id="wfm-video-plan-workflow-name" class="wfm-lab-plan-workflow-name"></span>
|
||||
<button type="button" id="wfm-video-plan-workflow-load-btn" class="wfm-btn wfm-btn-xs" title="Load this plan's recorded workflow">Open Workflow</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-plan-basesettings">
|
||||
<div>
|
||||
<label for="wfm-video-plan-aspect-ratio" id="wfm-video-plan-aspect-ratio-label">Aspect Ratio</label>
|
||||
<select id="wfm-video-plan-aspect-ratio" class="wfm-input"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-megapixels" id="wfm-video-plan-megapixels-label">Megapixels</label>
|
||||
<input type="number" id="wfm-video-plan-megapixels" class="wfm-input" step="0.01" min="0.01" value="0.2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-multiple" id="wfm-video-plan-multiple-label">Multiple</label>
|
||||
<input type="number" id="wfm-video-plan-multiple" class="wfm-input" step="8" min="8" value="32">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-block-duration" id="wfm-video-block-duration-label">Duration (seconds)</label>
|
||||
<input type="number" id="wfm-video-block-duration" class="wfm-input" step="0.5" min="0.5" value="2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-seed-value" id="wfm-video-plan-seed-value-label">Noise Seed</label>
|
||||
<div class="wfm-video-seed-inline">
|
||||
<input type="number" id="wfm-video-plan-seed-value" class="wfm-input" value="0">
|
||||
<label class="wfm-video-seed-randomize-inline"><input type="checkbox" id="wfm-video-plan-seed-randomize" checked> Rand</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-plan-extrasettings">
|
||||
<div>
|
||||
<label for="wfm-video-plan-width" id="wfm-video-plan-width-label">Width</label>
|
||||
<input type="number" id="wfm-video-plan-width" class="wfm-input" step="16" min="16" value="1280">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-height" id="wfm-video-plan-height-label">Height</label>
|
||||
<input type="number" id="wfm-video-plan-height" class="wfm-input" step="16" min="16" value="720">
|
||||
</div>
|
||||
<div>
|
||||
<label id="wfm-video-plan-turbo-mode-label">Turbo Mode</label>
|
||||
<label class="wfm-video-checkbox-inline"><input type="checkbox" id="wfm-video-plan-turbo-mode"> Enabled</label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-turbo-strength" id="wfm-video-plan-turbo-strength-label">Strength</label>
|
||||
<input type="number" id="wfm-video-plan-turbo-strength" class="wfm-input" step="0.05" min="0" value="1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-turbo-steps" id="wfm-video-plan-turbo-steps-label">Steps</label>
|
||||
<input type="number" id="wfm-video-plan-turbo-steps" class="wfm-input" step="1" min="1" value="8">
|
||||
</div>
|
||||
<div>
|
||||
<label id="wfm-video-plan-prompt-enhance-label">Prompt Enhance</label>
|
||||
<label class="wfm-video-checkbox-inline"><input type="checkbox" id="wfm-video-plan-prompt-enhance"> Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-timeline-wrap">
|
||||
<div class="wfm-video-timeline-track" id="wfm-video-timeline-track"></div>
|
||||
<div class="wfm-video-timeline-toolbar">
|
||||
<button type="button" id="wfm-video-block-split-btn" class="wfm-btn wfm-btn-xs">+ Split</button>
|
||||
<button type="button" id="wfm-video-block-add-btn" class="wfm-btn wfm-btn-xs">+ Block</button>
|
||||
<button type="button" id="wfm-video-block-delete-btn" class="wfm-btn wfm-btn-xs">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-plan-main-row">
|
||||
<div class="wfm-video-block-editor" id="wfm-video-block-editor"></div>
|
||||
|
||||
<div class="wfm-video-runplan-panel">
|
||||
<div class="wfm-video-runplan-tabnav">
|
||||
<button type="button" class="wfm-video-runplan-tab active" data-runplan-tab="run">Run</button>
|
||||
<button type="button" class="wfm-video-runplan-tab" data-runplan-tab="plan">Plan</button>
|
||||
</div>
|
||||
|
||||
<div id="wfm-video-runplan-run" class="wfm-video-runplan-content active">
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button id="wfm-video-plan-run-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Run</button>
|
||||
<button id="wfm-video-plan-cancel-btn" class="wfm-btn wfm-btn-danger" style="display:none;">Cancel</button>
|
||||
</div>
|
||||
<button id="wfm-video-plan-pause-btn" class="wfm-btn" style="width:100%;margin-top:6px;" disabled>Pause</button>
|
||||
|
||||
<div class="wfm-gen-progress-container" style="margin-top:8px;">
|
||||
<div class="wfm-gen-progress-track">
|
||||
<div id="wfm-video-plan-progress-bar" class="wfm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span id="wfm-video-plan-progress-text" class="wfm-gen-progress-text">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wfm-video-runplan-plan" class="wfm-video-runplan-content" style="display:none;">
|
||||
<label>Note</label>
|
||||
<textarea id="wfm-video-plan-note" class="wfm-input wfm-lab-note" rows="3"></textarea>
|
||||
|
||||
<button id="wfm-video-plan-save-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:8px;">Plan Save</button>
|
||||
<button id="wfm-video-plan-saveas-btn" class="wfm-btn" style="width:100%;margin-top:6px;">Save As</button>
|
||||
<button id="wfm-video-plan-clear-btn" class="wfm-btn" style="width:100%;margin-top:6px;">Plan Clear</button>
|
||||
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-plan-drop-zone" style="margin-top:10px;">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<input type="file" id="wfm-video-plan-file-input" accept="application/json,.json,image/png,.png" style="display:none;">
|
||||
Drop plan (.json/.png) here or click to select
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit: 簡易動画編集(クリップのタイムライン配置・トリム・結合書き出し)。
|
||||
プレビューは上部のSource/Resultペインを再利用する(専用videoは持たない)。
|
||||
タイムライン+下の共有ツールバーはPlan subtabの.wfm-video-timeline-*と同じ構造
|
||||
(個別行ボタンではなく「選択中クリップに対して働く」共通ボタン方式)。 -->
|
||||
<div id="wfm-video-subtab-edit" class="wfm-video-subtab-panel wfm-video-edit-panel" data-video-subtab-panel="edit" style="display:none;">
|
||||
<div class="wfm-video-timeline-wrap">
|
||||
<div class="wfm-video-edit-track-tabs" id="wfm-video-edit-track-tabs">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-video-edit-track-tab active" data-track="video" id="wfm-video-edit-track-video">Video</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-video-edit-track-tab" data-track="pip" id="wfm-video-edit-track-pip">Overlay</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-video-edit-track-tab" data-track="text" id="wfm-video-edit-track-text">Text</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-video-edit-track-tab" data-track="audio" id="wfm-video-edit-track-audio">Audio</button>
|
||||
</div>
|
||||
<div class="wfm-video-edit-timeline-track" id="wfm-video-edit-timeline-track" data-track="video"></div>
|
||||
<div class="wfm-video-timeline-toolbar">
|
||||
<button type="button" id="wfm-video-edit-move-left-btn" class="wfm-btn wfm-btn-xs" title="Move left">◀</button>
|
||||
<button type="button" id="wfm-video-edit-move-right-btn" class="wfm-btn wfm-btn-xs" title="Move right">▶</button>
|
||||
<button type="button" id="wfm-video-edit-duplicate-btn" class="wfm-btn wfm-btn-xs">Duplicate</button>
|
||||
<button type="button" id="wfm-video-edit-delete-btn" class="wfm-btn wfm-btn-xs">Delete</button>
|
||||
<button type="button" id="wfm-video-edit-preview-btn" class="wfm-btn wfm-btn-xs">▶ Preview</button>
|
||||
<button type="button" id="wfm-video-edit-undo-btn" class="wfm-btn wfm-btn-xs" title="Undo (Ctrl+Z)" disabled>↶</button>
|
||||
<button type="button" id="wfm-video-edit-redo-btn" class="wfm-btn wfm-btn-xs" title="Redo (Ctrl+Y)" disabled>↷</button>
|
||||
<span class="wfm-i2i-status wfm-video-edit-total-duration" id="wfm-video-edit-total-duration" style="margin-left:auto;"></span>
|
||||
<button type="button" id="wfm-video-edit-clear-btn" class="wfm-btn wfm-btn-xs wfm-btn-danger">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-edit-main-row">
|
||||
<!-- Tool menu: its contents follow the timeline's track view (data-view, set by _setTrackView):
|
||||
video = trim + crop, text = text overlays, pip = overlay clips, audio = audio. Export stays fixed on the right. -->
|
||||
<div class="wfm-video-edit-tools" id="wfm-video-edit-tools" data-view="video">
|
||||
<div class="wfm-video-edit-trim-panel" id="wfm-video-edit-trim-panel"></div>
|
||||
<div class="wfm-video-edit-pip-panel">
|
||||
<div class="wfm-video-edit-section-head"><span id="wfm-video-edit-pip-title">Overlay clips (PinP)</span></div>
|
||||
<div class="wfm-video-edit-sound-head">
|
||||
<!-- The real <input> sits transparently over the button (not display:none behind a <label>), so the click lands on the native file input itself. -->
|
||||
<label class="wfm-btn wfm-btn-xs wfm-video-edit-file-btn wfm-video-edit-file-overlay" tabindex="0" role="button"><span id="wfm-video-edit-pip-btn">+ Overlay clip...</span><input type="file" id="wfm-video-edit-pip-input" multiple accept="video/*,image/*"></label>
|
||||
</div>
|
||||
<div class="wfm-video-edit-sound-list" id="wfm-video-edit-pip-list"></div>
|
||||
</div>
|
||||
<div class="wfm-video-edit-audio-panel">
|
||||
<div class="wfm-video-edit-section-head"><span id="wfm-video-edit-audio-title">Audio</span></div>
|
||||
<label class="wfm-video-checkbox-inline">
|
||||
<input type="checkbox" id="wfm-video-edit-keep-audio" checked> <span id="wfm-video-edit-keep-audio-label">Keep original audio</span>
|
||||
</label>
|
||||
<div class="wfm-video-edit-audio-row">
|
||||
<label for="wfm-video-edit-orig-vol" id="wfm-video-edit-orig-vol-label">Original volume</label>
|
||||
<input type="range" id="wfm-video-edit-orig-vol" min="-30" max="12" step="1" value="0">
|
||||
<span class="wfm-video-edit-audio-val" id="wfm-video-edit-orig-vol-val">0 dB</span>
|
||||
</div>
|
||||
<div class="wfm-video-edit-bgm-file">
|
||||
<label class="wfm-btn wfm-btn-xs wfm-video-edit-file-btn" tabindex="0" role="button"><span id="wfm-video-edit-bgm-btn">BGM...</span><input type="file" id="wfm-video-edit-bgm-input" accept="audio/*,video/mp4,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.mp4" style="display:none;"></label>
|
||||
<span class="wfm-i2i-status wfm-video-edit-bgm-name" id="wfm-video-edit-bgm-name"></span>
|
||||
<button type="button" id="wfm-video-edit-bgm-clear" class="wfm-btn wfm-btn-xs" title="Remove BGM">✕</button>
|
||||
</div>
|
||||
<div class="wfm-i2i-status wfm-video-edit-bgm-hint" id="wfm-video-edit-bgm-hint">Drop an audio file here, or use "Set as BGM" on a video in Asset</div>
|
||||
<div class="wfm-video-edit-audio-row">
|
||||
<label for="wfm-video-edit-bgm-vol" id="wfm-video-edit-bgm-vol-label">BGM volume</label>
|
||||
<input type="range" id="wfm-video-edit-bgm-vol" min="-30" max="12" step="1" value="-6">
|
||||
<span class="wfm-video-edit-audio-val" id="wfm-video-edit-bgm-vol-val">-6 dB</span>
|
||||
</div>
|
||||
<div class="wfm-video-edit-audio-row">
|
||||
<label for="wfm-video-edit-bgm-offset" id="wfm-video-edit-bgm-offset-label">BGM start (s)</label>
|
||||
<input type="number" id="wfm-video-edit-bgm-offset" class="wfm-input" min="0" step="0.1" value="0">
|
||||
</div>
|
||||
<div class="wfm-video-edit-sound-head">
|
||||
<label class="wfm-btn wfm-btn-xs wfm-video-edit-file-btn wfm-video-edit-file-overlay" tabindex="0" role="button"><span id="wfm-video-edit-sound-btn">+ Sound...</span><input type="file" id="wfm-video-edit-sound-input" multiple accept="audio/*,video/*"></label>
|
||||
</div>
|
||||
<div class="wfm-video-edit-sound-list" id="wfm-video-edit-sound-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-edit-export-panel">
|
||||
<div id="wfm-video-edit-project-name" class="wfm-i2i-status" style="min-height:14px;margin-bottom:4px;"></div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button type="button" id="wfm-video-edit-save-btn" class="wfm-btn wfm-btn-sm" style="flex:1;">Save</button>
|
||||
<button type="button" id="wfm-video-edit-saveas-btn" class="wfm-btn wfm-btn-sm" style="flex:1;">Save As</button>
|
||||
<!-- File pickers are native <label>-wrapped inputs (same as the Video Source drop zone),
|
||||
not a button calling input.click() from JS — that programmatic path can be refused
|
||||
by the browser without any error (confirmed on a user's machine for the BGM picker). -->
|
||||
<label id="wfm-video-edit-load-btn" class="wfm-btn wfm-btn-sm wfm-video-edit-file-btn" style="flex:1;" tabindex="0" role="button">Load<input type="file" id="wfm-video-edit-load-file-input" accept="application/json,.json" style="display:none;"></label>
|
||||
</div>
|
||||
<button type="button" id="wfm-video-edit-export-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:8px;">Export</button>
|
||||
<div class="wfm-gen-progress-container" style="margin-top:8px;">
|
||||
<div class="wfm-gen-progress-track">
|
||||
<div id="wfm-video-edit-progress-bar" class="wfm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span id="wfm-video-edit-progress-text" class="wfm-gen-progress-text">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右ペイン: プロパティ(Frame抽出 / GIF変換の2タブ) -->
|
||||
<!-- 右ペイン: プロパティ(Frame抽出 / GIF変換の2タブ)。中央ペインと同様に常時表示 -->
|
||||
<div class="wfm-video-properties-panel">
|
||||
<div class="wfm-video-prop-tabnav">
|
||||
<button type="button" class="wfm-video-prop-tab active" data-prop-tab="frame" id="wfm-video-prop-tab-frame">Frame</button>
|
||||
@@ -2812,6 +3123,9 @@
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-video-frame-preview-wrap" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-video-frame-preview-img">
|
||||
</div>
|
||||
<label class="wfm-video-checkbox-inline" style="margin-top:6px;">
|
||||
<input type="checkbox" id="wfm-video-frame-add-to-assets"> Add to Video Assets group
|
||||
</label>
|
||||
<button type="button" id="wfm-video-frame-save-btn" class="wfm-btn wfm-btn-primary wfm-btn-sm" style="width:100%;margin-top:6px;" disabled>Save to Output</button>
|
||||
<span class="wfm-i2i-status" id="wfm-video-frame-status"></span>
|
||||
</div>
|
||||
@@ -2907,13 +3221,8 @@
|
||||
</div>
|
||||
<div id="wfm-tagger-ollama-settings" style="display:none">
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-ollama-url-label"></label>
|
||||
<input type="text" id="wfm-tagger-ollama-url" class="wfm-search-input" value="http://127.0.0.1:11434" style="flex:1;">
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-ollama-model-label"></label>
|
||||
<select id="wfm-tagger-ollama-model" class="wfm-select wfm-tagger-select"></select>
|
||||
<button id="wfm-tagger-ollama-refresh-btn" class="wfm-btn wfm-btn-sm" title="Refresh models">↻</button>
|
||||
<span id="wfm-tagger-ollama-backend-value" style="flex:1;color:var(--wfm-text-secondary);font-size:12px;"></span>
|
||||
<button id="wfm-tagger-ollama-settings-btn" class="wfm-btn wfm-btn-sm" title="Settings">⚙</button>
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row" style="align-items:flex-start">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-ollama-prompt-label" style="padding-top:4px;"></label>
|
||||
@@ -2975,18 +3284,13 @@
|
||||
<div class="wfm-tagger-section-header">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:600;">
|
||||
<input type="checkbox" id="wfm-tagger-batch-ollama-enable">
|
||||
Ollama VLM
|
||||
<span id="wfm-tagger-batch-ollama-header-label">VLM Tagging</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="wfm-tagger-batch-ollama-settings">
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-batch-url-label"></label>
|
||||
<input type="text" id="wfm-tagger-batch-ollama-url" class="wfm-search-input" value="http://127.0.0.1:11434" style="flex:1;">
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-batch-model-label"></label>
|
||||
<select id="wfm-tagger-batch-ollama-model" class="wfm-select wfm-tagger-select"></select>
|
||||
<button id="wfm-tagger-batch-ollama-refresh-btn" class="wfm-btn wfm-btn-sm">↻</button>
|
||||
<span id="wfm-tagger-batch-backend-value" style="flex:1;color:var(--wfm-text-secondary);font-size:12px;"></span>
|
||||
<button id="wfm-tagger-batch-ollama-settings-btn" class="wfm-btn wfm-btn-sm" title="Settings">⚙</button>
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row" style="align-items:flex-start">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-batch-prompt-label" style="padding-top:4px;"></label>
|
||||
@@ -3034,11 +3338,15 @@
|
||||
<button id="wfm-tagger-db-search-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"></button>
|
||||
<button id="wfm-tagger-db-show-all-btn" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-tagger-db-export-btn" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-tagger-db-delete-selected-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"></button>
|
||||
</div>
|
||||
<div id="wfm-tagger-db-table-wrap" class="wfm-tagger-db-table-wrap">
|
||||
<table id="wfm-tagger-db-table" class="wfm-table wfm-tagger-db-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="wfm-tagger-db-th-select">
|
||||
<input type="checkbox" id="wfm-tagger-db-select">
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th id="wfm-tagger-db-th-file"></th>
|
||||
<th id="wfm-tagger-db-th-interrogator"></th>
|
||||
@@ -3214,6 +3522,7 @@
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<button id="wfm-ai-test-btn" class="wfm-btn">Test connection</button>
|
||||
<button id="wfm-ai-unload-btn" class="wfm-btn">Unload Model</button>
|
||||
<span id="wfm-ai-test-result" class="wfm-ai-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,26 @@ const fetchObjectInfo = async () => {
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const getNodeDef = (nodeType) => {
|
||||
if (state.objectInfo[nodeType]) return state.objectInfo[nodeType];
|
||||
return typeof LiteGraph !== "undefined" ? LiteGraph.registered_node_types[nodeType]?.nodeData : undefined;
|
||||
};
|
||||
|
||||
let objectInfoPromise = null;
|
||||
const ensureObjectInfo = () => {
|
||||
if (objectInfoPromise) return objectInfoPromise;
|
||||
const registered = typeof LiteGraph !== "undefined" ? LiteGraph.registered_node_types : {};
|
||||
if (Object.values(registered).some(d => d?.nodeData?.python_module)) {
|
||||
return (objectInfoPromise = Promise.resolve());
|
||||
}
|
||||
objectInfoPromise = fetchObjectInfo().then((info) => {
|
||||
state.objectInfo = info;
|
||||
// Category/Packageを表示中なら取得完了後に描き直す
|
||||
if (state.topTab === "nodes" && (state.activeTab2 === "category" || state.activeTab2 === "package")) renderContent();
|
||||
});
|
||||
return objectInfoPromise;
|
||||
};
|
||||
|
||||
const extractPackageName = (pythonModule) => {
|
||||
if (!pythonModule || pythonModule === "nodes") return "ComfyUI (Built-in)";
|
||||
const parts = pythonModule.split(".");
|
||||
@@ -104,13 +124,15 @@ const extractPackageName = (pythonModule) => {
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const [metadata, nodeSets, groups, objectInfo] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(), fetchObjectInfo(),
|
||||
// /object_info は数MBになりNodesタブ表示を待たせるため、ここでは待たない。
|
||||
// Category/Package は LiteGraph の登録済みノード定義(nodeData)から取れるので、
|
||||
// それが無い環境でのみバックグラウンドで取得する(ensureObjectInfo)。
|
||||
const [metadata, nodeSets, groups] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(),
|
||||
]);
|
||||
state.metadata = metadata;
|
||||
state.nodeSets = nodeSets;
|
||||
state.groups = groups;
|
||||
state.objectInfo = objectInfo;
|
||||
|
||||
// Extract favorites from metadata
|
||||
state.favorites = [];
|
||||
@@ -121,6 +143,7 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
state.loaded = true;
|
||||
ensureObjectInfo();
|
||||
};
|
||||
|
||||
// ============================================
|
||||
@@ -713,6 +736,44 @@ window.wfmSendImageToSelectedNode = (filename) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 動画版「Send to LV node」。ネイティブの Load Video ノード(comfy_extras/nodes_video.py の
|
||||
// LoadVideo、node type "LoadVideo")の "file" ウィジェットへ書き込む。"image"ウィジェットと違い
|
||||
// "file"という名前は他ノードとも衝突しうるため、node.typeを"LoadVideo"に絞って探す。
|
||||
function _wfmFindVideoWidget() {
|
||||
const graph = app.graph;
|
||||
if (!graph) return null;
|
||||
|
||||
let selected = [];
|
||||
try {
|
||||
const sel = app.canvas?.selected_nodes;
|
||||
if (sel) selected = Object.values(sel);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const pools = [selected, graph._nodes || []];
|
||||
for (const pool of pools) {
|
||||
for (const node of pool) {
|
||||
if (node?.type !== "LoadVideo") continue;
|
||||
const widget = node?.widgets?.find((w) => w.name === "file");
|
||||
if (widget) return widget;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
window.wfmSendVideoToSelectedNode = (filename) => {
|
||||
const widget = _wfmFindVideoWidget();
|
||||
if (!widget) {
|
||||
throw new Error("No video widget found (select a native Load Video node)");
|
||||
}
|
||||
if (widget.options?.values && !widget.options.values.includes(filename)) {
|
||||
widget.options.values.unshift(filename);
|
||||
}
|
||||
widget.value = filename;
|
||||
widget.callback?.(widget.value);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadWorkflowOnCanvas = async (filename) => {
|
||||
const displayName = filename.replace(/\.json$/i, "");
|
||||
showToast(`Loading "${displayName}"...`, "info");
|
||||
@@ -2224,7 +2285,7 @@ const renderGroups = (container) => {
|
||||
// ============================================
|
||||
|
||||
const getNodeCategory = (nodeType) => {
|
||||
const info = state.objectInfo[nodeType];
|
||||
const info = getNodeDef(nodeType);
|
||||
if (info?.category) return info.category.split("/")[0] || "uncategorized";
|
||||
// Fallback: LiteGraph type string often contains category as prefix
|
||||
const def = typeof LiteGraph !== "undefined"
|
||||
@@ -2235,7 +2296,7 @@ const getNodeCategory = (nodeType) => {
|
||||
};
|
||||
|
||||
const getNodePackage = (nodeType) => {
|
||||
const info = state.objectInfo[nodeType];
|
||||
const info = getNodeDef(nodeType);
|
||||
if (!info) return "ComfyUI (Built-in)";
|
||||
return extractPackageName(info.python_module || "");
|
||||
};
|
||||
@@ -2369,9 +2430,51 @@ const createDraggableItem = (label, type, data, subtitle) => {
|
||||
el.classList.remove("dragging");
|
||||
});
|
||||
|
||||
// Alt+クリックでお気に入りのON/OFF(Nodesサブタブの単体ノード項目のみ)
|
||||
if (type === "single" && data?.classType) {
|
||||
el.addEventListener("click", (e) => {
|
||||
if (!e.altKey) return;
|
||||
e.preventDefault();
|
||||
toggleNodeFavorite(data.classType, el);
|
||||
});
|
||||
}
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
const toggleNodeFavorite = async (name, el) => {
|
||||
const prev = !!state.metadata[name]?.favorite;
|
||||
const next = !prev;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nodeName: name, favorite: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
console.warn("[WFM] failed to toggle node favorite:", err);
|
||||
return;
|
||||
}
|
||||
state.metadata[name] = { ...(state.metadata[name] || {}), favorite: next };
|
||||
state.favorites = state.favorites.filter(n => n.name !== name);
|
||||
if (next) state.favorites.push({ name, display_name: name });
|
||||
|
||||
if (state.topTab === "nodes" && !state.activeTab2 && state.activeTab === "favorites") {
|
||||
renderContent(); // 解除した項目をFavorites一覧から外す
|
||||
return;
|
||||
}
|
||||
// その他のタブはDOMだけ更新(パッケージ選択などの表示状態を保つ)
|
||||
const label = el.querySelector(".wfm-nlp-item-label");
|
||||
label?.querySelector(".wfm-nlp-fav-star")?.remove();
|
||||
if (next && label) {
|
||||
const star = document.createElement("span");
|
||||
star.className = "wfm-nlp-fav-star";
|
||||
star.textContent = "★";
|
||||
label.prepend(star);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Toggle & exports
|
||||
// ============================================
|
||||
@@ -3159,6 +3262,7 @@ function _extractLoRAs(wf) {
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "LoraLoader") add(n.inputs?.lora_name, n.inputs?.strength_model, n.inputs?.strength_clip);
|
||||
else if (ct === "LoraLoaderModelOnly") add(n.inputs?.lora_name, n.inputs?.strength, 1.0);
|
||||
else if (ct === "ImageMetadataLoRALoader") { for (let i = 1; i <= 3; i++) add(n.inputs?.[`lora_${i}`], n.inputs?.[`strength_model_${i}`], n.inputs?.[`strength_clip_${i}`]); }
|
||||
else if (ct === "Lora Loader (LoraManager)") {
|
||||
const lorasData = n.inputs?.loras;
|
||||
const list = lorasData?.__value__ ?? (Array.isArray(lorasData) ? lorasData : null);
|
||||
@@ -3172,6 +3276,9 @@ function _extractLoRAs(wf) {
|
||||
function _isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("TextEncode") || ct.includes("TextEncoderSD"); }
|
||||
function _isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
|
||||
function _isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
|
||||
// LiveChatStream(ComfyUI-LiveChatStream)のRETURN_NAMES順 = INPUT_TYPESの対応ウィジェット名
|
||||
// (metadata-tab.jsのLIVE_CHAT_STREAM_OUTPUT_KEYSと同一。ported from metadata-tab.js)
|
||||
const _LIVE_CHAT_STREAM_OUTPUT_KEYS = ["prompt_text", "negative_text", "response_text", "chat_p_text", "chat_n_text", "thinking_text"];
|
||||
|
||||
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
|
||||
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
|
||||
@@ -3253,17 +3360,16 @@ function _extractPromptsFromNodeSet(nodes, links) {
|
||||
continue;
|
||||
}
|
||||
if (!_isTextEncoderNode(type)) continue;
|
||||
const text = n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") { textMap.set(n.id, text); }
|
||||
else if (Array.isArray(n.inputs)) {
|
||||
const textInput = n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt");
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const text2 = _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
// リンク化されたtextウィジェットはwidgets_values[0]に古い入力値が残る(実行時には使われない)ため、
|
||||
// リンク先の解決結果を優先する
|
||||
const textInput = Array.isArray(n.inputs)
|
||||
? n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt")
|
||||
: null;
|
||||
const linkedText = textInput?.link != null && linkOrigin.has(textInput.link)
|
||||
? _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, linkOrigin.get(textInput.link), linkSlot.get(textInput.link) ?? 0)
|
||||
: null;
|
||||
const text = linkedText || n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") textMap.set(n.id, text);
|
||||
}
|
||||
const pos = new Set(), neg = new Set();
|
||||
let foundSampler = false;
|
||||
@@ -3273,11 +3379,13 @@ function _extractPromptsFromNodeSet(nodes, links) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — resolve through its
|
||||
// "guider" input (CFGGuider/DualCFGGuider/BasicGuider) instead.
|
||||
let inputsToScan = n.inputs;
|
||||
let viaBasicGuider = false;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
viaBasicGuider = guiderNode?.type === "BasicGuider";
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
@@ -3285,7 +3393,9 @@ function _extractPromptsFromNodeSet(nodes, links) {
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
// BasicGuider (Flux/Ming Image etc., no negative): its single "conditioning" input is positive.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive")
|
||||
|| (viaBasicGuider && name === "conditioning");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 1ノードでprompt/negative_prompt両方を
|
||||
@@ -3363,6 +3473,12 @@ function _resolveLinkedText(wf, srcId, slot, depth = 0) {
|
||||
if (!src || typeof src !== "object") return null;
|
||||
const ct = src.class_type ?? "";
|
||||
if (_isPromptStylerNode(ct)) { const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative; return (v && typeof v === "string") ? v : null; }
|
||||
// LiveChatStream — 出力は同名+"_text"の非表示ウィジェットの値をそのまま返すだけなので個別対応
|
||||
if (ct === "LiveChatStream") {
|
||||
const key = _LIVE_CHAT_STREAM_OUTPUT_KEYS[slot];
|
||||
const v = key ? src.inputs?.[key] : undefined;
|
||||
return (typeof v === "string" && v) ? v : null;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
|
||||
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
|
||||
if (ct === "ComfySwitchNode") {
|
||||
@@ -3428,14 +3544,18 @@ function _extractPromptsAPI(wf) {
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
let viaBasicGuider = false;
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
viaBasicGuider = guiderNode?.class_type === "BasicGuider";
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
// BasicGuider (Flux/Ming Image etc., no negative): its single "conditioning" input is positive.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive")
|
||||
|| (viaBasicGuider && key === "conditioning");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 1ノードでprompt/negative_prompt両方を
|
||||
@@ -3916,6 +4036,44 @@ function isValidAiUrl(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// "Unload all now" (VRAM): same server step as Workflow Studio's Settings → VRAM Management
|
||||
// (/api/wfm/vram/prepare, py/routes/vram_routes.py). static/js/vram-prepare.js can't be imported
|
||||
// here (it pulls in the SPA's app.js), so the Ollama URLs are collected from the same localStorage
|
||||
// keys directly — the SPA is served from this origin. The server always adds 127.0.0.1:11434.
|
||||
function _aiVramOllamaUrls(extraUrl) {
|
||||
const urls = [];
|
||||
for (const key of ["wfm_ai_settings", "wfm_prompt_ai_settings", "wfm_tagger_ai_settings"]) {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(key) || "{}");
|
||||
if ((s.backend || "ollama") === "ollama") urls.push(s.backendUrl || getAiBackendDefaultUrl("ollama"));
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const d = JSON.parse(localStorage.getItem("wfm_decision_settings") || "{}");
|
||||
if (d.backend === "ollama" && d.baseUrl) urls.push(d.baseUrl);
|
||||
} catch {}
|
||||
if (extraUrl) urls.push(extraUrl);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
async function aiVramUnloadAll(extraUrl) {
|
||||
const r = await fetch("/api/wfm/vram/prepare", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: "all", urls: _aiVramOllamaUrls(extraUrl) }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok || d.status !== "ok") throw new Error(d.message || `HTTP ${r.status}`);
|
||||
const gb = (mb) => (mb / 1024).toFixed(1);
|
||||
let text;
|
||||
if (d.unloaded.length) text = `Unloaded ${d.unloaded.map((u) => u.name).join(", ")} — free ${gb(d.free_before_mb)} → ${gb(d.free_after_mb)} GB`;
|
||||
else if (d.failed.length) text = "";
|
||||
else if (d.unreachable?.length) text = `Cannot reach Ollama: ${d.unreachable.map((u) => u.url).join(", ")}`;
|
||||
else text = `No Ollama models are loaded (free ${gb(d.free_after_mb)} GB)`;
|
||||
if (d.failed.length) text += (text ? " / " : "") + `Could not unload: ${d.failed.join(", ")}`;
|
||||
return { text, ok: !d.failed.length && !(d.unreachable?.length && !d.unloaded.length) };
|
||||
}
|
||||
|
||||
function loadAiCfg() {
|
||||
try { return JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) || "{}"); } catch { return {}; }
|
||||
}
|
||||
@@ -4272,6 +4430,13 @@ const renderAiTab = (container) => {
|
||||
<input type="text" id="wfm-nlp-ai-free-dst" class="wfm-nlp-ai-input" placeholder="e.g. German" style="flex:1;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
<div class="wfm-nlp-ai-sec-title">VRAM (Ollama)</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<button id="wfm-nlp-ai-vram-unload" class="wfm-nlp-ai-btn" title="Unload every model loaded in Ollama on localhost (decision models included) — same as Settings → VRAM Management in Workflow Studio">Unload all now</button>
|
||||
</div>
|
||||
<span id="wfm-nlp-ai-vram-status" class="wfm-nlp-ai-status"></span>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
<button id="wfm-nlp-ai-save" class="wfm-nlp-ai-btn wfm-nlp-ai-btn-primary" style="width:100%;">Save</button>
|
||||
</div>
|
||||
@@ -4438,6 +4603,27 @@ const setupAiHandlers = (container) => {
|
||||
}
|
||||
});
|
||||
|
||||
// VRAM: unload every loaded Ollama model (includes the URL typed in Connection when backend is Ollama)
|
||||
container.querySelector("#wfm-nlp-ai-vram-unload")?.addEventListener("click", async () => {
|
||||
const btn = container.querySelector("#wfm-nlp-ai-vram-unload");
|
||||
const statusEl = container.querySelector("#wfm-nlp-ai-vram-status");
|
||||
const backend = container.querySelector("input[name='wfm-nlp-ai-backend']:checked")?.value || "ollama";
|
||||
const url = container.querySelector("#wfm-nlp-ai-url")?.value?.trim() || "";
|
||||
btn.disabled = true;
|
||||
statusEl.textContent = "Unloading...";
|
||||
statusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
|
||||
try {
|
||||
const res = await aiVramUnloadAll(backend === "ollama" && isValidAiUrl(url) ? url : "");
|
||||
statusEl.textContent = res.text;
|
||||
statusEl.className = "wfm-nlp-ai-status " + (res.ok ? "wfm-nlp-ai-ok" : "wfm-nlp-ai-err");
|
||||
} catch (err) {
|
||||
statusEl.textContent = `Failed: ${err.message}`;
|
||||
statusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Model refresh button
|
||||
container.querySelector("#wfm-nlp-ai-model-refresh")?.addEventListener("click", () => refreshModels());
|
||||
|
||||
|
||||