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=
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
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画像連携
|
||||
|
||||
@@ -2,6 +2,458 @@
|
||||
|
||||
---
|
||||
|
||||
## 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 昇順降順)
|
||||
|
||||
+16
-10
@@ -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), Sort
|
||||
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, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, Video Playback Volume, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
|
||||
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, PSD 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)
|
||||
@@ -75,11 +76,11 @@ Model Browser, Thumbnail / Table views, Table column sort, Search & Filter, User
|
||||
### Image Edit Tab (v0.3.65)
|
||||
Layer-based image editor, Loading images, Open PSD button, Close button, New button, Tools, Layer panel, Duplicate layer (⧉), Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
|
||||
|
||||
### Video Tab (v0.5.0 – v0.7.2)
|
||||
Dedicated video generation UI, independent of GenerateUI, Plan subtab, Base settings row, Edit subtab, 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
|
||||
### 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, Model unload, 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,11 +158,16 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +75,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **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>
|
||||
|
||||
@@ -122,6 +124,8 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **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>
|
||||
|
||||
@@ -234,9 +238,10 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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, Video Plans, Style presets) and the Tagger SQLite database; two opt-in checkboxes can also include ComfyUI's default workflows folder (`user/default/workflows`) and the Impact Pack's wildcard folder in the export — both of those are export-only and stored under an `_external/` prefix in the ZIP that Restore always skips, since auto-overwriting files a different plugin/ComfyUI core manages would be unsafe; copy them back by hand if needed. Restoring overwrites existing files; restart ComfyUI afterward for either restore path
|
||||
- **Full Backup (ZIP)** (v0.5.2) — additionally covers what the JSON bundle can't represent: folder-based data (AI Skills, Lab Plans, Video Plans, Video Edit projects, Style presets) and the Tagger SQLite database; two opt-in checkboxes can also include ComfyUI's default workflows folder (`user/default/workflows`) and the Impact Pack's wildcard folder in the export — both of those are export-only and stored under an `_external/` prefix in the ZIP that Restore always skips, since auto-overwriting files a different plugin/ComfyUI core manages would be unsafe; copy them back by hand if needed. A third opt-in checkbox (v0.7.6) bundles the clips/BGM/sounds/overlay clips that saved Video Edit projects reference in ComfyUI's input folder (the project JSON only stores references) under `_video_edit_media/`; unlike `_external/`, these are restored into the input folder, never overwriting a file that already exists there. Restoring overwrites existing files; restart ComfyUI afterward for either restore path
|
||||
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
|
||||
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`; the same color and mechanism also drives the Video tab's field highlighting (v0.5.4, see above)
|
||||
@@ -258,6 +263,17 @@ 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); **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
|
||||
@@ -266,13 +282,15 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **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; PSD files (v0.6.0) are fully decomposed into their original layers instead of being flattened into one image
|
||||
- **Send GenUI Image button** — toolbar button (next to Image Edit); uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded
|
||||
- **Send to LI node button** (v0.5.2) — toolbar button (next to Send GenUI Image); uploads the selected image and writes its filename into the `image` widget of the node currently selected on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination and cross-window bridge as the Image Edit tab's **Send to LI node** button (renamed from "Send to Workflow" in this same release), so both entry points now share one consistent name; requires Workflow Studio to have been opened from ComfyUI's top menu button
|
||||
- **Send to LV node button** (v0.7.14) — toolbar button (next to Send to LI node), the video equivalent: uploads the selected video and writes its filename into the `file` widget of the node currently selected on the ComfyUI canvas, restricted to ComfyUI's native **Load Video** node (`LoadVideo`) since `file` as a widget name isn't unique enough to search for generically the way `image` is; only enabled when the selected item is a video (mp4/webm); same cross-window bridge and launch requirement as Send to LI node
|
||||
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay; when the selected file is an MP4 video (v0.6.3), it is routed to Comic Creator's Layout-tab Video tool instead — inserted as a new video object in the selected panel (playable, capturable to a still frame) rather than as a flattened image; sending a video while the Image tab is the intended destination is not yet supported and shows an info toast instead
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — toolbar button next to the view-mode (Thumbnail/Table) switch (v0.5.2, moved from the toolbar's rightmost position so it sits with the other display-affecting controls); resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
|
||||
@@ -312,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
|
||||
@@ -414,14 +435,19 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Video Tab (v0.5.0 – v0.7.2)</h3></summary>
|
||||
<summary><h3>Video Tab (v0.5.0 – v0.7.4)</h3></summary>
|
||||
|
||||
- **Dedicated video generation UI, independent of GenerateUI** — a purpose-built tab for MiniMax H3 (Image to Video), LTX-2.5 (Text / Image / First & Last Frame to Video), and Wan2.2 (Text to Video) workflows (LTX-2.5/Wan2.2: v0.5.4); does not share state with the GenerateUI tab, and model selection (UNet/CLIP/VAE/Audio VAE) is fixed to the loaded workflow's own defaults rather than editable here
|
||||
- **Plan subtab** (center panel, batch redesign in v0.5.5) — a **plan** is a sequence of timeline blocks, each a fully self-contained generation job with its own Prompt, Duration, and First/Last Image; split the timeline with **+ Split** / **+ Block** / **Delete**, then **Run** to generate every block in order (Pause/Cancel work mid-run) — a plan with a single block behaves exactly like the old one-shot form. First Image can be None, an explicit image, or "use previous block's last frame" (chaining — an explicit Last Image on the previous block always wins over extracting one from its generated output). Save / Save As / Clear a plan (`ws_videoplan_*.json`, with a PNG contact-sheet thumbnail that embeds the full plan data so dropping the image back on works too)
|
||||
- **Base settings row** (topbar, v0.5.7) — Aspect Ratio / Megapixels / Multiple / Duration (seconds) / Noise Seed on one row — Duration always reflects the currently selected block (each block keeps its own value), the rest are plan-wide — plus a second row of model-specific fields: Width / Height (become directly editable for workflows with no Resolution Selector node, e.g. Wan2.2 and LTX-2.5's First & Last Frame to Video), Turbo Mode / Strength / Steps (MiniMax H3's turbo LoRA — Turbo Mode doubles as Wan2.2's own turbo toggle, sharing one field), and Prompt Enhance (LTX-2.5 only). Every field in both rows is enabled only when the loaded workflow actually has it — see Field highlight color below (one exception: Wan2.2's Text-to-Video variant doesn't expose Noise Seed at all, so it alone leaves that field disabled)
|
||||
- **Edit subtab** (v0.7.0) — a lightweight clip timeline for trimming and concatenating video/image clips outside of generation: add clips via the Video Source panel's **Add to Edit** button or the Asset subtab's **Send to Edit** button, drag blocks to reorder (or use the ◀/▶ toolbar buttons), Duplicate/Delete the selected clip, Clear to empty the timeline. The trim panel for a selected video clip shows a Clipchamp-style scrubber — a time ruler, a draggable playhead synced to the Source preview, and draggable in/out handles with time badges (image clips get a simple hold-duration field instead); the playhead's own time badge sits a row above the in/out handle badges (v0.7.2) so it stays legible even when the playhead lands right on a trim point, and the scrubber has extra headroom/height and spacing from the clip name above it (v0.7.2). **▶ Preview** plays every ready clip back-to-back (trimmed to its in/out points) in the result preview, and **Export** builds and runs the actual ComfyUI graph (trim → concatenate if more than one clip → save), tagging the result into Video Temp automatically — exporting more than one clip currently drops audio, since ComfyUI's concatenation node requires uniform audio across all inputs
|
||||
- **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) 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), 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)
|
||||
- **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
|
||||
@@ -450,11 +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
|
||||
- **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
|
||||
@@ -467,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
|
||||
@@ -571,11 +599,16 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages
|
||||
|
||||
+46
-1
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -94,6 +111,144 @@ def setup_routes(app: web.Application):
|
||||
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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -235,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:
|
||||
|
||||
+157
-38
@@ -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)
|
||||
|
||||
+156
-10
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
+33
-28
@@ -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,5 +1,5 @@
|
||||
"""Video tab API routes: last-frame capture, animated GIF conversion, and
|
||||
Plan (batch timeline) file management."""
|
||||
"""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
|
||||
@@ -22,6 +22,7 @@ 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)
|
||||
@@ -95,6 +96,35 @@ async def handle_probe_video(request: web.Request) -> web.Response:
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
+320
-32
@@ -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}
|
||||
+318
-17
@@ -11,14 +11,36 @@ import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子(.mp4は動画だがGalleryでは静止画と同じ一覧・配信経路を共有する。
|
||||
# 動画拡張子(PyAV(av)がコンテナ形式を問わず共通で扱えるため、寸法/再生時間/埋め込み
|
||||
# メタデータ読み取り・サムネイル抽出は両形式で同じコードパスを通る)
|
||||
VIDEO_EXTENSIONS = {".mp4", ".webm"}
|
||||
|
||||
# 音声拡張子(ComfyUIの音楽生成出力)。動画と同様にPyAVで再生時間・埋め込みメタデータを読む。
|
||||
# サムネイルは波形画像を生成する。
|
||||
AUDIO_EXTENSIONS = {".wav", ".mp3", ".ogg", ".m4a", ".flac", ".opus"}
|
||||
# 波形サムネイルの版数(描画仕様を変えたら上げる)。static/js/gallery-tab.js の WAVE_THUMB_VERSION と同じ値にすること。
|
||||
WAVE_THUMB_VERSION = "wave2"
|
||||
_NB_INDEX_CACHE_MAX = 16
|
||||
|
||||
# 音声サムネイル(波形)の色。拡張子ごとに変えて、一覧で形式を見分けやすくする。
|
||||
AUDIO_WAVE_COLORS = {
|
||||
".mp3": (56, 189, 248), # blue
|
||||
".wav": (74, 222, 128), # green
|
||||
".flac": (251, 191, 36), # amber
|
||||
".ogg": (192, 132, 252), # purple
|
||||
".opus": (192, 132, 252), # purple(Ogg系は同色)
|
||||
".m4a": (251, 113, 133), # rose
|
||||
}
|
||||
|
||||
# サポートする画像拡張子(動画・音声はGalleryでは静止画と同じ一覧・配信経路を共有する。
|
||||
# .psdはブラウザが直接レンダリングできないため、配信時に合成済みPNGへ変換する)
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".mp4", ".psd"}
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".psd"} | VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||
|
||||
# Canvas2D の globalCompositeOperation 文字列 -> psd_tools.constants.BlendMode 名
|
||||
# (Image Edit タブの Layer.blendMode はCanvas2Dの合成モード文字列をそのまま保持している)
|
||||
@@ -54,6 +76,10 @@ _SAVE_EXT_BY_MIME = {
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
# Comic Creator (eagle_comic_creator_spa) の nanobananaタブが生成履歴を記録するフォルダ名。
|
||||
# 画像と同じフォルダに history.jsonl (1行1リクエスト、filenames配列で画像と対応) が書かれる。
|
||||
NANOBANANA_FOLDER_NAME = "cc_nanobanana"
|
||||
|
||||
|
||||
def _decode_image_data_url(image_data: str) -> tuple[bytes, str]:
|
||||
"""data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。
|
||||
@@ -175,6 +201,10 @@ class _FolderCache:
|
||||
|
||||
|
||||
class GalleryService:
|
||||
_nb_index_cache: "OrderedDict[str, tuple]" = OrderedDict() # history.jsonlの索引キャッシュ(上限付き)
|
||||
_nb_index_lock = threading.Lock()
|
||||
_convert_lock = threading.Lock() # 変換は同時に1本だけ実行する
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = data_dir
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
@@ -414,6 +444,21 @@ class GalleryService:
|
||||
_walk(folder)
|
||||
return entries
|
||||
|
||||
def make_list_item(self, name: str, abs_path: str, size: int, mtime: float) -> dict:
|
||||
"""一覧アイテムdict(list_imagesと同形式)を作る。セマンティック検索の結果表示用。"""
|
||||
meta = self.metadata_store.get(abs_path)
|
||||
return {
|
||||
"filename": name,
|
||||
"path": abs_path,
|
||||
"size": size,
|
||||
"mtime": mtime,
|
||||
"ext": Path(name).suffix.lower(),
|
||||
"favorite": meta.get("favorite", False),
|
||||
"tags": meta.get("tags", []),
|
||||
"memo": meta.get("memo", ""),
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
def list_images(
|
||||
self,
|
||||
folder_path: str,
|
||||
@@ -571,6 +616,58 @@ class GalleryService:
|
||||
pass
|
||||
return self._read_vault_prompt(image_path)
|
||||
|
||||
def _read_nanobanana_entry(self, image_path: Path) -> dict | None:
|
||||
"""画像がcc_nanobananaフォルダ内にあり、同フォルダのhistory.jsonlに対応エントリが
|
||||
あれば返す(1行=1生成リクエストで複数画像filenamesを束ねているため、自分の
|
||||
ファイル名を含む行を新しい方から探す。同名再生成があれば最新を優先する)。"""
|
||||
if image_path.parent.name != NANOBANANA_FOLDER_NAME:
|
||||
return None
|
||||
history_path = image_path.parent / "history.jsonl"
|
||||
index = self._load_nanobanana_index(history_path)
|
||||
return index.get(image_path.name) if index else None
|
||||
|
||||
def _load_nanobanana_index(self, history_path: Path) -> dict | None:
|
||||
"""history.jsonl を「ファイル名→エントリ」の辞書にして返す。(mtime, size)をキーに
|
||||
キャッシュし、メタデータ要求のたびの全読み込みを避ける。同名再生成は新しい行を優先する。"""
|
||||
try:
|
||||
st = history_path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
key = str(history_path)
|
||||
sig = (st.st_mtime_ns, st.st_size)
|
||||
with self._nb_index_lock:
|
||||
cached = self._nb_index_cache.get(key)
|
||||
if cached and cached[0] == sig:
|
||||
self._nb_index_cache.move_to_end(key)
|
||||
return cached[1]
|
||||
try:
|
||||
lines = history_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
index: dict = {}
|
||||
for line in reversed(lines): # 新しい行から。最初に見つかったものを優先
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(entry, dict): # 破損行([]・数値など)は無視
|
||||
continue
|
||||
filenames = entry.get("filenames")
|
||||
if not isinstance(filenames, list):
|
||||
continue
|
||||
for name in filenames:
|
||||
if isinstance(name, str) and name not in index:
|
||||
index[name] = entry
|
||||
with self._nb_index_lock:
|
||||
self._nb_index_cache[key] = (sig, index)
|
||||
self._nb_index_cache.move_to_end(key)
|
||||
while len(self._nb_index_cache) > _NB_INDEX_CACHE_MAX:
|
||||
self._nb_index_cache.popitem(last=False)
|
||||
return index
|
||||
|
||||
def _find_vault_category_root(self, image_path: Path) -> Path | None:
|
||||
"""image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ
|
||||
(=ponyxlWildcardsVaultのカテゴリルート) を探す。
|
||||
@@ -659,8 +756,12 @@ class GalleryService:
|
||||
embedded = self._read_png_metadata(path)
|
||||
elif ext in {".jpg", ".jpeg"}:
|
||||
embedded = self._read_jpeg_metadata(path)
|
||||
elif ext == ".mp4":
|
||||
elif ext == ".webp":
|
||||
embedded = self._read_webp_metadata(path)
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
embedded = self._read_mp4_metadata(path)
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
embedded = self._read_audio_metadata(path)
|
||||
|
||||
dims = self._read_media_dimensions(path, ext)
|
||||
saved = self.metadata_store.get(str(path))
|
||||
@@ -689,6 +790,7 @@ class GalleryService:
|
||||
"memo": saved.get("memo", ""),
|
||||
"groups": saved.get("groups", []),
|
||||
"image_prompt": self._read_sidecar_prompt(path),
|
||||
"nanobanana": self._read_nanobanana_entry(path),
|
||||
}
|
||||
|
||||
def _read_png_metadata(self, path: Path) -> dict:
|
||||
@@ -707,8 +809,6 @@ class GalleryService:
|
||||
chunk_type = f.read(4).decode("ascii", errors="ignore")
|
||||
if chunk_len > _PNG_CHUNK_MAX:
|
||||
f.seek(chunk_len + 4, 1)
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
continue
|
||||
chunk_data = f.read(chunk_len)
|
||||
f.read(4) # CRC
|
||||
@@ -728,16 +828,55 @@ class GalleryService:
|
||||
rest = rest[null_idx2 + 1:]
|
||||
null_idx3 = rest.index(b"\x00")
|
||||
value = rest[null_idx3 + 1:].decode("utf-8", errors="replace")
|
||||
result[key] = value
|
||||
# 先勝ち: png_extractor.extract_png_workflow(ワークフロー読み込み)と
|
||||
# 同じチャンクを採用し、Gallery表示との食い違いを防ぐ。
|
||||
if key not in result:
|
||||
result[key] = value
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
# IENDで止めない: ComfyUI-Custom-Scripts等はIENDの後ろに
|
||||
# workflowチャンクを追記するため、ファイル末尾まで読む。
|
||||
except Exception as e:
|
||||
logger.debug("PNG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_webp_metadata(self, path: Path) -> dict:
|
||||
"""WebPのRIFF EXIFチャンクから埋め込みworkflow/prompt(JSON文字列)を抽出する。
|
||||
ComfyUIはWebPのEXIFに "workflow:{...}" / "prompt:{...}" 形式で書き込む
|
||||
(static/js/metadata-tab.js の extractWorkflowFromEXIF と同じ処理)。"""
|
||||
result = {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(12)
|
||||
if len(head) < 12 or head[:4] != b"RIFF" or head[8:12] != b"WEBP":
|
||||
return result
|
||||
while True:
|
||||
hdr = f.read(8)
|
||||
if len(hdr) < 8:
|
||||
break
|
||||
fourcc = hdr[:4]
|
||||
size = struct.unpack("<I", hdr[4:])[0]
|
||||
if fourcc == b"EXIF":
|
||||
if size > _PNG_CHUNK_MAX:
|
||||
break
|
||||
text = f.read(size).decode("utf-8", errors="replace")
|
||||
decoder = json.JSONDecoder()
|
||||
for key in ("workflow", "prompt"):
|
||||
idx = text.find(key + ":{")
|
||||
if idx < 0:
|
||||
continue
|
||||
start = idx + len(key) + 1
|
||||
try:
|
||||
_, end = decoder.raw_decode(text, start)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
result[key] = text[start:end]
|
||||
break
|
||||
f.seek(size + (size & 1), 1)
|
||||
except Exception as e:
|
||||
logger.debug("WebP metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_jpeg_metadata(self, path: Path) -> dict:
|
||||
"""JPEGのEXIF/commentからメタデータを抽出(簡易)"""
|
||||
result = {}
|
||||
@@ -777,11 +916,30 @@ class GalleryService:
|
||||
logger.debug("MP4 metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_audio_metadata(self, path: Path) -> dict:
|
||||
"""音声ファイルのタグ(ComfyUIのSaveAudio系は prompt/workflow をJSON文字列で書き込む)をPyAVで抽出する。
|
||||
FLACのVorbisコメント等はキーの大文字小文字が実装依存のため、prompt/workflowは小文字キーでも引けるようにする。"""
|
||||
result = {}
|
||||
try:
|
||||
import av
|
||||
with av.open(str(path)) as container:
|
||||
tags = dict(container.metadata or {})
|
||||
for stream in container.streams.audio:
|
||||
for k, v in (stream.metadata or {}).items():
|
||||
tags.setdefault(k, v)
|
||||
for k, v in tags.items():
|
||||
result[k] = v
|
||||
if k.lower() in ("workflow", "prompt"):
|
||||
result.setdefault(k.lower(), v)
|
||||
except Exception as e:
|
||||
logger.debug("Audio metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_media_dimensions(self, path: Path, ext: str) -> dict:
|
||||
"""画像は幅高さ、動画は幅高さ+再生時間(秒)を返す。取得できない項目はNone。"""
|
||||
info: dict = {"width": None, "height": None, "duration": None}
|
||||
try:
|
||||
if ext == ".mp4":
|
||||
if ext in VIDEO_EXTENSIONS:
|
||||
import av
|
||||
with av.open(str(path)) as container:
|
||||
if container.streams.video:
|
||||
@@ -790,6 +948,11 @@ class GalleryService:
|
||||
info["height"] = stream.height
|
||||
if container.duration:
|
||||
info["duration"] = container.duration / 1_000_000
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
import av
|
||||
with av.open(str(path)) as container:
|
||||
if container.duration:
|
||||
info["duration"] = container.duration / 1_000_000
|
||||
elif ext == ".psd":
|
||||
from psd_tools import PSDImage
|
||||
psd = PSDImage.open(path)
|
||||
@@ -820,8 +983,12 @@ class GalleryService:
|
||||
embedded = None
|
||||
if ext == ".png":
|
||||
embedded = self._read_png_metadata(path)
|
||||
elif ext == ".mp4":
|
||||
elif ext == ".webp":
|
||||
embedded = self._read_webp_metadata(path)
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
embedded = self._read_mp4_metadata(path)
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
embedded = self._read_audio_metadata(path)
|
||||
|
||||
if embedded:
|
||||
key_groups = (("workflow", "Workflow"), ("prompt", "Prompt"))
|
||||
@@ -854,6 +1021,87 @@ class GalleryService:
|
||||
# メタデータ保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# ファイル変換(音声変換 / 動画からの音声抽出)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def convert_file(self, image_path: str, opts: dict) -> dict:
|
||||
"""音声ファイル(または動画の音声トラック)を指定形式へ変換し、同じフォルダに別ファイルとして保存する。
|
||||
|
||||
opts: format(flac/mp3/opus/m4a/wav), bitrate(kbps), sample_rate(0=元のまま), channels(keep/mono/stereo),
|
||||
inherit_metadata(prompt/workflow等のタグ引き継ぎ。タグを持てない形式は対象外),
|
||||
copy_gallery_meta(お気に入り/タグ/メモ/グループの引き継ぎ), delete_original(変換成功後に元を削除)
|
||||
"""
|
||||
from .convert_service import OUTPUT_FORMATS, convert_audio, supports_tags
|
||||
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return {"ok": False, "error": "File not found"}
|
||||
if p.suffix.lower() not in AUDIO_EXTENSIONS | VIDEO_EXTENSIONS:
|
||||
return {"ok": False, "error": "Not an audio/video file"}
|
||||
if not self._check_path_allowed(p):
|
||||
return {"ok": False, "error": "Access denied"}
|
||||
fmt = str(opts.get("format", ""))
|
||||
if fmt not in OUTPUT_FORMATS:
|
||||
return {"ok": False, "error": f"Unsupported format: {fmt}"}
|
||||
|
||||
def _int(key: str, default: int = 0) -> int:
|
||||
try:
|
||||
return max(0, int(opts.get(key) or default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
ext = OUTPUT_FORMATS[fmt]["ext"]
|
||||
inherit = bool(opts.get("inherit_metadata", True))
|
||||
|
||||
# 出力名の決定〜変換完了までを同一ロック内で行う(同時リクエストで同じ名前を選んで
|
||||
# 上書きし合うのを防ぐ)。エラー時に消すのは自分が決めたdstだけ。
|
||||
with self._convert_lock:
|
||||
dst = p.with_suffix(ext)
|
||||
if dst.exists(): # 同名(同形式への再変換を含む)は上書きせず連番
|
||||
counter = 1
|
||||
while True:
|
||||
dst = p.with_name(f"{p.stem}_converted{'' if counter == 1 else counter}{ext}")
|
||||
if not dst.exists():
|
||||
break
|
||||
counter += 1
|
||||
try:
|
||||
result = convert_audio(
|
||||
p, dst, fmt,
|
||||
bitrate_kbps=_int("bitrate"),
|
||||
sample_rate=_int("sample_rate"),
|
||||
channels=str(opts.get("channels", "keep")),
|
||||
inherit_metadata=inherit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("convert_file: failed for %s: %s", p, e)
|
||||
try:
|
||||
dst.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
dst_str = str(dst.resolve()).replace("\\", "/")
|
||||
if opts.get("copy_gallery_meta", True):
|
||||
saved = self.metadata_store.get(str(p))
|
||||
carry = {k: saved[k] for k in ("favorite", "tags", "memo", "groups") if saved.get(k)}
|
||||
if carry:
|
||||
self.metadata_store.save(str(dst.resolve()), carry)
|
||||
self._folder_cache.invalidate(p.parent)
|
||||
|
||||
deleted = False
|
||||
if opts.get("delete_original"):
|
||||
deleted = bool(self.delete_images([str(p)])["deleted"])
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"path": dst_str,
|
||||
"filename": dst.name,
|
||||
"tags_written": result["tags_written"],
|
||||
"tags_skipped": inherit and not supports_tags(fmt),
|
||||
"deleted": deleted,
|
||||
}
|
||||
|
||||
def save_image_meta(self, image_path: str, data: dict) -> bool:
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
@@ -1081,8 +1329,8 @@ class GalleryService:
|
||||
p = Path(img_path).resolve()
|
||||
if not p.is_file() or p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
if p.suffix.lower() == ".mp4":
|
||||
continue # 動画はPSDレイヤーにできない
|
||||
if p.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
|
||||
continue # 動画・音声はPSDレイヤーにできない
|
||||
if not self._check_path_allowed(p):
|
||||
continue
|
||||
try:
|
||||
@@ -1306,6 +1554,48 @@ class GalleryService:
|
||||
logger.warning("serve_image: psd composite failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
def _render_audio_waveform(self, path: Path, out_path: Path, size: int) -> None:
|
||||
"""音声ファイルの波形を size×size のJPEGとして out_path に書き出す。
|
||||
8kHzモノラルへリサンプルしてから列ごとの最大振幅を描く(長尺でも軽く保つ)。"""
|
||||
import av
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
max_samples = 8000 * 600 # 先頭10分まで
|
||||
chunks = []
|
||||
total = 0
|
||||
with av.open(str(path)) as container:
|
||||
stream = container.streams.audio[0]
|
||||
resampler = av.AudioResampler(format="flt", layout="mono", rate=8000)
|
||||
for frame in container.decode(stream):
|
||||
for rf in resampler.resample(frame):
|
||||
arr = rf.to_ndarray().reshape(-1)
|
||||
chunks.append(arr)
|
||||
total += arr.size
|
||||
if total >= max_samples:
|
||||
break
|
||||
data = np.concatenate(chunks) if chunks else np.zeros(1, dtype=np.float32)
|
||||
data = np.abs(data[:max_samples])
|
||||
|
||||
cols = size
|
||||
edges = np.linspace(0, data.size, cols + 1).astype(int)
|
||||
peaks = np.array([
|
||||
data[edges[i]:max(edges[i + 1], edges[i] + 1)].max() if edges[i] < data.size else 0.0
|
||||
for i in range(cols)
|
||||
])
|
||||
top = float(peaks.max())
|
||||
if top > 0:
|
||||
peaks = peaks / top
|
||||
|
||||
color = AUDIO_WAVE_COLORS.get(path.suffix.lower(), (56, 189, 248))
|
||||
img = Image.new("RGB", (size, size), (24, 28, 38))
|
||||
draw = ImageDraw.Draw(img)
|
||||
mid = size / 2
|
||||
for x, v in enumerate(peaks):
|
||||
h = max(1.0, float(v) * (size * 0.42))
|
||||
draw.line([(x, mid - h), (x, mid + h)], fill=color)
|
||||
img.save(out_path, "JPEG", quality=85, optimize=True)
|
||||
|
||||
def serve_thumbnail(self, image_path: str, width: int = 256) -> Path | None:
|
||||
"""縮小サムネイルのPathを返す。
|
||||
ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。
|
||||
@@ -1338,7 +1628,9 @@ class GalleryService:
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
cache_key = hashlib.md5(f"{p}:{mtime}:{width}".encode()).hexdigest()
|
||||
# 音声は波形の描画仕様(色)を変えたらキャッシュを作り直せるよう版数をキーに含める
|
||||
wave_ver = f":{WAVE_THUMB_VERSION}" if p.suffix.lower() in AUDIO_EXTENSIONS else ""
|
||||
cache_key = hashlib.md5(f"{p}:{mtime}:{width}{wave_ver}".encode()).hexdigest()
|
||||
cache_dir = self.data_dir / "thumb_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
thumb_path = cache_dir / f"{cache_key}.jpg"
|
||||
@@ -1346,10 +1638,19 @@ class GalleryService:
|
||||
if thumb_path.exists():
|
||||
return thumb_path
|
||||
|
||||
# MP4は動画のためPillowで開けない。PyAV(av)で先頭フレームを抽出しJPEGとしてキャッシュする。
|
||||
# avが未導入/デコード失敗の場合、元mp4ファイルは<img>的な経路に渡せないため
|
||||
# 音声はPillowで開けない。PyAVでデコードして波形をJPEGに描画しキャッシュする。
|
||||
if p.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
try:
|
||||
self._render_audio_waveform(p, thumb_path, width)
|
||||
return thumb_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_thumbnail: audio waveform failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
# 動画(mp4/webm)はPillowで開けない。PyAV(av)で先頭フレームを抽出しJPEGとしてキャッシュする。
|
||||
# avが未導入/デコード失敗の場合、元ファイルは<img>的な経路に渡せないため
|
||||
# (GIF/SVGと違い元ファイルへフォールバックできない) Noneを返しプレースホルダー表示に委ねる。
|
||||
if p.suffix.lower() == ".mp4":
|
||||
if p.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
try:
|
||||
import av
|
||||
from PIL import Image
|
||||
|
||||
@@ -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
|
||||
+149
-25
@@ -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"}
|
||||
@@ -9,10 +9,11 @@ import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
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
|
||||
@@ -21,19 +22,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_model_lock = threading.Lock()
|
||||
|
||||
# Unsloth requires an Authorization: Bearer <UNSLOTH_API_KEY> header even for
|
||||
# local access. api_url is client-supplied per request, so without a host
|
||||
# check a client could point it at an arbitrary server and exfiltrate the key
|
||||
# (SSRF). Mirrors unsloth_routes.py's _is_allowed_base_url/_ALLOWED_HOSTS.
|
||||
_UNSLOTH_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _is_allowed_unsloth_url(api_url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(api_url)
|
||||
return parsed.scheme in ("http", "https") and parsed.hostname in _UNSLOTH_ALLOWED_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
# 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:
|
||||
@@ -256,9 +247,17 @@ class TaggerService:
|
||||
|
||||
# ── VLM Tagging (Ollama / LM Studio / Lemonade / Unsloth) ──
|
||||
|
||||
# キー未設定でUnslothに拒否された場合の案内(py/routes/unsloth_routes.pyと同文)
|
||||
_UNSLOTH_KEYLESS_REJECTED = (
|
||||
"Unsloth rejected the request without an API key (HTTP 401). Either set UNSLOTH_API_KEY "
|
||||
"in the plugin's .env (copy .env.example) and restart ComfyUI, or turn on Keyless API "
|
||||
"access -> \"Chat and inference\" in Unsloth Desktop's Settings -> API."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _unsloth_api_key() -> Optional[str]:
|
||||
"""UNSLOTH_API_KEY を環境変数(.env)から取得。Unslothはローカルでも常に必須。"""
|
||||
"""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
|
||||
@@ -270,27 +269,29 @@ class TaggerService:
|
||||
def vlm_models(self, backend: str, api_url: str) -> list:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
if not is_allowed_backend_url(api_url):
|
||||
logger.error("vlm_models: %s", _URL_HINT)
|
||||
return []
|
||||
if backend == "ollama":
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
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":
|
||||
if not _is_allowed_unsloth_url(api_url):
|
||||
logger.error("vlm_models: Unsloth backend URL must point to localhost/127.0.0.1/::1")
|
||||
return []
|
||||
key = self._unsloth_api_key()
|
||||
if not key:
|
||||
logger.error("vlm_models: UNSLOTH_API_KEY is not set")
|
||||
return []
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/v1/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with _guarded_urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["id"] for m in data.get("data", []))
|
||||
except Exception as e:
|
||||
if (backend == "unsloth" and isinstance(e, urllib.error.HTTPError) and e.code == 401
|
||||
and not self._unsloth_api_key()):
|
||||
logger.error("vlm_models: %s", self._UNSLOTH_KEYLESS_REJECTED)
|
||||
return []
|
||||
logger.error("vlm_models: %s", e)
|
||||
return []
|
||||
|
||||
@@ -298,6 +299,8 @@ class TaggerService:
|
||||
thinking_mode: bool = False, max_tokens: int = 0) -> dict:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
if not is_allowed_backend_url(api_url):
|
||||
return {"error": _URL_HINT}
|
||||
if backend == "ollama":
|
||||
body = {
|
||||
"model": model,
|
||||
@@ -315,19 +318,15 @@ class TaggerService:
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
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":
|
||||
if not _is_allowed_unsloth_url(api_url):
|
||||
return {"error": "Unsloth backend URL must point to localhost/127.0.0.1/::1"}
|
||||
key = self._unsloth_api_key()
|
||||
if not key:
|
||||
return {"error": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI."}
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
@@ -345,7 +344,7 @@ class TaggerService:
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
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", "")
|
||||
@@ -360,6 +359,9 @@ class TaggerService:
|
||||
return {"tags": ", ".join(tags), "count": len(tags)}
|
||||
except Exception as e:
|
||||
logger.error("vlm_predict: %s", e)
|
||||
if (backend == "unsloth" and isinstance(e, urllib.error.HTTPError) and e.code == 401
|
||||
and not self._unsloth_api_key()):
|
||||
return {"error": self._UNSLOTH_KEYLESS_REJECTED}
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── ファイルメタデータ書込 ────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
@@ -157,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}
|
||||
|
||||
@@ -99,7 +99,7 @@ class WorkflowStudio:
|
||||
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)
|
||||
@@ -116,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
-1
@@ -1,7 +1,7 @@
|
||||
[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.7.2"
|
||||
version = "0.8.2"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -33,3 +33,13 @@ psd-tools>=1.10
|
||||
# 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; }
|
||||
|
||||
+78
-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; }
|
||||
|
||||
@@ -409,6 +409,78 @@
|
||||
flex: none; /* stay a fixed-height full-width bar; never shrink/stretch with clip content */
|
||||
}
|
||||
|
||||
/* ── Edit: トラック切替(ビデオ=青/テキスト=緑/オーディオ=赤)──
|
||||
--wfm-track-color は表示中トラックの色。トラックバーの枠線・ブロックの帯色に使う。
|
||||
ビデオ色はテーマ可変の--wfm-primaryではなく固定の青にして、他トラックと並べても色が変わらないようにする。 */
|
||||
.wfm-video-edit-track-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.wfm-video-edit-track-tab {
|
||||
--tab-color: var(--wfm-track-video);
|
||||
border-bottom: 2px solid var(--tab-color);
|
||||
}
|
||||
.wfm-video-edit-track-tab[data-track="video"] { --tab-color: var(--wfm-track-video); }
|
||||
.wfm-video-edit-track-tab[data-track="pip"] { --tab-color: var(--wfm-track-video); }
|
||||
.wfm-video-edit-track-tab[data-track="text"] { --tab-color: var(--wfm-track-text); }
|
||||
.wfm-video-edit-track-tab[data-track="audio"] { --tab-color: var(--wfm-track-audio); }
|
||||
.wfm-video-edit-track-tab.active {
|
||||
background: var(--tab-color);
|
||||
border-color: var(--tab-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-track {
|
||||
--wfm-track-video: #4a9eff;
|
||||
--wfm-track-text: #3fb950;
|
||||
--wfm-track-audio: #f85149;
|
||||
--wfm-track-color: var(--wfm-track-video);
|
||||
}
|
||||
.wfm-video-edit-track-tabs { --wfm-track-video: #4a9eff; --wfm-track-text: #3fb950; --wfm-track-audio: #f85149; }
|
||||
.wfm-video-edit-timeline-track[data-track="pip"] { --wfm-track-color: var(--wfm-track-video); }
|
||||
.wfm-video-edit-timeline-track[data-track="text"] { --wfm-track-color: var(--wfm-track-text); }
|
||||
.wfm-video-edit-timeline-track[data-track="audio"] { --wfm-track-color: var(--wfm-track-audio); }
|
||||
.wfm-video-edit-timeline-track[data-track="pip"],
|
||||
.wfm-video-edit-timeline-track[data-track="text"],
|
||||
.wfm-video-edit-timeline-track[data-track="audio"] { border-color: var(--wfm-track-color); }
|
||||
.wfm-video-edit-timeline-track[data-track="video"] .wfm-video-edit-timeline-block {
|
||||
border-left: 3px solid var(--wfm-track-color);
|
||||
}
|
||||
|
||||
/* テキスト/オーディオ表示: 絶対時間(秒×px/秒)で配置するレーン */
|
||||
.wfm-video-edit-lane {
|
||||
position: relative;
|
||||
flex: none;
|
||||
min-height: 100%;
|
||||
}
|
||||
.wfm-video-edit-lane-clipband {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px dashed var(--wfm-border);
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wfm-video-edit-lane-clipband.selected { background: rgba(255, 255, 255, 0.06); }
|
||||
.wfm-video-edit-lane-item {
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--wfm-track-color) 35%, transparent);
|
||||
border: 1px solid var(--wfm-track-color);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wfm-video-edit-lane-item.selected { background: var(--wfm-track-color); color: #fff; }
|
||||
.wfm-video-edit-lane-item.dim { opacity: 0.55; }
|
||||
|
||||
.wfm-video-edit-timeline-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -602,6 +674,237 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ---- Edit: テキストオーバーレイ(Phase 3) / オーディオ(Phase 4) ---- */
|
||||
.wfm-video-edit-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-row {
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-row-head {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-row-head input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||||
gap: 6px 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-grid > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-grid > label.wfm-video-checkbox-inline {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-grid input[type="color"] {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.wfm-video-edit-audio-panel {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-edit-audio-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 2px 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-edit-audio-row > label {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfm-video-edit-audio-row input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wfm-video-edit-audio-row input[type="number"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.wfm-video-edit-audio-val {
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wfm-video-edit-bgm-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-audio-panel.drag-over {
|
||||
outline: 2px dashed var(--wfm-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-bgm-hint {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* <label>で包んだファイル選択を.wfm-btnと同じ見た目にする */
|
||||
.wfm-video-edit-file-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wfm-video-edit-bgm-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-edit-crop-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-edit-crop-row {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-crop-row > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin: 0;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
#wfm-video-edit-crop-toggle.active {
|
||||
background: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Sourceプレビュー上のクロップ矩形。外側はbox-shadowで暗転、
|
||||
.interactive時のみドラッグ(移動/四隅リサイズ)を受け付ける */
|
||||
.wfm-video-edit-crop-layer {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-video-edit-crop-rect {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.wfm-video-edit-crop-layer.interactive .wfm-video-edit-crop-rect {
|
||||
pointer-events: auto;
|
||||
cursor: move;
|
||||
border: 2px solid var(--wfm-primary);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.wfm-video-edit-crop-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--wfm-primary);
|
||||
border: 1px solid #fff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-crop-layer.interactive .wfm-video-edit-crop-handle { display: block; }
|
||||
.wfm-video-edit-crop-handle[data-h="nw"] { left: -7px; top: -7px; cursor: nwse-resize; }
|
||||
.wfm-video-edit-crop-handle[data-h="ne"] { right: -7px; top: -7px; cursor: nesw-resize; }
|
||||
.wfm-video-edit-crop-handle[data-h="sw"] { left: -7px; bottom: -7px; cursor: nesw-resize; }
|
||||
.wfm-video-edit-crop-handle[data-h="se"] { right: -7px; bottom: -7px; cursor: nwse-resize; }
|
||||
|
||||
/* プレビュー枠上のテキスト近似表示レイヤー(実際の焼き込みはサーバー側)。
|
||||
left/top/width/heightはJSが表示中の<video>/<img>の実寸に合わせて設定する */
|
||||
.wfm-video-edit-text-layer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-item {
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
line-height: 1.2;
|
||||
font-family: "Meiryo", "Yu Gothic", "Noto Sans JP", sans-serif;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-item.outline {
|
||||
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000, 0 0 2px #000;
|
||||
}
|
||||
|
||||
.wfm-video-edit-text-item.bg {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 0.15em 0.3em;
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
.wfm-video-asset-detail label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
@@ -664,6 +967,7 @@
|
||||
}
|
||||
|
||||
.wfm-video-preview-frame {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 260px;
|
||||
@@ -809,3 +1113,59 @@
|
||||
}
|
||||
|
||||
.wfm-video-project-item-delete:hover { color: var(--wfm-danger); }
|
||||
|
||||
/* ── Edit: 追加サウンド(オーディオトラックに配置する効果音など)のリスト ── */
|
||||
.wfm-video-edit-sound-head { margin-top: 8px; }
|
||||
.wfm-video-edit-sound-list { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
.wfm-video-edit-sound-row {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-left: 3px solid #f85149;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wfm-video-edit-sound-row-head { display: flex; align-items: center; gap: 6px; }
|
||||
.wfm-video-edit-sound-name { flex: 1; min-width: 0; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wfm-video-edit-sound-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin-top: 4px; }
|
||||
.wfm-video-edit-sound-grid label { display: flex; flex-direction: column; font-size: 10px; color: var(--wfm-text-secondary); }
|
||||
.wfm-video-edit-sound-grid input { min-width: 0; }
|
||||
|
||||
/* ── Edit: オーバーレイクリップ(PinP)パネル・プレビュー枠 ── */
|
||||
.wfm-video-edit-pip-panel { margin-bottom: 10px; }
|
||||
.wfm-video-edit-pip-row { border-left-color: #4a9eff; }
|
||||
.wfm-video-edit-pip-preset { width: auto; max-width: 96px; padding: 0 2px; font-size: 10px; }
|
||||
.wfm-video-edit-pip-box {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-video-edit-pip-box img { width: 100%; height: 100%; object-fit: fill; display: block; pointer-events: none; }
|
||||
|
||||
/* ── Edit: ツールメニューはトラック表示(data-view)に連動 ──
|
||||
video=トリム+クロップ / text=テキストオーバーレイ / pip=オーバーレイクリップ / audio=オーディオ。
|
||||
書き出し(右の固定パネル)は常に表示。 */
|
||||
.wfm-video-edit-tools { flex: 1; min-width: 0; }
|
||||
.wfm-video-edit-tools .wfm-video-edit-pip-panel,
|
||||
.wfm-video-edit-tools .wfm-video-edit-audio-panel { display: none; max-width: 480px; margin-top: 0; padding-top: 0; border-top: none; }
|
||||
.wfm-video-edit-tools[data-view="pip"] .wfm-video-edit-pip-panel,
|
||||
.wfm-video-edit-tools[data-view="audio"] .wfm-video-edit-audio-panel { display: block; }
|
||||
.wfm-video-edit-tools[data-view="pip"] .wfm-video-edit-trim-panel,
|
||||
.wfm-video-edit-tools[data-view="audio"] .wfm-video-edit-trim-panel { display: none; }
|
||||
.wfm-video-edit-tools[data-view="video"] .wfm-video-edit-tool-text,
|
||||
.wfm-video-edit-tools[data-view="text"] .wfm-video-edit-tool-trim { display: none; }
|
||||
.wfm-video-edit-tools[data-view="text"] .wfm-video-edit-text-section { margin-top: 0; }
|
||||
|
||||
/* 透明な本物の<input type=file>をボタンに重ねる方式(label+display:none経由に頼らない) */
|
||||
.wfm-video-edit-file-overlay { position: relative; overflow: hidden; }
|
||||
.wfm-video-edit-file-overlay > input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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);
|
||||
@@ -176,6 +177,8 @@ export function openAiBackendSettingsModal(storageKey, title, onSaved) {
|
||||
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);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.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";
|
||||
@@ -461,6 +462,8 @@ function initSettingsTab() {
|
||||
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);
|
||||
|
||||
+10
-2
@@ -249,6 +249,8 @@ function applyI18nToHtml() {
|
||||
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");
|
||||
@@ -472,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",
|
||||
@@ -553,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",
|
||||
@@ -579,7 +586,8 @@ function applyI18nToHtml() {
|
||||
"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-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",
|
||||
|
||||
@@ -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">
|
||||
@@ -1490,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);
|
||||
|
||||
@@ -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表示時に
|
||||
@@ -291,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
|
||||
@@ -311,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,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;
|
||||
},
|
||||
@@ -628,8 +649,21 @@ export const comfyWorkflow = {
|
||||
_objectInfoCache = null;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
// 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 {};
|
||||
@@ -692,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]
|
||||
@@ -778,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),
|
||||
@@ -787,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];
|
||||
}
|
||||
}
|
||||
@@ -1085,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;
|
||||
@@ -1167,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,
|
||||
@@ -1270,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({
|
||||
@@ -1296,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") {
|
||||
@@ -1383,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,
|
||||
@@ -1651,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);
|
||||
}
|
||||
+377
-29
@@ -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";
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,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`,
|
||||
@@ -58,11 +61,36 @@ function _isReservedGroup(name) {
|
||||
return _RESERVED_GROUPS.includes(name);
|
||||
}
|
||||
|
||||
// 拡張子から動画ファイル(mp4)かどうかを判定する。
|
||||
// 拡張子から動画ファイル(mp4/webm)かどうかを判定する。
|
||||
// 一覧アイテムは path/filename、詳細メタ(get_image_metadata)は ext フィールドを持つため両対応。
|
||||
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
|
||||
export function isVideoFile(img) {
|
||||
const source = img?.ext || img?.filename || img?.path || "";
|
||||
return source.toLowerCase().endsWith(".mp4");
|
||||
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連携)。
|
||||
@@ -275,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);
|
||||
@@ -288,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");
|
||||
}
|
||||
@@ -418,6 +454,7 @@ async function loadFolderTree() {
|
||||
state.folderTree = data;
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
refreshSemanticDots(state.outputRoot);
|
||||
|
||||
if (isFirstLoad) {
|
||||
// 初回のみ root を自動選択
|
||||
@@ -473,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");
|
||||
@@ -556,15 +594,37 @@ async function loadImages() {
|
||||
params.recursive = "true";
|
||||
}
|
||||
|
||||
// セマンティック検索/類似画像モード: サーバーがスコア順で返すためソート・各フィルタは適用しない
|
||||
const semMode = getSemanticMode();
|
||||
|
||||
try {
|
||||
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) {
|
||||
@@ -663,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" : ""}`;
|
||||
@@ -791,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("");
|
||||
@@ -870,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) {
|
||||
@@ -972,17 +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>
|
||||
`;
|
||||
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;
|
||||
@@ -998,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([
|
||||
@@ -1009,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);
|
||||
}
|
||||
|
||||
@@ -1034,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");
|
||||
@@ -1078,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");
|
||||
@@ -1088,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 })),
|
||||
@@ -1641,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>
|
||||
@@ -1652,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 () => {
|
||||
@@ -1664,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) {
|
||||
@@ -1747,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}
|
||||
@@ -1764,6 +2056,7 @@ function openLightbox(img) {
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
if (isVideoFile(img)) applyStoredVideoVolume(overlay.querySelector("video"));
|
||||
if (isAudioFile(img)) applyStoredVideoVolume(overlay.querySelector("audio"));
|
||||
}
|
||||
|
||||
// ── 画像比較ライトボックス ─────────────────────────────────────
|
||||
@@ -1776,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}
|
||||
@@ -1799,7 +2094,7 @@ function openCompare(paths) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelectorAll("video").forEach(applyStoredVideoVolume);
|
||||
overlay.querySelectorAll("video, audio").forEach(applyStoredVideoVolume);
|
||||
}
|
||||
|
||||
// ── outputパス取得 ────────────────────────────────────────────
|
||||
@@ -1968,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");
|
||||
@@ -2061,8 +2387,17 @@ function bindEvents() {
|
||||
document.getElementById("wfm-gallery-search")?.addEventListener("input", (e) => {
|
||||
state.search = e.target.value;
|
||||
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 = "";
|
||||
@@ -2218,6 +2553,11 @@ 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]);
|
||||
@@ -2228,7 +2568,7 @@ function bindEvents() {
|
||||
if (!window._wfmImageEditTab) return;
|
||||
|
||||
const allPaths = [...state.selectedImages];
|
||||
const paths = allPaths.filter(p => !/\.mp4$/i.test(p));
|
||||
const paths = allPaths.filter(p => !isVideoFile({ path: p }) && !isAudioFile({ path: p }));
|
||||
if (paths.length === 0) {
|
||||
showToast(t("editLayersNoImages"), "error");
|
||||
return;
|
||||
@@ -2355,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,
|
||||
});
|
||||
}
|
||||
|
||||
+855
-72
File diff suppressed because one or more lines are too long
+225
-26
@@ -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 = "";
|
||||
|
||||
+12
-1
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
+131
-19
@@ -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", () => {
|
||||
|
||||
+235
-3
@@ -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 } 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";
|
||||
|
||||
@@ -455,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>
|
||||
@@ -674,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>
|
||||
@@ -803,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>
|
||||
@@ -842,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;">
|
||||
@@ -1122,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");
|
||||
@@ -1237,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");
|
||||
@@ -1491,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);
|
||||
@@ -1522,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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* to the real Gallery tab via the "Open in Gallery" button.
|
||||
*/
|
||||
|
||||
import { VIDEO_GROUP, VTEMP_GROUP, ensureVideoGroup, isVideoFile } from "./gallery-tab.js";
|
||||
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 } from "./video-edit-tab.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
|
||||
@@ -33,7 +33,7 @@ const _s = {
|
||||
loaded: false, // becomes true once the Asset subtab has been shown at least once
|
||||
searchQuery: "",
|
||||
viewMode: "grid", // "grid" | "table"
|
||||
kindFilter: "all", // "all" | "video" | "image"
|
||||
kindFilter: "all", // "all" | "video" | "image" | "audio"
|
||||
};
|
||||
|
||||
function _formatDate(mtime) {
|
||||
@@ -44,7 +44,8 @@ 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 === "image") 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;
|
||||
}
|
||||
@@ -212,6 +213,7 @@ function _selectImage(img) {
|
||||
// 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));
|
||||
@@ -232,6 +234,7 @@ function _renderDetail(img) {
|
||||
// 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;">
|
||||
@@ -241,8 +244,11 @@ function _renderDetail(img) {
|
||||
<label style="margin-top:10px;">Memo</label>
|
||||
<textarea id="wfm-video-asset-memo" class="wfm-textarea" rows="3"></textarea>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-memo-save" style="margin-top:6px;">Save Memo</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-send-to-edit" style="width:100%;margin-top:12px;">${t("videoEditSendToEdit")}</button>
|
||||
<div id="wfm-video-asset-set-plan-image-row" style="display:${isVideoFile(img) ? "none" : "flex"};gap:6px;margin-top:6px;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-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>
|
||||
@@ -287,6 +293,58 @@ function _renderDetail(img) {
|
||||
}
|
||||
});
|
||||
|
||||
// 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)}`);
|
||||
|
||||
+1944
-53
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
const _PANES = {
|
||||
source: { video: "wfm-video-source-preview-video", img: "wfm-video-source-preview-img", placeholder: "wfm-video-source-preview-placeholder" },
|
||||
result: { video: "wfm-video-preview-video", img: "wfm-video-preview-img", placeholder: "wfm-video-preview-placeholder" },
|
||||
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 };
|
||||
@@ -111,3 +111,16 @@ export function getAllPreviewVideoElements() {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,11 +127,28 @@ function _applyVideoI18n() {
|
||||
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");
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
+64
-10
@@ -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
|
||||
@@ -623,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");
|
||||
@@ -655,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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,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");
|
||||
@@ -960,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" />`
|
||||
@@ -1074,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));
|
||||
|
||||
+173
-32
@@ -91,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>
|
||||
@@ -247,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>
|
||||
@@ -1390,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>
|
||||
@@ -1574,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 / 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 — settings are independent from the AI TOOL and Tagger tabs</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>
|
||||
@@ -1639,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>
|
||||
@@ -1661,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>
|
||||
@@ -1671,20 +1680,27 @@
|
||||
<li id="wfm-help-settings-4">ComfyUI connection URL and 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>
|
||||
@@ -1692,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>
|
||||
@@ -1773,9 +1791,10 @@
|
||||
<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">
|
||||
@@ -1787,15 +1806,20 @@
|
||||
<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-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</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, PSD; 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>
|
||||
@@ -1818,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">
|
||||
@@ -1832,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">
|
||||
@@ -1854,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>
|
||||
@@ -1873,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>
|
||||
@@ -1881,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">
|
||||
@@ -1928,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, and use the Unload Model button (next to Test connection) to free the loaded model from VRAM/memory — Ollama uses keep_alive:0, 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 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>
|
||||
|
||||
@@ -1950,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">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 — independent from the Prompt tab's own AI settings; customize the prompt and Max Tags inline; 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">
|
||||
@@ -2105,7 +2129,7 @@
|
||||
|
||||
<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 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.</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">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>
|
||||
@@ -2116,10 +2140,10 @@
|
||||
<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): browse every saved plan file (thumbnail, block count, last updated), click one to load it into the Plan editor, or delete it with the × button; clicking the same tab again collapses the panel.</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. 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. Exporting more than one clip currently drops audio, since ComfyUI's concatenation node requires uniform audio across all inputs.</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).</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>
|
||||
@@ -2245,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>
|
||||
@@ -2268,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>
|
||||
@@ -2287,6 +2320,7 @@
|
||||
<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>
|
||||
@@ -2329,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>
|
||||
@@ -2402,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>
|
||||
@@ -2573,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>
|
||||
@@ -2591,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>
|
||||
@@ -2615,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 ギャラリーパネル -->
|
||||
@@ -2785,6 +2873,7 @@
|
||||
<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>
|
||||
@@ -2935,29 +3024,81 @@
|
||||
(個別行ボタンではなく「選択中クリップに対して働く」共通ボタン方式)。 -->
|
||||
<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-timeline-track" id="wfm-video-edit-timeline-track"></div>
|
||||
<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">
|
||||
<div class="wfm-video-edit-trim-panel" id="wfm-video-edit-trim-panel"></div>
|
||||
<!-- 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>
|
||||
<button type="button" id="wfm-video-edit-load-btn" class="wfm-btn wfm-btn-sm" style="flex:1;">Load</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>
|
||||
<input type="file" id="wfm-video-edit-load-file-input" accept="application/json,.json" style="display:none;">
|
||||
<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">
|
||||
|
||||
+204
-18
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user