Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf18e89ce | ||
|
|
7237bec315 | ||
|
|
f687f8d8ea | ||
|
|
8a3de00440 | ||
|
|
cddfa4fdc1 | ||
|
|
ac675ce74f | ||
|
|
74eb8b779f | ||
|
|
baed398fe8 | ||
|
|
77cfedb243 | ||
|
|
ad70622942 | ||
|
|
f726418aeb | ||
|
|
a917e82ad7 | ||
|
|
2fc2365461 | ||
|
|
337fd6ecb8 | ||
|
|
831ba1bd43 | ||
|
|
899feb1bb5 | ||
|
|
075e2a8c62 | ||
|
|
77163de202 | ||
|
|
7f0ecc2ba3 | ||
|
|
ac733db81e | ||
|
|
7f2720ff8f | ||
|
|
f9dbf3f399 | ||
|
|
a99bfc19a1 | ||
|
|
d75a9c812f | ||
|
|
925a249cbe | ||
|
|
e067f34cd4 | ||
|
|
995e98e1aa | ||
|
|
398be43060 | ||
|
|
1155cf1233 | ||
|
|
5aef634115 | ||
|
|
b09cd2ef90 | ||
|
|
426f27c05a | ||
|
|
336e37724f | ||
|
|
7274017347 | ||
|
|
b94ab48ca4 | ||
|
|
ed3512d269 | ||
|
|
9ed91e64f8 | ||
|
|
c0db7374b7 |
@@ -17,6 +17,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9 # 1.0.1
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
@@ -6,11 +6,9 @@ __pycache__/
|
||||
*.egg-info/
|
||||
data/
|
||||
|
||||
# graphify: keep the knowledge graph itself under version control,
|
||||
# but exclude the run-by-run cost/token log, the local extraction
|
||||
# cache (both churn every rebuild and have no value to other clones),
|
||||
# and the machine-local interpreter/scan-root pointers.
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/.graphify_python
|
||||
graphify-out/.graphify_root
|
||||
# graphify: local-only knowledge graph output, not shared with other clones
|
||||
graphify-out/
|
||||
|
||||
# Working-notes plan docs — local scratch, not meant to ship with the repo
|
||||
VIDEO_EDIT_TAB_PLAN.md
|
||||
QWEN_CHAT_INTEGRATION_PLAN.md
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
Condensed version-by-version index of DEVLOG.md (each line: version + one-line summary of what changed and why). Full rationale, code details, and "How to apply" lessons live in the excluded DEVLOG.md itself; this index exists so graphify can extract a queryable semantic node per release without feeding the full log to the local Ollama model. Regenerate with `python tools/generate_doc_index.py devlog` whenever DEVLOG.md changes.
|
||||
|
||||
- **v0.7.2(2026-09-18)** — Workflowタブにソート機能追加(Date/Name 昇順降順)
|
||||
- **v0.7.1(2026-09-17)** — セキュリティ修正: Tagger VLM の Unsloth 経路でAPIキーがSSRFにより外部送信され得る不備を修正
|
||||
- **v0.7.0(2026-09-17)** — プレビュー枠の高さ固定・AssetサブタブをPlan/Editと並ぶ中央下部へ移設・トリムパネルにClipchamp風スクラバー追加・Frame保存のVideo Assets登録とAsset→Plan画像連携
|
||||
- **v0.6.3** — Send CCボタンでMP4動画をComic Creatorの新設「動画ツール」へ送れるように対応
|
||||
- **v0.6.2** — Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
|
||||
- **v0.6.1** — Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
|
||||
- **v0.6.0** — Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
|
||||
- **v0.5.8** — Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
|
||||
- **v0.5.7** — 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
|
||||
- **v0.5.6** — バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
|
||||
- **v0.5.5** — Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
|
||||
- **v0.5.4** — 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
|
||||
- **v0.5.3** — バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
|
||||
- **v0.5.2** — 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
|
||||
- **v0.5.1** — 機能追加: mp4動画の埋め込みメタデータ対応(Gallery Prompt/JSON・Metadataサブタブ・サイドパネルIタブ)+ Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
|
||||
- **v0.5.0** — 機能追加: mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
|
||||
|
||||
@@ -2,6 +2,351 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.7.2(2026-09-18)
|
||||
|
||||
### Workflowタブにソート機能追加(Date/Name 昇順降順)
|
||||
|
||||
Workflowタブのグループフィルター右隣に、Galleryタブと同じパターンのソートドロップダウン(Date Newest/Oldest、Name A-Z/Z-A)を追加した。`wf.mtime`(バックエンドが既に返していたファイル更新時刻)を使ったクライアント側ソートで、選択状態は`localStorage`(`wfm_workflow_sort`)に永続化する。
|
||||
|
||||
**変更ファイル**: `templates/index.html`(`#wfm-sort` select追加)、`static/js/workflow-tab.js`(`sortWorkflows()`追加、`renderGrid()`で`filterWorkflows()`の結果に適用)
|
||||
|
||||
### Videoタブ左ペイン幅調整・Editトリムパネルのバグ修正とUI調整
|
||||
|
||||
**左ペイン幅**: PlanサブタブのフォームパネルをGenerateUI寄りの380px固定から253px(約2/3)に縮小し、中央プレビュー/タイムラインを広げた(`.wfm-video-form-panel`の`width`のみの変更、`flex:1`の中央パネルが自動で広がる)。
|
||||
|
||||
**「現在位置を使用」ボタンでトリムスクラバーの見た目が更新されないバグを修正**: トリムのStart/End数値入力とスクラバー(ドラッグハンドル・ハイライト範囲)は同じ`commit()`関数を経由する設計だが、`commit()`はスクラバー自身の`updateRange()`(ハンドル位置・ハイライト帯の再描画)を呼んでいなかった。ハンドルをドラッグした場合のみ呼び出し元が直接`updateRange()`を叩いていたため気付かれておらず、数値入力や「現在位置を使用」ボタンではclipのtrimStart/trimEndは正しく更新されるのにスクラバーの表示だけ古いままになっていた。`_wireTrimScrubber()`が`updateRange`関数自体を返すようにし、`_renderTrimPanel()`側で`commit()`から必ずそれを呼べるようにして解消。
|
||||
|
||||
**トリムスクラバーのバッジ重なりを解消**: 開始/終了/再生位置の時刻バッジ(ハンドルから浮き上がるラベル)がクリップ名やルーラー目盛りと重なる、また再生位置がトリム開始/終了に近いとStart/EndバッジとPlayheadバッジ同士が重なって読めなくなる不具合をユーザーが発見。クリップ名とスクラバーの間隔を拡大、スクラバー上部にバッジ専用の余白(`padding-top`)を確保、トラック高さを36px→52pxに拡大した上で、Playheadバッジのみ`top`をStart/Endバッジより高い位置(2段目)にずらして重なりを回避した。
|
||||
|
||||
**変更ファイル**: `static/css/video-tab.css`(`.wfm-video-form-panel`, `.wfm-video-trim-scrubber/-track/-badge/-badge-playhead`)、`static/js/video-edit-tab.js`(`_wireTrimScrubber()`が`updateRange`を返すよう変更、`_renderTrimPanel()`の`commit()`から呼び出し、クリップ名/トリム行の余白調整)
|
||||
|
||||
**How to apply**: 同じ状態変更を複数の入力経路(数値入力・ボタン・ドラッグ)から行える場合、各経路が同じ「見た目を再描画する関数」を実際に呼んでいるかを確認すること——1経路(ドラッグ)だけがたまたま直接呼んでいたために、他の経路のバグが長く気付かれずに残っていた。
|
||||
|
||||
### Video Assetタブに動画/画像フィルタドロップダウン追加
|
||||
|
||||
Asset切替ドロップダウン(`__Video Assets__`/`__Video Temp__`/All Video Assets)の右隣に、幅を約半分にした動画/画像タイプフィルタ(All Types / Video Only / Image Only)を追加。既存の`isVideoFile()`判定を再利用してクライアント側でリストを絞り込む。
|
||||
|
||||
**変更ファイル**: `templates/index.html`(`#wfm-video-asset-kind` select追加)、`static/js/video-asset-tab.js`(`_s.kindFilter`追加、`_filteredImages()`で適用)
|
||||
|
||||
### Video Edit プロジェクト永続化(Phase 5)とサイドバーProjectのPlan/Editモード切替
|
||||
|
||||
[VIDEO_EDIT_TAB_PLAN.md](VIDEO_EDIT_TAB_PLAN.md)のPhase 5(永続化)を実装。他の未着手フェーズ(クロップ/テキストオーバーレイ/BGM合成)と比べ、`video_plan_service.py`と同型のCRUDパターンをほぼそのまま流用できるため最軽量(見積もり1〜1.5日相当)と判断し、最初に着手した。
|
||||
|
||||
**バックエンド**: `video_plan_service.py`と同じ形(パス安全性チェック付きlist/get/save/delete、`updated_at`自動付与)の`VideoEditProjectService`を新設し、`video_edit_project/`ディレクトリに`ws_videoeditproj_*.json`として保存する。`video_plan_service.py`との共通base化は行わず独立クラスのまま(両者のスキーマは無関係なので、無理に共通化しても行数が減るだけで得るものがないという既存の`VideoPlanService`のドキュメント方針を踏襲)。
|
||||
|
||||
**保存対象**: タイムラインのクリップ順序・トリム区間・各クリップが参照するアップロード済みサーバーファイル(`serverRef`)のみ。アップロード未完了のクリップは保存対象から除外する。
|
||||
|
||||
**読込方式**: 保存されたクリップをComfyUIの`/view`エンドポイントからBlobとして再取得し、`File`化した上で既存の「クリップ追加」経路(`addClipFromFile()`→アップロード→probe)にそのまま通してから、保存済みのトリム値を上書き適用する方式にした。`serverRef`とメタデータを信頼してプレビュー生成ロジックを別に書く(再アップロードを省く)選択肢もあったが、既存の十分テスト済みの1経路だけを通ることを優先し、実装量とリスクを抑えた。
|
||||
|
||||
**サイドバーProjectタブの切替**: 中央のPlan/Editどちらのサブタブがアクティブかを`video-tab.js`が`_centerSubtabMode`として記憶し、サイドバーのProjectパネルを開く/切り替えるたびにそのモードを渡すようにした。`video-project-tab.js`は元々Video Plan専用だったリスト表示・削除ロジックを、モードごとの設定テーブル(取得/削除エンドポイント、開く関数、件数ラベル等)に切り出して汎用化。Editモードでのアイテムクリックは新設した`video-edit-tab.js`の`openSavedVideoEditProject()`(`video-plan-tab.js`の`openSavedVideoPlan()`と同型)に委譲する。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`py_compile`で構文確認、開発元リポジトリと実行時`custom_nodes`フォルダ間でファイルのdiffが空になることを確認して同期。Python側(新規サービス・ルート追加)はComfyUI完全再起動が必須(インポートは起動時一度きりのため)である旨をユーザーに案内済み。
|
||||
|
||||
**変更ファイル**: `py/services/video_edit_project_service.py`(新規)、`py/config.py`(`VIDEO_EDIT_PROJECT_DIR`)、`py/routes/video_routes.py`(`/api/wfm/video/edit/projects`系4エンドポイント)、`static/js/video-edit-tab.js`(`_buildProjectData()`/`_saveProject()`/`_restoreClipFromSaved()`/`_loadProjectData()`/`openSavedVideoEditProject()`、Save/Save As/Loadボタン配線)、`static/js/video-project-tab.js`(`_MODES`設定テーブルによるPlan/Edit汎用化)、`static/js/video-tab.js`(`_centerSubtabMode`追跡)、`templates/index.html`(Save/Save As/Load UI)、`static/js/i18n.js`(新規メッセージキー・ヘルプ本文更新、英/日/中3言語)
|
||||
|
||||
**How to apply**: 新機能の永続化を設計する際、対象データを直接シリアライズしてサーバー参照込みで復元しようとすると(プレビュー生成・probe結果の整合性など)別経路のロジックを新たに書く必要が出やすい。既存の「新規追加」経路がテスト済みで十分軽ければ、保存データを最小限(サーバー参照+差分パラメータ)に絞り、読込時はその経路をそのまま再利用する方が実装・保守コストが低い。
|
||||
|
||||
関連: [[project_video_edit_tab_feature_branch]]
|
||||
|
||||
---
|
||||
|
||||
## v0.7.1(2026-09-17)
|
||||
|
||||
### セキュリティ修正: Tagger VLM の Unsloth 経路でAPIキーがSSRFにより外部送信され得る不備を修正
|
||||
|
||||
セキュリティレビューで、Unsloth Desktop連携の2つの経路のうちAI TOOLタブ用プロキシ(`unsloth_routes.py`)にはリクエスト先ホストを`localhost`/`127.0.0.1`/`::1`に制限するallowlistが入っていたが、Taggerタブ VLM経路(`tagger_service.py`の`vlm_models`/`vlm_predict`、`backend=="unsloth"`時)には同等の検証が無く、クライアントがリクエストごとに指定する`api_url`へそのまま`Authorization: Bearer <UNSLOTH_API_KEY>`を送信してしまう実装になっていた。ComfyUIを`--listen 0.0.0.0`等で外部公開している環境では、設定変更なしにこの経路経由でAPIキーが任意の外部ホストへ流出し得る。`unsloth_routes.py`と同じallowlistロジックを`tagger_service.py`側にも追加し、loopback以外の`api_url`はキーを付与せずエラーを返すようにした。
|
||||
|
||||
副次的に、G'MIC-Qt起動(`gmic_routes.py`)の`gmic_qt_path`検証も、存在チェックのみだったのを絶対パス・`.exe`拡張子・実ファイルであることの検証に強化した(`shell=True`は使用しておらずコマンドインジェクションではないが、外部公開環境で設定変更と組み合わさった際のローカル実行リスクを下げる防御的対応)。
|
||||
|
||||
**How to apply**: 同一サービス(Unslothなど認証キーを要する外部API)へ複数の呼び出し経路がある場合、ホスト制限などのセキュリティ検証はルート/サービス単位ではなく呼び出し経路ごとに漏れなく入れる必要がある。片方だけ実装して安心しないこと。
|
||||
|
||||
## v0.7.0(2026-09-17)
|
||||
|
||||
### プレビュー枠の高さ固定・AssetサブタブをPlan/Editと並ぶ中央下部へ移設・トリムパネルにClipchamp風スクラバー追加・Frame保存のVideo Assets登録とAsset→Plan画像連携
|
||||
|
||||
Edit サブタブMVP実装後、実際にユーザーが使う中で見つかった調整要望をまとめて対応した。
|
||||
|
||||
**プレビュー枠の高さ固定**: 中央ペインのSource/Resultプレビュー枠が`min-height`指定のみだったため、動画ソースのネイティブサイズ(特に縦長)によって枠が伸縮し、下のPlan/Editサブタブの表示位置がソースを選ぶたびに動いてしまっていた。枠を`height:260px`固定にし、動画/画像側は`width/height:auto; max-width/max-height:100%`(ネイティブサイズが枠より大きい場合のみ縮小、小さい場合は拡大しない)に変更して解消。
|
||||
|
||||
**AssetサブタブをPlan/Editと並ぶ中央下部の第3サブタブへ移設**: 左サイドバーにあったAsset一覧・詳細パネルを中央ペイン下部(Plan/Editと同じ場所)へ移設し、左=一覧(サムネイル/テーブル切替可)・右=選択ファイルの詳細という2カラムレイアウトに再構成。一覧上部にグループ切替・ファイル名検索・表示切替(▦グリッド/☰テーブル)ボタンを追加した。左サイドバーはProjectのみのシンプルなパネルになった。
|
||||
|
||||
**トリムパネルにClipchamp風ビジュアルスクラバーを追加**: 動画クリップのトリム編集がテキスト入力(開始/終了秒)のみだったのを、時間目盛り(ルーラー)・Sourceプレビューの`currentTime`と同期するドラッグ可能な再生ヘッド・時刻バッジ付きのドラッグ可能なイン/アウトハンドルを備えたビジュアルなスクラバーに拡張した。全位置を`clip.duration`基準の%指定にすることでpx/秒換算の管理を持たずに済ませている。既存のStart/End数値入力・「現在位置を使用」ボタンとは同じ`commit`関数を通して連動する。ハンドルドラッグ中は`_renderTimeline()`の再描画を避け、pointerup時のみ確定してパフォーマンスに配慮。playheadとトリムハンドルが同じ位置に重なってもハンドル操作を優先できるようz-indexを調整した。
|
||||
|
||||
**Frame保存のVideo Assets登録とAsset→Plan画像連携**: 右ペインFrameタブの「Save to Output」に「Add to Video Assets group」チェックボックスを追加し、チェック時は保存したフレームをGalleryの`__Video Assets__`グループへ自動登録できるようにした。またAssetタブの画像アセット詳細に「Set as First Image」「Set as Last Image」ボタンを追加し、PlanタブのFirst/Last Imageへ直接画像をアップロードできるようにした(動画クリップでは非表示、選択中のPlanブロックへ反映後Planサブタブへ自動切り替え)。
|
||||
|
||||
**検証**: いずれもKaptureで実機ComfyUI_5(ポート8189)に接続し、正方形/縦長動画でのプレビュー枠固定確認、Asset新レイアウトでのグリッド/テーブル切替・検索・選択、トリムスクラバーのplayhead同期・トラッククリックシーク・ハンドルドラッグ(playhead重なり時含む)・複数クリップ切替時のリスナー管理、Frame保存後のGalleryグループ登録をAPI経由で確認、Asset画像からFirst/Last Imageへの反映まで一通り実機確認済み。
|
||||
|
||||
**How to apply**: プレビュー枠のようにコンテンツ依存でサイズが変わる要素が、隣接するUIの表示位置に影響する場合は、コンテンツ側ではなく枠側を固定サイズにしてコンテンツを内側で縮小表示する方が安全([[project_video_edit_tab_feature_branch]])。ドラッグ可能なUI要素同士が同じ座標に重なる可能性がある場合は、操作頻度の高い方(今回はトリムハンドル)のz-indexを優先し、もう一方の当たり判定に埋もれないようにする。
|
||||
|
||||
関連: [[project_video_edit_tab_feature_branch]]
|
||||
|
||||
### Video タブ Edit サブタブ: 簡易動画編集機能(クリップタイムライン・トリム・結合・書き出し)を新規実装
|
||||
|
||||
これまで「Editing tools coming soon」のプレースホルダーのみだったVideoタブのEditサブタブに、Adobe Premiereライクな簡易動画編集機能を実装した。詳細設計・調査結果は[VIDEO_EDIT_TAB_PLAN.md](VIDEO_EDIT_TAB_PLAN.md)にまとめてある。
|
||||
|
||||
**MVP機能**: クリップのタイムライン配置(左詰め、実秒数×固定px/秒スケール)、トリム(開始/終了秒)、複数クリップの結合書き出し、タイムライン全体の疑似連続プレビュー、静止画/アニメーション画像クリップ(表示時間指定)、Clear/複製/削除/並べ替え(◀▶ボタンおよびネイティブドラッグ&ドロップ)。新規ファイル`static/js/video-edit-tab.js`が中心実装。
|
||||
|
||||
**Phase 0実機検証での重要な発見(当初の設計を訂正)**:
|
||||
- ComfyUI Core 0.35〜0.36には`VideoSlice`という名前のノードは存在せず、正しいclass_typeは`"Video Slice"`(スペース込み)。`VideoTrim`(experimental)の`trim`入力はフラット値`{"start_time":..}`を渡すと**エラーにならず黙って何もトリムしない**(`.get("trim")`で一段余計にネストを剥がす実装のため)。実際に出力duration をprobeして初めて発覚し、「`execution_success`だけでは検証不十分」という教訓を得た。
|
||||
- `ConcatenateVideo`のAutogrow入力`videos`は、ネストしたJSONオブジェクトではなく`"videos.video0"`のようなドット結合フラットキーがinputsのトップレベルに必要(ComfyUI Core `Autogrow.TemplatePrefix`のドット結合ロジックに由来)。
|
||||
- `ConcatenateVideo`は解像度不一致で実行時ハードエラーになるため、クライアント側で事前チェックし、動画クリップ同士の不一致は書き出しをブロック、画像クリップは`ImageScale`(crop=center)で自動フィットして回避。
|
||||
- 動画クリップ(音声あり)と画像由来クリップ(無音)を結合すると`ConcatenateVideo`が音声レイアウト不一致でエラーになる。複数クリップ結合時のみ、全クリップを`GetVideoComponents`→`CreateVideo`で無音統一する対策を実装(単一クリップ書き出しは音声を保持。**複数クリップ結合時は音声が失われるのが既知の限界**)。
|
||||
|
||||
**静止画/アニメーション画像クリップ対応**: 既存のAsset一覧(`__Video Assets__`/`__Video Temp__`グループ)には元々ビデオ専用フィルタが無く画像も一覧されていたが、プレビューが常に`<video>`要素前提で画像では何も表示されなかった。`video-preview.js`のSource/Resultプレビュー枠に`<img>`コンパニオン要素を追加し、`mediaType`引数で切り替えるよう拡張。画像クリップは`LoadImage`→(必要時)`ImageScale`→`RepeatImageBatch(amount=表示秒数×24fps)`→`CreateVideo`で正確な長さの動画セグメントに変換して書き出しグラフに組み込む。
|
||||
|
||||
**UI設計はPlanサブタブに合わせて再設計**: 当初は縦リスト+各行ボタンだったが、ユーザー指示によりPlanタブの横トラック+共有ツールバー方式(◀▶/複製/削除/クリアが選択中クリップに対して働く)に変更。タイムライン自体はPlanのブロックのようにflex-growで幅一杯に引き伸ばさず、実秒数の固定幅で左詰め(空きトラックはそのまま空く)。この過程で、Edit実装以前の「Editing tools coming soon」プレースホルダー時代の重複CSSルール(`align-items:center`等)が残存しており、実装後もタイムライン全体が中央に縮んで表示される不具合を発見・修正した。
|
||||
|
||||
**タイムライン連続プレビュー**: 新規`<video>`要素を追加せず、Export結果が最終的に書き出される「生成された動画」(Result)プレビュー枠を共有し、全クリップ(トリム区間反映)を順番に連続再生する機能を追加(「▶ プレビュー / ■ 停止」トグル)。ユーザー要望「このプレビューの動画=書き出しされる動画」に対応。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/構文チェックで確認。開発元リポジトリと実行時`custom_nodes`は別実体のため、変更のたびに両フォルダへ同期しmd5ハッシュ比較で完全一致を確認。Kapture(ブラウザDevTools連携)で実機ComfyUI_5(ポート8189)に接続し、クリップ追加・トリム・並べ替え(ドラッグ含む)・複製・削除・クリア・連続プレビュー・単一/複数クリップ書き出し・静止画クリップの書き出し・動画+画像混在結合を一通り実機確認済み。
|
||||
|
||||
**How to apply**: ComfyUI Coreの新しいノード(特にexperimental扱いのもの)をAPIプロンプト形式で直接呼び出す際は、`/object_info`のクラス名・入力スキーマを鵜呑みにせず、実際に`/prompt`で実行して**出力を実測**するまで検証を終えたとみなさない——`execution_success`は型検証を通っただけで、パラメータが意図通り効いたかは別問題(今回`VideoTrim`のトリムが黙って無視される形で顕在化)。Autogrow(`COMFY_AUTOGROW_V3`)型の動的入力は、フラットなドット結合キー(`"prefix{name}"`を`.`区切りでinputsトップレベルに並べる)で渡す。
|
||||
|
||||
関連: [[project_dev_deploy_sync]]
|
||||
|
||||
## v0.6.3
|
||||
|
||||
### Send CCボタンでMP4動画をComic Creatorの新設「動画ツール」へ送れるように対応
|
||||
|
||||
Comic Creator(`eagle_comic_creator_spa/comfyui-comic-creator`、別リポジトリ)側にレイアウトタブの新機能「動画ツール」(MP4をコマに配置して再生・停止・音量/ミュート調整・指定フレームでの静止画キャプチャができる実験的機能)が追加されたのに伴い、Galleryタブの「Send CC」ボタンをこれに対応させた。
|
||||
|
||||
**問題**: 既存の`sendCcBtn`クリックハンドラ(`gallery-tab.js`)は、選択中ファイルのURLを`window.parent.insertImageFromUrl()`(レイアウトタブ宛て)または`window.parent._ccImageTab.loadFromUrl()`(Imageタブ宛て)へ渡すだけで、`.mp4`を判別する分岐が無かった。Comic Creator側の受け口は画像専用ロジックしか持たないため、動画を選んでSend CCを押しても正しく処理されなかった。
|
||||
|
||||
**対応**: `gallery-tab.js`に既にあった`isVideoFile()`ヘルパー(拡張子判定、Video Assetsグループのバッジ表示等で使用中)で選択中ファイルが動画かどうかを判定し、レイアウトタブ宛て(デフォルト経路)で動画の場合はComic Creator側が今回新設した受け口`window.parent.insertVideoFromUrl(url, filename)`を呼ぶよう分岐した。Imageタブ宛て(`_ccI2ITargetMode === 'image'`)で動画を選んだ場合は、Comic Creator側が今回Imageタブの動画編集には対応していないため、トースト(新規i18nキー`galleryVideoNotSupportedInImageTab`、英/日/中3言語)で案内して送信を中断するようにした。
|
||||
|
||||
**検証**: 双方の変更点を構文チェック(`node --check`)のみで確認。ComfyUI実機でのSend CC往復動作(Gallery側でmp4選択→送信→Comic Creator側でのオーバーレイ表示・再生確認)はユーザー側での実機確認待ち。
|
||||
|
||||
**How to apply**: Comic Creator側とWorkflow Studio側は別リポジトリだが、Send CC・Send to LI nodeのような`window.parent`/`window.opener`経由のクロスウィンドウ連携APIは、どちらか一方だけを変更すると壊れる。片方(今回はComic Creator側)に新しい受け口(`insertVideoFromUrl`)を追加したら、呼び出し元であるこちらのGalleryタブ側も同時に更新する必要がある。
|
||||
|
||||
## v0.6.2
|
||||
|
||||
### Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
|
||||
|
||||
ユーザーから「Comic CreatorのImageタブ同様にこちらのImage Editタブをペンタブレット対応させたい」という依頼を起点に、Draw/Maskツールの筆圧対応、Fillツール(バケツ塗りつぶし)の移植、そして実機フィードバックを受けた追加のペンタブレット不具合修正まで一連の作業を行った。
|
||||
|
||||
**Draw/Maskツールの筆圧対応**: `image-edit-tab.js`のキャンバスイベントを`mousedown/mousemove/mouseup/mouseleave`から`pointerdown/pointermove/pointerup/pointerleave`に変更し、両canvasに`touch-action: none`を設定。`PointerEvent.pressure`(ペン=実際の筆圧、マウス=ボタン押下中は常に0.5)を`DrawTool`/`MaskTool`の`onMouseDown`/`onMouseMove`に伝播させ、両ツールに`pressureEnabled`(既定ON)を追加——筆圧に応じてブラシサイズ(0.3〜1.7倍)とDraw側は不透明度(0.4〜1.6倍)を可変化する。pressure=0.5(マウス)では従来と同じ1.0倍になるため、マウス操作の挙動は変わらない。実装はComic Creator(`eagle_comic_creator_spa`)のImageタブで先行実装済みだった筆圧対応パターンをそのまま移植した。
|
||||
|
||||
**Fillツール(バケツ塗りつぶし)を新規追加**: Comic CreatorのImageタブにあった`FillTool.js`(スキャンライン方式のフラッドフィル、Solid/Gradient(線形・放射状)両対応、particle_engine.js由来のグラデーション評価ロジック)をそのまま移植し、`image-edit-tab.js`にツール切り替え・アクティブ化・クリック処理・プロパティパネル(Mode/Color/Ramp/Direction dial/Tolerance/Opacity)を追加、ショートカット`G`を割り当てた。ツールバーのボタンは`templates/index.html`に静的HTMLとして定義されていたため個別追加が必要だった(`TOOL_DEFS`配列だけでは表示されない)。
|
||||
|
||||
**追加のペンタブレット不具合修正(ユーザー実機フィードバック)**: Fillツールのグラデーション設定UI——色ストップを並べた「Ramp」(ドラッグでストップ位置を変更)と方向・強度を指定する「Direction」ミニダイヤル——がペンタブレットでスライド操作できないという報告を受けた。原因はこの2つのミニウィジェットのドラッグ処理だけが`mousedown/mousemove/mouseup`のまま残っていたこと(`touch-action`未指定でブラウザのパン/スクロールジェスチャに奪われやすく、`document`に張った`mousemove`はポインタキャプチャが無いため速いペンの動きを取りこぼしやすい)。`_setupFillGradientRamp()`/`_setupFillGradientDir()`を`pointerdown/pointermove/pointerup/pointercancel`に変更し、`canvas.style.touchAction = "none"`と`canvas.setPointerCapture(e.pointerId)`を追加、ドラッグ中のリスナーも`document`ではなく`canvas`自身に張るよう変更した。
|
||||
|
||||
**Comic Creator側も同じ不具合を横展開で修正**: Fillツールの移植元であるComic Creator(`image-tab.js`)にも同一パターンの`_setupFillGradientRamp()`/`_setupFillGradientDir()`に加え、Shapeツールの塗りグラデーションランプ`_setupShapeGradRamp()`(同一コードパターンの複製)が存在し、同じ理由でペンタブレット操作ができない状態だった。3箇所とも同じPointer Events化を適用して修正した。
|
||||
|
||||
**検証**: Kaptureで実ブラウザ(ComfyUI_5上のWorkflow Studio `/wfm`、Comic Creator `/ccc`)を操作し、Draw/Mask/Fillそれぞれで新規キャンバス作成→クリック描画/塗りつぶし→コンソールエラーなし・レイヤーサムネイル即時更新を確認。Ramp/Directionダイヤルもクリックでのストップ選択・ハイライト表示が正しく動作することを確認した(ペン特有の筆圧・ドラッグ挙動そのものは自動化ツールでは検証できないため、筆圧の効き具合とドラッグ操作は実機ユーザー確認による)。Comic Creator側は開発フォルダがComfyUI custom_nodes配下にシンボリックリンクされているため、コピー不要で編集がそのまま反映される構成だった(Workflow Studio側は開発リポジトリと実行時custom_nodesが別実体のため、変更のたびに手動コピーが必要)。
|
||||
|
||||
**How to apply**: キャンバスベースの描画・ドラッグ操作を実装する際は、最初から`mousedown/mousemove/mouseup`ではなく`pointerdown/pointermove/pointerup/pointercancel`(Pointer Events)を使い、`touch-action: none`を設定する——マウス専用に見えるミニウィジェット(カラーランプ、方向ダイヤル等)であっても、後からペンタブレットで使われた際に同じ不具合が起きる。ドラッグ中の`move`/`up`リスナーは`document`ではなく対象の`canvas`自身に張り、`setPointerCapture()`を使うと要素境界を越えた高速な動きでも取りこぼさない。同一パターンのコードが複数箇所(ファイル間・同一ファイル内)に複製されている場合、1箇所の不具合報告を機に全箇所を横展開で確認する。
|
||||
|
||||
関連: [[project_v062_fill_tool_pentablet]], [[feedback_realtime_debug_logs_and_kapture]], [[project_comic_creator_deploy_symlink]]
|
||||
|
||||
## v0.6.1
|
||||
|
||||
### Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
|
||||
|
||||
ユーザーから「ギャラリータブで検索を行うと読み込み中のままでクリアを行っても中断できない。50枚程度のフォルダで検索をかけても時間がかかっている」という報告を受け、原因調査から修正まで対応した。
|
||||
|
||||
**フロントエンドの2つの不具合**(`gallery-tab.js`): 検索欄はキー入力のたびに即座に(debounceなしで)`loadImages()`を呼んでおり、しかも直前のfetchをキャンセルする仕組み(AbortController)が無かった。そのため「クリア」を押した直後でも、その前に打っていた検索文字列の(まだ処理中の)リクエストが後から返ってきて、クリア後の空検索の結果を上書きしてしまい、「読み込み中のまま/検索結果のまま」に見えていた。検索入力に300msのdebounceを追加し、`loadImages()`にAbortController管理を追加(新しいリクエスト発行時に前回の未完了リクエストを中断)、「クリア」「Clear filters」ボタンはdebounceタイマーもクリアして即座に反映するようにした。
|
||||
|
||||
**バックエンドのロック競合**(`gallery_metadata.py`, `gallery_service.py`): フォルダを開くたびに起動するバックグラウンドインデックス処理が、`gallery_metadata.json`(画像数が多い環境では1MBを超える)全体を`threading.RLock`保持したままJSONシリアライズ+ディスク書き込みしており、その間ずっと検索リクエストの`get()`呼び出しがロック待ちでブロックされていた。さらに10枚処理するごとに1件ずつ`save()`を呼んでいたため、実質10回分の全体書き込みが発生していた。`_save_to_disk()`をロック内ではデータのdeepcopyのみに留め、実際のファイルI/Oは別の書き込み専用ロック(`_write_lock`)でロック外に出すよう変更し、バックグラウンドインデックス側もまとめて1回で保存する`save_batch()`を新設して使うようにした。また検索時は(該当時に全メタデータのJSON全体書き込みを伴う)`cleanup_stale_images()`をスキップするようにした(通常の検索なしロード時に実行されれば十分なため)。
|
||||
|
||||
**根本原因: ponyxlWildcardsVaultフォールバック探索でのVaultフォルダ誤検出**(`gallery_service.py`): 上記2件を直しても実機で53枚のフォルダへの検索が約3秒かかっており、一時的な計測ログを実機に仕込んで原因を特定した。出力ルート直下にComfyUI/StabilityMatrixが作る空の`thumbnails`フォルダがたまたま存在しており、これが検索対象画像の`.txt`サイドカーが無い場合のフォールバックである`_find_vault_category_root()`(ponyxlWildcardsVault形式——「直下にthumbnails(_option2)フォルダを持つ祖先ディレクトリ」というゆるい条件で判定)によって誤ってVaultのcategory_rootと認識されていた。誤検出されたcategory_root(1万枚超のファイルを含む出力ルート)に対し、`_load_vault_leaves()`がキャッシュの妥当性確認のため画像1枚ごとに`glob("*.yaml")`+`glob("*.yml")`を実行しており、1回あたり約53msかかるglobを53回(検索対象の画像数分)実行して合計2.8秒——実測ベンチマークで再現・確認した数値と実機ログの数値がほぼ一致した。`_load_vault_leaves()`をTTL(60秒)ベースのキャッシュに変更し、TTL内はglobを省略するようにして解消した。
|
||||
|
||||
**検証**: Kaptureで実ブラウザ(ComfyUI_5上のWorkflow Studio)を操作し、ネットワークリクエストのタイミングを実測しながら段階的に検証した。Python変更後の再起動が確実に反映されているかはプロセス起動時刻とファイル更新時刻を比較して都度確認した。最終的に53枚のフォルダへの検索は3327ms→74msまで短縮し、検索・クリアとも実機で即座に反映されることを確認した。デバッグ用の計測ログは検証完了後に削除済み。
|
||||
|
||||
**How to apply**: サブフォルダを持つ画像出力ルートで「Vault風フォルダ名(thumbnails等)の存在だけで判定するフォールバック検出」を書くと、無関係な巨大フォルダを誤検出してglob/scandir系の操作が本来の対象より何桁も遅くなることがある——検出ロジック自体だけでなく、誤検出時に何度も高コストな操作(特に大量ファイルを含むディレクトリへのglob)が繰り返し呼ばれないか、呼び出し頻度とキャッシュの妥当性も含めて確認する。原因不明のパフォーマンス低下は仮説で止めず、実機に一時的な計測ログ(`perf_counter`で区間ごとの時間を`logger.warning`に出力)を仕込んで実測すると、コードレビューだけでは気づけない誤検出のような盲点が見つかることがある。
|
||||
|
||||
関連: [[feedback_asyncio_blocking_in_aiohttp_routes]], [[feedback_realtime_debug_logs_and_kapture]], [[feedback_verify_restart_via_process_creation_time]]
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
|
||||
|
||||
ユーザーから「ギャラリーで複数画像を選択してPSDファイルを作成したい」という依頼を起点に、段階的に機能を積み上げてPSD対応を一式実装した。
|
||||
|
||||
**ライブラリ選定の紆余曲折**: 当初はPythonでPSDを書き込めるライブラリとして`pytoshop`を候補に選定したが、実際にインストールを試すとWindows用のプリビルドwheelが存在せず、`packbits`というC拡張のビルドに`cl.exe`(Visual Studio Build Tools)を要求することが発覚した。StabilityMatrix/ComfyUI Portable環境のユーザーは通常Cコンパイラを持たないため、この依存はほぼ確実にインストール失敗を招く。代わりに`psd-tools>=1.10`を採用した——1.10以降は読み取り専用ではなく`PSDImage.frompil()`/`PixelLayer.frompil()`による書き込みにも対応しており、依存(`attrs`, `numpy`, `Pillow`, `typing-extensions`)は全てプリビルドwheelを持ちCコンパイラ不要。実機と同等の条件(venv構築→pip install→実際にPSD作成→再読み込みで往復検証)でレイヤー構成・重なり順・opacity・blend_mode・visibleが正しく機能することを確認してから実装に着手した。
|
||||
|
||||
**Gallery: 複数選択→PSD一括書き出し** — Galleryの一括操作バーに「PSD作成」ボタンを追加。選択した複数画像を、既存のZIPエクスポート機能と同じ選択・送信パターンを踏襲して1つのPSDファイル(各画像=1レイヤー、レイヤー名=ファイル名)にまとめてダウンロードする。画像サイズが異なる場合は最大サイズのキャンバスに左上基準で配置する仕様とした(ユーザー選定)。バックエンドは`GalleryService.build_psd_from_images()`、ルートは`POST /wfm/gallery/images/export-psd`。
|
||||
|
||||
**Image Edit: Save PSD / Open PSD** — Image Editタブ自体にも「Save PSD」(アクションバー)を追加。`LayerManager.layers`を背面→前面順に走査し、各レイヤーをキャンバス全体サイズのoffscreen canvasに`Layer.applyTransform()`(回転・反転・拡縮)のみベイクして書き出す——PSDのピクセルレイヤーは矩形しか持てず回転角度を保持できないため、位置・変形は必ずピクセルに焼き込む一方、opacity・blend_mode・visibleは実際のPSDレイヤー属性としてそのまま渡し、Photoshop側で編集可能な状態を保つ。`maskApply=true`のクリッピングマスクは対象レイヤーと合成して1枚にベイクする簡略化とし、PSD本来のレイヤーマスク機構は使わない(「ライブラリで素直に対応できない部分は無視してよい」というユーザーの了承のもとでの意図的な妥協)。続けて逆方向の「Open PSD」も実装——`psd_tools.PSDImage.open()`→`psd.descendants()`で全レイヤーを再帰的に走査し、`layer.topil()`でレイヤーのbbox範囲にクロップ済みの実サイズ・実位置の画像を復元、`layer.bbox`/`opacity`/`blend_mode`/`visible`を`Layer`オブジェクトに変換する。グループ自体(フォルダ)はスキップしつつ中身は再帰的に取り込み、PSD側のブレンドモード(4バイトタグ)とCanvas2Dの`globalCompositeOperation`文字列の相互変換テーブルを新設した(Canvas2Dに対応物が無いモードはNormalにフォールバック)。
|
||||
|
||||
**Gallery → Image Edit: 複数画像を一括レイヤー転送** — Galleryの一括操作バーに「レイヤーとして編集」ボタンを追加。選択した複数画像(mp4は自動除外)でImage Editタブに切り替え、各画像を独立レイヤーとして展開する。既存の単一画像版「Image Edit」送信ボタンと同じ`window._wfmImageEditTab`グローバル参照の仕組みを再利用し、PSDファイルを経由しない直接URL読み込み方式(`loadLayersFromUrls()`新設)とした——各URLを`new Image()`で並列ロードし、最大幅・高さをキャンバスサイズとして採用、`fitToCanvas()`(既存ヘルパー)で各画像をアスペクト比を保ったまま中央配置する。
|
||||
|
||||
**Gallery: PSDファイルの一覧・サムネイル・プレビュー表示** — `.psd`を`IMAGE_EXTENSIONS`に追加し、Galleryのフォルダ一覧・検索・グループ機能に自動的に乗るようにした。ブラウザは`<img>`でPSDを直接レンダリングできないため、`serve_image`/`serve_thumb`ルートで`.psd`の場合は`psd_tools`で全レイヤーを合成(非表示レイヤーは除外)した上でPNG/JPEGに変換し、mtimeベースのキャッシュキーでディスクキャッシュして配信する方式にした。情報パネルの幅・高さもPSDヘッダーから取得するようにした。
|
||||
|
||||
**実機テストで発覚した2件のバグとその修正**:
|
||||
|
||||
1. **サムネイル表示が遅く、他の無関係なPNGサムネイルまで真っ黒になる**——原因は`serve_image`/`serve_thumb`ルートがPSD合成という重いCPU処理を`async def`ハンドラ内で直接同期実行しており、aiohttpの単一スレッドイベントループ全体をブロックしていたこと。PSD1枚の合成中は他の並行リクエスト(別画像のサムネイル配信など)も完全に止まり、結果的に無関係な画像まで表示が固まって見えた。`serve_image`・`serve_thumb`・`import_psd_layers`・新設`get_psd_layers`の4ルートを`loop.run_in_executor(None, ...)`でデフォルトのThreadPoolExecutorに逃がすことで解消した。この教訓は今後の重い処理全般に適用すべき一般原則として別途メモリに記録した。
|
||||
2. **単一画像「Image Edit」送信ボタンでPSDを開くと1レイヤーしかない**(「Open PSD」ボタンでは正しく全レイヤー開ける)——原因はこのボタンが常に`loadFromUrl()`(1枚のフラット画像として読み込む経路)を使っており、PSD専用のレイヤー分解経路を通っていなかったこと。サーバー上のファイルを再アップロードせず直接レイヤー分解できる新規`GET /wfm/gallery/image/psd-layers`(`GalleryService.get_psd_layers_from_path()`)を追加し、既存の`import_psd_layers()`とロジックを`_extract_psd_layers()`に共通化した上で、PSDファイルの場合はこの新エンドポイントを使うよう分岐させた。
|
||||
|
||||
**Image Editタブのアクションバー・レイヤーパネル改善**: 「Upload」を「File Open」に改名し「Open PSD」をその右隣に移動;新規「Close」ボタン(確認ダイアログ後、現在のドキュメントを破棄してプレースホルダー表示に戻す)を追加;レイヤーパネルヘッダーを2段構成(1段目=+/⧉(複製)/−/↑/↓、2段目=「Mask」ボタン単独、旧「M」から改名・移動)に変更;新規「複製レイヤー」ボタン(⧉)を追加し、`Layer.toJSON()`→`Layer.fromJSON()`のシリアライズ往復でアクティブレイヤーを複製(id再採番)して元レイヤーの直前に挿入する方式とした。実装にあたり、レイヤーリストクリック時の「選択」ロジック(種別・現在ツールに応じたキャンバス同期)を`_activateLayerById()`という共通メソッドに抽出し、複製直後の自動選択でも再利用することで、レイヤー種別(画像・描画・マスク・テキスト)を問わず正しく動作するようにした。
|
||||
|
||||
**ヘルプドキュメント更新**: 上記全機能についてヘルプタブを更新(`templates/index.html` + `i18n.js`英日中3言語 + `app.js`のhelpIdMapの3点セット)。更新作業の検証中に、今回の変更とは無関係の既存バグを2件発見し合わせて修正した——Image Editの「タイプアイコン」説明が英語版で重複定義されておりマスクレイヤーの表記が欠けていた点(片方は死んだ定義だったため削除)、日本語版でStyle Catalogの「Load in GenerateUI」項目が丸ごと未翻訳で英語表示にフォールバックしていた点(翻訳を追加)。
|
||||
|
||||
**検証**: PSD書き込み/読み込みロジックは全て、実際の`GalleryService`コード(相対importを差し替えたコピーをテスト用venvで直接実行)に対してテストスクリプトで検証した——複数レイヤー・サイズ不一致・非表示レイヤー・opacity/blend_mode・グループ内レイヤー・クリッピングマスクを含むPSDを実際に構築し、書き出し→再読み込みで往復一致することを確認。Gallery一括PSD書き出し・Image Edit Save/Open PSD・Gallery表示・Gallery→Image Editレイヤー転送・アクションバー/レイヤーパネル改善は全てユーザーが実機(実ComfyUI環境)で動作確認済み。
|
||||
|
||||
**How to apply**: Cコンパイラを要求するPythonライブラリはWindows向けComfyUIカスタムノードでは避け、`pip install --only-binary=:all: <lib>`でプリビルドwheelの有無を先に確認してから採用する。aiohttpの`async def`ルート内でCPU負荷の高い同期処理(画像デコード・合成・リサイズ等)を直接呼ばず、`loop.run_in_executor()`でスレッドプールに逃がす——特にサムネイル/プレビューのように頻繁に並行して呼ばれるエンドポイントは、1回の遅延が他の全リクエストに波及するため重要。ヘルプ更新は`index.html`だけでなく`i18n.js`(3言語)と`app.js`の`helpIdMap`の3点セットで行う。
|
||||
|
||||
関連: [[project_gallery_psd_export]], [[project_gallery_psd_display]], [[project_gallery_to_image_edit_layers]], [[project_v04x_image_edit_ui_polish]], [[feedback_psd_export_library_choice]], [[feedback_asyncio_blocking_in_aiohttp_routes]]
|
||||
|
||||
## v0.5.8
|
||||
|
||||
### Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
|
||||
|
||||
GitHub上の外部コントリビューター(`censor-ed`氏、fork経由)からPR #2「Tagger DBの複数選択削除を追加」が届いたため、内容確認・実機検証・マージまで一貫して対応した。PRの変更内容はバックエンド(`tagger_routes.py`に`DELETE /wfm/tagger/db`エンドポイントを新設、`tagger_db_service.py`に`delete_bulk()`をプレースホルダ経由のSQLite `DELETE ... WHERE id IN (...)`で実装)とフロントエンド(DBテーブルの各行にチェックボックス、ヘッダーに全選択チェックボックス、既存の単一削除`_dbDelete`と同じ`confirm()`パターンを踏襲した確認ダイアログ、英日中3言語のi18n)。
|
||||
|
||||
コードレビューでは危険な処理(外部送信・eval・shell実行等)は無く、SQLインジェクション対策も適切と判断。ただし2点の懸念を指摘した:(1) 一括削除の`count`がAPIレスポンスで返る削除件数の妥当性、(2) 全選択チェックボックス`_dbToggleSelection`が単純トグル(クリックのたびに全チェックボックスの状態を反転)方式になっており、一部だけ選択済みの状態でクリックすると直感に反する(選択済み行が外れ未選択行が入る)挙動になる懸念。
|
||||
|
||||
実機検証はKapture経由でComfyUI_5(ポート8189)に実際に接続して実施。データベースには実データ(既存のタグ付け済み画像)しか存在しないため、削除操作自体は実行せず安全に検証する方針を取った:一括削除APIは`curl`で存在しないID(999999)を渡して直接叩き、`ids`未指定時の400エラー・不正な値混入時の500エラーハンドリングを確認。UI側はKaptureで実際にチェックボックスを操作し、行クリックとチェックボックスクリックが正しく分離されていること、未選択で削除ボタンを押すと警告トーストが出ること、削除ボタンで`confirm()`ダイアログ(日本語i18n表示)が開くことを確認した上で`mcp__kapture__dialog`の`accept:false`でキャンセルし、実データには一切触れずに検証を完了した。この過程で指摘(2)の懸念が実機で再現することを確認——ID66のみ選択した状態で全選択チェックボックスをクリックすると、ID66のチェックが外れ他の全行がチェックされるという実際の誤動作を確認した。またAPIレベルの検証で指摘(1)も実バグと確定:存在しないID 1件を渡しても`{"ok": true, "count": 1}`が返っていた。原因は`handle_db_delete_bulk`が`delete_bulk()`の戻り値(実際の削除件数=`cur.rowcount`)を使わず、渡された`ids`の件数(`len(ids)`)をそのまま返していたため。
|
||||
|
||||
この検証プロセス中に、ComfyUIの「再起動」がユーザー申告だけでは信頼できないケースに遭遇した。Pythonファイル(`tagger_routes.py`)をコピーしてComfyUI再起動を依頼したところ、依頼直後は新エンドポイントへの`DELETE`が405 Method Not Allowed(`Allow: GET,HEAD`のみ)を返し続けた。原因調査でComfyUI起動ログ(`user/comfyui_8189.log`)や`app.router`のルート登録順序(ComfyUIコア自身のSPAフォールバック用static routeとの競合の可能性)を疑ったが、最終的にはPowerShellの`Get-CimInstance Win32_Process`でプロセスの実際の`CreationDate`を確認し、動いているComfyUIプロセスの起動時刻がコードをコピーした時刻より前であることを突き止めた——つまりユーザーが「再起動しました」と報告した操作は、ブラウザページのリロード等では反映されず、プロセス自体は起動されたままだった。ユーザーに再度プロセスの完全停止・起動を依頼し、新しいプロセス起動時刻(コピー時刻より後)を確認してから再テストしたところ405エラーは解消した。
|
||||
|
||||
2点のバグ修正(`handle_db_delete_bulk`が`delete_bulk()`の戻り値を`count`として返すよう変更、`_dbToggleSelection`をクリックされたヘッダーチェックボックス自身の新しい状態(`e.target.checked`)に全行を揃える一般的な「全選択」挙動に変更)をコミットし、`maintainerCanModify`権限を使ってPR元のフォークブランチ(`censor-ed/ComfyUI-Workflow-Studio`の`feature/tagger-bulk-delete`)に直接pushしてPRへ反映、`gh pr merge`でGitHub上のPR #2を正式にマージした(マージコミット`e067f34`)。ローカル`main`をfast-forward更新し、実行時`custom_nodes`フォルダへ最終版を再同期して`diff --strip-trailing-cr`で完全一致を確認した上で、最後にもう一度ComfyUI再起動→APIレスポンス確認まで実施した。
|
||||
|
||||
**検証**: バックエンドAPI(`curl`直叩き、正常系・異常系)とフロントエンドUI(Kaptureブラウザ操作、チェックボックス・確認ダイアログ・トースト)の両方を実機で確認。修正コミット後も同様に再検証し、`count`が正しい値(0)を返すこと・全選択チェックボックスが選択済み行を含めて正しく全ON/全OFFされることを確認した。実データは一度も削除していない。
|
||||
|
||||
**How to apply**: 外部コントリビューターのPRを取り込む際は、まず実行時`custom_nodes`フォルダと開発リポジトリの現在のブランチが完全一致していること(改行コード差分のみ)を確認してから安全にファイルを上書きする([[project_dev_deploy_sync]])。DBやファイルシステムなど実データを持つ機能のUIテストでは、可能な限り「削除・変更操作自体は実行せず、確認ダイアログの表示とキャンセル」「存在しないIDでのAPI直接テスト」など非破壊的な手段で検証を組み立てる。Pythonファイル変更後のComfyUI再起動は、ユーザーの「再起動しました」という申告だけでなく、`Get-CimInstance Win32_Process`等でプロセスの実際の起動時刻を確認し、コード変更時刻より後であることを検証してから動作確認に進む——ブラウザのページリロードやタブの再接続だけでは、バックエンドのPythonプロセス自体は再起動されていない場合がある。APIレスポンスの件数系フィールド(`count`等)は、渡された引数の長さではなく実際の処理結果(DBの`rowcount`等)を返すべきというありがちなバグパターンに注意する。「全選択」チェックボックスは常時トグルではなく、クリックされた自身の新しい状態を全体に適用する実装が直感に合う。
|
||||
|
||||
関連: [[project_dev_deploy_sync]], [[feedback_comfyui_running_check_before_launch]], [[feedback_realtime_debug_logs_and_kapture]]
|
||||
|
||||
## v0.5.7
|
||||
|
||||
### 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
|
||||
|
||||
「Videoプランの追加で設定のエクスポート、バックアップ機能の更新が必要では」との確認依頼を受けて調査。v0.5.5で追加された`video_plan/`ディレクトリ(Plan JSON+インデックスPNG、`lab_plan/`と同構造)が、フルバックアップZIP機能の説明文言から漏れていないか確認した。実装(`_build_full_backup_zip`)自体は`DATA_DIR`配下のトップレベルエントリを汎用的に走査する設計(除外リスト`_FULL_BACKUP_EXCLUDE_NAMES`は`gmic_temp`/`thumb_cache`/`wildcard`の3つのみ)のため、`video_plan/`は既存コードの変更なしに自動的にバックアップ対象へ含まれていた——つまり機能的な欠陥は無かった。一方、ユーザー向け説明文(`fullBackupHint`/`helpSettings15`のi18n.js3言語、README.mdのFull Backup説明、`settings_routes.py`のコード内コメント)は「AI Skills、Lab Plans、Style presets」という列挙のままVideo Plan機能追加時に更新されておらず、実際にバックアップされるデータの説明として不正確になっていた。該当6箇所+READMEを「Video Plans」を含む形に修正。
|
||||
|
||||
### Video Plan (Planサブタブ) 中央パネルの設定項目レイアウト刷新 + MiniMax H3 Turbo/LTX-2.5 Prompt Enhance/Wan2.2 Turbo対応
|
||||
|
||||
「Duration・Noise SeedをMultipleの右隣に移動し、その下に新しい行でWidth/Height/MiniMaxのturbo_mode/strength/steps・LTX-2.5のprompt_enhance・Wan2.2のenable_turbo_mode(MiniMaxのturbo_modeと兼用)を追加したい」との要望。着手前にComfyUI_5環境の直近保存ワークフロー(`minimax_turbo.json`、LTX-2.5の`ltx_25_test`/`test2`/`test3`、Wan2.2の`wan22_test`/`test2`/`test3_i`)を実際にPythonでパースしてサブグラフの公開入力を調査し、対応可否を事前検証してから実装した。
|
||||
|
||||
調査で判明した構造: MiniMax H3は`turbo_mode`(BOOLEAN)/`turbo_model_strength`(FLOAT、LoRA強度)/`turbo_steps`(INT)をサブグラフの直接ウィジェットとして公開しており、これらがユーザーの言う「strength」「steps」に対応する。LTX-2.5は3バリアントとも`prompt_enhance`(BOOLEAN)を公開。Wan2.2は`enable_turbo_mode`(BOOLEAN)を公開しており、MiniMaxの`turbo_mode`と意味的に同一のためUI上は1つの「Turbo Mode」欄として兼用する設計にした。width/heightはMiniMax・LTX-2.5のText/Image版ではResolutionSelector経由(サブグラフ入力がリンク接続されている)だが、LTX-2.5のFirst & Last Frame版とWan2.2は全バリアントでResolutionSelectorを持たずサブグラフの直接ウィジェット値のまま——既存コードのコメント通りの構造を実データで確認できた。noise_seedは全ファミリーで`name="noise_seed"`に統一されている(LTX-2.5のText to Videoのみ`label="seed"`を持つため、ラベルではなくname直接一致で検索するヘルパーを新設)が、**Wan2.2のText to Videoだけサブグラフの公開入力に`noise_seed`が存在しない**(Image to Videoにはある)という非対称性を発見した。
|
||||
|
||||
実装は`video-workflow.js`の`locateVideoModelNodes()`を拡張し、上記7項目(width/height直接指定用インデックス、noise_seed、turbo_mode、turbo_strength、turbo_steps、prompt_enhance)のインデックスを検出して返すようにした(対応する入力が無ければ-1を返し、呼び出し側で無効化・非ハイライトの判断に使う)。既存のAspect Ratio/Megapixels/Multipleと全く同じ「ワークフローが対応する場合のみ入力可能+ラベルハイライト」の仕組みで新規7項目も統一し、新しい選択UIを作らず既存の`wfm-model-label-active`パターンを再利用した。noise_seedについては値の書き込みロジックをあえて追加しなかった——`comfyUI.generate()`がAPI変換後のワークフロー全体から`noise_seed`/`seed`キーを持つ全ノードを一括で上書きする既存の`applySeedToWorkflow()`が必ず後から実行されるため、`applyBlockToWorkflow()`側で書いても意味がなく、Plan全体のシード管理は引き続きそちらに一任する設計とした。
|
||||
|
||||
UI面ではDuration(元々ブロックごとの独立値)とNoise Seed(Plan全体で共有)を、機能的な性質は変えずにDOM位置だけtopbarのbasesettings行へ移動した。Durationはブロックエディタの`innerHTML`再生成に巻き込まれない固定要素にする必要があったため、change時のリスナー登録をブロック切替のたびに再アタッチする方式から、初期化時に一度だけ登録し値の同期のみブロック切替時に行う方式(`_initDurationControl()`新設)に変更した。新規7項目は`_plan.base_settings`に追加しPlan保存/読込(`_buildPlanData`/`_applyPlanData`)にも対応させた——Aspect Ratio等と同様Plan全体で共有する値とし、ブロックごとには持たせない設計とした。
|
||||
|
||||
実機検証はComfyUI_5(ユーザーが起動中のポート8189、8188に誤って重複起動してしまった分は作業後に停止)上でKaptureブラウザ操作により実施。MiniMax H3ワークフローで`Turbo Mode`/`Strength`/`Steps`が正しくハイライト・値反映され`Width`/`Height`は無効化されること、LTX-2.5 First & Last Frame版で`Width`/`Height`が有効化・ハイライトされること、Wan2.2 Text to Videoで`Noise Seed`のみ非ハイライト(disabled)になり`Turbo Mode`はハイライトされること、ブロック追加後にDurationがブロックごとに独立して保持される(1つ目=5秒のまま、2つ目=7秒に変更後も1つ目に戻ると5秒を維持)ことを、DOM要素のクラス・disabled状態を直接読み取って確認した。
|
||||
|
||||
ヘルプ文言は`helpVideo1`/`helpVideo2`/`helpVideo10`をindex.html・i18n.js(英日中3言語)・app.jsの`helpIdMap`の3点セットで更新——`helpIdMap`自体は既存のキーマッピングをそのまま流用でき変更不要だったが、`applyI18nToHtml`が起動時にi18n.jsの内容でDOM要素を上書きする一方でindex.html側の静的テキストが更新漏れになっていたため、ソース整合性のため合わせて修正した。README.mdのVideo Tabセクション(Plan/Edit subtabsの基本設定説明、Field highlight colorの対象フィールド一覧)も新規7項目を反映して更新した。
|
||||
|
||||
**検証**: `node --check`で変更した全JSファイルの構文確認。実機(ComfyUI_5、ポート8189)でMiniMax H3/LTX-2.5(First & Last Frame版)/Wan2.2(Text to Video版)の3ワークフローをKapture経由で実際にロードし、ハイライト・disabled状態・値反映・ブロックごとのDuration独立性をDOM評価で確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイル(`video-workflow.js`/`video-plan-tab.js`/`i18n.js`/`index.html`/`video-tab.css`)を都度両フォルダへ同期し`diff`で完全一致を確認。
|
||||
|
||||
**How to apply**: UIに新しい項目を追加する際、「ワークフローによって対応する場合のみ有効化・ハイライトする」という既存パターンが確立している画面では、新規に選択UIやトグルを作らず既存の仕組み(本プロジェクトでは`wfm-model-label-active`クラス+ラベルID登録)にそのまま乗せるのが一貫性を保ちやすい([[feedback_reuse_existing_ui_before_building_new]])。同系統に見えるモデルファミリー間でも、実際のワークフローJSONをパースして公開入力の有無を確認しないと「あるはず」の項目が無い非対称性(今回のWan2.2 Text to VideoのNoise Seed欠如)を見落とす——README/ヘルプ文言に「ワークフローXにはYが無い」という例外を書く前に、必ず実データで裏取りする。UI上は1つのフィールドとして統一表示しつつ、内部的には複数のワークフローファミリーそれぞれの異なる入力名(`turbo_mode`と`enable_turbo_mode`)にマッピングする「兼用フィールド」パターンは、意味が同じであることをコメントで明記した上でフォールバック演算子(`||`)で検出すればよく、UI側の分岐は不要になる。生成時に上流のAPI変換・送信処理(今回の`applySeedToWorkflow`)が特定のキーを一括で上書きする既存の仕組みがある場合、新規追加するフィールドの値が同じキーに書き込まれるなら、二重に値を設定するコードを書かず上流の処理に委ねる方が正確(後から上書きされる無駄なコードパスを残さない)。
|
||||
|
||||
関連: [[project_video_plan_asset_tab]], [[project_dev_deploy_sync]], [[feedback_reuse_existing_ui_before_building_new]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.6
|
||||
|
||||
### バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
|
||||
|
||||
ユーザー(censor-ed氏)からのIssue報告: `wd-swinv2-tagger-v3`でタグ付けすると画像内容と一致しない不自然なタグが出力される。調査依頼者自身が原因を特定しており、`tagger_service.py`のONNX推論前処理がWD Tagger系モデルの学習時前処理と異なっていた(単純リサイズ+RGBのまま0.0〜1.0正規化)のに対し、WD Tagger系標準の前処理は「透過は白背景で合成→アスペクト比を維持したまま白背景で正方形にパディング→BICUBICリサイズ→RGB→BGR→0〜255のfloat32のまま(正規化なし)」であるとの具体的な指摘だった。`_predict_onnx()`をこの標準前処理に修正(本プラグインがサポートするONNXタガーはREADME記載の通りWD Tagger系のみのためパス全体を統一。DeepDanbooru用`_predict_dd()`は元々正しい前処理のため変更なし)。実機で`wd-eva02-large-tagger-v3`を使い、くまの着ぐるみパペットを持つ緑髪キャラクターのアニメ調画像でタグ付けを検証したところ、修正前は前処理が公式実装と異なる状態だったのに対し、修正後は`green_hair, bear_ears, bear_costume, paw_gloves, hoodie, animal_hood, animal_costume`など画像内容と高精度に一致する自然なタグが生成されることを確認した。
|
||||
|
||||
---
|
||||
|
||||
## v0.5.5
|
||||
|
||||
### Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
|
||||
|
||||
「Videoタブにバッチ動画生成機能を追加したい。既存の生成UIタブのLab機能を動画生成用に整えたイメージ」との要望を起点に着手。従来のVideoタブ(単発生成フォーム)をPlan/Asset/Editの3タブ構成に再設計し、複数セッションにわたるユーザー実機確認・レイアウト調整・追加要望への対応、および調査の過程で発見した重大バグの根本修正までを1リリースにまとめた。
|
||||
|
||||
**1. Planタブ: タイムラインブロックによるバッチ動画生成**: 「基本設定→タイムラインをブロック分割→各ブロックにFirst/Last画像とプロンプトを設定→実行で順次バッチ生成」という要望を実装。LabタブがCheckpoint/LoRA/VAE/Prompt/Ksamplerそれぞれ独立したキーフレーム配列を持つ設計なのに対し、Video Planは「各ブロックが完結した1本の生成ジョブ」という単位でデータを持つ点が本質的に異なるため、Lab方式(`_buildWorkflowForIteration`によるキーフレーム解決)ではなく新規のデータモデル(`{id, color, duration, prompt, first_image_mode, first_image_filename, last_image_filename}`の配列)を設計した。First ImageはNone/画像を指定/前ブロックの最終フレームを使用の3択(`first_image_mode`)を持ち、chain_previous選択時は前ブロックに明示的なLast Imageがあればそれを直接使い(フレーム抽出不要)、無ければ前ブロックの生成動画から実際の最終フレームを`extractLastFrameBlob()`(Labのchain image機能で実績のある関数を再利用)でクライアント側抽出してアップロードする、という優先順位をユーザーと合意の上で実装した。Run/Pause/Cancelと進捗バーはLabタブの`_runLabBatch`と同じPromiseベースの一時停止機構を踏襲。ワークフロー操作(モデルファミリー自動判定・ノードへの値注入)はVideoタブの既存ロジックから`video-workflow.js`として抽出し、Plan/旧単発フォームの双方から呼べる純粋関数群にした。
|
||||
|
||||
**2. Plan永続化**: Labの`ws_labplan_`プレフィックス+PNGコンタクトシートへのiTXt埋め込み方式をそのまま踏襲し、`ws_videoplan_`プレフィックスでPlan JSON+インデックス画像を保存する仕組みを新規実装(`video_plan_service.py`はLabのサービスを複製、共通化はしない方針)。ブロックが1個のPlan=旧来の単発生成と等価になるよう設計し、「Load in Video」ボタンは常にブロック1個の新規Planを生成するようにした。
|
||||
|
||||
**3. Assetタブ: Galleryのグループ機能を再利用した素材管理**: 独自のフォルダブラウザを新規実装する代わりに、`feeder-tab.js`の「Galleryモード」(Gallery既存バックエンドをグループスコープで叩く軽量グリッド)と同じパターンを踏襲。当初は生成された動画を全て予約グループ`__VideoAssets__`へ自動追加する設計だったが、ユーザーからのフィードバックで用途を再定義:`__VideoAssets__`はユーザーがGalleryで手動キュレーションする「素材」グループとし、バッチ実行の自動追加先は新設の予約グループ`__vtemp__`(Planを保存していない生成物の一時置き場)に変更。Plan保存済みの場合は`VideoPlan:<name>`サブグループへ、未保存の場合は`__vtemp__`へ、という条件分岐にした。Assetタブのグループ選択には「All Video Assets」(`__VideoAssets__`+`__vtemp__`の合算、バックエンドに単一グループでの合算フィルタが無いため2回フェッチしてマージ)に加え、両グループの個別選択・Plan別サブグループ選択も追加。Galleryタブ側には`__vtemp__`用の🔒予約表示(`__Feeder__`と同様リネーム・削除不可)と、詳細パネルに`__vtemp__`をワンクリックでクリアする「VtC」ボタン(既存の`__Feeder__`用「FC」ボタンと同じ`groups/{name}/clear`APIを利用)を追加した。
|
||||
|
||||
**4. UIレイアウトの複数回反復**: 初回実装は左サイドバー(380px固定幅)にPlan/Asset/Editの3サブタブを縦積みする構成にしたが、実機確認で「Timeline+First/Last画像+Promptを横並びにしたい」という要望とレイアウト崩れの報告を受け、Planタブの中身を中央の広いプレビュー欄の下部に移動(ブロックエディタをFirst Image/Last Image/Prompt/Duration+colorスウォッチの横並び4カラムに再設計、実行系(Run/Pause/Cancel/進捗/Seed)とプラン管理系(Note/Save/Load)を「Run」「Plan」の2タブに整理)。続けて「これをPlanタブ化してEditタブを追加、左ペインのPlan/Editは削除」との指示で、中央パネルにPlan/Edit専用のタブナビを新設し、左サイドバーにはAssetのみを残した。この際、両タブナビが同じ`.wfm-video-subtab-btn`/`.wfm-video-subtab-panel`クラスを共有する実装だったため「Assetを選ぶと中央のPlan/Editも連動して消える」という不具合が発生 — 中央タブの切替処理を`.wfm-video-center-panel`スコープに限定したquerySelectorAllに変更し、サイドバーのAsset単独ボタンは独立したshow/hideトグルとして分離することで解消した。中央・右ペイン(プレビュー、Frame抽出/GIF変換/Video Source)はサブタブ切替と独立した常時表示パネルとして、レイアウト変更を通じて無改修のまま維持している。
|
||||
|
||||
**5. バグ修正: Galleryのグループ追加がジャンクションフォルダ環境で一覧に反映されない**: 「生成完了後に`__vtemp__`グループには入らない。Galleryで手動追加したコンテンツは表示される」との報告を受けた調査で発見。ユーザー環境の`ComfyUI_5\output`はStabilityMatrixの共有出力フォルダ機能によるWindowsジャンクション(実体は`StabilityMatrix\Images\Text2Img`)だった。`save_image_meta`(タグ/メモ保存)には既に「`get_image_metadata()`側はresolve()済みパスをキーにするため保存側も統一する」という同種バグの修正コメントがあったが、`add_to_group`/`remove_from_group`/`toggle_favorite`/`bulk_set_favorite`の4箇所には同じ修正が未適用のまま残っていた。そのためグループ追加自体はサーバー上で成功する(ジャンクション形式の未解決パスをキーに保存)が、一覧取得(`list_images`)はフォルダを`.resolve()`した実パスでスキャンするためキーが一致せず、「追加はされるが一覧に出てこない」というサイレント不具合になっていた。4箇所とも`save_image_meta`と同じresolve()統一パターンに修正。Video機能に限らずGallery全体(お気に入り・グループ機能)に影響していた既存バグで、今回の調査で偶然発見・修正できた。
|
||||
|
||||
**6. バグ修正: Galleryのグループフィルタがサブフォルダ内のファイルを見つけられない**: 5の修正後も「rootで`__VideoAssets__`フィルタ時は画像のみ、動画は表示されない」との報告。動画は`video/`サブフォルダに保存されるが、Galleryタブの`loadImages()`はグループフィルタの有無に関わらず常に非再帰スキャン(現在のフォルダ直下のみ)だったため、グループメンバーがサブフォルダにあると机上から漏れていた。グループフィルタが有効な時のみ`recursive=true`を付与するよう修正。
|
||||
|
||||
**7. バグ修正: チェイン画像が無関係な既存ファイルにすり替わる**: 「2ブロックのPlanで2つ目のFirst Imageを前ブロックの最終フレームに設定したが、生成された2つ目の動画は無関係な画像が使われている」との報告。調査の結果、`convertUiToApi()`(UI形式→API形式変換の共通処理)が持つ「COMBO項目の値がキャッシュ済み`/object_info`の選択肢に無い場合、無警告で選択肢の先頭に差し替える」というフォールバック機構(チェックポイント名の互換性対応が主目的で元々存在)が原因と判明。LoadImageノードの「image」もCOMBO型のため対象になり、バッチ実行中にアップロードした抽出フレーム画像のファイル名はその時点でキャッシュ済みの`/object_info`にまだ存在せず、無関係な既存ファイル(選択肢の先頭)に差し替えられていた。LabタブのchainImage機能がこの問題に遭遇しないのは、Labが既にAPI形式のワークフローを直接操作し`convertUiToApi()`を経由しないため。`comfyui-workflow.js`に`invalidateObjectInfoCache()`を新設し、チェイン用フレーム・ユーザーの手動画像アップロードの直後に呼んで次回変換時に最新の`/object_info`を再取得させるよう修正した。
|
||||
|
||||
**8. バグ修正: `__VideoAssets__`グループが再起動後に一覧・詳細パネルから消える**: 「再起動してギャラリーを確認したがグループに`__VideoAssets__`、`__vtemp__`が表示されなくなった」との報告。`gallery_metadata.json`を実データで直接確認したところ、`__vtemp__`はレジストリ・10件のメンバーシップとも正常だった一方、`__VideoAssets__`はグループレジストリ配列からも全画像のメンバーシップからも完全に消えていた(`delete_group()`はレジストリと全画像の`groups`配列を同時に消す実装で、症状と完全に一致)。原因は`__vtemp__`/`VideoPlan:<name>`と異なり、`__VideoAssets__`はバッチ実行側から`ensure_group`を呼ぶコードが一切無い設計だったこと — 3番で予約グループ保護(🔒表示・削除ボタン無効化)を追加する**前**の時点でManage Groupsから削除操作が可能だったため、その時点で削除され、以後`add_to_group`は個別画像への書き込みは成功してもレジストリを再登録しないため二度と一覧に出てこなくなっていた。`gallery-tab.js`に`ensureFeederGroup()`と同型の`ensureVideoGroup()`を新設し、VideoタブのAssetタブ初期化のたびに`__VideoAssets__`を確実に再登録するよう修正。削除時に失われた過去のメンバーシップ自体(開発中のテスト動画)はバックアップが無く復元不可能である旨をユーザーに報告した(このensure漏れが後に13番の伏線になる — `__VideoAssets__`側だけ直し`__vtemp__`側のensureが漏れていた)。
|
||||
|
||||
**9. Assetタブに保存済みPlan一覧を表示する「Project」サブタブを追加**: 「AssetタブにVideo Planを表示したい」との要望を起点に着手。何を指すか確認したところ「右ペインにプロジェクトタブを追加し、保存済み一覧、各動画のplan名表示としたい」との回答。調査の結果、バックエンドには`GET /api/wfm/video/plans`一覧APIと`/wfm_data/video_plan/{name}.png`サムネイル配信が2番のPlan永続化実装時から既に存在していたが、フロントエンドからは一度も呼ばれておらず丸ごと未使用のまま埋まっていたことが判明した。新規`video-project-tab.js`で保存済みPlan一覧(サムネイル・名前・ブロック数・更新日時)表示、クリックでのPlanエディタ読み込み(`video-plan-tab.js`に`openSavedVideoPlan()`をexport)、削除機能を実装し、Assetカードには生成元のPlan名をバッジ表示する機能(`img.groups`から`VideoPlan:<name>`グループを逆引き)も合わせて追加した。当初の指示通り右ペイン(Frame/GIFタブの隣)に実装したが、直後のフィードバックで「左ペインに変更したい」との指示を受け、左サイドバー(Assetタブの隣)へ丸ごと再配置した。この移動に伴い、左サイドバーのトグルロジックを「Assetボタン単体のshow/hide」から「クリックした方を開き他方を閉じる、同じボタンの再クリックで両方とも折りたたむ」という排他的トグルに拡張した。
|
||||
|
||||
**10. 中央下部のワークフロー名表示行を1行に統合**: 「Open Workflowボタン行を上段の現在読み込んでいるワークフロー名行と1行にしたい」との要望。`wfm-video-wf-name`(現在ロード中のワークフロー名)と`wfm-video-plan-workflow-row`(Planに記録されたワークフロー名+Open Workflowボタン)が縦に2行表示されていたのを、`.wfm-video-wf-row`という横並びflexコンテナでまとめ1行に統合した。
|
||||
|
||||
**11. ワークフロー設定可能項目ハイライト表示の復活**: 「以前の単発生成フォーム時の設定項目のハイライト表示を行いたい」との要望を受けて調査したところ、v0.5.4で一度実装されていた機能(`wfm-model-label-active`クラス、GenerateUI Modelタブと共通の設定色)が、4番のPlan/Asset/Edit再設計で単発フォームのコードごと失われ、ヘルプ文言だけが取り残されていたことが判明した。Planタブのブロックエディタ向けに`_updateFieldHighlights()`を再実装:Prompt/Duration/First Image/Last Image/Aspect Ratio/Megapixels/Multipleの各ラベルにIDを付与し、読み込んだワークフローが実際にそのフィールドをサポートしている場合のみハイライトする(Wan2.2のようにResolutionSelectorが無いワークフローではAspect Ratio/Megapixels/Multipleは非ハイライトのまま)。ワークフローロード時・Open Workflowボタン使用時・ブロック切り替え時(`_renderBlockEditor()`はブロック選択のたびにDOMを作り直すため都度再適用が必要)のいずれでも正しく再適用されるようにし、ハイライト色を注入するCSSセレクタ(`settings-tab.js`の`applyModelTabActiveColor()`)も旧レイアウトの`.wfm-video-form-panel`から新レイアウトの`.wfm-video-plan-content`に更新した(4番のレイアウト変更で参照先の無いセレクタになっていた)。この過程で、ヘルプ本文(`helpVideo8`)がHTML側とi18n.js側で別内容になっている既存の不整合(HTML側は旧ハイライト説明、i18n.js側はチェイン機能の説明で後者が実際に表示される)を発見し、ハイライト説明を新規`helpVideo10`として追加し直した。
|
||||
|
||||
**12. Video Assets/Video Tempグループの命名統一・自動登録の運用整理**: 「__vtempグループが消えてしまっている」という報告と、ユーザー自身による修正方針の提示(表示名をグループ名で統一、VideoPlan:自動登録を廃止し常にVideo Tempへ、VtCボタン名を「Clear __Video Temp__」に変更)を受けて実施。3番/8番で導入した`__VideoAssets__`/`__vtemp__`を`__Video Assets__`/`__Video Temp__`に改名し、Assetタブの表示名もグループ名からそのまま導出する形に統一(「Temporary (unsaved runs)」という独自表示名は廃止)。3番の「Plan保存済みなら`VideoPlan:<name>`サブグループへ」という自動振り分けは廃止し、Plan保存の有無に関わらず常に`__Video Temp__`へ登録するよう変更 — `__Video Assets__`への整理は完全にユーザー任意のタイミングに委ねる設計に転換した。対応してAssetタブの「VideoPlan:」動的取得・フィルタ表示ロジックと、9番で追加したAssetカードへのPlan名バッジ表示も、対応するグループ自体が無くなったため削除した。バックエンド予約グループリスト(`py/routes/gallery_routes.py`の`_RESERVED_GROUPS`)も新グループ名に追従させている。既存の`VideoPlan:`グループ自体はユーザー側で削除する前提で進めた。
|
||||
|
||||
**13. 重大バグ修正: `gallery_metadata.json`の非アトミック書き込みによるデータ消失(根本原因)**: 「再起動するとグループに登録した内容が消えます」との報告を受けた調査。まず8番の`ensureVideoGroup()`が実は`__VideoAssets__`(現`__Video Assets__`)しかensureしておらず`__vtemp__`(現`__Video Temp__`)はensureしていなかったという見落としを修正したが、実データ(`user/default/Workflow-Studio/gallery_metadata.json`、画像9,470件)を直接調べたところ、より深刻な問題が見つかった。`GalleryMetadataStore._save_to_disk()`が`open(path, "w")`による直接上書きの非アトミック書き込みで、バックグラウンドインデックス処理(`_bg_index_folder`、未キャッシュ画像10枚ごとに保存)が大量画像フォルダに対して極めて高頻度にこのファイルへ書き込み続けていた(実測でファイルサイズが0.5秒間隔で数百KB単位で変動し、外部プロセスから読むとタイミングによってJSONパースが失敗する状態だった)。この書き込みの最中にComfyUIプロセスが終了すると、ファイルが不完全なJSONのまま残り、次回起動時の`_load()`がパースに失敗して警告ログを出すだけで中身を空の`{"images": {}, "groups": []}`にリセットしてしまう — 以降1回でも保存が走ると、それまでの全グループ登録・タグ・メモが跡形もなく上書きされて消える、という構造だった。実際に確認したところ、グループの登録リスト自体(`__Feeder__`/`__Video Assets__`/`__Video Temp__`等)は残っていたが、動画がどのグループに属しているかという個々のメンバーシップ情報は失われていた。`_save_to_disk()`を一時ファイル書き込み+`Path.replace()`によるアトミックな置き換えに変更し、`_load()`がJSON破損を検出した場合は単に空データにするのではなく`.corrupt`ファイルとしてバックアップしてから起動するよう変更した(今後同様の事態が起きても手動復旧の余地を残す)。単体テスト(アトミック書き込み・破損時バックアップ・復旧後の再保存)で動作確認。今回失われた登録内容自体はバックアップが無く復元不可能である旨をユーザーに報告した。
|
||||
|
||||
**14. 中央プレビューの2ペイン化**: 手描きの構成図での要望「動画ソース・ファイルドロップエリアを左端に移動、プレビューを2つにして左はアセットの選択動画/ドロップされた動画のプレビュー兼用、右は生成された動画のプレビュー用」を実装。従来は単一のプレビュー(Video Sourceドロップの表示専用で、Assetタブの選択動画やPlan実行結果を自動表示する仕組み自体がそもそも存在しなかった)だったのを、3カラム構成(左: Video Sourceフォーム、中央: Asset選択/Sourceドロップ兼用プレビュー、右: 生成動画専用プレビュー、いずれも新規実装)に再構成した。`video-tab.js`・`video-asset-tab.js`(Asset選択)・`video-plan-tab.js`(バッチ実行結果)の3ファイルがプレビュー状態を共有する必要があったため、新規`video-preview.js`という共有モジュールに切り出した(video-tab.jsが既に他の2ファイルをimportしているため、逆方向のimportを追加すると循環参照になることを回避するため)。Frame/GIFツールは「直近にアクティブになった方のプレビュー」を対象に動作する設計にした。Asset選択時はGalleryの絶対パスをComfyUIのfilename/subfolder/type形式に変換する手間を避けるため、動画をBlobとしてfetchし「ローカルファイル」として扱うことで、GIF変換の既存アップロードロジックをそのまま流用できるようにした。Assetサイドバーの詳細パネルにあった動画プレビュー(`<video>`要素)は、中央プレビューに統合されたため削除した。
|
||||
|
||||
**15. ヘルプの整合性チェック**: リリース前の最終確認としてVideo Tabヘルプ全11項目(Title/Desc/1〜10)をHTML・i18n.js(3言語)・app.jsのhelpIdMapで突き合わせ、11番で発見した`helpVideo8`の不整合以外にも、`helpVideoDesc`(旧「Plan / Asset / Edit」の一括り記述が9番以降のサイドバー分離構造と食い違っていた)と`helpVideo1`/`helpVideo2`/`helpVideo3`(HTML側が旧単発フォーム時代の文言のまま放置され、実際に表示されるi18n.js側の内容と長年食い違っていた)を発見・修正した。README.mdのVideo Tabセクションも同様に、4番のPlan/Asset/Edit再設計自体が一切反映されないまま単発フォーム時代の説明で放置されていたことが判明し、9〜14番までの内容を反映して全面的に書き直した。
|
||||
|
||||
**16. `pyproject.toml`のlicense記述をComfy Registry仕様準拠の形式に修正**: リリース準備の一環で、`license = "MIT"`という単純文字列形式が、公式仕様(`license = {file = "LICENSE"}`または`{text = "..."}`のいずれか)に該当しない非推奨形式であり、`comfy env`実行時にcomfy-cliが警告を出していたことを確認した([[project_registry_flagged_versions]]で追跡しているv0.5.0〜v0.5.3の原因不明のFlagged/Pending状態の調査依頼を受けて着手)。合わせて、参照先の`LICENSE`ファイル自体がリポジトリに存在しなかった(README.mdの「MIT License」という一文のみ)ため新規作成し、`license = { file = "LICENSE" }`に修正した。調査の結果、Comfy Registryのセキュリティスキャンは主にAI+静的解析(Ruff)によるPythonコード内の危険なパターン検出(`eval`/`exec`/`subprocess`/`pickle.loads`/カスタムpip wheel等)が対象で、`pyproject.toml`のメタデータ自体(license記述含む)がスキャン対象になるという情報は見つからなかった — つまり今回の修正はComfy Registryの公式仕様に沿った改善ではあるが、Flagged/Banned問題の直接的な解決策である確証はない。一方で、他の開発者(GitHub issue経由)でも同様の原因不明なFlaggedステータスが複数報告されており、pyproject.tomlの記述とは無関係にRegistry側の自動スキャンが誤検知(false positive)を起こしている可能性が高いことが分かった。具体例として、外部バイナリをsubprocessで呼び出す正当なコード(GraphicsMagick連携)が誤検知された報告があり、本プロジェクトにも同種のパターン(`gmic_routes.py`の`subprocess.Popen`+Windows固有の`CREATE_NEW_CONSOLE`フラグ、`wildcard_service.py`の`["cmd", "/c", "mklink", ...]`)が存在するため、これらが誤検知の一因になっている可能性はあるが確証はない(いずれも正当な機能で、変更は行っていない)。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`python -m py_compile`で構文確認、`templates/index.html`はPythonの`html.parser`でタグ対応チェックとJinja2実レンダリングで検証。ジャンクションバグの調査・検証は、ユーザーが起動中のComfyUI_5(ポート8189で稼働)に対し`curl`でAPIを直接叩いて実施 — `os.path.realpath()`で実際にジャンクションであることを確認し、`groups/{name}/images`(生成成功)と`list_images`(グループフィルタで空)の食い違いを再現、修正版デプロイ後の再検証はユーザー自身の実機確認で完了。8番の`__VideoAssets__`消失バグは、実行中サーバーの`gallery_metadata.json`を直接読み込んで原因を特定した(読み取り中にサーバー側の`_save_to_disk()`と競合しJSONDecodeErrorになることがあったため、パース成功するまで数回リトライして実データを確認)。13番の根本原因調査では、同じファイルへの読み取りを連続実行してもファイルサイズと成否が毎回変わることを確認し、実データが継続的に高頻度書き込みされている実態そのものを証拠として特定に用いた。13番の修正自体は`tempfile`を使った独立テストスクリプトで正常保存・再ロード後のデータ保持・破損ファイル検出時のバックアップ・復旧後の再保存を単体検証(ComfyUI起動不要)。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更・新規ファイルを都度両フォルダへ同期しCRLF/LF差異を除いた`diff`で完全一致を確認、Python変更(13番・12番の`gallery_routes.py`予約グループリスト)を含む回では実行時側の`__pycache__`もクリアした。各段階(レイアウト・Projectサブタブ追加・ハイライト復活・グループ運用整理・データ消失バグ・プレビュー2ペイン化・ヘルプ整合性)ともユーザー自身の実機確認・スクリーンショットや手描き構成図の提供を受けて次の修正に進む反復的な進め方で実施した。16番のRegistry仕様調査はWeb検索でComfy公式ドキュメント(`docs.comfy.org/registry/specifications`)とGitHub issue(Comfy-Org/ComfyUI-Manager #2927, #2997)を確認し、`pyproject.toml`の`license = { file = "LICENSE" }`変更後は`toml`パッケージでパース可能なことを確認した。
|
||||
|
||||
**How to apply**: 「◯◯機能では動くのに△△機能では動かない」という報告があった場合、両者の実行経路が本当に同じか(今回はLabがAPI形式を直接操作するのに対しVideoはUI形式から`convertUiToApi()`を経由する、という違い)を疑うと真因に早く到達できる。ComfyUIフロントエンドの`convertUiToApi()`のようなCOMBO値検証は「無警告でのフォールバック」を行う設計になっている場合があるため、実行時に動的アップロードした値をCOMBO項目に書き込む機能(画像チェイン等)を追加する際は、そのCOMBOの選択肢キャッシュが最新かどうかを必ず確認する必要がある。StabilityMatrixのような複数ComfyUIインスタンスでモデル/出力フォルダを共有するツールはジャンクション/シンボリックリンクを使うため、`Path.resolve()`をパスキーの一部に使うコードとファイルシステムスキャンで得たパスとの間に「解決する/しない」の不一致が生まれやすい — 同じ画像を指すパス文字列の保存/検索が全経路で一貫した規約(このプロジェクトでは「常にresolve()後の文字列で統一」)に従っているか、新しい書き込み経路を追加するたびに確認する価値がある。複数のタブ切替グループが同じCSSクラス/data属性を共有する実装は、後から片方だけ物理的に別の場所へ移動すると意図せず連動してしまうため、独立させたいグループは早い段階でクラス名やquerySelectorAllのスコープを分けておくとよい。予約グループ(`__Feeder__`のような「削除されると誰も自動で作り直さない」特別なグループ)を新設する際は、保護(削除禁止)の実装と同時に「常に存在することを保証する`ensure`呼び出し」もセットで用意しないと、保護を追加する前の空白期間や手違いで一度消えた場合に二度と復活しない — この2つは別々の対策であり、片方だけでは不十分。実行中サーバーが継続的に書き込んでいるJSONファイルを外部から直接読んで調査する際は、書き込みと衝突して不完全な内容を読んでしまうことがあるため、パースが安定して成功するまでリトライするのが安全。JSON等の設定/メタデータファイルへの永続化処理を新規実装・レビューする際は、単純な`open(path, "w")`による直接上書きになっていないか必ず確認する — 書き込み頻度が低いうちは問題が表面化しなくても、対象データ量が増えて書き込み回数が増えるほど「書き込み中のプロセス終了でファイルが壊れる」リスクが顕在化しやすく、かつ壊れたファイルのロード失敗を「空データにフォールバック」で処理していると、その後の1回の保存で過去の全データが消えるという最悪の組み合わせになる(一時ファイル+`Path.replace()`のアトミック置き換えと、破損検出時のバックアップ退避はセットで対策する)。UI再設計でコードごと削除した機能(今回のハイライト表示)は、対応するヘルプ文言だけが取り残されて「存在しない機能の説明」として残り続けることがあるため、大きめのUI再設計を行った際はヘルプ文言側の棚卸しも忘れずに行う。ユーザー向けドキュメント(README等)は実装が大きく変わった直後に更新を忘れると、次に触るまで長期間ズレたまま放置されやすい — 大規模なUI再設計コミットを作る際は、その場でREADMEの該当セクションも一緒に更新するのが安全(今回はPlan/Asset/Edit再設計そのものが数バージョン分READMEに未反映のまま残っていた)。
|
||||
|
||||
関連: [[project_v050_video_tab]], [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.4
|
||||
|
||||
### 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
|
||||
|
||||
複数セッションにまたがり実施された作業をまとめてリリース。Videoタブが従来MiniMax H3専用だった検出/生成ロジックをLTX-2.5・Wan2.2にも対応させ、続けて生成UIタブ側の解析ロジックにも同じ2ファミリーへの対応を追加した。並行してAI Assistant設定(Prompt/Tagger)をUnslothバックエンド対応・タブ別モーダル化・モデルアンロード機能へ刷新した。
|
||||
|
||||
**1. Videoタブ: LTX-2.5 / Wan2.2対応(ノード検出の汎用化)**: 従来MiniMax H3専用だったサブグラフ検出/フォーム反映ロジックを、サブグラフが公開する入力のlabel(無ければname)ベースで意味的にスロットを特定する方式に一般化した。MiniMax H3/LTX-2.5系は内部ノード型のプレフィックス(`LTXV*`等)で、Wan2.2は該当する固有ノード型を持たないためサブグラフ名自体("...(Wan2.2)"表記)で家系を判定する。これによりVideoタブの「Load in Video」からLTX-2.5のText/Image/First&Last-Frame to VideoワークフローとWan2.2のText to Videoワークフローも読み込み・生成できるようになった。同梱していない`minimax_test.json`の自動読み込みも廃止。
|
||||
|
||||
**2. バグ修正: convertUiToApi()のMultiTypeウィジェット脱落**: object_infoが複合型(例: `LTXVEmptyLatentAudio.frame_rate: "FLOAT,INT"`)を宣言している入力が、単純な完全一致判定(`upper === "INT" || ...`)に引っかからずウィジェット名/型リストから丸ごと脱落し、後続ウィジェットの値が1つずつずれる問題を発見・修正した。実際に`LTXVEmptyLatentAudio.batch_size`にframe_rateの値が誤って書き込まれ、動画生成が"Expected size 1 but got size 25"で失敗する原因になっていた。`_resolveWidgetType()`を新設し、カンマ区切りの型宣言から実際にウィジェットスロットを占める単一のスカラー型を解決するよう修正。
|
||||
|
||||
**3. 動画再生ボリュームの保存設定**: 動画を切り替えるたびに音量が最大へ戻ってしまう問題を解消するため、設定タブに音量設定を追加しVideo/Galleryタブの全プレーヤー間で保存・共有されるようにした。
|
||||
|
||||
**4. GenerateUIタブ: LTX-2.5 / Wan2.2対応(プロンプト検出・Latent Image・タイムアウト)**: Videoタブとは独立した解析経路(`analyzeWorkflow()`、フラット化済みAPI形式ワークフローを解析)側でも同じ2ファミリーの通常ワークフロー読み込みに対応させる作業。実機確認したところ、Model タブ(Checkpoint/VAE/Diffusion Model/Text Encoder/LoRA)は既存の`UNETLoader`/`CLIPLoader`/`VAELoader`ベースの汎用検出でWan2.2/LTX-2.5とも既に問題なく動作していたが、次の2点に不備を発見した。
|
||||
|
||||
- **Positive/Negative Prompt検出(LTX-2.5)**: LTX-2.5は`LTXVDualCFGGuider`(positive/negativeが同一の`LTXVConditioning`ノードの別出力スロットを指す)+ `LTXVConditioning`(自身のpositive/negative入力を介して実際のプロンプトソースへ分岐)という専用ノード構成を使うため、既存の`CFGGuider`/`InstructPixToPixConditioning`向けロール伝播ロジックでは拾えず、Positive/Negative Promptが空欄のままSaveVideoノードへのフォールバック表示になっていた。`LTXVDualCFGGuider`をロール伝播の起点に、`LTXVConditioning`を`InstructPixToPixConditioning`と同型の分岐伝播ノードとして追加し解決した。
|
||||
- **Latent Image検出(Wan2.2・LTX-2.5共通)**: SettingsタブのLatent Imageセクションが`EmptyLatentImage`/`EmptySD3LatentImage`/`EmptyFlux2LatentImage`のみ対応しており、Wan2.2の`EmptyHunyuanLatentVideo`とLTX-2.5の`EmptyLTXVLatentVideo`が未対応で「No EmptyLatentImage node found」表示になっていた。両ノード型を追加し、動画特有の`length`(フレーム数)入力を編集するフィールドも新設した(Duration×FPSの数式ノードにリンクされ非リテラルなことが多いため、Width/Height同様「linked」表示にフォールバックする)。
|
||||
- あわせて`isVideoWorkflow()`(生成タイムアウトを30分に延長する判定)を、`SaveVideo`/`CreateVideo`ノードや動画用latentノードの存在でも判定できるよう拡張した。従来はMiniMax H3のようなオールインワン`*ToVideo`ノードの有無でしか判定できず、LTX-2.5/Wan2.2は通常の静止画と同じ短いタイムアウトで打ち切られる恐れがあった。
|
||||
|
||||
**5. Videoタブ左パネルのハイライト表示**: GenerateUIのModelタブが持つ「ワークフローに実際に該当する項目のラベルをハイライト」する仕組み(`wfm-model-label-active`クラス、設定タブでカスタマイズ可能な共通色)をVideoタブの左パネルにも適用した。Prompt/Durationは常時、First Frame/Last Frameはサブグラフがそのスロットを公開している場合のみ、Aspect Ratio/Megapixels/MultipleはResolutionSelectorノードが存在する場合のみハイライトする — Wan2.2のように該当ノードがなく操作不可(disabled)な項目は、既存の無効化表示と矛盾しないよう非ハイライトのままにした。
|
||||
|
||||
**6. バグ修正: 生成UIタブRAW JSON検索がヒット箇所にカーソルを当てて不意に編集させてしまう**: 検索でヒットした際に`editor.focus()` + `setSelectionRange()`でテキストエリアへフォーカス・選択範囲を作っていたため、検索直後にキー入力すると選択範囲が上書きされ意図せずJSONが書き換わってしまう問題があった。ハイライト表示(`<mark>`によるオーバーレイ)とスクロール追従は維持しつつ、フォーカス移動と選択状態の作成のみを取りやめ、編集はユーザーが明示的にエディタをクリックした場合のみに限定した。
|
||||
|
||||
**7. AI Assistant設定: Unsloth対応・Prompt/Tagger独立モーダル化・モデルアンロード機能**: Prompt AI(Prompt タブ)とTagger VLM(Taggerタブ)にUnslothバックエンドを追加した(サーバー側プロキシでAPIキーを付与)。Taggerタブは従来Ollama直呼び出しだった実装を、Ollama/LM Studio/Lemonade/Unslothの4バックエンド共通の`vlm_models`/`vlm_predict`エンドポイントに統一し、API URL直接入力欄を廃止した。中央Settingsタブにあった単一の「AI Assistant Settings」パネルを廃止し、Prompt/Tagger各タブに⚙設定ボタン+専用モーダル(`ai-settings-modal.js`、Backend/Connection/Model/Generation/Unload/Saveの共通UIコンポーネント)を新設、それぞれ独立した`localStorage`キーで設定を持つように変更した(PromptとTaggerで異なるバックエンド/モデルを使い分けられる)。Thinking mode / Max tokensの設定をPrompt/Tagger双方の生成呼び出しに反映(Tagger側はPython側の`vlm_predict`にも反映)。Ollama(`keep_alive:0`)/LM Studio(`/api/v1/models/unload`)/Lemonade(`/api/v1/unload`)向けのモデルアンロード機能を追加し、AI TOOLタブの設定にもUnloadボタンを追加した(Unslothは対応する公式APIが無いため非対応として明示)。
|
||||
|
||||
**8. CI: publish-node-actionのバージョンをSHA固定**: `Comfy-Org/publish-node-action@main`という可変ブランチ参照は、アクション側が更新された際に挙動が変わりうるサプライチェーンリスクのため、リリースタグ1.0.1のコミットSHAに固定するよう`.github/workflows/publish.yml`を修正した([[project_registry_flagged_versions]]で追跡している過去のRegistry Banned/Flagged原因調査の一環)。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUIタブ(LTX-2.5/Wan2.2対応・RAW JSON検索の挙動)とVideoタブ(ハイライト色機能)のヘルプ本文を英語・日本語・中国語の3言語で追加・更新した(Prompt/Settings/AI TOOL/Taggerタブのヘルプは7番の対応セッション内で既に更新済み)。README.mdはWorkflowタブ(Load in Video対象の拡大)、GenerateUIタブ(MultiTypeバグ修正・LTX-2.5/Wan2.2対応)、Videoタブ(対応ファミリー拡大・ハイライト色・音量設定・見出しバージョン範囲更新)、Settingsタブ(AI Assistant Settings廃止の案内・音量設定・ハイライト色の共有先追記)、Prompt/Taggerタブ(AI Assistant設定のモーダル化・マルチバックエンド化)、AI TOOLタブ(モデルアンロード機能)を更新した。
|
||||
|
||||
**検証**: 全JS/Pythonファイルを`node --check`/`ast.parse()`で構文確認。GenerateUIタブのLTX-2.5/Wan2.2対応は、実機ブラウザ(Kapture)でwan22_test.json / ltx_25_test.jsonの2ワークフローを実際に生成UIタブへ読み込み、Positive/Negative Promptの実文表示・Latent Image欄(Width/Height/Batch Size/Length)の表示・ハイライト色の点灯状態をスクリーンショットで確認した。RAW JSON検索のフォーカス修正は、検索実行後にキー入力してもtextareaの値が変化しないことを実機で確認した。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルを都度両フォルダへ同期しCRLF/LF差異を除いた`diff`で完全一致を確認した。
|
||||
|
||||
**How to apply**: `analyzeWorkflow()`のような中央解析関数はワークフロー「ファミリー」が増えるたびに、既存パターン(KSampler系のロール伝播、EmptyLatentImage系のlatentノード検出)と同型の分岐を追加していく設計で拡張できる — 逆に言えば、新しいノード構成(今回のLTXVDualCFGGuider/LTXVConditioningのような「複数出力を持つ1ノードがpositive/negativeを兼ねる」パターン)は`InstructPixToPixConditioning`のような既存の同型ケースを探して倣うと実装が速い。同じワークフローファミリーへの対応が複数タブ(Video/GenerateUI)にまたがる場合、検出ロジック自体は各タブの解析経路(Video: サブグラフを直接歩く、GenerateUI: フラット化済みAPI形式を解析)が別実装なので、片方を直しても他方は直らない — 「Videoタブでは動くのにGenerateUIでは動かない」という状況は、まさにこの二重実装のズレが原因だった。テキストエリアへの自動フォーカス/selectionRangeは、検索・ハイライトのような「見せるだけ」の操作と「編集を許可する」操作を暗黙に混同させるため、ハイライト表示に徹する機能ではフォーカス移動自体を避けるのが安全。
|
||||
|
||||
関連: [[project_v050_video_tab]], [[project_dev_deploy_sync]], [[project_v0336_conventions]], [[project_registry_flagged_versions]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.3
|
||||
|
||||
### バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
|
||||
|
||||
ユーザーからの複数のバグ報告・改善要望を1セッションで順に対応した作業。
|
||||
|
||||
**1. バグ修正: Labタブで起動時デフォルトワークフロー読み込み後に別ワークフローを読み込むと前の内容が残る**: 「別のワークフローを読み込んだ。一部以前のワークフローの内容が残ったまま表示される(クリックするか他のタブから戻ると消える)」との報告。Labタブの「Setting 1」列(Checkpoint/LoRA/VAE/Prompt/KSampler)はGenerateUIの現在の読み込み内容をライブ反映する仕組みだが、その再描画(`refreshLabLiveDefaults()`)は「サブタブをLabに切り替えたとき」の一箇所(`generate-tab.js`のサブタブクリックハンドラ)でしかトリガーされていなかった。Labタブを既に開いた状態で別のワークフローを読み込んだ場合、`loadWorkflowIntoEditor()`側では再描画が一切呼ばれず、前のワークフローの表示がそのまま残っていた。「セルをクリックしたり他のタブから戻ると直る」という報告内容は、それらの操作がたまたま再描画をトリガーしていたことの裏返しだった。`loadWorkflowIntoEditor()`の末尾で、現在アクティブなサブタブがLabの場合は`refreshLabLiveDefaults()`を追加で呼ぶよう修正。
|
||||
|
||||
**2. 改善: Galleryタブの生成後の更新が遅い**: 「生成後の画像表示に更新して読み直すのに時間がかかる」との報告。実データ(Output先の`Text2Img`フォルダ、画像9,777枚)で`_scan_folder()`を実測したところ**7.5秒**かかっており、原因を切り分けたところ`entry.stat(follow_symlinks=True)`と、各ファイルごとに呼んでいた`Path(entry.path).resolve()`の2箇所がボトルネックと判明。特に`resolve()`の再解決が支配的で、これだけで1ファイルあたり約0.7ms×9,777件のファイルシステム往復が発生していた。`_scan_folder()`の呼び出し元(`list_images()`/`_bg_index_folder()`)は既にフォルダ自体を`Path(folder_path).resolve()`済みで渡しているため、`entry.path`は既に絶対パスであり、エントリ単位での再`resolve()`は不要だった。`entry.is_file()`/`entry.stat()`を`follow_symlinks=False`に変更し、パス生成も`entry.path.replace("\\", "/")`に簡略化(出力フォルダにシンボリックリンクの画像が置かれる想定はないため安全と判断)。同じ理由で`os.walk()`ベースだった`_scan_folder_recursive()`(ImagePrompt/Style Catalog用の再帰スキャン)も、`os.scandir()`を自前で再帰しDirEntryベースでstatする実装に書き換えた。実測で9,777枚のフォルダに対する`list_images()`全体が0.14秒(コールド)/0.08秒(フォルダキャッシュ温存時)まで短縮(約50倍高速化)。
|
||||
|
||||
**3. 改善: Galleryフォルダツリーのrootをデフォルト展開状態に**: 上記の高速化を受けて「rootフォルダはデフォルトで展開状態で表示したい」との要望。フォルダスキャンが軽くなったことでサブフォルダの内容もすぐ確認できるようになった一方、`renderTreeNode()`はrootも他のノードと同じく初期状態は折りたたみ(▶)だったため気付きにくかった。root(`isRoot`)の場合のみ、ツリー構築時に矢印を展開済み(▼)にし子ノードを即座にレンダリングするよう`gallery-tab.js`を修正。
|
||||
|
||||
**4. READMEスクリーンショット刷新**: 「以下のスクリーンショットは削除したい」との依頼でGenUI Feeder / GenUI LoRA Stack / Models Multi-select Menu / Tagger Tab / Customizeの5件をREADME.mdの表と`docs/`実ファイルの両方から削除(残った項目は2列レイアウトに詰め直し)。続けてVideo Tab・Lab Tab(2枚)・AI TOOL Tabの新規スクリーンショット計4枚をユーザーが`docs/`に配置し、READMEの表に追加した。
|
||||
|
||||
**検証**: JS/Pythonとも`node --check`/`ast.parse()`で構文確認。Gallery高速化は開発元リポジトリの`GalleryService`を実データ(実際の`Text2Img`フォルダ、9,777枚)に対してvenv Pythonから直接呼び出し、修正前後の`_scan_folder()`/`list_images()`の実行時間をベンチマークして改善を数値で確認(7.5秒→0.063秒、`list_images()`全体は0.14秒)。返却されるパス文字列が修正前と同じ絶対パス形式であることも確認済み。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更・削除した全ファイル(`generate-tab.js`, `gallery_service.py`, `gallery-tab.js`, `README.md`, `docs/`配下の画像)を都度両フォルダへ同期し`diff`/存在確認で完全一致を確認、Pythonファイル変更のため`__pycache__`もクリア。全項目ともユーザー自身の実機確認で解消・反映を確認済み。
|
||||
|
||||
**How to apply**: あるタブ/サブタブの表示を「別の場所(GenerateUI本体など)の現在の状態をライブ反映する」設計にする場合、再描画のトリガーを「そのタブへの切替」1箇所だけに置くと、切替を経由しない別の更新経路(今回はワークフロー読み込み)で表示が古いまま取り残される。ライブ反映される値の更新元(今回は`loadWorkflowIntoEditor()`)側でも、現在表示中のタブを見て能動的に再描画を呼ぶ必要がある。パフォーマンス調査では「体感で重い処理」を分解して各行を実測するまで真犯人を決め打ちしない方がよい — 今回も当初`follow_symlinks=True`のstat呼び出しを主因と想定したが、実測すると`Path.resolve()`の方が支配的だった。Windows上で大量ファイルを扱う`os.scandir()`ループでは、`entry.path`が既に呼び出し元でresolve済みのフォルダ配下の絶対パスであることを踏まえ、エントリ単位での`resolve()`/`follow_symlinks=True`はファイル数に比例した無視できないコストになり得るため避ける。バックエンドの表示速度を改善した際は、それに応じてフロントエンドのデフォルトUI状態(今回は折りたたみ初期状態)も見直す価値がある — 「遅いから畳んでおく」という妥協がボトルネック解消後も残ったままになりがち。
|
||||
|
||||
関連: [[project_dev_deploy_sync]]
|
||||
|
||||
---
|
||||
|
||||
## v0.5.2
|
||||
|
||||
### 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
|
||||
@@ -15,10 +360,12 @@ v0.5.0/v0.5.1で専用Videoタブを整備したMiniMax H3動画生成ワーク
|
||||
**3. バグ修正: LabタブKsampler列が動画ワークフローでも表示される**: 「Model同様に該当ノードがなければ非表示にしたい」との要望に対し当初`sampler_nodes.length > 0`で判定したが、実機確認で「まだ表示される」との報告。原因はMiniMax H3のサブグラフ内部に実際に`SamplerCustomAdvanced`が配線されており(内部プリセットの一部で、ユーザーが調整すべきものではない)、単純な有無判定では引っかからなかったこと。判定を`comfyWorkflow.isVideoWorkflow(analysis)`ベースに切り替え、動画ワークフロー全体ではKsampler列自体を常に非表示にするよう修正した。
|
||||
|
||||
**4. Videoタブ拡張: Frame抽出 / アニメーションGIF変換**: Eagleプラグイン(video-to-frame, video2gif)を参考に要望された2機能。Eagle版はNode.js+ffmpegバイナリ直接呼び出しの実装だったため、ComfyUI-Workflow-Studioの構成(Python/aiohttpバックエンド)に合わせて設計し直した。フレーム抽出は`<video>`+`<canvas>`でブラウザ完結(サーバー往復・追加依存なし)。GIF変換はユーザー確認の上サーバー側実装を選択:PyAV(デコード)+ Pillow(GIFエンコード)— どちらもComfyUI本体が既に要求している依存のため、ffmpegバイナリや新規ライブラリの追加インストールは不要。`py/services/video_service.py` / `py/routes/video_routes.py`を新規追加し、`folder_paths.get_save_image_path()`でComfyUI標準の自動採番保存パターンをそのまま踏襲。合成テスト動画(PyAVで生成)を使いvenv環境でデコード→GIFエンコードの実処理を検証済み。
|
||||
- あわせてVideoタブの中央パネルを再設計:「結果」見出し/「まだ結果がありません」表示を廃止し、生成結果とドロップされた任意のローカル動画を同じプレビュー枠に統合、横に**Video Source**ドロップ/クリック選択エリア(Clearボタン付き)を新設。右パネルの「Properties」プレースホルダーを**Frame**/**GIF**の2タブに置き換え。生成結果に限らず任意の動画に対してFrame/GIF機能が使える設計にした。
|
||||
|
||||
- あわせてVideoタブの中央パネルを再設計:「結果」見出し/「まだ結果がありません」表示を廃止し、生成結果とドロップされた任意のローカル動画を同じプレビュー枠に統合、横に**Video Source**ドロップ/クリック選択エリア(Clearボタン付き)を新設。右パネルの「Properties」プレースホルダーを**Frame**/**GIF**の2タブに置き換え。生成結果に限らず任意の動画に対してFrame/GIF機能が使える設計にした。
|
||||
|
||||
**5. データ管理の監査とZIPフルバックアップ機能の新規実装**: 「設定のデータ管理を現在のバージョンで対応できているか確認してほしい」との依頼を受け、既存の`_DATA_FILES`(単一JSONバンドル方式)と実際のデータディレクトリを突き合わせ監査。**`civitai_cache.json`(3.6MB、CivitAI連携キャッシュ)がエクスポート対象から漏れていた**ことを発見し即修正。また、ヘルプが謳う「export all plugin data」に反し、フォルダ形式のデータ(`ai_skills/`、`lab_plan/`、`style/`)とSQLite DB(`tagger.db`)が単一JSONバンドル方式では原理的に扱えず完全に対象外になっていた点をユーザーに報告、ZIP形式のフルバックアップ機能を新規実装する方針で合意。`DATA_DIR`配下を丸ごとZIP化する`GET /api/wfm/settings/export-full`/`POST /api/wfm/settings/import-full`を追加(一時ファイル系`gmic_temp`/`thumb_cache`と、他custom_node(comfyui-impact-pack)へのシンボリックリンクである`wildcard/`のみ除外、`settings.json`はAPIキーを除外してから含める)。復元側はZip Slip対策(`../`等でDATA_DIR外に書き込もうとするエントリは`relative_to()`チェックでスキップ)を実装し、悪意あるZIPでの検証済み。
|
||||
- 追加要望「デフォルトワークフローフォルダー(`user/default/workflows`)とwildcardフォルダー(`comfyui-impact-pack/wildcards`)もオプションでバックアップに含めたいが、インポートは除外し手動対応としたい」に対応:エクスポート画面にオプトインのチェックボックスを2つ追加、有効時はZIP内に`_external/`プレフィックスで格納し、復元処理は`_external/`配下のエントリを常にスキップする実装にした(他プラグイン/ComfyUI本体が管理する領域を自動上書きしないため)。実データ(ワークフロー535件・wildcard 34件)で検証済み。
|
||||
|
||||
- 追加要望「デフォルトワークフローフォルダー(`user/default/workflows`)とwildcardフォルダー(`comfyui-impact-pack/wildcards`)もオプションでバックアップに含めたいが、インポートは除外し手動対応としたい」に対応:エクスポート画面にオプトインのチェックボックスを2つ追加、有効時はZIP内に`_external/`プレフィックスで格納し、復元処理は`_external/`配下のエントリを常にスキップする実装にした(他プラグイン/ComfyUI本体が管理する領域を自動上書きしないため)。実データ(ワークフロー535件・wildcard 34件)で検証済み。
|
||||
|
||||
**6. Send to LI node機能をGalleryタブにも追加 + ボタン名統一**: Image Editタブに既にあった「選択画像をComfyUIキャンバス上の選択中ノードのimageウィジェットへ送る」機能(`window.opener.wfmSendImageToSelectedNode`、`node_sets_menu.js`で定義されたクロスウィンドウ橋渡し)を、Galleryタブの選択画像にも使えるよう追加。Galleryの画像は任意のフォルダにあり得るため、既存の「Send GenUI Image」と異なりLoadImageノードの有無に依存せず、`/upload/image`でinputフォルダへアップロードしてからファイル名を書き込む方式にした。あわせてImage Edit側の既存ボタン名「Send to Workflow」を「Send to LI node」に統一(トースト文言・コメント・ヘルプも合わせて更新)。
|
||||
|
||||
@@ -73,7 +420,8 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**4. First Frame/Last Frameを両方optionalに、LoadImageノードが無いワークフローにも対応**: 「First Frameなしでも動作させたい(ノードをバイパスで処理)。またノードのないワークフローにも対応させたい」との要望。`minimax_test1.json`(first_frame/last_frame双方とも未接続、対応するLoadImageノードがトップレベルに存在しない構成)を新たに確認し、`locateMiniMaxNodes()`のLoadImageノード必須チェックを撤廃。first_frame未指定時はリンクなしのまま実行(バイパス)し、画像が指定された場合のみその場でLoadImageノードを動的に注入する設計に変更(ノードID/リンクIDはclone内の実際の最大値+1で採番するため重複の心配がない)。first_frameスロットにもlast_frameと同様のClearボタンを追加した。
|
||||
|
||||
**5. バグ修正: mp4のタグ/メモがComfyUI再起動後に消える**: 「タグ、メモadd、保存操作はできるが記録されていない。画像ファイルもできていない。エラー表示はなし、ブラウザもなし」との報告を受け、`comfyui_8189.log`を直接確認したところ`Cleaned up 189 stale metadata entries from ...\Text2Img`という決定的なログを発見。**根本原因**: `cleanup_stale_images()`は「現在表示中のフォルダ配下」のメタデータキーを全階層(サブフォルダ含む)対象にstale判定するが、Galleryがルートフォルダを非再帰スキャンした場合、`existing_paths`(現在のファイル一覧)にはルート直下のファイルしか含まれない。Videoタブで生成した動画は`SaveVideo`の`filename_prefix`設定で`video/MiniMax_H3/`というサブフォルダに保存されるため、ルートフォルダを開くたびに「サブフォルダ内ファイルは非再帰スキャンには映らない→存在しないと誤判定→メタデータ削除」が発生し、189件ものタグ/メモ/お気に入り情報が消えていた。`cleanup_stale_images()`に`recursive`引数を追加し、非再帰スキャン時はfolder_path直下のみを対象にするよう修正(`gallery_service.py`の`list_images()`から`recursive`をそのまま伝播)。既に削除済みのデータはバックアップが無く復元不可能だった旨をユーザーに伝えた。
|
||||
- 副次的に発見したフロントエンドのバグも合わせて修正: `saveMetaField()`がサーバーのレスポンス(`{"ok": bool}`)を一切チェックせず常に成功したかのようにローカル状態を楽観的更新していたため、サーバー側で保存が拒否されてもUI上はエラーなく成功したように見えてしまう作りになっていた。レスポンスの`ok`フィールドを検証し失敗時は例外を投げるよう変更、`addTag`/`removeTag`/メモ保存ハンドラは戻り値を見て成功時のみローカル状態を更新するよう修正した。保険的措置として`save_image_meta`(バックエンド)の保存キーも`get_image_metadata`と同じ`resolve()`後のパスに統一した。
|
||||
|
||||
- 副次的に発見したフロントエンドのバグも合わせて修正: `saveMetaField()`がサーバーのレスポンス(`{"ok": bool}`)を一切チェックせず常に成功したかのようにローカル状態を楽観的更新していたため、サーバー側で保存が拒否されてもUI上はエラーなく成功したように見えてしまう作りになっていた。レスポンスの`ok`フィールドを検証し失敗時は例外を投げるよう変更、`addTag`/`removeTag`/メモ保存ハンドラは戻り値を見て成功時のみローカル状態を更新するよう修正した。保険的措置として`save_image_meta`(バックエンド)の保存キーも`get_image_metadata`と同じ`resolve()`後のパスに統一した。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、新規Video Tabヘルプページを追加。README.mdはバージョンバッジ更新、Workflow Tab(サイドパネルツールバーの3ボタン構成)、Gallery Tab(Downloadボタンの新配置・mp4対応・GenUIボタンへの改名)、新規Video Tabセクション(3ペイン構成・optional First/Last Frame・ノードなしワークフロー対応)を追加した。
|
||||
|
||||
@@ -124,7 +472,8 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**4. yamlワイルドカードを使うサンプルワークフロー**: 「yamlファイルのワイルドカードを使用するワークフローを作成してもらうことはできますか」との依頼。調査の結果、このプラグイン自体のワイルドカード展開(SPA側の`_expandWildcardsInWorkflow`)は`.txt`拡張子固定でyaml非対応であり、yaml形式は`ImpactWildcardEncode`/`ImpactWildcardProcessor`ノード(Impact Pack、サーバー側処理)でのみ解決できることが判明。プラグインのwildcardディレクトリはjunction経由でImpact Packの`wildcards/`フォルダへ直結しているため、通常のWildcard File Manager経由で保存したyamlファイルがそのままImpactWildcardEncodeから参照可能。サンプルyamlワイルドカードファイル(`ws_sample_season_lighting.yaml`)とImpactWildcardEncodeノードを使うサンプルワークフロー(`ws_yaml_wildcard_sample.json`)を作成し、実際にComfyUIで生成→出力PNGのメタデータで`populated_text`が階層キー参照から正しく展開されていることを確認した。
|
||||
|
||||
**5. Galleryタブ詳細パネルに「Prompt」タブ新設**: 「メタデータでも確認できるが頻度を考えるとマウス操作が多いため、プロンプトだけはこちらでも表示としたい。ボタンは右端でデフォルトで選択状態としたい」との要望。当初は単純な全文連結表示で実装したが、「メタデータタブの表示の様に複数プロンプトがあるケースも考慮したい」とのフィードバックを受け、Metadataタブの`extractPrompts`/`buildPromptItem`をexportし、完全に同じPOS/NEGバッジ付きリスト→クリックで全文表示のUIに作り直した。
|
||||
- さらに実機確認の中で、同一画像でもMetadataタブとGalleryタブでpositive数が食い違うケース(`Krea2_turbo_00133_.png`、Metadataでは2件のPositiveが見える)を発見。原因は埋め込みデータの優先順位の違い — Metadataタブは`prompt`(API形式、サブグラフ展開済みでフラット)を優先するのに対し、Gallery側サーバーAPIは`workflow`(UI形式、ノード位置等を保持)を優先していた。トップレベルとサブグラフに独立した2系統のサンプリングを持つワークフローでは、UI形式のトップレベルnodes配列に一方の系統しか含まれず、`extractPromptsLiteGraph`の早期returnロジックがその系統しか拾えないため。既存のLoad GenUI/JSON表示/Copy & Send Canvas等には一切影響しないよう、`gallery_service.py`に優先順位を選べる`_extract_embedded_workflow`ヘルパーを追加し、新規`extract_prompt_workflow_from_metadata`(API形式優先)を`/wfm/gallery/image/workflow`のレスポンスへ`prompt_workflow`として追加、Galleryの Promptタブの抽出だけこちらを使うようにした。Python側の変更を反映するため、ユーザー自身の手でComfyUIを再起動していただいた。
|
||||
|
||||
- さらに実機確認の中で、同一画像でもMetadataタブとGalleryタブでpositive数が食い違うケース(`Krea2_turbo_00133_.png`、Metadataでは2件のPositiveが見える)を発見。原因は埋め込みデータの優先順位の違い — Metadataタブは`prompt`(API形式、サブグラフ展開済みでフラット)を優先するのに対し、Gallery側サーバーAPIは`workflow`(UI形式、ノード位置等を保持)を優先していた。トップレベルとサブグラフに独立した2系統のサンプリングを持つワークフローでは、UI形式のトップレベルnodes配列に一方の系統しか含まれず、`extractPromptsLiteGraph`の早期returnロジックがその系統しか拾えないため。既存のLoad GenUI/JSON表示/Copy & Send Canvas等には一切影響しないよう、`gallery_service.py`に優先順位を選べる`_extract_embedded_workflow`ヘルパーを追加し、新規`extract_prompt_workflow_from_metadata`(API形式優先)を`/wfm/gallery/image/workflow`のレスポンスへ`prompt_workflow`として追加、Galleryの Promptタブの抽出だけこちらを使うようにした。Python側の変更を反映するため、ユーザー自身の手でComfyUIを再起動していただいた。
|
||||
|
||||
**6. Send to Canvasのフォールバック方式を刷新**: 「このワークフローを生成UIタブに読み込むと...またキャンバスへ送るでAPI形式のためタイトルをキャンバスへドラッグのメッセージが表示されますがこの動作を変更したい...UI形式のサムネイルがないパターンのワークフローも同様にしたい」との要望。`window.opener`経由の直接ロードに失敗した場合の従来のフォールバック(`localStorage`+パネルタイトルのドラッグ、API形式のワークフローはそもそも非対応でエラーのみ)を、Workflow Studio LibraryのW(Workflows)タブを該当ワークフローで絞り込み表示する方式に統一した。`node_sets_menu.js`に`showWorkflowInLibrary()`をexportし、`top_menu_extension.js`が新規localStorageキー`wfm_library_filter_request`への`storage`イベントを監視して呼び出す(同一オリジンの別タブでのみ発火する仕様を利用)。フォーマット(UI形式・API形式)を問わず同じ導線になり、サムネイル付きの実物のワークフローカードからユーザー自身がドラッグできるようになった。Galleryタブの「Copy & Send Canvas」(画像埋め込みのその場限りのワークフローが対象で、そもそもLibrary一覧=保存済みファイルには存在しない)は今回のスコープ外として変更していない。
|
||||
|
||||
@@ -151,14 +500,15 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**2. GenerateUIタブ: Save ボタンをBatch/Labサブタブで非表示化**: 「ワークフローのSaveボタンをBatch、Labサブタブ選択時、非表示にしたい」との要望。両サブタブは現在ロード中のワークフロー本体ではなく、ワークフローの複製やLab独自のPlanファイルを操作するため、Saveボタンが誤解を招く(クリックしても実際に触っているワークフローとは別物が保存される)ことへの対処。サブタブ切替ハンドラ(`generate-tab.js`)にワンライナーの表示切替を追加。
|
||||
|
||||
**3. Promptタブに「Table」表示を新設**: 「現在のプロンプトタブをタブ化してテーブルタブを追加したい(既存はフォームタブとする)。テーブルタブではプロンプトのプリセット、ワイルドカード、スタイルがそれぞれ表形式で一覧表示、編集可能としたい」との要望を起点に、複数回のフィードバックを経て以下の形に発展した:
|
||||
- 既存の3カラムレイアウト(AI Assistant / Presets・Preset Manager / Wildcard・Style)をまるごと「Form」パネルとしてラップし、新設の「Table」パネルと上部のトップレベルタブで切り替える構成にした。
|
||||
- Tableパネル内にPresets・Wildcards・Styleの3つの編集可能テーブルを新設(`prompt-table.js`新規ファイル)。既存の`prompt-tab.js`が保持するAPIヘルパー(`fetchPresets`/`apiUpdatePreset`/`wcSaveFile`/`styleApiUpdate`等)とリフレッシュ関数を`export`して再利用し、Table側での編集は即座にForm側にも反映されるようにした。
|
||||
- **「Presets右隣にPresets Groupを追加したい」**: グループ管理専用の4つ目のサブタブを新設。グループの作成・リネーム・削除・メンバー一覧(バッジ表示・×で削除)に対応。
|
||||
- **「共通で左端に番号(自動)列を追加したい。個別の削除ボタンは誤操作が予想されるため上部にボタンを設置し番号列で該当番号選択、削除という手順にしたい」**: 4テーブル共通で自動採番の`#`列を新設し、番号クリックで行選択(複数可・赤ハイライト)→ツールバーの「Delete」ボタンで一括削除、という手順に統一。個別の行削除ボタンは全廃した。新規行追加時は番号セルの代わりに小さな✓(Add)/✕(Cancel)アイコンを表示する設計にした。
|
||||
- **「バッチグループの登録方法を変更したい」**: PresetsテーブルにForm側と同じ仕組みの「B」列(クリックでグレー⇔黄色にトグルしBatchグループへ登録/解除)と、Batchグループを一括クリアする「BC」ボタンをツールバー上部に追加。Presets Groupテーブルからは予約グループ「Batch」を非表示にし、Batch管理をPresetsテーブル側のB列/BCボタンへ一本化した。
|
||||
- **「PresetsのコピーボタンとグループドロップダウンをテーブルのPresetsタブに配置したい。これによりPresets GroupタブのAdd Presetsをなくしたい(プリセットが多くなった際に選択が困難なため)」**: Presets Groupテーブルの「Add Preset」列(プリセットが多いと使いづらい長大なドロップダウン)を廃止。代わりにPresetsテーブルのツールバーに「PP Copy」「NP Copy」(選択中1件のPositive/Negativeをクリップボードへコピー)と、グループ選択+「Add to Group」(選択中の複数プリセットを一括で選んだグループへ追加)を新設した。プリセット側を検索・選択してから短いグループ一覧から選ぶ流れにしたことで、プリセット数が多くても操作しやすい設計になった。
|
||||
- **「選択クリアボタンも追加したい」**: 4テーブル共通で「Deselect」ボタンをツールバーへ追加し、選択状態を一括解除できるようにした。
|
||||
- **「プリセット、ワイルドカード、スタイルで番号をWクリックでモーダルでプロンプトの表示、編集したい」**: `#`列のダブルクリックで、既存の共有モーダル基盤(`app.js`の`openModal`/`closeModal`)を再利用したプロンプト編集モーダルを開くようにした。Presets/Styleは正負プロンプト切り替えボタン+共有の大型テキストエリア(切り替えても両方の編集内容を保持)、Wildcardsはファイル内容のみのテキストエリアで、Save/Closeボタンを備える。
|
||||
|
||||
- 既存の3カラムレイアウト(AI Assistant / Presets・Preset Manager / Wildcard・Style)をまるごと「Form」パネルとしてラップし、新設の「Table」パネルと上部のトップレベルタブで切り替える構成にした。
|
||||
- Tableパネル内にPresets・Wildcards・Styleの3つの編集可能テーブルを新設(`prompt-table.js`新規ファイル)。既存の`prompt-tab.js`が保持するAPIヘルパー(`fetchPresets`/`apiUpdatePreset`/`wcSaveFile`/`styleApiUpdate`等)とリフレッシュ関数を`export`して再利用し、Table側での編集は即座にForm側にも反映されるようにした。
|
||||
- **「Presets右隣にPresets Groupを追加したい」**: グループ管理専用の4つ目のサブタブを新設。グループの作成・リネーム・削除・メンバー一覧(バッジ表示・×で削除)に対応。
|
||||
- **「共通で左端に番号(自動)列を追加したい。個別の削除ボタンは誤操作が予想されるため上部にボタンを設置し番号列で該当番号選択、削除という手順にしたい」**: 4テーブル共通で自動採番の`#`列を新設し、番号クリックで行選択(複数可・赤ハイライト)→ツールバーの「Delete」ボタンで一括削除、という手順に統一。個別の行削除ボタンは全廃した。新規行追加時は番号セルの代わりに小さな✓(Add)/✕(Cancel)アイコンを表示する設計にした。
|
||||
- **「バッチグループの登録方法を変更したい」**: PresetsテーブルにForm側と同じ仕組みの「B」列(クリックでグレー⇔黄色にトグルしBatchグループへ登録/解除)と、Batchグループを一括クリアする「BC」ボタンをツールバー上部に追加。Presets Groupテーブルからは予約グループ「Batch」を非表示にし、Batch管理をPresetsテーブル側のB列/BCボタンへ一本化した。
|
||||
- **「PresetsのコピーボタンとグループドロップダウンをテーブルのPresetsタブに配置したい。これによりPresets GroupタブのAdd Presetsをなくしたい(プリセットが多くなった際に選択が困難なため)」**: Presets Groupテーブルの「Add Preset」列(プリセットが多いと使いづらい長大なドロップダウン)を廃止。代わりにPresetsテーブルのツールバーに「PP Copy」「NP Copy」(選択中1件のPositive/Negativeをクリップボードへコピー)と、グループ選択+「Add to Group」(選択中の複数プリセットを一括で選んだグループへ追加)を新設した。プリセット側を検索・選択してから短いグループ一覧から選ぶ流れにしたことで、プリセット数が多くても操作しやすい設計になった。
|
||||
- **「選択クリアボタンも追加したい」**: 4テーブル共通で「Deselect」ボタンをツールバーへ追加し、選択状態を一括解除できるようにした。
|
||||
- **「プリセット、ワイルドカード、スタイルで番号をWクリックでモーダルでプロンプトの表示、編集したい」**: `#`列のダブルクリックで、既存の共有モーダル基盤(`app.js`の`openModal`/`closeModal`)を再利用したプロンプト編集モーダルを開くようにした。Presets/Styleは正負プロンプト切り替えボタン+共有の大型テキストエリア(切り替えても両方の編集内容を保持)、Wildcardsはファイル内容のみのテキストエリアで、Save/Closeボタンを備える。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUI Tab(Saveボタン非表示の説明)・Prompt Tab(新規カード「Form / Table」「Table View」10項目)・Gallery Tab(Metadataサブタブの説明)・Metadata Tab(Galleryタブ内へ移動した旨の注記、`metadata-tab.js`側の独自i18n適用ロジックへ追加)を更新。README.mdはGenerateUI TabのSaveボタン説明を拡張、Prompt Tabセクションに新規ブロックを追加、「Metadata Tab」セクションをGalleryタブの4番目のサブタブとして「Metadata Gallery subtab」に改称の上、Style_Catalog Gallery subtabの直後へ移設した。
|
||||
|
||||
@@ -181,10 +531,11 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
ユーザーから「トップバーで保存した2つのワークフロー(I2I_rmbg.json/I2I_rmbg_2.json)を生成UIタブで読み込んで生成するとRemove Background(RMBG)ノードが機能せず透過画像が作成されない。ComfyUIのExport(API)で保存した3つ目のファイルは正しく動く」との報告を受けて調査した一連のセッション。
|
||||
|
||||
**1. ワークフロー解析の調査**: RMBGノードは`background_color`という「ウィジェットを持たない特殊な入力(COLORCODE型、`model`より前の位置)」を持つ特徴的な構造で、UI形式→API形式変換時に`widgets_values`の対応がずれる可能性を最初に疑った。`comfyui-workflow.js`の`convertUiToApi()`を段階的に検証:
|
||||
- `/object_info/RMBG`を実機から取得し、`_getWidgetInputNames()`が`forceInput`相当(widgetキーを持たない入力)を正しく除外し、`background_color`がウィジェット名リストから漏れることを確認。
|
||||
- ユーザー提供の`I2I_rmbg_2.json`をブラウザ内(Playwright MCP)で実際に`convertUiToApi()`にかけ、変換結果を確認 — `model`/`sensitivity`/`background`など全フィールドが正しくマッピングされていた。
|
||||
- その変換結果を実際に`/prompt`へキューイングして実行、さらにGenerateUIタブの本物のGenerateボタンをクリックする経路でも実行 — いずれも出力PNGはRGBA・アルファチャンネル0〜255の**正しい透過画像**だった。
|
||||
- 結論: 現在デプロイされているv0.4.3のワークフロー解析ロジックに該当バグは再現せず、コード変更は行わなかった。報告時点でより古いバージョンを使っていた可能性が高いと判断し、ユーザーに調査結果と再検証手順を報告した。
|
||||
|
||||
- `/object_info/RMBG`を実機から取得し、`_getWidgetInputNames()`が`forceInput`相当(widgetキーを持たない入力)を正しく除外し、`background_color`がウィジェット名リストから漏れることを確認。
|
||||
- ユーザー提供の`I2I_rmbg_2.json`をブラウザ内(Playwright MCP)で実際に`convertUiToApi()`にかけ、変換結果を確認 — `model`/`sensitivity`/`background`など全フィールドが正しくマッピングされていた。
|
||||
- その変換結果を実際に`/prompt`へキューイングして実行、さらにGenerateUIタブの本物のGenerateボタンをクリックする経路でも実行 — いずれも出力PNGはRGBA・アルファチャンネル0〜255の**正しい透過画像**だった。
|
||||
- 結論: 現在デプロイされているv0.4.3のワークフロー解析ロジックに該当バグは再現せず、コード変更は行わなかった。報告時点でより古いバージョンを使っていた可能性が高いと判断し、ユーザーに調査結果と再検証手順を報告した。
|
||||
|
||||
**2. Labタブ: モデルの無いワークフローで無意味なキーフレームが作成される問題**: 調査の副産物として、RMBGのみ(Checkpoint/LoRA/VAEいずれのノードも無い)ワークフローをLabタブで開くと、Model列に「1: (ワークフローの設定のまま)」という空のキーフレーム行が常に表示されてしまうことを実機で発見。ユーザーから「不要なため作成しない形にしたい」との要望を受けて修正。`_buildWorkflowForIteration()`は対象ノードIDが存在しない場合、書き込みを黙ってスキップするだけ(`write()`の`wf[nodeId]`ガード)なので、この行はRun時に何の効果も持たない死んだコントロールだった。`_hasNodeForColumn(col)`を新設し、`_renderColumn()`と`_initColumnButtons()`の+ボタンで判定 — 対応ノードが無い列は「このワークフローに該当ノードがありません」というプレースホルダーを表示し、キーフレーム追加もブロックする。VAEだけは特殊扱いで、専用VAELoaderが無くてもCheckpointノードが存在すれば内蔵VAEの上書き注入が意味を持つため(既存の`_buildWorkflowForIteration`のVAELoader自動注入ロジック)、Checkpoint存在時はVAE列も通常表示のままにした。
|
||||
|
||||
@@ -209,6 +560,7 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
ユーザーから「生成UIタブのLab機能のLora、ModelタブのLoraのLoraノード自体をバイパスする機能を追加したい。Lab機能ではキーフレームごとに切り替え対応可能としたい」との要望を受けて着手。同じセッション内で続けて「Lab機能のLoraにLora syntax、Trigger wordsの適用を可能にしたい」「AI TOOLタブのCHATの画像生成でチャットによる設定項目を増やしたい」という要望にも順次対応した一連のセッション。
|
||||
|
||||
**1. LoRAノードのバイパス機能**: 実装方針の分岐点(ComfyUI本来のBypass(mode:4)相当の配線迂回か、strength=0にする簡易実装か)をAskUserQuestionでユーザーに確認し、簡易実装(strength=0)を選択。理由は、Lab/GenerateUIが扱う`workflow`は既に`comfyui-workflow.js`の`convertUiToApi()`でAPI形式に変換済みで`mode`の概念自体が失われており、`mode:4`を書き込んでも実行エンジンには一切効果がないため——本物のBypass相当の配線再接続を自前実装するのは過剰投資と判断した。
|
||||
|
||||
- **GenerateUI Model タブ**: LoRA列ヘッダーの「Refresh Stack group」ボタン左に⛔トグルボタンを追加(`comfyui-editor.js`の`toggleLoraNodeBypass()`/`_loraNodeBypass`/`_loraBypassSnapshot`)。対象ノードのstrength値をゼロ化し、退避したスナップショットから復元をトグルする。Lora Managerノードの場合はLoRAリスト自体(複数エントリ)は差し替えず維持したまま各エントリのstrength/clipStrengthだけを0にする。Single/Stack Applyボタンを押すとバイパス状態は自動クリアされ、あとから無言で再適用されることはない。
|
||||
- **Lab機能**: 既存の行単位「bypassed」(キーフレーム自体を無視するスキップ機能)とは独立した、LoRA列専用の`nodeBypass`フィールドをキーフレームの値に追加。編集モーダルに「LoRAノードをバイパス(強度を0にする)」チェックボックスを新設。`_applyLabLoraToWorkflow()`で`nodeBypass`時はstrength/active/textを0/false/空に強制する。
|
||||
|
||||
@@ -217,9 +569,10 @@ v0.5.0で追加したmp4 Gallery対応(一覧・サムネイル・再生のみ
|
||||
**3. AI TOOL Chat: generate_imageツールのパラメータ拡張**: AskUserQuestionで追加したい項目をユーザーに確認し、KSampler系(steps/cfg/sampler_name/scheduler/denoise/seed)・画像サイズ(width/height)・生成枚数(batch_size)を選択。`comfyui-editor.js`に`setGenerationParams()`を新設し、Lab機能の`_buildWorkflowForIteration`と同じ`sampler_nodes`のstepsNodeId/cfgNodeId等のノードID間接参照パターンを再利用することで、Advanced Samplingワークフロー(Flux.1/2、SD3.5等でKSamplerが複数ノードに分割される構成)にも対応した。seedは`window._wfmGenerateTab.generate()`(実体は`handleGenerate`)へのオプション引数として渡すよう、`generate-tab.js`の`handleGenerate`/`_coreGenerate`にgenOptions引数を追加(既存の呼び出し元との後方互換は維持)。batch_size>1時は複数画像がチャットバブルに表示されるよう`appendChatBubble`に`imageUrls`(複数URL)対応を追加した。実機のOllama(ornith-1.5:9b)でツール呼び出しから画像生成までEnd-to-Endで検証し、指定した各パラメータが正しく反映されることを確認した。
|
||||
|
||||
**4. Resolution Selectorノード対応**: ユーザーから実際のワークフロー画像(aspect_ratio COMBO・megapixels・multipleウィジェットを持つ「Resolution Selector」ノード)を見せられ、「3:2などで指定可能か」と問われて調査。`/object_info/ResolutionSelector`で確認したところComfyUI本体組み込みノード(`comfy_extras.nodes_resolution`)で、aspect_ratioは`"3:2 (Photo)"`のような固定ラベル付きCOMBO値と判明した。当初のwidth/height直接指定実装ではこの手のノードに非対応だったため、追加で以下を実装:
|
||||
- `comfyui-workflow.js`の`analyzeWorkflow()`に`resolution_selector_nodes`カテゴリを新設。
|
||||
- `comfyui-editor.js`に`resolveAspectRatioOption(nodeType, requested)`を新設。LLMが渡す短い`"3:2"`形式を、`/object_info`から取得した実際のCOMBO選択肢と前方一致でマッチさせ`"3:2 (Photo)"`のような完全な文字列に解決する(未知の比率はそのままフォールバックし、ComfyUI側の検証エラーに委ねる)。
|
||||
- `setGenerationParams()`にResolution Selector優先ロジックを追加:aspect_ratio指定時はEmptyLatentImageへのwidth/height直接書き込みをスキップし、ResolutionSelectorノードのaspect_ratioだけを書き換える——EmptyLatentImageのwidth/heightがResolutionSelectorの出力へのリンクになっている場合、直接数値を書き込むとそのリンクを上書きしてしまい、意図した比率指定が無視されてしまうため。
|
||||
|
||||
- `comfyui-workflow.js`の`analyzeWorkflow()`に`resolution_selector_nodes`カテゴリを新設。
|
||||
- `comfyui-editor.js`に`resolveAspectRatioOption(nodeType, requested)`を新設。LLMが渡す短い`"3:2"`形式を、`/object_info`から取得した実際のCOMBO選択肢と前方一致でマッチさせ`"3:2 (Photo)"`のような完全な文字列に解決する(未知の比率はそのままフォールバックし、ComfyUI側の検証エラーに委ねる)。
|
||||
- `setGenerationParams()`にResolution Selector優先ロジックを追加:aspect_ratio指定時はEmptyLatentImageへのwidth/height直接書き込みをスキップし、ResolutionSelectorノードのaspect_ratioだけを書き換える——EmptyLatentImageのwidth/heightがResolutionSelectorの出力へのリンクになっている場合、直接数値を書き込むとそのリンクを上書きしてしまい、意図した比率指定が無視されてしまうため。
|
||||
|
||||
**ヘルプ・README更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、`helpLab16`/`helpLab17`(Lab LoRAバイパス・プロンプト適用)、`helpGen29`(GenerateUI Model LoRAバイパスボタン)を新規追加し、`helpAi3`(AI TOOL Chatの説明)を拡張パラメータの説明を含めて更新した。README.mdのGenerateUI Tab/Lab subtab/AI TOOL Tabの各セクションも同内容で更新した。
|
||||
|
||||
@@ -5281,8 +5634,7 @@ SPA および Workflow Studio Library サイドパネルの両方に AI タブ
|
||||
**`applyTextareaFontSize(size)` 追加(export)**
|
||||
|
||||
- `<style id="wfm-ta-font-size-style">` を `<head>` に注入
|
||||
- 対象セレクタ(対象を 8 箇所に一括適用):
|
||||
| 対象 | ID |
|
||||
- 対象セレクタ(対象を 8 箇所に一括適用):| 対象 | ID |
|
||||
| ---------------------------------------- | -------------------------- |
|
||||
| Generate UI — Positive Prompt | `#wfm-prompt-pos-text` |
|
||||
| Generate UI — Negative Prompt | `#wfm-prompt-neg-text` |
|
||||
@@ -6566,6 +6918,7 @@ Feeder 専用スタイルを末尾に追加:
|
||||
|
||||
- **型バッジの削除:** ThumbView・CardView・SidePanelの `renderSideInfo` から `typeBadge = badgeHtml(typeLabel)` を削除。ユーザー定義バッジのみ表示
|
||||
- **`GENUI_TYPE_MAP` 追加:**
|
||||
|
||||
```javascript
|
||||
const GENUI_TYPE_MAP = {
|
||||
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
|
||||
@@ -6579,6 +6932,7 @@ Feeder 専用スタイルを末尾に追加:
|
||||
|
||||
hypernetwork / embedding は対象外
|
||||
- **`applyToGenUI(modelName, modelType)`** 追加:
|
||||
|
||||
- `comfyUI.currentWorkflow` からターゲット `inputKey` を持つノードを検索
|
||||
- `node.inputs[inputKey] = modelName` で直接セット
|
||||
- `<select id="wfm-model-${key}">` の表示値も同期
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ketle-man
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -26,12 +26,12 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Features (tab -> feature names)
|
||||
|
||||
### Workflow Tab
|
||||
Thumbnail / Table views, Thumbnail side panel, Badge filtering, Search, Side panel tabs, Badge management, AI summary, Import / Export, Side panel toolbar buttons, Default view setting, Search clear (✕), Clear all filters (✕ Clear)
|
||||
Thumbnail / Table views, Thumbnail side panel, Badge filtering, Search, Side panel tabs, Badge management, AI summary, Import / Export, Side panel toolbar buttons, Default view setting, Search clear (✕), Clear all filters (✕ Clear), Sort
|
||||
|
||||
### Canvas Snapshot (v0.1.2)
|
||||
One-click capture, Auto-save as thumbnail, Embedded workflow metadata, Auto-import
|
||||
@@ -49,10 +49,10 @@ Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column —
|
||||
3-column layout, AI chat assistant, Image attachment, Translation, Prompt presets, Preset Manager, Group management, Clipboard copy, GenUI Set, Wildcard/Style tab bar, Wildcard input toolbar, Wildcard file manager, Style manager, Form / Table toggle, Table view — Presets / Presets Group / Wildcards / Style
|
||||
|
||||
### Settings Tab
|
||||
2-column layout, Collapsible sections, Theme selection, Theme customization, Workflows directory, Gallery output directory, Eagle connection, Ollama connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
|
||||
2-column layout, Collapsible sections, Theme selection, Theme customization, Workflows directory, Gallery output directory, Eagle connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, Video Playback Volume, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
|
||||
|
||||
### Gallery Tab (v0.3.44)
|
||||
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send CC button, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, Performance
|
||||
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send CC button, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, PSD file support, Performance
|
||||
|
||||
### ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)
|
||||
Visual prompt library, 3-column layout, Prompt builder (right pane), Plain-text prompt storage, ponyxlWildcardsVault format support, Seed data importer
|
||||
@@ -70,16 +70,16 @@ Node Browser, Search & Filter, Package badges, Node detail panel, Node Sets, Rig
|
||||
Model Browser, Thumbnail / Table views, Table column sort, Search & Filter, User-defined badges, Side panel tabs, CivitAI integration, CivitAI panel — Info / Sample sub-tabs, CivitAI panel states, Batch CivitAI fetch, Detail modal, GenUI Model button, Group management, Table view memo, Preview images, Enable / Disable models, Multi-select & bulk operations, Search clear (✕), Clear all filters (✕ Clear)
|
||||
|
||||
### Tagger Tab (v0.3.38)
|
||||
3 sub-tabs, Model support, Threshold sliders, Ollama VLM, Single tab, Single output options, Batch tab, DB tab, Dependencies
|
||||
3 sub-tabs, Model support, Threshold sliders, VLM tagging, Single tab, Single output options, Batch tab, DB tab, Dependencies
|
||||
|
||||
### Image Edit Tab (v0.3.65)
|
||||
Layer-based image editor, Loading images, New button, Tools, Layer panel, Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
|
||||
Layer-based image editor, Loading images, Open PSD button, Close button, New button, Tools, Layer panel, Duplicate layer (⧉), Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
|
||||
|
||||
### Video Tab (v0.5.0 – v0.5.2)
|
||||
Dedicated MiniMax H3 video generation UI, independent of GenerateUI, 3-pane layout, First Frame / Last Frame are both optional, Node lookup instead of hardcoded IDs, Load in Video, Preview frame + Video Source panel, Frame tab, GIF tab
|
||||
### Video Tab (v0.5.0 – v0.7.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
|
||||
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, 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
|
||||
@@ -160,6 +160,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
- **[psd-tools](https://pypi.org/project/psd-tools/)** (v0.6.0) — enables all PSD (Photoshop) features: Gallery bulk "Create PSD" export, Gallery browsing/thumbnails for `.psd` files, and Image Edit's Save PSD / Open PSD buttons; `pip install -r requirements.txt` (ships prebuilt wheels, no C compiler required); without it, PSD-related actions show an error toast with the install command instead of failing silently
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,29 +30,29 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
| :-------------------------------------: | :---------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Prompt Input Assistance | Gen UI Feeder |
|
||||
| :----------------------------------------------------------: | :---------------------------------------: |
|
||||
|  |  |
|
||||
| Prompt Input Assistance | GenUI Batch |
|
||||
| :----------------------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| GenUI LoRA Stack | GenUI Batch |
|
||||
| :---------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Models Multi-select Menu | Top Bar |
|
||||
| :-----------------------------------------------------------: | :---------------------------: |
|
||||
|  |  |
|
||||
| Top Bar | Image Edit Tab |
|
||||
| :----------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| WS Library | Library Information |
|
||||
| :----------------------------------: | :---------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Tagger Tab | Image Edit Tab |
|
||||
| :-------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
| Video Tab | AI TOOL Tab |
|
||||
| :----------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Customize | |
|
||||
| :--------------------------------: | :-: |
|
||||
|  | |
|
||||
| Video Tab - Edit | Video Tab - Asset |
|
||||
| :----------------------------------: | :-----------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Lab Tab | Lab Index Image |
|
||||
| :--------------------------------: | :------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,10 +69,11 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
|
||||
- **AI summary** — generate workflow descriptions using Ollama
|
||||
- **Import / Export** — import workflows from files or clipboard; **Send to Canvas** button sends the selected workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **Side panel toolbar buttons** (v0.5.0) — selecting a card shows three buttons in the top toolbar: **Load in GenerateUI**, **Load in Video** (new — loads a MiniMax H3 Image-to-Video workflow into the Video tab; shows an error toast for workflows that don't contain that node), and **Send to Canvas**; the detail modal's duplicate copies of these three buttons were removed in favor of this single toolbar location
|
||||
- **Side panel toolbar buttons** (v0.5.0) — selecting a card shows three buttons in the top toolbar: **Load in GenerateUI**, **Load in Video** (loads a MiniMax H3 Image-to-Video, LTX-2.5 (Text/Image/First&Last-Frame to Video), or Wan2.2 (Text to Video) workflow into the Video tab (LTX-2.5/Wan2.2 support: v0.5.4); shows an error toast for workflows that don't contain a supported node), and **Send to Canvas**; the detail modal's duplicate copies of these three buttons were removed in favor of this single toolbar location
|
||||
- **Default view setting** — persist your preferred view mode (Thumbnail / Table)
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, group filter, and badge filter (ALL) in one click
|
||||
- **Sort** (v0.7.2) — Date (Newest/Oldest) / Name (A-Z/Z-A) dropdown next to the group filter, persisted via localStorage — same pattern as the Gallery tab's sort control
|
||||
|
||||
</details>
|
||||
|
||||
@@ -117,7 +118,9 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Right pane (Batch Queue)** — 7 columns: Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / **Style**; each column header has an enable checkbox (radio behavior: only one at a time); count shown per column; Style batch applies each selected style sequentially to a workflow copy
|
||||
- **Lora batch prompt sync** (v0.3.69) — for each selected LoRA, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt (rebuilt from the original prompt each time, not accumulated), then restored to the pre-batch state when the batch completes or is stopped; if a selected LoRA is not yet recognized by ComfyUI's model list, a warning toast suggests a Refresh or restart
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and Guider-based "Advanced Sampling" workflows (`SamplerCustomAdvanced`/`SamplerCustom` + `CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `KSamplerSelect` + `*Scheduler` — Flux.2 Klein, LongCat, Boogu, HiDream E1, etc.)
|
||||
- **MultiType widget-value bug fix** (v0.5.4) — a node input declaring a comma-joined MultiType (e.g. LTX-2.5's `LTXVEmptyLatentAudio.frame_rate: "FLOAT,INT"`) was silently dropped from the widget list instead of being recognized as a scalar widget, which shifted every subsequent widget's value onto the wrong input (surfaced as `LTXVEmptyLatentAudio.batch_size` receiving `frame_rate`'s value and video generation failing with "Expected size 1 but got size 25")
|
||||
- **Video workflow support** (v0.5.2) — loading a MiniMax H3 (or other all-in-one video-generation) workflow now works from GenerateUI too, not just the dedicated Video tab: the Input tab's Prompt field picks up its `prompt` widget, the Image tab picks up its LoadImage-based `first_frame` the same way any other I2I workflow does, and generation runs with a longer timeout since video generation takes much longer than a still image; the result preview shows the generated mp4 inline (`<video>`) instead of trying to render it as an image
|
||||
- **LTX-2.5 / Wan2.2 support** (v0.5.4) — these follow ComfyUI's regular node-graph pattern rather than an all-in-one node, so they needed their own detection: Positive/Negative Prompt now traces through LTX-2.5's `LTXVDualCFGGuider`/`LTXVConditioning` nodes to the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's `EmptyHunyuanLatentVideo` and LTX-2.5's `EmptyLTXVLatentVideo` (width/height/batch size, plus an added **Length (frames)** field) instead of showing "No EmptyLatentImage node found"; the extended generation timeout now also triggers off a `SaveVideo`/`CreateVideo` node anywhere in the graph, not just an all-in-one node
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
|
||||
</details>
|
||||
@@ -184,7 +187,8 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<summary><h3>Prompt Tab</h3></summary>
|
||||
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard/Style support (right)
|
||||
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
- **AI chat assistant** — generate and refine prompts interactively using Ollama, LM Studio, Lemonade, or Unsloth (multi-backend: v0.5.4)
|
||||
- **⚙ Settings modal** (v0.5.4) — replaces the old central Settings-tab panel; opens a dedicated modal (Backend / Connection / Model / Generation / Unload / Save) scoped to this tab only, saved under its own `localStorage` key so it stays independent of the Tagger tab's VLM settings; **Thinking mode** and **Max tokens** apply to every chat request, and an **Unload** button frees the selected model from Ollama/LM Studio/Lemonade (Unsloth has no such API and is shown as unsupported)
|
||||
- **Image attachment** — attach reference images for vision-capable models
|
||||
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
|
||||
- **Prompt presets** — save/load reusable prompt templates (positive & negative) with category support
|
||||
@@ -225,16 +229,17 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Workflows directory** — configure which folder to scan for workflows
|
||||
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
|
||||
- **Eagle connection** — set Eagle API endpoint for auto-save
|
||||
- **Ollama connection** — configure Ollama server URL
|
||||
- **CivitAI Host** — choose which site opens when clicking a model link: `civitai.com` (SFW only) or `civitai.red` (unrestricted); saved to `settings.json` and synced to `localStorage` for the Models tab to use without extra fetches
|
||||
- **CivitAI API Key** — optional Bearer token for authenticated CivitAI access; stored in `settings.json` (excluded from data exports); environment variable `CIVITAI_API_KEY` takes priority if set
|
||||
- **Default workflow** — set a workflow to auto-load on startup
|
||||
- **Default Checkpoint** (v0.3.73) — optional checkbox to force every checkpoint loader node's model to a chosen default whenever a workflow is loaded into GenerateUI; a safeguard against generation errors or garbled images from a stale/mismatched checkpoint saved in the workflow file
|
||||
- ~~AI Assistant Settings~~ (removed in v0.5.4) — the central Ollama backend/URL/model panel that used to live here was replaced by a ⚙ Settings button + modal directly in the Prompt tab and the Tagger tab, each with its own independent configuration (backend, URL, model, Thinking mode, Max tokens, and a model Unload button) — see those tabs below; the AI TOOL tab's own Settings pane is unaffected
|
||||
- **Video Playback Volume** (v0.5.4) — the video preview's volume is remembered here and shared across every video player in the plugin (Video tab, Gallery tab), instead of resetting to maximum whenever a different video is opened
|
||||
- **Data Management** — export core settings/metadata/prompts/CivitAI cache to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
|
||||
- **Full Backup (ZIP)** (v0.5.2) — additionally covers what the JSON bundle can't represent: folder-based data (AI Skills, Lab Plans, 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, Style presets) and the Tagger SQLite database; two opt-in checkboxes can also include ComfyUI's default workflows folder (`user/default/workflows`) and the Impact Pack's wildcard folder in the export — both of those are export-only and stored under an `_external/` prefix in the ZIP that Restore always skips, since auto-overwriting files a different plugin/ComfyUI core manages would be unsafe; copy them back by hand if needed. Restoring overwrites existing files; restart ComfyUI afterward for either restore path
|
||||
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
|
||||
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`; the same color and mechanism also drives the Video tab's field highlighting (v0.5.4, see above)
|
||||
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
|
||||
- **G'MIC-Qt Integration** — set the path to `gmic_qt.exe` for the Image Edit Filter tool; download G'MIC-Qt Standalone from `gmic.eu/download.html` (Windows: `gmic_qt-win64.zip`), extract anywhere, then enter the full path to `gmic_qt.exe` and click Save
|
||||
- **Language** — English / Japanese / Chinese
|
||||
@@ -254,7 +259,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **MP4 video support** (v0.5.0) — `.mp4` files are browsable, previewable (detail panel with native `<video controls>`, lightbox, and Compare), and included in favorites/tags/groups/move/delete/export alongside images; server-side thumbnails are generated from the first video frame via PyAV (falls back to a placeholder if PyAV/ffmpeg decoding isn't available); a small ▶ badge overlays video thumbnails in the grid
|
||||
- **MP4 embedded metadata** (v0.5.1) — the Prompt and JSON tabs now also work for `.mp4` files: reads the `workflow`/`prompt` container tags ComfyUI's `SaveVideo` writes into the mp4 (via PyAV server-side), including all-in-one video-generation nodes that carry their prompt directly on a `prompt` input rather than a separate `CLIPTextEncode`-style node (e.g. `MiniMaxH3ImageToVideo`)
|
||||
- **Info tab: dimensions & duration** (v0.5.1) — the detail panel's Info tab now also shows image dimensions (width×height) or video duration, in addition to filename, size, date, tags, and memo
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations: select group → "Add to Group" / "Remove from Group"; Favorite All / Unfavorite All; Compare (2–4 images); Move To...; **Export** (downloads all selected images as a ZIP file); Delete File
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations: select group → "Add to Group" / "Remove from Group"; Favorite All / Unfavorite All; Compare (2–4 images); Move To...; **Export** (downloads all selected images as a ZIP file); **Create PSD** (v0.6.0, combines the selected images into one layered PSD file, positioned top-left on a canvas sized to the largest image); **Edit as Layers** (v0.6.0, opens all selected images in the Image Edit tab at once, each as its own layer); Delete File
|
||||
- **Image Compare** — select 2–4 images with Ctrl+click, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; grid adapts to the number of selected images
|
||||
- **Prompt search** — text search covers filename, tags, memo, and prompt text (A1111 `parameters` field cached on first detail panel open)
|
||||
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
|
||||
@@ -265,15 +270,16 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Prompt tab** (v0.4.7) — selected by default (the most frequently checked piece of info shouldn't need an extra click); lists every positive/negative prompt candidate found in the embedded workflow with POS/NEG badges, reusing the exact same list→click→full-text UI as the Metadata tab; extracts from the API-format embedding when available rather than the UI-format one, since a workflow with independent top-level and subgraph sampling chains (e.g. Krea-2) can otherwise have some of its prompts missed
|
||||
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab; **Copy & Send Canvas** button copies the JSON to the clipboard and sends the workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **GenUI button** (renamed from "Load GenUI" in v0.5.0 for readability) — loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; shows a warning toast if no workflow is embedded or the format is unsupported; Metadata button is styled green, GenUI button uses the primary accent color
|
||||
- **Image Edit button** — toolbar button; sends the selected image directly to the Image Edit tab as the base layer
|
||||
- **Image Edit button** — toolbar button; sends the selected image directly to the Image Edit tab as the base layer; PSD files (v0.6.0) are fully decomposed into their original layers instead of being flattened into one image
|
||||
- **Send GenUI Image button** — toolbar button (next to Image Edit); uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded
|
||||
- **Send to LI node button** (v0.5.2) — toolbar button (next to Send GenUI Image); uploads the selected image and writes its filename into the `image` widget of the node currently selected on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination and cross-window bridge as the Image Edit tab's **Send to LI node** button (renamed from "Send to Workflow" in this same release), so both entry points now share one consistent name; requires Workflow Studio to have been opened from ComfyUI's top menu button
|
||||
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay
|
||||
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay; when the selected file is an MP4 video (v0.6.3), it is routed to Comic Creator's Layout-tab Video tool instead — inserted as a new video object in the selected panel (playable, capturable to a still frame) rather than as a flattened image; sending a video while the Image tab is the intended destination is not yet supported and shows an info toast instead
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — toolbar button next to the view-mode (Thumbnail/Table) switch (v0.5.2, moved from the toolbar's rightmost position so it sits with the other display-affecting controls); resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
|
||||
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
|
||||
- **Output folder configurable** — set the scanned output folder from Settings tab
|
||||
- **SVG file support** (v0.3.77) — `.svg` files are browsable, previewable (detail panel and lightbox), and included in favorites/tags/groups/move/delete/export alongside raster formats; served as-is with no rasterized thumbnail, since `<img>` scales vector graphics natively at any size
|
||||
- **PSD file support** (v0.6.0) — `.psd` files are browsable alongside other formats; thumbnails and full-size previews show every layer merged together (hidden layers excluded), rendered server-side via `psd-tools` and disk-cached; the underlying PSD file itself is never modified — only the Gallery preview is a flattened rendering
|
||||
- **Performance** — server-side 256px JPEG thumbnail generation with disk cache (`data/thumb_cache/`); infinite-scroll paging (50 images per page, IntersectionObserver); folder-level mtime cache (60s TTL); bulk operations use single-request API endpoints
|
||||
|
||||
</details>
|
||||
@@ -368,11 +374,12 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **3 sub-tabs** — Single / Batch / DB for single-image tagging, folder batch processing, and tag database management
|
||||
- **Model support** — WD Tagger (ONNX, NCHW/NHWC auto-detect), SwinV2 (ONNX), DeepDanbooru (.h5, requires TensorFlow); place each model in its own subfolder under `ComfyUI/models/tagger/<model-name>/` containing the `.onnx` + `selected_tags.csv` (or `.h5` + label file)
|
||||
- **Threshold sliders** — General threshold (default 0.35) and Character threshold for `character:` prefix tags (default 0.85); sliders update the display value in real time
|
||||
- **Ollama VLM** — optional vision model alongside WD Tagger; set API URL and select model (↻ to refresh list); results from both models are merged into one comma-separated tag string
|
||||
- **VLM tagging** (v0.3.38, multi-backend: v0.5.4) — optional vision model alongside WD Tagger, running on Ollama, LM Studio, Lemonade, or Unsloth through the same unified backend used by the Prompt tab; results from both models are merged into one comma-separated tag string
|
||||
- **⚙ Settings modal** (v0.5.4) — a Settings button (Single and Batch tabs) opens the same modal component as the Prompt tab's AI Assistant (Backend / Connection / Model / Generation / Unload), but saved under its own `localStorage` key so Tagger's VLM backend/model choice is independent of the Prompt tab's; **Thinking mode** and **Max tokens** apply to VLM requests too, and the **Unload** button frees the selected model (Ollama/LM Studio/Lemonade; Unsloth unsupported) — replaces the old Tagger-only direct Ollama URL/model fields
|
||||
- **Single tab** — drag & drop an image onto the preview area or click Upload; Gallery detail panel **Tagger** button opens the selected image directly here
|
||||
- **Single output options** — (1) **GenUI:P** — appends tags to the GenerateUI tab's positive prompt and immediately applies to the loaded workflow; (2) **Send to Prompt** — appends tags to the Prompt tab's positive textarea; (3) **Save to Gallery** — saves tags to Gallery image metadata (requires image opened from Gallery); (4) **Write to File** — embeds tags into JPEG EXIF (`ImageDescription`) or PNG `tEXt` chunk (`Tags` key), other formats get a `.tags.json` sidecar; (5) **Save to DB** — stores in internal SQLite database
|
||||
- **Batch tab** — enter a folder path, configure WD Tagger and Ollama settings; output options: **Save to DB** (default on), **Write to File** (EXIF/PNG metadata), **Write .txt** (creates `<filename>.txt` alongside each image with all tags); real-time progress bar and log; Stop cancels after the current image
|
||||
- **DB tab** — searchable SQLite database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records
|
||||
- **Batch tab** — enter a folder path, configure WD Tagger thresholds and (via the same ⚙ Settings modal as Single) the VLM backend; output options: **Save to DB** (default on), **Write to File** (EXIF/PNG metadata), **Write .txt** (creates `<filename>.txt` alongside each image with all tags); real-time progress bar and log; Stop cancels after the current image
|
||||
- **DB tab** — searchable SQLite database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records; **bulk delete** (v0.5.8) — per-row checkboxes plus a header checkbox to select/deselect all, a confirmation dialog before deleting the selected records
|
||||
- **Dependencies** — install into ComfyUI's embedded Python: `python_embedded\python.exe -m pip install -r requirements.txt`; for GPU inference use `onnxruntime-gpu`; TensorFlow (DeepDanbooru) is optional and commented out in `requirements.txt`
|
||||
|
||||
</details>
|
||||
@@ -380,39 +387,50 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<details>
|
||||
<summary><h3>Image Edit Tab (v0.3.65)</h3></summary>
|
||||
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG
|
||||
- **Loading images** — drag & drop onto the canvas, Upload button, or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG or a fully layered PSD
|
||||
- **Loading images** — drag & drop onto the canvas, **File Open** button (renamed from Upload in v0.6.0), or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
- **Open PSD button** (v0.6.0, next to File Open) — loads a layered PSD file from disk and decomposes it into individual layers, restoring each layer's position, size, visibility, opacity, and blend mode; groups and non-raster layers (adjustment layers, etc.) are flattened into their rendered appearance
|
||||
- **Close button** (v0.6.0, next to Open PSD) — closes the current document after a confirmation prompt, discarding all layers and unsaved edits and returning to the empty placeholder view
|
||||
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers and automatically adds an empty Layer 1 (v0.3.70)
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Fill (G), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Select** — click to select; drag to move; drag corner handles to resize; drag circle handle to rotate; Flip H / Flip V / Rotate angle in the options bar; double-click a text layer to re-edit its content
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge; **pen tablet support** (v0.6.2) — brush size and opacity respond to pen pressure (mouse behaves exactly as before); the canvas listens for Pointer Events so pen, touch, and mouse all work through the same code path
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends the current canvas composite (plus the active mask layer's content, if one is selected) to a MaskEditorOne node on the ComfyUI canvas — the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph (same selection logic as **Send to LI node**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Fill** (v0.6.2) — bucket fill; click a contiguous same-color region on the active layer to flood-fill it (scanline-based, ported from the Comic Creator plugin's Image tab); **Properties pane**: Mode (Solid / Gradient) — Solid: Color picker; Gradient: Shape (Linear / Radial), a draggable color-stop Ramp (click a stop to select it, drag to reposition, +/- to add/remove stops, color swatch to edit the selected stop's color), and a Direction dial (drag to set angle/radius); both Tolerance (0–255, color-match threshold) and Opacity apply to either mode; Ramp and Direction dial are pointer-event based with pointer capture, so pen tablets work the same as mouse
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **Mask** on the second row of the Layers header (renamed from "M" and moved to its own row in v0.6.0) to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); brush size responds to pen pressure on a tablet (v0.6.2, same as the Draw tool); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends the current canvas composite (plus the active mask layer's content, if one is selected) to a MaskEditorOne node on the ComfyUI canvas — the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph (same selection logic as **Send to LI node**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
|
||||
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
|
||||
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
|
||||
- **Inpaint** (v0.3.71) — composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like `VAEEncodeForInpaint`); also supports workflows built around the **Mask Editor One** custom node (`comfyui-mask-editor-one`) instead of LoadImage — image/mask are pushed via its node_id-keyed server cache and `layer_data` widget; **Properties pane**: Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), Denoise (applied to KSampler), Run button; **Use dedicated workflow** checkbox — when off, generates against the workflow currently loaded in GenerateUI; when on, pick a saved workflow from the dropdown to load, edit, and generate entirely in the background without disturbing what's shown in GenerateUI; the result image is shown back in the Properties pane when generation completes; requires a Mask layer painted first (Mask tool)
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load; header is arranged in two rows (v0.6.0) — +, **⧉ Duplicate**, −, ↑, ↓ on top, **Mask** alone below
|
||||
- **Duplicate layer (⧉)** (v0.6.0) — copies the active layer, including its pixels, position, size, opacity, blend mode, and visibility, and inserts the copy directly above the original as the new active layer; works on any layer type (image, draw, text, mask)
|
||||
- **Layer lock** — locked layers show an orange bounding box and 🔒 icon on the canvas; move/resize/rotate are disabled while locked; click the 🔓 button in the layer row to unlock
|
||||
- **Text quality** — text layers are rendered at their measured bounding-box size; resizing with the Select tool regenerates the canvas at the new display resolution so text stays sharp
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Send to LI node** (renamed from "Send to Workflow" in v0.5.2) — uploads to ComfyUI's input folder via `/upload/image`, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable; the Gallery tab's toolbar has the same button (v0.5.2, see Gallery Tab below) so a selected Gallery image can be sent the same way without going through Image Edit first
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Save PSD** (v0.6.0, downloads a layered PSD file with every layer preserved separately — position, rotation, flip, and scale are baked into each layer's pixels since PSD layers cannot store a rotation angle, while opacity, blend mode, and visibility are kept as real, editable PSD layer attributes; clipping masks are merged into their target layer since PSD's native layer-mask mechanism isn't used); **Send to LI node** (renamed from "Send to Workflow" in v0.5.2) — uploads to ComfyUI's input folder via `/upload/image`, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable; the Gallery tab's toolbar has the same button (v0.5.2, see Gallery Tab below) so a selected Gallery image can be sent the same way without going through Image Edit first
|
||||
- **Canvas navigation** — scroll-wheel zoom, Space + drag to pan; zoom indicator in the bottom bar
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), Delete (remove selected layer when 2+ exist)
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), G (Fill), Delete (remove selected layer when 2+ exist)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Video Tab (v0.5.0 – v0.5.2)</h3></summary>
|
||||
<summary><h3>Video Tab (v0.5.0 – v0.7.2)</h3></summary>
|
||||
|
||||
- **Dedicated MiniMax H3 video generation UI, independent of GenerateUI** — a purpose-built tab for the MiniMax H3 (Image to Video) workflow; does not share state with the GenerateUI tab, and model selection (UNet/CLIP/VAE/Audio VAE) is fixed to the loaded workflow's own defaults rather than editable here
|
||||
- **3-pane layout** — left: form (First Frame / Last Frame / Prompt / Aspect Ratio / Megapixels / Multiple / Duration / Noise Seed / Generate); center: preview frame + Video Source panel, plus an Editing pane reserved for future video-editing tools; right: **Frame** / **GIF** property tabs (v0.5.2, see below)
|
||||
- **First Frame / Last Frame are both optional** — leaving First Frame empty runs the workflow text-only (the node is simply left unconnected — a "bypass"); workflows that don't even include a LoadImage node for First Frame are supported out of the box, and a LoadImage node is only injected into the graph on the fly if an image is actually supplied
|
||||
- **Node lookup instead of hardcoded IDs** — locates the MiniMax H3 subgraph instance, its linked Resolution Selector, and (if present) its linked LoadImage node by walking the workflow's subgraph definitions and links, so a workflow doesn't need to match the reference `minimax_test.json`'s exact node IDs
|
||||
- **Load in Video** (Workflow tab) — load any workflow containing a MiniMax H3 Image-to-Video node straight into this tab from the Workflow tab's side-panel toolbar (next to Load in GenerateUI); unsupported workflows show an error toast instead of loading
|
||||
- **Preview frame + Video Source panel** (v0.5.2, replaces the old Result panel) — the center pane's preview shows either a just-generated video or one loaded independently of generation; a **Video Source** drop zone next to it accepts any local video file (drag-and-drop or click to pick), with a **Clear** button to reset back to empty — the Frame/GIF tools below work on whatever is currently shown here, generated or not; generated results are still saved to Gallery metadata (embedded workflow JSON) automatically, so the Gallery tab's Workflow/Prompt panels work on generated videos the same way they do for images
|
||||
- **Frame tab** (v0.5.2) — captures the preview video's current playback position as a still frame: play/seek to the position you want, click **Capture Frame** (client-side canvas, no server round-trip), then **Save to Output** to write it as a PNG into ComfyUI's own Output folder (auto-numbered, same pattern as other generated images)
|
||||
- **GIF tab** (v0.5.2) — converts the preview video to an animated GIF; set Start/End (seconds), FPS, and an optional Max Width, then **Convert to GIF** — the server decodes the video (PyAV) and encodes an animated GIF (Pillow), both dependencies ComfyUI itself already requires, so no ffmpeg binary or extra install is needed; the result is saved into ComfyUI's Output folder and previewed inline
|
||||
- **Dedicated video generation UI, independent of GenerateUI** — a purpose-built tab for MiniMax H3 (Image to Video), LTX-2.5 (Text / Image / First & Last Frame to Video), and Wan2.2 (Text to Video) workflows (LTX-2.5/Wan2.2: v0.5.4); does not share state with the GenerateUI tab, and model selection (UNet/CLIP/VAE/Audio VAE) is fixed to the loaded workflow's own defaults rather than editable here
|
||||
- **Plan subtab** (center panel, batch redesign in v0.5.5) — a **plan** is a sequence of timeline blocks, each a fully self-contained generation job with its own Prompt, Duration, and First/Last Image; split the timeline with **+ Split** / **+ Block** / **Delete**, then **Run** to generate every block in order (Pause/Cancel work mid-run) — a plan with a single block behaves exactly like the old one-shot form. First Image can be None, an explicit image, or "use previous block's last frame" (chaining — an explicit Last Image on the previous block always wins over extracting one from its generated output). Save / Save As / Clear a plan (`ws_videoplan_*.json`, with a PNG contact-sheet thumbnail that embeds the full plan data so dropping the image back on works too)
|
||||
- **Base settings row** (topbar, v0.5.7) — Aspect Ratio / Megapixels / Multiple / Duration (seconds) / Noise Seed on one row — Duration always reflects the currently selected block (each block keeps its own value), the rest are plan-wide — plus a second row of model-specific fields: Width / Height (become directly editable for workflows with no Resolution Selector node, e.g. Wan2.2 and LTX-2.5's First & Last Frame to Video), Turbo Mode / Strength / Steps (MiniMax H3's turbo LoRA — Turbo Mode doubles as Wan2.2's own turbo toggle, sharing one field), and Prompt Enhance (LTX-2.5 only). Every field in both rows is enabled only when the loaded workflow actually has it — see Field highlight color below (one exception: Wan2.2's Text-to-Video variant doesn't expose Noise Seed at all, so it alone leaves that field disabled)
|
||||
- **Edit subtab** (v0.7.0) — a lightweight clip timeline for trimming and concatenating video/image clips outside of generation: add clips via the Video Source panel's **Add to Edit** button or the Asset subtab's **Send to Edit** button, drag blocks to reorder (or use the ◀/▶ toolbar buttons), Duplicate/Delete the selected clip, Clear to empty the timeline. The trim panel for a selected video clip shows a Clipchamp-style scrubber — a time ruler, a draggable playhead synced to the Source preview, and draggable in/out handles with time badges (image clips get a simple hold-duration field instead); the playhead's own time badge sits a row above the in/out handle badges (v0.7.2) so it stays legible even when the playhead lands right on a trim point, and the scrubber has extra headroom/height and spacing from the clip name above it (v0.7.2). **▶ Preview** plays every ready clip back-to-back (trimmed to its in/out points) in the result preview, and **Export** builds and runs the actual ComfyUI graph (trim → 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)
|
||||
- **Project subtab** (sidebar) — lists saved plan files while the center Plan subtab is active, or saved Edit projects while the center Edit subtab is active (v0.7.2 — thumbnail/block-or-clip-count/last-updated) — click one to load it into the matching editor, or delete it with the × button; the panel collapses back down when its own tab is clicked again, and switching Plan↔Edit while it's open refreshes the list in place
|
||||
- **Two center previews** (v0.5.5) — the left preview shows either the video currently selected in the Asset subtab or whatever's dropped/picked in the **Video Source** panel to its left (**Clear** resets it to empty); the right preview always shows the most recently generated clip; both preview frames are a fixed height (v0.7.0) so switching sources never shifts the Plan/Edit/Asset panel below them. The Frame/GIF tools act on whichever preview was touched most recently
|
||||
- **First Frame / Last Frame are both optional** — leaving First Image empty runs the workflow text-only (the node is simply left unconnected — a "bypass"); workflows that don't even include a LoadImage node for First Frame are supported out of the box (Wan2.2's Text-to-Video graph has neither slot at all), and a LoadImage node is only injected into the graph on the fly if an image is actually supplied
|
||||
- **Semantic node lookup, not hardcoded IDs** (generalized in v0.5.4) — detects which of the three families is loaded from its subgraph's internal node types (or, for Wan2.2, the subgraph's own name — it has no distinctive internal node type), then locates its Resolution Selector and LoadImage node(s) by the subgraph's *public input label* (`prompt`, `duration`, `width`, `first_frame`, `last_frame`) rather than a fixed slot index — so it keeps working even though the three families expose those inputs under different internal names; a workflow no longer needs to match any bundled reference file's exact node IDs, and no fallback test workflow is auto-loaded on tab open
|
||||
- **Field highlight color** — Prompt / Duration / First Image / Last Image / Aspect Ratio / Megapixels / Multiple / Noise Seed / Width / Height / Turbo Mode / Strength / Steps / Prompt Enhance labels turn the same highlight color as the GenerateUI Model tab (shared setting, customizable in the Settings tab — see below) when the loaded workflow actually exposes that field; e.g. a Wan2.2 workflow (no Resolution Selector node) leaves Aspect Ratio / Megapixels / Multiple unhighlighted while Width / Height light up instead
|
||||
- **Load in Video** (Workflow tab) — load any workflow containing a MiniMax H3 Image-to-Video node, an LTX-2.5 Text/Image/First&Last-Frame-to-Video node, or a Wan2.2 Text-to-Video node straight into this tab from the Workflow tab's side-panel toolbar (next to Load in GenerateUI) — starts a fresh single-block plan seeded from the workflow's own current values; unsupported workflows show an error toast instead of loading
|
||||
- **Frame tab** (right panel) — captures the active preview's current playback position as a still frame: play/seek to the position you want, click **Capture Frame** (client-side canvas, no server round-trip), then **Save to Output** to write it as a PNG into ComfyUI's own Output folder (auto-numbered, same pattern as other generated images); an optional **Add to Video Assets group** checkbox (v0.7.0) also tags the saved frame into Gallery's Video Assets group
|
||||
- **GIF tab** (right panel) — converts the active preview to an animated GIF; set Start/End (seconds), FPS, and an optional Max Width, then **Convert to GIF** — the server decodes the video (PyAV) and encodes an animated GIF (Pillow), both dependencies ComfyUI itself already requires, so no ffmpeg binary or extra install is needed; the result is saved into ComfyUI's Output folder and previewed inline
|
||||
- **Playback volume** — the preview player's volume is saved to the Settings tab and shared across every video player in the plugin (Video tab, Gallery tab), so it no longer resets to maximum each time a different video is opened
|
||||
|
||||
</details>
|
||||
|
||||
@@ -436,6 +454,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
|
||||
- **Settings pane — Generation** (v0.3.98) — **Thinking mode** toggle (off by default; sends Ollama's `think` flag, and regardless of backend, any `<think>...</think>` block is stripped from the output when off so reasoning models don't clutter Translation/Chat/TOOLS results) and **Max tokens** (Ollama: `options.num_predict`; LM Studio/Lemonade/Unsloth: `max_tokens`; `0` = unlimited)
|
||||
- **Model unload** (v0.5.4) — an Unload button next to the model selector frees VRAM/memory by unloading the currently selected model from Ollama (`keep_alive:0` via `/api/generate`, the officially documented method — Ollama has no dedicated unload endpoint), LM Studio (`/api/v1/models/unload`), or Lemonade (`/api/v1/unload`); Unsloth exposes no equivalent API and the button is disabled for that backend
|
||||
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API directly from the browser (`/v1/chat/completions`, `/v1/models`); Unsloth via the same OpenAI-compatible API but relayed through a server-side proxy that attaches the `Authorization: Bearer` header; VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade/Unsloth)
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
|
||||
@@ -555,6 +574,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
- **[psd-tools](https://pypi.org/project/psd-tools/)** (v0.6.0) — enables all PSD (Photoshop) features: Gallery bulk "Create PSD" export, Gallery browsing/thumbnails for `.psd` files, and Image Edit's Save PSD / Open PSD buttons; `pip install -r requirements.txt` (ships prebuilt wheels, no C compiler required); without it, PSD-related actions show an error toast with the install command instead of failing silently
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -1 +0,0 @@
|
||||
{"0": "models-tab.js", "1": "prompt-tab.js", "2": "models_routes.py", "3": "generate-tab.js", "4": "lab-tab.js", "5": "gallery_routes.py", "6": "gallery-tab.js", "7": "ImageEditTab", "8": "ai-tab.js", "10": "feeder-tab.js", "11": "NodesService", "12": "settings_routes.py", "13": "metadata-tab.js", "14": "node_sets_menu.js", "15": "GalleryService", "16": "workflow-tab.js", "17": "tagger_routes.py", "18": "WildcardService", "19": "t", "20": "renderContent", "21": "comfyui-editor.js", "23": "TaggerService", "24": "top_menu_extension.js", "25": "settings-tab.js", "26": "setupAiHandlers", "27": "PromptsService", "28": "workflow_routes.py", "29": "GalleryMetadataStore", "30": "WorkflowService", "31": "lab_routes.py", "32": "SkillService", "33": "LayerManager", "34": "SelectTool", "35": "CivitaiService", "36": "DrawTool", "37": "MaskTool", "39": "gallery_service.py", "41": "image-prompt-tab.js", "42": "KSampler Node", "44": "style-catalog-tab.js", "45": "add", "46": "comfyui-workflow.js", "47": "BlurTool", "48": "MaskShapeTool", "49": "MaskVectorTool", "50": "ShapeTool", "51": "gmic_routes.py", "52": "ollama_routes.py", "53": "TextTool", "54": "analyze_workflow", "55": "MaskTextTool", "56": "i18n.js", "57": ".list_images", "58": "MaskAlphaTool", "59": "MaskColorTool", "60": "gallery_feeder_extension.js", "61": "createPanel", "62": "util.js", "63": "BgRemove", "65": "WFS_GalleryFeeder", "66": "app.js", "67": "InpaintI2IActions", "68": "Sam3Segmentation", "69": "fromWorkflow", "70": "_extractPromptsAPI", "71": "generate_doc_index.py", "72": "WS Inpaint Basic Workflow", "73": "._bg_index_folder", "74": "SettingsService", "75": "import_style_prompt_seed.py", "76": "Image Edit (FireRed Image Edit 1.1) node", "77": "Version 0.3.9", "78": "DWPose Image Feeder Workflow", "79": "civitai_service.py", "81": "WFS_PromptText", "82": "workflow_service.py", "84": "config.py", "85": "Tagger Tab requirements.txt", "88": "Chinese Language Support", "89": "Version 0.3.95", "97": "Lab Subtab", "100": "prestartup_script.py", "101": ".get_image_prompt_root", "102": ".get_style_catalog_root", "103": ".start_background_index", "104": ".update_workflows_dir", "105": "Version 0.4.2", "106": "Workflow Studio Banner", "107": "Workflow Studio Icon", "108": "BiRefNet Model", "110": "SDXL 2D Pose Editor I2I Workflow", "111": "ComfyUI Manager", "112": "Tagger Tab Screenshot (WD Tagger/DeepDanbooru)", "113": "Models Tab UI Screenshot", "114": "Workflow Studio Topbar Icon Strip", "115": "GenerateUI LoRA Stack Tab Screenshot", "116": "Workflow Studio Favicon Icon", "117": "2D Pose Editor to Image Edit (FireRed) Workflow", "118": "3D Pose Editor to Image Edit (Flux.2 Klein) Workflow", "119": "SDXL DWPose ControlNet Workflow", "120": "SDXL From Metadata T2I Workflow", "121": "SDXL Img2Img + FaceDetailer Workflow", "122": "SDXL Image Feeder Img2Img Workflow", "123": "SDXL T2I + FaceDetailer Workflow", "124": "GenerateUI Batch Tab Screenshot", "125": "Multiple Select Menu Screenshot", "126": "Image Edit Tab Screenshot", "127": "Workflow Tab Screenshot", "128": "Prompt Tab Input Assistance Screenshot", "129": "GenerateUI Feeder Tab Screenshot", "130": "Workflow Studio Library Panel (Lora Info View)", "131": "Library Panel I Tab (Prompts) Screenshot", "132": "Settings Tab Screenshot (Language, Theme)", "133": "ComfyUI Workflow Studio Thumbnail", "135": "Feeder Subtab", "137": "Help & Support Tab", "140": "Metadata Tab", "144": "comfyui-workflow-studio", "146": "py/__init__.py", "147": "nodes/__init__.py", "148": "routes/__init__.py", "149": "services/__init__.py", "150": "python-dotenv", "155": "SD1.5 Basic Text-to-Image Workflow", "156": "SDXL Text-to-Image Basic Workflow Screenshot", "9": "Backend API Routes", "22": "External Plugin Ecosystem", "38": "Registry Publish & Docs", "40": "Generate & Export Utilities", "43": "ComfyUI Client & Mask Bridge", "64": "Workflow Analyzer Internals", "80": "Doc Index Automation", "83": "Project Directory Layout", "86": "Data Storage Files", "87": "AI Tool Version History", "90": "Windows Long Path Handling", "91": "Route Registration", "92": "Caching & Unique IDs", "93": "Service Layer", "94": "Lazy Input Evaluation", "95": "HuggingFace Config", "96": "Node Cache Invalidation", "98": "Mask Editor One Release", "99": "PowerShell Encoding", "109": "Chinese Locale", "134": "English Locale", "136": "ComfyUI folder_paths API", "138": "i18n System", "139": "Japanese Locale", "141": "Node Class Mappings", "142": "OrderedDict Usage", "143": "Output Directory API", "145": "ComfyUI PromptServer", "151": "Silent Failure Diagnosis", "152": "Temp Directory API", "153": "Path Utility", "154": "Input Validation Hook"}
|
||||
@@ -1,546 +0,0 @@
|
||||
# Graph Report - ComfyUI-Workflow-Studio (2026-08-22)
|
||||
|
||||
## Corpus Check
|
||||
- 20 files · ~567,102 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 2320 nodes · 5560 edges · 157 communities (79 shown, 78 thin omitted)
|
||||
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 328 edges (avg confidence: 0.87)
|
||||
- Token cost: 22,191 input · 55,365 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- models-tab.js
|
||||
- prompt-tab.js
|
||||
- models_routes.py
|
||||
- generate-tab.js
|
||||
- lab-tab.js
|
||||
- gallery_routes.py
|
||||
- gallery-tab.js
|
||||
- ImageEditTab
|
||||
- ai-tab.js
|
||||
- Backend API Routes
|
||||
- feeder-tab.js
|
||||
- NodesService
|
||||
- settings_routes.py
|
||||
- metadata-tab.js
|
||||
- node_sets_menu.js
|
||||
- GalleryService
|
||||
- workflow-tab.js
|
||||
- tagger_routes.py
|
||||
- WildcardService
|
||||
- t
|
||||
- renderContent
|
||||
- comfyui-editor.js
|
||||
- External Plugin Ecosystem
|
||||
- TaggerService
|
||||
- top_menu_extension.js
|
||||
- settings-tab.js
|
||||
- setupAiHandlers
|
||||
- PromptsService
|
||||
- workflow_routes.py
|
||||
- GalleryMetadataStore
|
||||
- WorkflowService
|
||||
- lab_routes.py
|
||||
- SkillService
|
||||
- LayerManager
|
||||
- SelectTool
|
||||
- CivitaiService
|
||||
- DrawTool
|
||||
- MaskTool
|
||||
- Registry Publish & Docs
|
||||
- gallery_service.py
|
||||
- Generate & Export Utilities
|
||||
- image-prompt-tab.js
|
||||
- KSampler Node
|
||||
- ComfyUI Client & Mask Bridge
|
||||
- style-catalog-tab.js
|
||||
- add
|
||||
- comfyui-workflow.js
|
||||
- BlurTool
|
||||
- MaskShapeTool
|
||||
- MaskVectorTool
|
||||
- ShapeTool
|
||||
- gmic_routes.py
|
||||
- ollama_routes.py
|
||||
- TextTool
|
||||
- analyze_workflow
|
||||
- MaskTextTool
|
||||
- i18n.js
|
||||
- .list_images
|
||||
- MaskAlphaTool
|
||||
- MaskColorTool
|
||||
- gallery_feeder_extension.js
|
||||
- createPanel
|
||||
- util.js
|
||||
- BgRemove
|
||||
- Workflow Analyzer Internals
|
||||
- WFS_GalleryFeeder
|
||||
- app.js
|
||||
- InpaintI2IActions
|
||||
- Sam3Segmentation
|
||||
- fromWorkflow
|
||||
- _extractPromptsAPI
|
||||
- generate_doc_index.py
|
||||
- WS Inpaint Basic Workflow
|
||||
- ._bg_index_folder
|
||||
- SettingsService
|
||||
- import_style_prompt_seed.py
|
||||
- Image Edit (FireRed Image Edit 1.1) node
|
||||
- Version 0.3.9
|
||||
- DWPose Image Feeder Workflow
|
||||
- civitai_service.py
|
||||
- Doc Index Automation
|
||||
- WFS_PromptText
|
||||
- workflow_service.py
|
||||
- Project Directory Layout
|
||||
- config.py
|
||||
- Tagger Tab requirements.txt
|
||||
- Data Storage Files
|
||||
- AI Tool Version History
|
||||
- Chinese Language Support
|
||||
- Version 0.3.95
|
||||
- Windows Long Path Handling
|
||||
- Route Registration
|
||||
- Caching & Unique IDs
|
||||
- Service Layer
|
||||
- Lazy Input Evaluation
|
||||
- HuggingFace Config
|
||||
- Node Cache Invalidation
|
||||
- Lab Subtab
|
||||
- Mask Editor One Release
|
||||
- PowerShell Encoding
|
||||
- prestartup_script.py
|
||||
- .get_image_prompt_root
|
||||
- .get_style_catalog_root
|
||||
- .start_background_index
|
||||
- .update_workflows_dir
|
||||
- Version 0.4.2
|
||||
- Workflow Studio Banner
|
||||
- Workflow Studio Icon
|
||||
- BiRefNet Model
|
||||
- Chinese Locale
|
||||
- SDXL 2D Pose Editor I2I Workflow
|
||||
- ComfyUI Manager
|
||||
- Tagger Tab Screenshot (WD Tagger/DeepDanbooru)
|
||||
- Models Tab UI Screenshot
|
||||
- Workflow Studio Topbar Icon Strip
|
||||
- GenerateUI LoRA Stack Tab Screenshot
|
||||
- Workflow Studio Favicon Icon
|
||||
- 2D Pose Editor to Image Edit (FireRed) Workflow
|
||||
- 3D Pose Editor to Image Edit (Flux.2 Klein) Workflow
|
||||
- SDXL DWPose ControlNet Workflow
|
||||
- SDXL From Metadata T2I Workflow
|
||||
- SDXL Img2Img + FaceDetailer Workflow
|
||||
- SDXL Image Feeder Img2Img Workflow
|
||||
- SDXL T2I + FaceDetailer Workflow
|
||||
- GenerateUI Batch Tab Screenshot
|
||||
- Multiple Select Menu Screenshot
|
||||
- Image Edit Tab Screenshot
|
||||
- Workflow Tab Screenshot
|
||||
- Prompt Tab Input Assistance Screenshot
|
||||
- GenerateUI Feeder Tab Screenshot
|
||||
- Workflow Studio Library Panel (Lora Info View)
|
||||
- Library Panel I Tab (Prompts) Screenshot
|
||||
- Settings Tab Screenshot (Language, Theme)
|
||||
- ComfyUI Workflow Studio Thumbnail
|
||||
- English Locale
|
||||
- Feeder Subtab
|
||||
- ComfyUI folder_paths API
|
||||
- Help & Support Tab
|
||||
- i18n System
|
||||
- Japanese Locale
|
||||
- Metadata Tab
|
||||
- Node Class Mappings
|
||||
- OrderedDict Usage
|
||||
- Output Directory API
|
||||
- comfyui-workflow-studio
|
||||
- ComfyUI PromptServer
|
||||
- python-dotenv
|
||||
- Silent Failure Diagnosis
|
||||
- Temp Directory API
|
||||
- Path Utility
|
||||
- Input Validation Hook
|
||||
- SD1.5 Basic Text-to-Image Workflow
|
||||
- SDXL Text-to-Image Basic Workflow Screenshot
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `t()` - 226 edges
|
||||
2. `showToast()` - 186 edges
|
||||
3. `ImageEditTab` - 53 edges
|
||||
4. `escapeHtml()` - 53 edges
|
||||
5. `GalleryService` - 45 edges
|
||||
6. `ComfyUI-Workflow-Studio` - 41 edges
|
||||
7. `initPromptTab()` - 33 edges
|
||||
8. `setup_routes()` - 32 edges
|
||||
9. `initSettingsTab()` - 31 edges
|
||||
10. `add()` - 31 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `ComfyUI-Workflow-Studio` --references--> `ImagePrompt Gallery Subtab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `Prompt Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `Gallery Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `GenerateUI Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
- `ComfyUI-Workflow-Studio` --references--> `Image Edit Tab` [INFERRED]
|
||||
CLAUDE.md → README.index.md
|
||||
|
||||
## Import Cycles
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/Sam3Segmentation.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/GmicIntegration.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BgRemove.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/image-prompt-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/InpaintI2IActions.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/MaskEditorOneBridge.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BlurTool.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/FileExport.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/comfyui-editor.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Architecture Layers** — workflowstudio, add_routes, py_wfm, py_routes, py_services, business_logic, web_comfyui, static, templates, data, settings_json, metadata_json, node_metadata_json [EXTRACTED 0.80]
|
||||
- **Workflow JSON Consistency Group** — last_node_id, last_link_id, links, workflow_json [EXTRACTED 0.85]
|
||||
- **i18n Languages** — i18n, english, japanese, chinese [EXTRACTED 0.90]
|
||||
- **Graphify Index Generation** — readme_index, devlog_index, generate_doc_index, graphifyignore, readme, devlog, claude_hooks, git_hooks [EXTRACTED 0.85]
|
||||
- **AI Feature Backends** — ollama, lm_studio, lemonade_server, unsloth [INFERRED 0.80]
|
||||
- **Image/Gallery Implementation References** — comfyui_custom_scripts, comfyui_gallery, infinite_image_browsing [INFERRED 0.70]
|
||||
- **Workflow Studio UI Navigation Tabs** — templates_index_html_workflow, templates_index_html_nodes, templates_index_html_models, templates_index_html_generate, templates_index_html_prompt, templates_index_html_gallery, templates_index_html_image_edit, templates_index_html_tagger, templates_index_html_settings, templates_index_html_help, templates_index_html_ai [EXTRACTED 0.85]
|
||||
- **Mask Editor One Inpainting Pipeline** — comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_load_checkpoint_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_mask_editor_one_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_positive_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_negative_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_vae_encode_for_inpainting_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_save_image_node [INFERRED]
|
||||
|
||||
## Communities (157 total, 78 thin omitted)
|
||||
|
||||
### Community 0 - "models-tab.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (120): openModal(), t(), badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange() (+112 more)
|
||||
|
||||
### Community 1 - "prompt-tab.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (107): closeModal(), refreshStylesList(), addChatMessage(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), applyToGenerateUI(), attachFile() (+99 more)
|
||||
|
||||
### Community 2 - "models_routes.py"
|
||||
Cohesion: 0.06
|
||||
Nodes (55): handle_change_preview(), handle_civitai_batch(), handle_civitai_cache(), handle_civitai_fetch(), handle_delete_models(), handle_get_disabled(), handle_get_filepath(), handle_get_groups() (+47 more)
|
||||
|
||||
### Community 3 - "generate-tab.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (64): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _buildSimpleGroupList(), _ckptBatch (+56 more)
|
||||
|
||||
### Community 4 - "lab-tab.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (67): _expandWildcardsInWorkflow(), _annotatedImageRef(), _applyLabLoraToWorkflow(), _applyPlanData(), _applyStyleToText(), _buildIndexImageDataUrl(), _buildLoraPromptInjection(), _buildPlanData() (+59 more)
|
||||
|
||||
### Community 5 - "gallery_routes.py"
|
||||
Cohesion: 0.10
|
||||
Nodes (54): add_to_group(), bulk_favorite(), bulk_group(), clear_group_images(), create_folder_route(), create_group(), delete_folder_route(), delete_group() (+46 more)
|
||||
|
||||
### Community 6 - "gallery-tab.js"
|
||||
Cohesion: 0.09
|
||||
Nodes (54): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+46 more)
|
||||
|
||||
### Community 8 - "ai-tab.js"
|
||||
Cohesion: 0.09
|
||||
Nodes (51): appendChatBubble(), _appendSkillSaveButton(), _appendSvgPreview(), _applyGenOptions(), buildTranslationMessages(), callChat(), callLLM(), callVLM() (+43 more)
|
||||
|
||||
### Community 9 - "Backend API Routes"
|
||||
Cohesion: 0.05
|
||||
Nodes (46): aiohttp, Jinja2, py/routes, _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test() (+38 more)
|
||||
|
||||
### Community 10 - "feeder-tab.js"
|
||||
Cohesion: 0.10
|
||||
Nodes (49): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+41 more)
|
||||
|
||||
### Community 11 - "NodesService"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): handle_create_set(), handle_delete_set(), handle_export_set(), handle_get_groups(), handle_get_metadata(), handle_list_sets(), handle_save_groups(), handle_save_metadata() (+20 more)
|
||||
|
||||
### Community 12 - "settings_routes.py"
|
||||
Cohesion: 0.09
|
||||
Nodes (44): _apply_import_bundle(), _build_export_bundle(), _find_style(), _get_comfyui_output_dir(), handle_create_style(), handle_delete_style(), handle_export(), handle_get() (+36 more)
|
||||
|
||||
### Community 13 - "metadata-tab.js"
|
||||
Cohesion: 0.11
|
||||
Nodes (40): buildLoRAItem(), buildModelItem(), buildPromptItem(), collectAllNodes(), collectUnique(), extractAllMetadata(), fromWorkflow(), extractCheckpoints() (+32 more)
|
||||
|
||||
### Community 14 - "node_sets_menu.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (37): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+29 more)
|
||||
|
||||
### Community 15 - "GalleryService"
|
||||
Cohesion: 0.10
|
||||
Nodes (13): GalleryService, Path, 許可するルートパスを更新する(Settings変更時に呼ぶ), ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ), パスが許可ルート配下かチェック(パストラバーサル防止)。 Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの…, outputフォルダのフォルダツリーを返す, 画像と同名の.txtサイドカーにプロンプトテキストを保存する, ワークフローを抽出する。 優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow] (+5 more)
|
||||
|
||||
### Community 16 - "workflow-tab.js"
|
||||
Cohesion: 0.13
|
||||
Nodes (35): readJsonStorage(), badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows() (+27 more)
|
||||
|
||||
### Community 17 - "tagger_routes.py"
|
||||
Cohesion: 0.14
|
||||
Nodes (24): Connection, handle_batch_start(), handle_batch_status(), handle_batch_stop(), handle_db_delete(), handle_db_export(), handle_db_list(), handle_db_save() (+16 more)
|
||||
|
||||
### Community 18 - "WildcardService"
|
||||
Cohesion: 0.12
|
||||
Nodes (21): handle_create_link(), handle_delete(), handle_get_content(), handle_link_status(), handle_list(), handle_remove_link(), handle_save(), Application (+13 more)
|
||||
|
||||
### Community 19 - "t"
|
||||
Cohesion: 0.12
|
||||
Nodes (35): _applyI18n(), _batchStart(), _batchStop(), _dbDelete(), _dbExport(), _dbLoad(), _dbSave(), _dbSearch() (+27 more)
|
||||
|
||||
### Community 20 - "renderContent"
|
||||
Cohesion: 0.14
|
||||
Nodes (35): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), createModelItem(), esc(), extractPackageName(), fetchModelList(), getNodeCategory() (+27 more)
|
||||
|
||||
### Community 21 - "comfyui-editor.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (28): _applyLoraToNode(), _attachPromptWeightControl(), _buildLoraManagerSyntax(), _buildLoraSyntax(), _buildPresetOptions(), _compositeImageWithMask(), _I2I_PLACEHOLDER_DEFAULT, _imageInputToDataURL() (+20 more)
|
||||
|
||||
### Community 22 - "External Plugin Ecosystem"
|
||||
Cohesion: 0.06
|
||||
Nodes (33): Canvas Snapshot, CivitAI, ComfyUI-Custom-Scripts, ComfyUI-Gallery, ComfyUI-Lora-Manager, comfyui-mask-editor-one, README Index — ComfyUI-Workflow-Studio, ComfyUI-Workflow-Studio (+25 more)
|
||||
|
||||
### Community 23 - "TaggerService"
|
||||
Cohesion: 0.14
|
||||
Nodes (5): Image, Path, WD Tagger / DeepDanbooru / Ollama VLM 推論サービス。, パストラバーサル防止: セパレータ・NUL・相対参照を拒否, TaggerService
|
||||
|
||||
### Community 24 - "top_menu_extension.js"
|
||||
Cohesion: 0.13
|
||||
Nodes (29): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), injectStyles(), NODE_SETS_TOOLTIP, saveSelectedAsNodeSet(), attachTopMenuButton(), beforeRegisterNodeDef() (+21 more)
|
||||
|
||||
### Community 25 - "settings-tab.js"
|
||||
Cohesion: 0.12
|
||||
Nodes (29): applyCustomFont(), applyCustomOverrides(), applyTheme(), BG_PATTERNS, buildLangOptions(), clearCustomOverrides(), COLOR_TARGETS, FONT_OPTIONS (+21 more)
|
||||
|
||||
### Community 26 - "setupAiHandlers"
|
||||
Cohesion: 0.11
|
||||
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
|
||||
|
||||
### Community 27 - "PromptsService"
|
||||
Cohesion: 0.13
|
||||
Nodes (16): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+8 more)
|
||||
|
||||
### Community 28 - "workflow_routes.py"
|
||||
Cohesion: 0.15
|
||||
Nodes (26): handle_analyze(), handle_change_thumbnail(), handle_delete(), handle_import(), handle_list(), handle_metadata(), handle_raw(), handle_reanalyze_all() (+18 more)
|
||||
|
||||
### Community 29 - "GalleryMetadataStore"
|
||||
Cohesion: 0.13
|
||||
Nodes (9): GalleryMetadataStore, Path, グループ名を変更し、全画像のgroupsフィールドも更新する, gallery_metadata.json の構造: { "images": { "<abs_path>": { "favorite": bool,…, 存在しないパスをグループから一括削除する(1回の保存で完結), グループメンバーのパスをsetで返す(高速フィルタ用), グループ内の全画像を除外する(グループ自体は残す), 画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ) (+1 more)
|
||||
|
||||
### Community 30 - "WorkflowService"
|
||||
Cohesion: 0.13
|
||||
Nodes (13): Get raw workflow JSON content., Save metadata (tags, memo, summary, etc.) for a workflow., Manages workflow files and metadata., Import workflow files. Returns list of results., Rename workflow and its associated thumbnail., Delete workflow JSON and associated thumbnail., Re-analyze a workflow and save results to metadata., Re-analyze all workflows and update metadata. (+5 more)
|
||||
|
||||
### Community 31 - "lab_routes.py"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): handle_delete(), handle_get_content(), handle_list(), handle_save(), handle_save_index_to_output(), Application, Request, Response (+6 more)
|
||||
|
||||
### Community 32 - "SkillService"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): handle_delete(), handle_get_content(), handle_list(), handle_save(), Application, Request, Response, AI skill (.md system prompt) file management API routes. (+8 more)
|
||||
|
||||
### Community 35 - "CivitaiService"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): CivitaiService, Build request headers, optionally including Bearer token., Calculate SHA256 hash of a file., Fetch model version info from CivitAI by SHA256 hash (GET). 429/5xx…, POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。 レスポンスの…, Extract relevant fields from CivitAI API response., Batch fetch CivitAI info for multiple model files. Phase 1: SHA256…, Clear cache for a specific hash or all. (+3 more)
|
||||
|
||||
### Community 38 - "Registry Publish & Docs"
|
||||
Cohesion: 0.10
|
||||
Nodes (22): actions/checkout@v4, Comfy-Org/publish-node-action@main, DEVLOG.md, Publish to Comfy Registry Workflow, graphifyignore, main Branch, pyproject.toml, README.md (+14 more)
|
||||
|
||||
### Community 39 - "gallery_service.py"
|
||||
Cohesion: 0.10
|
||||
Nodes (13): Gallery Metadata Store - ギャラリー画像のメタデータ永続化, _clean_vault_tags(), _decode_image_data_url(), _FolderCache, _parse_vault_yaml_leaves(), Gallery Service - outputフォルダの画像管理、メタデータ閲覧, フォルダ単位の画像スキャン結果キャッシュ。 フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。, data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。 ヘッダが無ければPNG扱い(旧互換)。 (+5 more)
|
||||
|
||||
### Community 40 - "Generate & Export Utilities"
|
||||
Cohesion: 0.17
|
||||
Nodes (8): showToast(), _applyDefaultCheckpointIfEnabled(), loadWorkflowIntoEditor(), saveCurrentWorkflow(), FileExport, GmicIntegration, _loadPlanWorkflow(), onLoadGenUIClick()
|
||||
|
||||
### Community 41 - "image-prompt-tab.js"
|
||||
Cohesion: 0.20
|
||||
Nodes (21): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+13 more)
|
||||
|
||||
### Community 42 - "KSampler Node"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): CLIP Text Encode Negative Prompt Node, CLIP Text Encode Positive Prompt Node, Inpaint with Mask Editor One (Workflow Screenshot), Load Checkpoint Node, Mask Editor One Node, Save Image Node, VAE Encode (for Inpainting) Node, 3D Pose Editor Node (comfyui-vrm-pose-editor) (+12 more)
|
||||
|
||||
### Community 43 - "ComfyUI Client & Mask Bridge"
|
||||
Cohesion: 0.16
|
||||
Nodes (8): comfyUI, comfyEditor, comfyWorkflow, MaskEditorOneBridge, MASK_TEXT_FONTS, fitToCanvas(), TOOL_DEFS, TEXT_FONTS
|
||||
|
||||
### Community 44 - "style-catalog-tab.js"
|
||||
Cohesion: 0.22
|
||||
Nodes (18): selectStyleByName(), activateStyleCatalogTab(), API, apiFetch(), copyText(), createThumbCard(), debounce(), initStyleCatalogTab() (+10 more)
|
||||
|
||||
### Community 45 - "add"
|
||||
Cohesion: 0.22
|
||||
Nodes (19): add(), fetchWorkflowRaw(), getCanvasDropPos(), handleInfoFile(), installCanvasDropHandler(), loadWorkflowOnCanvas(), placeClipTextEncodeNode(), placeLoraMgrNode() (+11 more)
|
||||
|
||||
### Community 46 - "comfyui-workflow.js"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): _CONTROL_AFTER_GENERATE, _findInjectedWidgetIndex(), _flattenSubgraphs(), _getDynamicComboSubNames(), _getWidgetInputNames(), _getWidgetInputTypes(), _isExtraWidgetValue(), _isLinkedWidgetName() (+5 more)
|
||||
|
||||
### Community 51 - "gmic_routes.py"
|
||||
Cohesion: 0.21
|
||||
Nodes (14): _gmic_run_gui(), handle_open(), handle_result(), handle_status(), Application, Request, Response, G'MIC-Qt integration API routes. (+6 more)
|
||||
|
||||
### Community 52 - "ollama_routes.py"
|
||||
Cohesion: 0.23
|
||||
Nodes (14): _get_ollama_config(), handle_chat(), handle_models(), handle_test(), Application, Request, Response, Ollama API proxy routes. (+6 more)
|
||||
|
||||
### Community 54 - "analyze_workflow"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): analyze_workflow(), _clip_type_from_ui_node(), _collect_all_ui_nodes(), _detect_model_type_from_name(), _model_name_from_api_node(), _model_name_from_ui_node(), Workflow analyzer - detects model types and counts input/output nodes., Analyze workflow JSON and return model types, input/output node counts. (+5 more)
|
||||
|
||||
### Community 56 - "i18n.js"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+4 more)
|
||||
|
||||
### Community 57 - ".list_images"
|
||||
Cohesion: 0.17
|
||||
Nodes (6): os.scandir() で画像ファイルを1回のシステムコールで列挙。 キャッシュがあればそれを返す。 Returns: [(name,…, os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。 ImagePrompt/Style…, 指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める), 画像のプロンプトテキストを返す。 1. 画像と同名の .txt サイドカーがあればそれを使う 2. 無ければ ponyxlWildcardsVault 形式…, image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ…, ponyxlWildcardsVault形式のフォールバック解決。 thumbnails_option2 の "{leaf}.preview3.ext"…
|
||||
|
||||
### Community 60 - "gallery_feeder_extension.js"
|
||||
Cohesion: 0.36
|
||||
Nodes (11): beforeRegisterNodeDef(), fetchGroupImages(), getInputWidget(), getOrCreate(), _ns, setWidgetValue(), startLoop(), stopLoop() (+3 more)
|
||||
|
||||
### Community 61 - "createPanel"
|
||||
Cohesion: 0.24
|
||||
Nodes (12): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), loadModelsData() (+4 more)
|
||||
|
||||
### Community 62 - "util.js"
|
||||
Cohesion: 0.31
|
||||
Nodes (10): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), getEagleSettings(), getSettings(), _joinBytes(), _n2b() (+2 more)
|
||||
|
||||
### Community 64 - "Workflow Analyzer Internals"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): EXTRA_PNGINFO, last_link_id, last_node_id, links array, Position Index, PROMPT, widgets_values, workflow_analyzer.py (+2 more)
|
||||
|
||||
### Community 65 - "WFS_GalleryFeeder"
|
||||
Cohesion: 0.27
|
||||
Nodes (4): Path, WFS_GalleryFeeder – Feeds images from a gallery group into a workflow., Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。, WFS_GalleryFeeder
|
||||
|
||||
### Community 66 - "app.js"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize()
|
||||
|
||||
### Community 69 - "fromWorkflow"
|
||||
Cohesion: 0.36
|
||||
Nodes (10): _collectAllNodes(), _collectUnique(), fromWorkflow(), _extractCheckpoints(), _extractDiffusionModels(), _extractLoRAs(), _extractMarkdownNoteModels(), addU() (+2 more)
|
||||
|
||||
### Community 70 - "_extractPromptsAPI"
|
||||
Cohesion: 0.33
|
||||
Nodes (10): _extractPrompts(), _extractPromptsAPI(), _extractPromptsFromNodeSet(), _extractPromptsLiteGraph(), _isPromptStylerNode(), _isSamplerNode(), _isTextEncoderNode(), _resolveEditPlusText() (+2 more)
|
||||
|
||||
### Community 71 - "generate_doc_index.py"
|
||||
Cohesion: 0.31
|
||||
Nodes (7): main(), PostToolUse hook: regenerate README.index.md / DEVLOG.index.md right after…, Path, generate_devlog_index(), generate_readme_index(), main(), Regenerate README.index.md / DEVLOG.index.md from README.md / DEVLOG.md.…
|
||||
|
||||
### Community 72 - "WS Inpaint Basic Workflow"
|
||||
Cohesion: 0.53
|
||||
Nodes (9): CLIP Text Encode (Prompt) - negative conditioning, CLIP Text Encode (Prompt) - positive conditioning, KSampler node (ddim sampler, 30 steps, cfg 4.0, denoise 1.00), Load Checkpoint node (hayochamixIDXL_v11+_bl checkpoint), Load Image node (clipspace painted mask input), Save Image node (ComfyUI filename prefix), VAE Decode node, VAE Encode (for Inpainting) node with grow_mask_by (+1 more)
|
||||
|
||||
### Community 73 - "._bg_index_folder"
|
||||
Cohesion: 0.25
|
||||
Nodes (5): Event, バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。 10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。, PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す, PNGのtEXtチャンクからメタデータを抽出, JPEGのEXIF/commentからメタデータを抽出(簡易)
|
||||
|
||||
### Community 74 - "SettingsService"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over…, Merge updates into existing settings., Manages application settings (data/settings.json)., SettingsService
|
||||
|
||||
### Community 75 - "import_style_prompt_seed.py"
|
||||
Cohesion: 0.36
|
||||
Nodes (8): clean_tags(), import_category(), main(), parse_yaml_leaves(), Path, Style/Prompt ギャラリー用シードデータ投入スクリプト。 comfyui_prompt_gallery / prompt_builder_proto…, comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。 「key:」行の直後が「-…, 1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)
|
||||
|
||||
### Community 76 - "Image Edit (FireRed Image Edit 1.1) node"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): 3D Pose Editor node, FireRed-Image-Edit-1.1_fp8mixed_comfy.safetensors, FireRed-Image-Edit-1.0-Lightning-8steps-v1.0.safetensors (LoRA), Image Edit (FireRed Image Edit 1.1) node, qwen_2.5_vl_7b_fp8_scaled.safetensors (CLIP), qwen_image_vae.safetensors (VAE), Save Image node, 3D Pose Editor to Image Edit (Fire Red) Workflow
|
||||
|
||||
### Community 77 - "Version 0.3.9"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): Models Tab, Nodes Tab, Prompt Tab, Version 0.1.2, Version 0.1.7, Version 0.2.3, Version 0.3.9, Workflow Tab
|
||||
|
||||
### Community 78 - "DWPose Image Feeder Workflow"
|
||||
Cohesion: 0.52
|
||||
Nodes (7): DWPose Image Feeder Workflow, Apply ControlNet Node, DWPose Estimator Node (comfyui-controlnet-aux), Image Feeder Node (comfyui-image-feeder), KSampler Node, Load Checkpoint Node (animagineXLRealistic_v6), Load ControlNet Model Node (OpenPoseXL2)
|
||||
|
||||
### Community 79 - "civitai_service.py"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): _get_ssl_context(), _make_ssl_context(), CivitAI API integration service., Return an SSL context with CA verification. 1. certifi CA bundle (available in…, Download an image from URL and save to save_path. Returns True on success.
|
||||
|
||||
### Community 80 - "Doc Index Automation"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): on_doc_edit.py, DEVLOG.index.md, generate_doc_index.py, pre-commit, graphify, README.index.md
|
||||
|
||||
### Community 81 - "WFS_PromptText"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): WFS_PromptText – Prompt preset node with positive/negative STRING outputs., Outputs positive and negative prompt strings., WFS_PromptText
|
||||
|
||||
### Community 82 - "workflow_service.py"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): extract_png_workflow(), Extract ComfyUI workflow JSON from PNG metadata., Extract ComfyUI workflow JSON from PNG byte data. Returns dict or None., Workflow CRUD and metadata management service.
|
||||
|
||||
### Community 83 - "Project Directory Layout"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): ComfyUI, SPA, static, templates, web/comfyui
|
||||
|
||||
### Community 84 - "config.py"
|
||||
Cohesion: 0.40
|
||||
Nodes (3): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Settings management service.
|
||||
|
||||
### Community 85 - "Tagger Tab requirements.txt"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Tagger Tab requirements.txt, onnxruntime (WD Tagger / SwinV2), piexif (JPEG EXIF tag writing), python-dotenv (Unsloth .env loading), tensorflow (optional, DeepDanbooru)
|
||||
|
||||
### Community 86 - "Data Storage Files"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): data directory, metadata.json, node_metadata.json, settings.json
|
||||
|
||||
### Community 87 - "AI Tool Version History"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): AI TOOL Tab, Version 0.3.14, Version 0.3.99
|
||||
|
||||
### Community 88 - "Chinese Language Support"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): Chinese Language Support, English Language Support, Japanese Language Support
|
||||
|
||||
### Community 89 - "Version 0.3.95"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): ImagePrompt Gallery Subtab, Style_Catalog Subtab, Version 0.3.95
|
||||
|
||||
### Community 90 - "Windows Long Path Handling"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): LongPathsEnabled, MAX_PATH, Windows
|
||||
|
||||
## Knowledge Gaps
|
||||
- **228 isolated node(s):** `state`, `groups`, `state`, `WF_RESERVED_GROUPS`, `AI_BACKEND_DEFAULT_URLS` (+223 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **78 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `showToast()` connect `Generate & Export Utilities` to `models-tab.js`, `prompt-tab.js`, `generate-tab.js`, `lab-tab.js`, `gallery-tab.js`, `ImageEditTab`, `ai-tab.js`, `feeder-tab.js`, `workflow-tab.js`, `t`, `comfyui-editor.js`, `settings-tab.js`, `LayerManager`, `image-prompt-tab.js`, `ComfyUI Client & Mask Bridge`, `style-catalog-tab.js`, `BlurTool`, `BgRemove`, `app.js`, `InpaintI2IActions`, `Sam3Segmentation`?**
|
||||
_High betweenness centrality (0.062) - this node is a cross-community bridge._
|
||||
- **Why does `t()` connect `models-tab.js` to `prompt-tab.js`, `app.js`, `generate-tab.js`, `lab-tab.js`, `gallery-tab.js`, `ai-tab.js`, `Generate & Export Utilities`, `feeder-tab.js`, `image-prompt-tab.js`, `style-catalog-tab.js`, `metadata-tab.js`, `workflow-tab.js`, `t`, `comfyui-editor.js`, `i18n.js`, `settings-tab.js`?**
|
||||
_High betweenness centrality (0.048) - this node is a cross-community bridge._
|
||||
- **Why does `GalleryService` connect `GalleryService` to `gallery_routes.py`, `.get_image_prompt_root`, `gallery_service.py`, `.get_style_catalog_root`, `._bg_index_folder`, `.start_background_index`, `.list_images`, `GalleryMetadataStore`?**
|
||||
_High betweenness centrality (0.027) - this node is a cross-community bridge._
|
||||
- **What connects `state`, `groups`, `state` to the rest of the system?**
|
||||
_228 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `models-tab.js` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.06185919343814081 - nodes in this community are weakly interconnected._
|
||||
- **Should `prompt-tab.js` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05572545022086307 - nodes in this community are weakly interconnected._
|
||||
- **Should `models_routes.py` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.0554954954954955 - nodes in this community are weakly interconnected._
|
||||
@@ -1,866 +0,0 @@
|
||||
{
|
||||
"CLAUDE.md": {
|
||||
"mtime": 1787279658.5963283,
|
||||
"seen": 1787380810.3754587,
|
||||
"ast_hash": "b2c014309df240903aa90a26914c8c19",
|
||||
"semantic_hash": "b2c014309df240903aa90a26914c8c19"
|
||||
},
|
||||
".claude/settings.json": {
|
||||
"mtime": 1787279595.344592,
|
||||
"seen": 1787380810.2925637,
|
||||
"ast_hash": "cd2c4308b55c5d1aa1b647cdec814b79",
|
||||
"semantic_hash": "cd2c4308b55c5d1aa1b647cdec814b79"
|
||||
},
|
||||
"__init__.py": {
|
||||
"mtime": 1781939762.254596,
|
||||
"seen": 1787279199.0011544,
|
||||
"ast_hash": "72d9ef06026dff7be74da81ff98a306a",
|
||||
"semantic_hash": "72d9ef06026dff7be74da81ff98a306a"
|
||||
},
|
||||
"prestartup_script.py": {
|
||||
"mtime": 1787036267.332648,
|
||||
"seen": 1787279199.0011563,
|
||||
"ast_hash": "7b5d482ef21a480d1b38356e8d82970b",
|
||||
"semantic_hash": "7b5d482ef21a480d1b38356e8d82970b"
|
||||
},
|
||||
"py/__init__.py": {
|
||||
"mtime": 1773797715.629029,
|
||||
"seen": 1787279199.0011575,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/config.py": {
|
||||
"mtime": 1786245317.3352194,
|
||||
"seen": 1787279199.001159,
|
||||
"ast_hash": "b013c27bb7cd9a79449961539fdcd30f",
|
||||
"semantic_hash": "b013c27bb7cd9a79449961539fdcd30f"
|
||||
},
|
||||
"py/nodes/__init__.py": {
|
||||
"mtime": 1774423045.6646452,
|
||||
"seen": 1787279199.00116,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/nodes/gallery_feeder_node.py": {
|
||||
"mtime": 1781943269.5205867,
|
||||
"seen": 1787279199.0011609,
|
||||
"ast_hash": "d37c520b0f76e70e60ec7c72db904bf4",
|
||||
"semantic_hash": "d37c520b0f76e70e60ec7c72db904bf4"
|
||||
},
|
||||
"py/nodes/prompt_text.py": {
|
||||
"mtime": 1774423045.6669831,
|
||||
"seen": 1787279199.0011618,
|
||||
"ast_hash": "43cf16f182b9eda5fade2b52c2439372",
|
||||
"semantic_hash": "43cf16f182b9eda5fade2b52c2439372"
|
||||
},
|
||||
"py/routes/__init__.py": {
|
||||
"mtime": 1773797716.663036,
|
||||
"seen": 1787279199.0011628,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/routes/eagle_routes.py": {
|
||||
"mtime": 1785464911.9023821,
|
||||
"seen": 1787279199.0011635,
|
||||
"ast_hash": "edd32039935a892317c55b434c9839ab",
|
||||
"semantic_hash": "edd32039935a892317c55b434c9839ab"
|
||||
},
|
||||
"py/routes/gallery_routes.py": {
|
||||
"mtime": 1786682156.6899943,
|
||||
"seen": 1787279199.001165,
|
||||
"ast_hash": "59e4c5d34ee2329ab9e0a66f841d1676",
|
||||
"semantic_hash": "59e4c5d34ee2329ab9e0a66f841d1676"
|
||||
},
|
||||
"py/routes/gmic_routes.py": {
|
||||
"mtime": 1782544784.03979,
|
||||
"seen": 1787279199.0011659,
|
||||
"ast_hash": "b909c65af6590c0ca058136cb0b9234e",
|
||||
"semantic_hash": "b909c65af6590c0ca058136cb0b9234e"
|
||||
},
|
||||
"py/routes/lab_routes.py": {
|
||||
"mtime": 1786322410.571655,
|
||||
"seen": 1787279199.0011666,
|
||||
"ast_hash": "48e94f71b11f2e494bb98bb7113e2baa",
|
||||
"semantic_hash": "48e94f71b11f2e494bb98bb7113e2baa"
|
||||
},
|
||||
"py/routes/models_routes.py": {
|
||||
"mtime": 1782289767.1073005,
|
||||
"seen": 1787279199.0011675,
|
||||
"ast_hash": "067aa9c89085fa3e077fdfacbb6be2cd",
|
||||
"semantic_hash": "067aa9c89085fa3e077fdfacbb6be2cd"
|
||||
},
|
||||
"py/routes/nodes_routes.py": {
|
||||
"mtime": 1774255599.0841086,
|
||||
"seen": 1787279199.0011685,
|
||||
"ast_hash": "dc5f7b12dce346631ff945c31bf03ac9",
|
||||
"semantic_hash": "dc5f7b12dce346631ff945c31bf03ac9"
|
||||
},
|
||||
"py/routes/ollama_routes.py": {
|
||||
"mtime": 1784126582.1613884,
|
||||
"seen": 1787279199.0011694,
|
||||
"ast_hash": "80552c0676f7bbd4209eb95cf7a03f5b",
|
||||
"semantic_hash": "80552c0676f7bbd4209eb95cf7a03f5b"
|
||||
},
|
||||
"py/routes/prompts_routes.py": {
|
||||
"mtime": 1774423045.6696556,
|
||||
"seen": 1787279199.0011702,
|
||||
"ast_hash": "53b5641f3ad6f65e8ae665601c4739d7",
|
||||
"semantic_hash": "53b5641f3ad6f65e8ae665601c4739d7"
|
||||
},
|
||||
"py/routes/settings_routes.py": {
|
||||
"mtime": 1786681377.025748,
|
||||
"seen": 1787279199.001171,
|
||||
"ast_hash": "bb2f332c04474ee39319c2d0b8107db9",
|
||||
"semantic_hash": "bb2f332c04474ee39319c2d0b8107db9"
|
||||
},
|
||||
"py/routes/skill_routes.py": {
|
||||
"mtime": 1785634791.033795,
|
||||
"seen": 1787279199.0011723,
|
||||
"ast_hash": "3318c506163c720c5412ec0f8ff3a28a",
|
||||
"semantic_hash": "3318c506163c720c5412ec0f8ff3a28a"
|
||||
},
|
||||
"py/routes/tagger_routes.py": {
|
||||
"mtime": 1781503496.8879485,
|
||||
"seen": 1787279199.001173,
|
||||
"ast_hash": "9c0559b594a69354882da0b1643e11f0",
|
||||
"semantic_hash": "9c0559b594a69354882da0b1643e11f0"
|
||||
},
|
||||
"py/routes/unsloth_routes.py": {
|
||||
"mtime": 1787036726.267709,
|
||||
"seen": 1787279199.001174,
|
||||
"ast_hash": "93d4de97937f472f9618a35a0b4211d6",
|
||||
"semantic_hash": "93d4de97937f472f9618a35a0b4211d6"
|
||||
},
|
||||
"py/routes/wildcard_routes.py": {
|
||||
"mtime": 1777370036.4121041,
|
||||
"seen": 1787279199.0011756,
|
||||
"ast_hash": "1d0ea981a44ffab77d62c214951f4536",
|
||||
"semantic_hash": "1d0ea981a44ffab77d62c214951f4536"
|
||||
},
|
||||
"py/routes/workflow_routes.py": {
|
||||
"mtime": 1773896239.095392,
|
||||
"seen": 1787279199.0011766,
|
||||
"ast_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3",
|
||||
"semantic_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3"
|
||||
},
|
||||
"py/services/__init__.py": {
|
||||
"mtime": 1773797717.739297,
|
||||
"seen": 1787279199.0011773,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/services/civitai_service.py": {
|
||||
"mtime": 1780233122.7587051,
|
||||
"seen": 1787279199.001178,
|
||||
"ast_hash": "3b4a2a118bc56dd27e768a2819b401e7",
|
||||
"semantic_hash": "3b4a2a118bc56dd27e768a2819b401e7"
|
||||
},
|
||||
"py/services/gallery_metadata.py": {
|
||||
"mtime": 1786170828.9941547,
|
||||
"seen": 1787279199.0011792,
|
||||
"ast_hash": "9d02a71d56b7ca2a88cdf8c56fadf912",
|
||||
"semantic_hash": "9d02a71d56b7ca2a88cdf8c56fadf912"
|
||||
},
|
||||
"py/services/gallery_service.py": {
|
||||
"mtime": 1786638406.9058151,
|
||||
"seen": 1787279199.0011802,
|
||||
"ast_hash": "c8b9858f015f7f4a934c4b630e905107",
|
||||
"semantic_hash": "c8b9858f015f7f4a934c4b630e905107"
|
||||
},
|
||||
"py/services/lab_plan_service.py": {
|
||||
"mtime": 1786322396.0192728,
|
||||
"seen": 1787279199.001181,
|
||||
"ast_hash": "5d2c928c077ad7b788f15419b39ef51f",
|
||||
"semantic_hash": "5d2c928c077ad7b788f15419b39ef51f"
|
||||
},
|
||||
"py/services/models_service.py": {
|
||||
"mtime": 1782393318.5989842,
|
||||
"seen": 1787279199.0011816,
|
||||
"ast_hash": "0885cc6852199fa45271192ea0d6713a",
|
||||
"semantic_hash": "0885cc6852199fa45271192ea0d6713a"
|
||||
},
|
||||
"py/services/nodes_service.py": {
|
||||
"mtime": 1774255599.0862236,
|
||||
"seen": 1787279199.0011828,
|
||||
"ast_hash": "8819590ffdc136fb2912d9c5696e26e0",
|
||||
"semantic_hash": "8819590ffdc136fb2912d9c5696e26e0"
|
||||
},
|
||||
"py/services/png_extractor.py": {
|
||||
"mtime": 1773797967.5810304,
|
||||
"seen": 1787279199.0011837,
|
||||
"ast_hash": "1282b6032d78cba2a4bbbfb876126bc1",
|
||||
"semantic_hash": "1282b6032d78cba2a4bbbfb876126bc1"
|
||||
},
|
||||
"py/services/prompts_service.py": {
|
||||
"mtime": 1774423045.6711988,
|
||||
"seen": 1787279199.0011845,
|
||||
"ast_hash": "79a98e220d2b556106e8530054547155",
|
||||
"semantic_hash": "79a98e220d2b556106e8530054547155"
|
||||
},
|
||||
"py/services/settings_service.py": {
|
||||
"mtime": 1773798498.9270506,
|
||||
"seen": 1787279199.0011854,
|
||||
"ast_hash": "dade5ea667a2c6408434ad3061c76db7",
|
||||
"semantic_hash": "dade5ea667a2c6408434ad3061c76db7"
|
||||
},
|
||||
"py/services/skill_service.py": {
|
||||
"mtime": 1785637306.8654232,
|
||||
"seen": 1787279199.0011866,
|
||||
"ast_hash": "5ebe579995c1c674e4219e58b3bd5855",
|
||||
"semantic_hash": "5ebe579995c1c674e4219e58b3bd5855"
|
||||
},
|
||||
"py/services/tagger_db_service.py": {
|
||||
"mtime": 1781503496.8949444,
|
||||
"seen": 1787279199.0011873,
|
||||
"ast_hash": "de0ca007db5159484e8ff3b8a36a3b84",
|
||||
"semantic_hash": "de0ca007db5159484e8ff3b8a36a3b84"
|
||||
},
|
||||
"py/services/tagger_service.py": {
|
||||
"mtime": 1781503496.8969479,
|
||||
"seen": 1787279199.0011883,
|
||||
"ast_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9",
|
||||
"semantic_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9"
|
||||
},
|
||||
"py/services/wildcard_service.py": {
|
||||
"mtime": 1777380000.0054882,
|
||||
"seen": 1787279199.0011892,
|
||||
"ast_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd",
|
||||
"semantic_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd"
|
||||
},
|
||||
"py/services/workflow_analyzer.py": {
|
||||
"mtime": 1785474766.4736228,
|
||||
"seen": 1787279199.00119,
|
||||
"ast_hash": "937d6b02a6235795cb22ceb39e81f719",
|
||||
"semantic_hash": "937d6b02a6235795cb22ceb39e81f719"
|
||||
},
|
||||
"py/services/workflow_service.py": {
|
||||
"mtime": 1775439760.4805496,
|
||||
"seen": 1787279199.0011911,
|
||||
"ast_hash": "a2e47c0b8780ec7ca292310b16733a8c",
|
||||
"semantic_hash": "a2e47c0b8780ec7ca292310b16733a8c"
|
||||
},
|
||||
"py/wfm.py": {
|
||||
"mtime": 1787036294.0749958,
|
||||
"seen": 1787279199.0011919,
|
||||
"ast_hash": "cb1c96536f67edb380d1010b6165d74d",
|
||||
"semantic_hash": "cb1c96536f67edb380d1010b6165d74d"
|
||||
},
|
||||
"pyproject.toml": {
|
||||
"mtime": 1787378263.2900941,
|
||||
"seen": 1787380810.3265483,
|
||||
"ast_hash": "f514fae02a3b48726f5073e932cfa1b3",
|
||||
"semantic_hash": "f514fae02a3b48726f5073e932cfa1b3"
|
||||
},
|
||||
"static/js/ai-tab.js": {
|
||||
"mtime": 1787293482.4888227,
|
||||
"seen": 1787380810.3265514,
|
||||
"ast_hash": "960b474155386a36000e6961b0b2e0c3",
|
||||
"semantic_hash": "960b474155386a36000e6961b0b2e0c3"
|
||||
},
|
||||
"static/js/app.js": {
|
||||
"mtime": 1787377516.2436306,
|
||||
"seen": 1787380810.3265538,
|
||||
"ast_hash": "7df994a3e8f6c34d27a1ccd88b207a5c",
|
||||
"semantic_hash": "7df994a3e8f6c34d27a1ccd88b207a5c"
|
||||
},
|
||||
"static/js/comfyui-client.js": {
|
||||
"mtime": 1786676208.2330563,
|
||||
"seen": 1787279199.0011961,
|
||||
"ast_hash": "bb546a6ae7bd35d1c16fee311681be73",
|
||||
"semantic_hash": "bb546a6ae7bd35d1c16fee311681be73"
|
||||
},
|
||||
"static/js/comfyui-editor.js": {
|
||||
"mtime": 1787293462.7015421,
|
||||
"seen": 1787380810.3274822,
|
||||
"ast_hash": "fe27626a41d18f02bdea2b89aaaf740b",
|
||||
"semantic_hash": "fe27626a41d18f02bdea2b89aaaf740b"
|
||||
},
|
||||
"static/js/comfyui-workflow.js": {
|
||||
"mtime": 1787293410.2500806,
|
||||
"seen": 1787380810.3274848,
|
||||
"ast_hash": "255cfe0c2172639ed698a5e9397d7144",
|
||||
"semantic_hash": "255cfe0c2172639ed698a5e9397d7144"
|
||||
},
|
||||
"static/js/feeder-tab.js": {
|
||||
"mtime": 1781939989.0796032,
|
||||
"seen": 1787279199.0011988,
|
||||
"ast_hash": "dcd5977c5fe691b87e2379cd0e018a12",
|
||||
"semantic_hash": "dcd5977c5fe691b87e2379cd0e018a12"
|
||||
},
|
||||
"static/js/gallery-tab.js": {
|
||||
"mtime": 1787368748.1550386,
|
||||
"seen": 1787380810.328425,
|
||||
"ast_hash": "0144bbe48e21eccc498714404e29e29e",
|
||||
"semantic_hash": "0144bbe48e21eccc498714404e29e29e"
|
||||
},
|
||||
"static/js/generate-tab.js": {
|
||||
"mtime": 1787369336.5249329,
|
||||
"seen": 1787380810.3284276,
|
||||
"ast_hash": "14b3ced4743af977596001606be49000",
|
||||
"semantic_hash": "14b3ced4743af977596001606be49000"
|
||||
},
|
||||
"static/js/i18n.js": {
|
||||
"mtime": 1787377822.448605,
|
||||
"seen": 1787380810.32843,
|
||||
"ast_hash": "d925b681cc485326659fbc24f5a87dda",
|
||||
"semantic_hash": "d925b681cc485326659fbc24f5a87dda"
|
||||
},
|
||||
"static/js/image-edit-tab.js": {
|
||||
"mtime": 1787199901.562169,
|
||||
"seen": 1787279199.001202,
|
||||
"ast_hash": "be7b15d85bb185e2fd3492f4d5657f21",
|
||||
"semantic_hash": "be7b15d85bb185e2fd3492f4d5657f21"
|
||||
},
|
||||
"static/js/image-edit/BgRemove.js": {
|
||||
"mtime": 1787126632.8601232,
|
||||
"seen": 1787279199.0012033,
|
||||
"ast_hash": "add6a58068bdadc0c7641202f36ddb27",
|
||||
"semantic_hash": "add6a58068bdadc0c7641202f36ddb27"
|
||||
},
|
||||
"static/js/image-edit/BlurTool.js": {
|
||||
"mtime": 1787126263.0542421,
|
||||
"seen": 1787279199.0012043,
|
||||
"ast_hash": "1e52bc3757110a271ec17d923784a91b",
|
||||
"semantic_hash": "1e52bc3757110a271ec17d923784a91b"
|
||||
},
|
||||
"static/js/image-edit/DrawTool.js": {
|
||||
"mtime": 1782717315.6436062,
|
||||
"seen": 1787279199.001205,
|
||||
"ast_hash": "a97a6a97ae28c804a7c5fa50448a2c87",
|
||||
"semantic_hash": "a97a6a97ae28c804a7c5fa50448a2c87"
|
||||
},
|
||||
"static/js/image-edit/FileExport.js": {
|
||||
"mtime": 1787192008.741717,
|
||||
"seen": 1787279199.0012057,
|
||||
"ast_hash": "cf645b775e537ad276c9e070c7849349",
|
||||
"semantic_hash": "cf645b775e537ad276c9e070c7849349"
|
||||
},
|
||||
"static/js/image-edit/GmicIntegration.js": {
|
||||
"mtime": 1787125777.3889306,
|
||||
"seen": 1787279199.0012069,
|
||||
"ast_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca",
|
||||
"semantic_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca"
|
||||
},
|
||||
"static/js/image-edit/InpaintI2IActions.js": {
|
||||
"mtime": 1787127273.4351766,
|
||||
"seen": 1787279199.0012076,
|
||||
"ast_hash": "9ea57922985b63d4ec8eb44219a8341b",
|
||||
"semantic_hash": "9ea57922985b63d4ec8eb44219a8341b"
|
||||
},
|
||||
"static/js/image-edit/LayerManager.js": {
|
||||
"mtime": 1782717943.772222,
|
||||
"seen": 1787279199.0012085,
|
||||
"ast_hash": "596cd1906208fe72e1c2953d4d7f7135",
|
||||
"semantic_hash": "596cd1906208fe72e1c2953d4d7f7135"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneBridge.js": {
|
||||
"mtime": 1787199887.897264,
|
||||
"seen": 1787279199.0012095,
|
||||
"ast_hash": "db2ef8c024c7e72baa0dfdb79b8abd4d",
|
||||
"semantic_hash": "db2ef8c024c7e72baa0dfdb79b8abd4d"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneTools.js": {
|
||||
"mtime": 1782719665.7021136,
|
||||
"seen": 1787279199.0012105,
|
||||
"ast_hash": "b6b16c43ce528f7c9921eab0f4eda335",
|
||||
"semantic_hash": "b6b16c43ce528f7c9921eab0f4eda335"
|
||||
},
|
||||
"static/js/image-edit/MaskTool.js": {
|
||||
"mtime": 1782699721.4711535,
|
||||
"seen": 1787279199.0012114,
|
||||
"ast_hash": "116469f33829c5b2e6270f4b278b2b9c",
|
||||
"semantic_hash": "116469f33829c5b2e6270f4b278b2b9c"
|
||||
},
|
||||
"static/js/image-edit/Sam3Segmentation.js": {
|
||||
"mtime": 1787126893.774897,
|
||||
"seen": 1787279199.0012121,
|
||||
"ast_hash": "98c3a330ae7ea3369c00168c54c66d53",
|
||||
"semantic_hash": "98c3a330ae7ea3369c00168c54c66d53"
|
||||
},
|
||||
"static/js/image-edit/SelectTool.js": {
|
||||
"mtime": 1782368193.2572634,
|
||||
"seen": 1787279199.0012128,
|
||||
"ast_hash": "cb8db6bd5ee742b15f3beee4a5173377",
|
||||
"semantic_hash": "cb8db6bd5ee742b15f3beee4a5173377"
|
||||
},
|
||||
"static/js/image-edit/ShapeTool.js": {
|
||||
"mtime": 1782375200.0253434,
|
||||
"seen": 1787279199.001214,
|
||||
"ast_hash": "36d00bf6b80ebea13314d2853528c44f",
|
||||
"semantic_hash": "36d00bf6b80ebea13314d2853528c44f"
|
||||
},
|
||||
"static/js/image-edit/TextTool.js": {
|
||||
"mtime": 1782368193.2592669,
|
||||
"seen": 1787279199.0012147,
|
||||
"ast_hash": "88f9e60251c2f78957e42c33ed5196f8",
|
||||
"semantic_hash": "88f9e60251c2f78957e42c33ed5196f8"
|
||||
},
|
||||
"static/js/image-prompt-tab.js": {
|
||||
"mtime": 1786638406.9362252,
|
||||
"seen": 1787279199.0012157,
|
||||
"ast_hash": "cf39285a30e3f8b79f6f51dfe13f86b5",
|
||||
"semantic_hash": "cf39285a30e3f8b79f6f51dfe13f86b5"
|
||||
},
|
||||
"static/js/json-highlight.js": {
|
||||
"mtime": 1773834925.1977189,
|
||||
"seen": 1787279199.0012164,
|
||||
"ast_hash": "e83e7a7a507d160b9cd503235a79135c",
|
||||
"semantic_hash": "e83e7a7a507d160b9cd503235a79135c"
|
||||
},
|
||||
"static/js/lab-tab.js": {
|
||||
"mtime": 1787310963.3056493,
|
||||
"seen": 1787380810.343902,
|
||||
"ast_hash": "11a5776c4163e2a97ef490e85de7db63",
|
||||
"semantic_hash": "11a5776c4163e2a97ef490e85de7db63"
|
||||
},
|
||||
"static/js/metadata-tab.js": {
|
||||
"mtime": 1787377525.9536967,
|
||||
"seen": 1787380810.3439052,
|
||||
"ast_hash": "5ac004dd3708aa4dc8890256232fd99e",
|
||||
"semantic_hash": "5ac004dd3708aa4dc8890256232fd99e"
|
||||
},
|
||||
"static/js/models-tab.js": {
|
||||
"mtime": 1787145102.2314196,
|
||||
"seen": 1787279199.001219,
|
||||
"ast_hash": "dcee8a68713cd300d0862b1c376d442f",
|
||||
"semantic_hash": "dcee8a68713cd300d0862b1c376d442f"
|
||||
},
|
||||
"static/js/models/badges.js": {
|
||||
"mtime": 1787143926.3653927,
|
||||
"seen": 1787279199.00122,
|
||||
"ast_hash": "0c1901a90a12f1b23df416d9eef11e1d",
|
||||
"semantic_hash": "0c1901a90a12f1b23df416d9eef11e1d"
|
||||
},
|
||||
"static/js/models/detail-panel.js": {
|
||||
"mtime": 1787144898.6748104,
|
||||
"seen": 1787279199.0012212,
|
||||
"ast_hash": "9907a4af0524b35d80e3b9fc7925324e",
|
||||
"semantic_hash": "9907a4af0524b35d80e3b9fc7925324e"
|
||||
},
|
||||
"static/js/models/filters.js": {
|
||||
"mtime": 1787144201.7602522,
|
||||
"seen": 1787279199.001222,
|
||||
"ast_hash": "afd754f68d01f64c51995929b3841e56",
|
||||
"semantic_hash": "afd754f68d01f64c51995929b3841e56"
|
||||
},
|
||||
"static/js/models/grid-view.js": {
|
||||
"mtime": 1787144745.5522888,
|
||||
"seen": 1787279199.0012226,
|
||||
"ast_hash": "492e91fff2b8743a5243f85a736eb689",
|
||||
"semantic_hash": "492e91fff2b8743a5243f85a736eb689"
|
||||
},
|
||||
"static/js/models/helpers.js": {
|
||||
"mtime": 1787144193.741004,
|
||||
"seen": 1787279199.0012236,
|
||||
"ast_hash": "1c7b796f2507f55403fa5d11f304580a",
|
||||
"semantic_hash": "1c7b796f2507f55403fa5d11f304580a"
|
||||
},
|
||||
"static/js/models/selection-bulk.js": {
|
||||
"mtime": 1787144787.0092065,
|
||||
"seen": 1787279199.0012245,
|
||||
"ast_hash": "d445ff4dc4e9810afea73ff53c76b05b",
|
||||
"semantic_hash": "d445ff4dc4e9810afea73ff53c76b05b"
|
||||
},
|
||||
"static/js/models/state.js": {
|
||||
"mtime": 1787143674.705312,
|
||||
"seen": 1787279199.0012252,
|
||||
"ast_hash": "172ced5fdc4c96095f2760d7ef12d77d",
|
||||
"semantic_hash": "172ced5fdc4c96095f2760d7ef12d77d"
|
||||
},
|
||||
"static/js/nodes-tab.js": {
|
||||
"mtime": 1782619197.3828287,
|
||||
"seen": 1787279199.0012262,
|
||||
"ast_hash": "3467d4682a8c20a2b209b5db224ce6e2",
|
||||
"semantic_hash": "3467d4682a8c20a2b209b5db224ce6e2"
|
||||
},
|
||||
"static/js/prompt-tab.js": {
|
||||
"mtime": 1787373450.5713615,
|
||||
"seen": 1787380810.352496,
|
||||
"ast_hash": "6d2e3cd1bbf2e99a5298cfb502a18d9e",
|
||||
"semantic_hash": "6d2e3cd1bbf2e99a5298cfb502a18d9e"
|
||||
},
|
||||
"static/js/settings-tab.js": {
|
||||
"mtime": 1787227602.6323576,
|
||||
"seen": 1787279199.001228,
|
||||
"ast_hash": "85b55cac9901afc9e94e32f0aa220d2b",
|
||||
"semantic_hash": "85b55cac9901afc9e94e32f0aa220d2b"
|
||||
},
|
||||
"static/js/style-catalog-tab.js": {
|
||||
"mtime": 1786678976.6511285,
|
||||
"seen": 1787279199.001229,
|
||||
"ast_hash": "a2956544bf800ac34f8084b8acd0714b",
|
||||
"semantic_hash": "a2956544bf800ac34f8084b8acd0714b"
|
||||
},
|
||||
"static/js/tagger-tab.js": {
|
||||
"mtime": 1781503496.933307,
|
||||
"seen": 1787279199.0012298,
|
||||
"ast_hash": "5e1c858a13e95148eeac1d5b5be5e4f3",
|
||||
"semantic_hash": "5e1c858a13e95148eeac1d5b5be5e4f3"
|
||||
},
|
||||
"static/js/util.js": {
|
||||
"mtime": 1787036300.7369778,
|
||||
"seen": 1787279199.001231,
|
||||
"ast_hash": "29774ed440f2b60a708c70db1b3ae208",
|
||||
"semantic_hash": "29774ed440f2b60a708c70db1b3ae208"
|
||||
},
|
||||
"static/js/workflow-tab.js": {
|
||||
"mtime": 1785474064.011484,
|
||||
"seen": 1787279199.001232,
|
||||
"ast_hash": "65caea21e8e02d81f2e85c75c7231dca",
|
||||
"semantic_hash": "65caea21e8e02d81f2e85c75c7231dca"
|
||||
},
|
||||
"tools/import_style_prompt_seed.py": {
|
||||
"mtime": 1786616195.405381,
|
||||
"seen": 1787279199.0012326,
|
||||
"ast_hash": "02997888c17a6061b7910cddfdd4d89a",
|
||||
"semantic_hash": "02997888c17a6061b7910cddfdd4d89a"
|
||||
},
|
||||
"web/comfyui/gallery_feeder_extension.js": {
|
||||
"mtime": 1781943835.232655,
|
||||
"seen": 1787279199.0012345,
|
||||
"ast_hash": "0048b031e64a433e29a5f7112e82012d",
|
||||
"semantic_hash": "0048b031e64a433e29a5f7112e82012d"
|
||||
},
|
||||
"web/comfyui/node_sets_menu.js": {
|
||||
"mtime": 1787199930.9575465,
|
||||
"seen": 1787279199.0012355,
|
||||
"ast_hash": "a9d7ad5d194ac2f4b125978a7b048042",
|
||||
"semantic_hash": "a9d7ad5d194ac2f4b125978a7b048042"
|
||||
},
|
||||
"web/comfyui/top_menu_extension.js": {
|
||||
"mtime": 1782696309.9055538,
|
||||
"seen": 1787279199.0012362,
|
||||
"ast_hash": "2c74498722b46d42bcc15f61ec35dfc4",
|
||||
"semantic_hash": "2c74498722b46d42bcc15f61ec35dfc4"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).json": {
|
||||
"mtime": 1779256606.5720088,
|
||||
"seen": 1787279199.0012372,
|
||||
"ast_hash": "52801ad2b42eca5e3a5f8fc51332d2c3",
|
||||
"semantic_hash": "52801ad2b42eca5e3a5f8fc51332d2c3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).json": {
|
||||
"mtime": 1779257129.3572881,
|
||||
"seen": 1787279199.001238,
|
||||
"ast_hash": "8260ca467693c21c497f98113abf37f9",
|
||||
"semantic_hash": "8260ca467693c21c497f98113abf37f9"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).json": {
|
||||
"mtime": 1779257864.2496417,
|
||||
"seen": 1787279199.001239,
|
||||
"ast_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e",
|
||||
"semantic_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.json": {
|
||||
"mtime": 1779255324.1402953,
|
||||
"seen": 1787279199.0012398,
|
||||
"ast_hash": "282ce14b11b8b88ddc91e1d318756c0d",
|
||||
"semantic_hash": "282ce14b11b8b88ddc91e1d318756c0d"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.json": {
|
||||
"mtime": 1784801838.7836998,
|
||||
"seen": 1787279199.0012407,
|
||||
"ast_hash": "43e83496dec4856ab9d97c8b950148ec",
|
||||
"semantic_hash": "43e83496dec4856ab9d97c8b950148ec"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.json": {
|
||||
"mtime": 1784801946.0336668,
|
||||
"seen": 1787279199.001242,
|
||||
"ast_hash": "ac0a865c1dacd2b30c489f08f8b4df4d",
|
||||
"semantic_hash": "ac0a865c1dacd2b30c489f08f8b4df4d"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.json": {
|
||||
"mtime": 1779253098.1947012,
|
||||
"seen": 1787279199.0012426,
|
||||
"ast_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51",
|
||||
"semantic_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.json": {
|
||||
"mtime": 1779256158.4671156,
|
||||
"seen": 1787279199.0012436,
|
||||
"ast_hash": "cfe237775d6fb844ac726639e3ae928b",
|
||||
"semantic_hash": "cfe237775d6fb844ac726639e3ae928b"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.json": {
|
||||
"mtime": 1779255768.6302803,
|
||||
"seen": 1787279199.0012448,
|
||||
"ast_hash": "0e1955da91149ba6280d39db7a54db6f",
|
||||
"semantic_hash": "0e1955da91149ba6280d39db7a54db6f"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.json": {
|
||||
"mtime": 1779255037.8698418,
|
||||
"seen": 1787279199.0012455,
|
||||
"ast_hash": "d670222d71df92e262ce8cb7299fd531",
|
||||
"semantic_hash": "d670222d71df92e262ce8cb7299fd531"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.json": {
|
||||
"mtime": 1779254360.9712114,
|
||||
"seen": 1787279199.0012462,
|
||||
"ast_hash": "463fca53dd0a4030555ff639661f2772",
|
||||
"semantic_hash": "463fca53dd0a4030555ff639661f2772"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.json": {
|
||||
"mtime": 1779253668.0067616,
|
||||
"seen": 1787279199.0012474,
|
||||
"ast_hash": "e2c677ee6670e37b466829c43ff9cd96",
|
||||
"semantic_hash": "e2c677ee6670e37b466829c43ff9cd96"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.json": {
|
||||
"mtime": 1779254714.0149894,
|
||||
"seen": 1787279199.0012484,
|
||||
"ast_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79",
|
||||
"semantic_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.json": {
|
||||
"mtime": 1779253180.3940487,
|
||||
"seen": 1787279199.0012493,
|
||||
"ast_hash": "beacbe857606b44d13aa2fc2f4872b76",
|
||||
"semantic_hash": "beacbe857606b44d13aa2fc2f4872b76"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.json": {
|
||||
"mtime": 1779253367.7975926,
|
||||
"seen": 1787279199.00125,
|
||||
"ast_hash": "0706c31d93f980b581e0e6eb376c0bcc",
|
||||
"semantic_hash": "0706c31d93f980b581e0e6eb376c0bcc"
|
||||
},
|
||||
"DEVLOG.index.md": {
|
||||
"mtime": 1787294395.7691903,
|
||||
"seen": 1787294443.2937605,
|
||||
"ast_hash": "34f4730b81cef4d6393b5867945e41de",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"README.index.md": {
|
||||
"mtime": 1787294395.7601545,
|
||||
"seen": 1787294443.293762,
|
||||
"ast_hash": "ccb030b076d60806eaff45744274cdfe",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"requirements.txt": {
|
||||
"mtime": 1787036261.1817627,
|
||||
"seen": 1787279199.0012598,
|
||||
"ast_hash": "5942e1febf9611227a9145b3f9a91c6e",
|
||||
"semantic_hash": "5942e1febf9611227a9145b3f9a91c6e"
|
||||
},
|
||||
"assets/banner.svg": {
|
||||
"mtime": 1776081443.419675,
|
||||
"seen": 1787279199.0012608,
|
||||
"ast_hash": "059ec317eaba9e353d220d883477927a",
|
||||
"semantic_hash": "059ec317eaba9e353d220d883477927a"
|
||||
},
|
||||
"assets/icon.svg": {
|
||||
"mtime": 1776081443.4219253,
|
||||
"seen": 1787279199.0012615,
|
||||
"ast_hash": "9689aa0178236c19d5240250e08a303e",
|
||||
"semantic_hash": "9689aa0178236c19d5240250e08a303e"
|
||||
},
|
||||
"docs/10_GenUI_Batch.png": {
|
||||
"mtime": 1781057457.561953,
|
||||
"seen": 1787279199.0012627,
|
||||
"ast_hash": "351c1647aa5becfb4a2dbfa7da043b24",
|
||||
"semantic_hash": "351c1647aa5becfb4a2dbfa7da043b24"
|
||||
},
|
||||
"docs/11_multiple_select_menu.png": {
|
||||
"mtime": 1781057528.0140572,
|
||||
"seen": 1787279199.0012636,
|
||||
"ast_hash": "b3082324b8200a4eac8868f94ec4e6c7",
|
||||
"semantic_hash": "b3082324b8200a4eac8868f94ec4e6c7"
|
||||
},
|
||||
"docs/12_Tagger.png": {
|
||||
"mtime": 1782551031.7107866,
|
||||
"seen": 1787279199.0012643,
|
||||
"ast_hash": "42806369c4c08140c87daea348b6fc0f",
|
||||
"semantic_hash": "42806369c4c08140c87daea348b6fc0f"
|
||||
},
|
||||
"docs/13_ImageEdit.png": {
|
||||
"mtime": 1782550946.6263509,
|
||||
"seen": 1787279199.0012655,
|
||||
"ast_hash": "25a313806ba3c2396c9361b42599b063",
|
||||
"semantic_hash": "25a313806ba3c2396c9361b42599b063"
|
||||
},
|
||||
"docs/1_workflowtab.png": {
|
||||
"mtime": 1779078100.5980048,
|
||||
"seen": 1787279199.0012662,
|
||||
"ast_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297",
|
||||
"semantic_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297"
|
||||
},
|
||||
"docs/2_modelstab.png": {
|
||||
"mtime": 1779077971.8302422,
|
||||
"seen": 1787279199.0012672,
|
||||
"ast_hash": "2c89e877f01e8de3151e655f305df6aa",
|
||||
"semantic_hash": "2c89e877f01e8de3151e655f305df6aa"
|
||||
},
|
||||
"docs/3_PromptInputAssistance.png": {
|
||||
"mtime": 1779077804.0445502,
|
||||
"seen": 1787279199.001268,
|
||||
"ast_hash": "6634ec20d561648ce223807d2fb010cc",
|
||||
"semantic_hash": "6634ec20d561648ce223807d2fb010cc"
|
||||
},
|
||||
"docs/4_GenUI_feeder.png": {
|
||||
"mtime": 1779077729.1484556,
|
||||
"seen": 1787279199.001269,
|
||||
"ast_hash": "e6b3607b7c53a2f601aff487c278a3fb",
|
||||
"semantic_hash": "e6b3607b7c53a2f601aff487c278a3fb"
|
||||
},
|
||||
"docs/5_topbar.png": {
|
||||
"mtime": 1779076905.6012611,
|
||||
"seen": 1787279199.0012698,
|
||||
"ast_hash": "bafc74e7452f83ae9c585aa11f76f208",
|
||||
"semantic_hash": "bafc74e7452f83ae9c585aa11f76f208"
|
||||
},
|
||||
"docs/6_ws_library.png": {
|
||||
"mtime": 1781057870.2308166,
|
||||
"seen": 1787279199.0012705,
|
||||
"ast_hash": "6645a6c3d9ea09d75d34255c76cfc68b",
|
||||
"semantic_hash": "6645a6c3d9ea09d75d34255c76cfc68b"
|
||||
},
|
||||
"docs/7_library_Infomation.png": {
|
||||
"mtime": 1779076410.2629254,
|
||||
"seen": 1787279199.0012715,
|
||||
"ast_hash": "acb54f299c244fd41457f8fe4ccfc1d6",
|
||||
"semantic_hash": "acb54f299c244fd41457f8fe4ccfc1d6"
|
||||
},
|
||||
"docs/8_Customize.png": {
|
||||
"mtime": 1779077388.8494852,
|
||||
"seen": 1787279199.0012722,
|
||||
"ast_hash": "c9f979d40753ce41c015b2d594007658",
|
||||
"semantic_hash": "c9f979d40753ce41c015b2d594007658"
|
||||
},
|
||||
"docs/9_GenUI_LoraStack.png": {
|
||||
"mtime": 1781057821.3736062,
|
||||
"seen": 1787279199.0012734,
|
||||
"ast_hash": "e2b08e5f2059554d305563c63286bd74",
|
||||
"semantic_hash": "e2b08e5f2059554d305563c63286bd74"
|
||||
},
|
||||
"docs/thumb.png": {
|
||||
"mtime": 1778457744.7955222,
|
||||
"seen": 1787279199.0012743,
|
||||
"ast_hash": "92a0dcf2c3457777f2945f6df2dff017",
|
||||
"semantic_hash": "92a0dcf2c3457777f2945f6df2dff017"
|
||||
},
|
||||
"static/favicon.svg": {
|
||||
"mtime": 1773877378.0368707,
|
||||
"seen": 1787279199.0012753,
|
||||
"ast_hash": "0cd547c25c922d632890be287d6084ff",
|
||||
"semantic_hash": "0cd547c25c922d632890be287d6084ff"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).png": {
|
||||
"mtime": 1779256606.562932,
|
||||
"seen": 1787279199.0012763,
|
||||
"ast_hash": "79b9d1e5e1303b523349b539d3dc1af3",
|
||||
"semantic_hash": "79b9d1e5e1303b523349b539d3dc1af3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).png": {
|
||||
"mtime": 1779257129.3453796,
|
||||
"seen": 1787279199.001277,
|
||||
"ast_hash": "02c572ab1c659d71dfd56e6b2b0334a5",
|
||||
"semantic_hash": "02c572ab1c659d71dfd56e6b2b0334a5"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).png": {
|
||||
"mtime": 1779257864.2375357,
|
||||
"seen": 1787279199.001278,
|
||||
"ast_hash": "bb03ed86c7d4460a3568a4a8f90e5f40",
|
||||
"semantic_hash": "bb03ed86c7d4460a3568a4a8f90e5f40"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.png": {
|
||||
"mtime": 1779255324.1356418,
|
||||
"seen": 1787279199.0012789,
|
||||
"ast_hash": "e5db6889f59078e4ead6b89b8c07e91e",
|
||||
"semantic_hash": "e5db6889f59078e4ead6b89b8c07e91e"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.png": {
|
||||
"mtime": 1784801838.7777002,
|
||||
"seen": 1787279199.00128,
|
||||
"ast_hash": "79321edb85aed7c0a8c777accc005486",
|
||||
"semantic_hash": "79321edb85aed7c0a8c777accc005486"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.png": {
|
||||
"mtime": 1784801946.027666,
|
||||
"seen": 1787279199.001281,
|
||||
"ast_hash": "bdd62facd6a8176c50e92264994dbbc2",
|
||||
"semantic_hash": "bdd62facd6a8176c50e92264994dbbc2"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.png": {
|
||||
"mtime": 1779253098.1897035,
|
||||
"seen": 1787279199.0012817,
|
||||
"ast_hash": "9582bb698439fff1a748315145538777",
|
||||
"semantic_hash": "9582bb698439fff1a748315145538777"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.png": {
|
||||
"mtime": 1779256158.4621174,
|
||||
"seen": 1787279199.001283,
|
||||
"ast_hash": "bb7b9e26eae992abe62cd50299da9cb2",
|
||||
"semantic_hash": "bb7b9e26eae992abe62cd50299da9cb2"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.png": {
|
||||
"mtime": 1779255768.6191878,
|
||||
"seen": 1787279199.0012836,
|
||||
"ast_hash": "ac0fc8184ff7e42389fd0d562daa88c4",
|
||||
"semantic_hash": "ac0fc8184ff7e42389fd0d562daa88c4"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.png": {
|
||||
"mtime": 1779255037.8606594,
|
||||
"seen": 1787279199.0012846,
|
||||
"ast_hash": "564f58d85a0b960154bd0a30ea111599",
|
||||
"semantic_hash": "564f58d85a0b960154bd0a30ea111599"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.png": {
|
||||
"mtime": 1779254360.9671092,
|
||||
"seen": 1787279199.0012853,
|
||||
"ast_hash": "98dfdb3ba374b0952c06995843f45171",
|
||||
"semantic_hash": "98dfdb3ba374b0952c06995843f45171"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.png": {
|
||||
"mtime": 1779253668.0007572,
|
||||
"seen": 1787279199.0012865,
|
||||
"ast_hash": "7285bb2a06aaf8dc7c5b37b84261be61",
|
||||
"semantic_hash": "7285bb2a06aaf8dc7c5b37b84261be61"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.png": {
|
||||
"mtime": 1779254714.0099926,
|
||||
"seen": 1787279199.0012872,
|
||||
"ast_hash": "13d4e7f87a87fd4c9a78427b38800cc6",
|
||||
"semantic_hash": "13d4e7f87a87fd4c9a78427b38800cc6"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.png": {
|
||||
"mtime": 1779253180.3890488,
|
||||
"seen": 1787279199.0012882,
|
||||
"ast_hash": "9d8dda899602b8d74c6b23350e58a0d0",
|
||||
"semantic_hash": "9d8dda899602b8d74c6b23350e58a0d0"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.png": {
|
||||
"mtime": 1779253367.7925913,
|
||||
"seen": 1787279199.0012891,
|
||||
"ast_hash": "51cae742b39316023cf5332f1d794854",
|
||||
"semantic_hash": "51cae742b39316023cf5332f1d794854"
|
||||
},
|
||||
".claude/hooks/on_doc_edit.py": {
|
||||
"mtime": 1787279578.9362085,
|
||||
"seen": 1787380810.2925577,
|
||||
"ast_hash": "1a39a0e2ffaac441e8decccad0e1c42c",
|
||||
"semantic_hash": "1a39a0e2ffaac441e8decccad0e1c42c"
|
||||
},
|
||||
"tools/generate_doc_index.py": {
|
||||
"mtime": 1787279532.9148839,
|
||||
"seen": 1787380810.3572273,
|
||||
"ast_hash": "d0cdaf32b36fdef641a9396fd4ec403f",
|
||||
"semantic_hash": "d0cdaf32b36fdef641a9396fd4ec403f"
|
||||
},
|
||||
".github/workflows/publish.yml": {
|
||||
"mtime": 1778460176.5699222,
|
||||
"seen": 1787380810.3754551,
|
||||
"ast_hash": "a83dab23e8d09fdc8626689e96dfeb0e",
|
||||
"semantic_hash": "a83dab23e8d09fdc8626689e96dfeb0e"
|
||||
},
|
||||
"templates/index.html": {
|
||||
"mtime": 1787377487.6004047,
|
||||
"seen": 1787380810.3754609,
|
||||
"ast_hash": "639c4691eb3439d1707cd972bbed1402",
|
||||
"semantic_hash": "639c4691eb3439d1707cd972bbed1402"
|
||||
},
|
||||
"static/js/prompt-table.js": {
|
||||
"mtime": 1787376568.1601257,
|
||||
"seen": 1787380810.3524997,
|
||||
"ast_hash": "ca7fa878074a6fd12d5a478109c929d9",
|
||||
"semantic_hash": "ca7fa878074a6fd12d5a478109c929d9"
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
|
||||
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
|
||||
VIDEO_PLAN_DIR = DATA_DIR / "video_plan"
|
||||
VIDEO_EDIT_PROJECT_DIR = DATA_DIR / "video_edit_project"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
|
||||
@@ -88,6 +88,10 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
|
||||
app.router.add_post("/wfm/gallery/images/move", move_images_route)
|
||||
app.router.add_post("/wfm/gallery/images/export-zip", export_images_zip)
|
||||
app.router.add_post("/wfm/gallery/images/export-psd", export_images_psd)
|
||||
app.router.add_post("/wfm/gallery/image/export-psd-layers", export_psd_from_layers)
|
||||
app.router.add_post("/wfm/gallery/image/import-psd-layers", import_psd_layers)
|
||||
app.router.add_get("/wfm/gallery/image/psd-layers", get_psd_layers)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -181,7 +185,10 @@ async def serve_image(request: web.Request) -> web.Response:
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
|
||||
img_path = _service.serve_image(path)
|
||||
# PSDの合成処理はCPU負荷が高く同期実行するとイベントループ全体をブロックする
|
||||
# (他の並行リクエスト、例えば別画像のサムネイル配信も止まってしまう)ためexecutorで実行する
|
||||
loop = asyncio.get_event_loop()
|
||||
img_path = await loop.run_in_executor(None, _service.serve_image, path)
|
||||
if img_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
@@ -203,7 +210,10 @@ async def serve_thumb(request: web.Request) -> web.Response:
|
||||
except ValueError:
|
||||
width = 256
|
||||
|
||||
thumb_path = _service.serve_thumbnail(path, width)
|
||||
# PSD/MP4のサムネイル生成はCPU負荷が高く同期実行するとイベントループ全体をブロックするため
|
||||
# executorで実行する(他画像のサムネイル配信が巻き添えで遅延・停止するのを防ぐ)
|
||||
loop = asyncio.get_event_loop()
|
||||
thumb_path = await loop.run_in_executor(None, _service.serve_thumbnail, path, width)
|
||||
if thumb_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
@@ -405,7 +415,7 @@ async def create_group(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_RESERVED_GROUPS = {"__Feeder__"}
|
||||
_RESERVED_GROUPS = {"__Feeder__", "__Video Assets__", "__Video Temp__"}
|
||||
|
||||
|
||||
async def rename_group(request: web.Request) -> web.Response:
|
||||
@@ -574,3 +584,97 @@ async def export_images_zip(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error exporting images to ZIP: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def export_images_psd(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/images/export-psd - 複数画像を1つのPSD(レイヤー分割)にしてダウンロード"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
return web.json_response({"error": "paths required"}, status=400)
|
||||
|
||||
psd_bytes = _service.build_psd_from_images(paths)
|
||||
|
||||
return web.Response(
|
||||
body=psd_bytes,
|
||||
content_type="image/vnd.adobe.photoshop",
|
||||
headers={"Content-Disposition": "attachment; filename=gallery_export.psd"}
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD export dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting images to PSD: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def export_psd_from_layers(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/export-psd-layers - Image Editタブのレイヤー配列を1つのPSDにしてダウンロード"""
|
||||
try:
|
||||
body = await request.json()
|
||||
width = int(body.get("width", 0))
|
||||
height = int(body.get("height", 0))
|
||||
layers = body.get("layers", [])
|
||||
if not layers:
|
||||
return web.json_response({"error": "layers required"}, status=400)
|
||||
|
||||
psd_bytes = _service.build_psd_from_layers(width, height, layers)
|
||||
|
||||
return web.Response(
|
||||
body=psd_bytes,
|
||||
content_type="image/vnd.adobe.photoshop",
|
||||
headers={"Content-Disposition": "attachment; filename=image_edit_export.psd"}
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD export dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting Image Edit layers to PSD: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def import_psd_layers(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/import-psd-layers - アップロードされたPSDをレイヤー配列(JSON)に分解して返す"""
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None:
|
||||
return web.json_response({"error": "file required"}, status=400)
|
||||
psd_bytes = await field.read(decode=False)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.import_psd_layers, psd_bytes)
|
||||
return web.json_response(result)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD import dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error importing PSD layers: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_psd_layers(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/image/psd-layers - サーバー上のPSDファイルをレイヤー配列(JSON)に分解して返す
|
||||
(Galleryから直接Image Editへ送る際、アップロードの往復を挟まずに済ませるため)"""
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.get_psd_layers_from_path, path)
|
||||
return web.json_response(result)
|
||||
except RuntimeError as e:
|
||||
logger.error("PSD import dependency missing: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error reading PSD layers: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -82,9 +82,9 @@ async def handle_open(request: web.Request) -> web.Response:
|
||||
# Retrieve setting for gmic path
|
||||
data = _settings.load()
|
||||
gmic_exe = data.get("gmic_qt_path", "")
|
||||
if not os.path.exists(gmic_exe):
|
||||
if not os.path.isabs(gmic_exe) or os.path.splitext(gmic_exe)[1].lower() != ".exe" or not os.path.isfile(gmic_exe):
|
||||
return web.json_response({
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure the path in settings."
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure an absolute .exe path in settings."
|
||||
}, status=400)
|
||||
|
||||
ext = ".png"
|
||||
|
||||
@@ -255,8 +255,8 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
# Full-backup (ZIP) export/import: unlike the flat _DATA_FILES JSON bundle above, this
|
||||
# covers the whole DATA_DIR — directory-shaped data (ai_skills/, lab_plan/, style/) and
|
||||
# the SQLite tagger.db that the JSON bundle can't represent. Excludes transient/cache
|
||||
# covers the whole DATA_DIR — directory-shaped data (ai_skills/, lab_plan/, video_plan/,
|
||||
# style/) and the SQLite tagger.db that the JSON bundle can't represent. Excludes transient/cache
|
||||
# dirs, and wildcard/ specifically because it's typically a symlink into a *different*
|
||||
# custom_node's folder (comfyui-impact-pack) rather than this plugin's own data.
|
||||
_FULL_BACKUP_EXCLUDE_NAMES = {"gmic_temp", "thumb_cache", "wildcard"}
|
||||
|
||||
@@ -18,8 +18,8 @@ _db = TaggerDbService(TAGGER_DB_FILE)
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/tagger/models", handle_models)
|
||||
app.router.add_post("/wfm/tagger/predict", handle_predict)
|
||||
app.router.add_get("/wfm/tagger/ollama/models", handle_ollama_models)
|
||||
app.router.add_post("/wfm/tagger/ollama/predict", handle_ollama_predict)
|
||||
app.router.add_get("/wfm/tagger/vlm/models", handle_vlm_models)
|
||||
app.router.add_post("/wfm/tagger/vlm/predict", handle_vlm_predict)
|
||||
app.router.add_post("/wfm/tagger/batch/start", handle_batch_start)
|
||||
app.router.add_get("/wfm/tagger/batch/status", handle_batch_status)
|
||||
app.router.add_post("/wfm/tagger/batch/stop", handle_batch_stop)
|
||||
@@ -27,6 +27,7 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/tagger/db/search", handle_db_search)
|
||||
app.router.add_put("/wfm/tagger/db/{id}", handle_db_update)
|
||||
app.router.add_delete("/wfm/tagger/db/{id}", handle_db_delete)
|
||||
app.router.add_delete("/wfm/tagger/db", handle_db_delete_bulk)
|
||||
app.router.add_get("/wfm/tagger/db/export", handle_db_export)
|
||||
app.router.add_post("/wfm/tagger/db/save", handle_db_save)
|
||||
app.router.add_post("/wfm/tagger/write_meta", handle_write_meta)
|
||||
@@ -61,25 +62,29 @@ async def handle_predict(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_models(request: web.Request) -> web.Response:
|
||||
async def handle_vlm_models(request: web.Request) -> web.Response:
|
||||
backend = request.rel_url.query.get("backend", "ollama")
|
||||
api_url = request.rel_url.query.get("api_url", "http://127.0.0.1:11434")
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.ollama_models, api_url)
|
||||
models = await asyncio.to_thread(_svc.vlm_models, backend, api_url)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_predict(request: web.Request) -> web.Response:
|
||||
async def handle_vlm_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.ollama_predict,
|
||||
_svc.vlm_predict,
|
||||
body.get("image_b64", ""),
|
||||
body.get("backend", "ollama"),
|
||||
body.get("api_url", "http://127.0.0.1:11434"),
|
||||
body.get("model", ""),
|
||||
body.get("prompt", ""),
|
||||
int(body.get("max_tags", 40)),
|
||||
bool(body.get("thinking_mode", False)),
|
||||
int(body.get("max_tokens", 0)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
@@ -96,7 +101,8 @@ async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
bool(body.get("use_ollama", False)),
|
||||
body.get("ollama_api", "http://127.0.0.1:11434"),
|
||||
body.get("vlm_backend", "ollama"),
|
||||
body.get("vlm_api_url", "http://127.0.0.1:11434"),
|
||||
body.get("ollama_model", ""),
|
||||
body.get("ollama_prompt", ""),
|
||||
int(body.get("ollama_max_tags", 40)),
|
||||
@@ -104,6 +110,8 @@ async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
bool(body.get("write_file", False)),
|
||||
bool(body.get("write_txt", False)),
|
||||
_db,
|
||||
bool(body.get("thinking_mode", False)),
|
||||
int(body.get("max_tokens", 0)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
@@ -161,6 +169,27 @@ async def handle_db_delete(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_db_delete_bulk(request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
ids = data.get("ids", [])
|
||||
|
||||
if not ids:
|
||||
return web.json_response(
|
||||
{"error": "No IDs specified"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
ids = [int(row_id) for row_id in ids]
|
||||
|
||||
deleted_count = await asyncio.to_thread(_db.delete_bulk, ids)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"count": deleted_count,
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_db_export(request: web.Request) -> web.Response:
|
||||
try:
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
"""Video tab utility API routes: last-frame capture, animated GIF conversion."""
|
||||
"""Video tab API routes: last-frame capture, animated GIF conversion, and
|
||||
Plan (batch timeline) file management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import VIDEO_PLAN_DIR, VIDEO_EDIT_PROJECT_DIR
|
||||
from ..services.video_service import VideoService
|
||||
from ..services.video_plan_service import VideoPlanService
|
||||
from ..services.video_edit_project_service import VideoEditProjectService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = VideoService()
|
||||
_plan_service = VideoPlanService(VIDEO_PLAN_DIR)
|
||||
_edit_project_service = VideoEditProjectService(VIDEO_EDIT_PROJECT_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/video/frame/save-to-output", handle_save_frame)
|
||||
app.router.add_post("/api/wfm/video/to-gif", handle_to_gif)
|
||||
app.router.add_get("/api/wfm/video/edit/probe", handle_probe_video)
|
||||
|
||||
app.router.add_get("/api/wfm/video/plans", handle_list_plans)
|
||||
app.router.add_get("/api/wfm/video/plans/content", handle_get_plan_content)
|
||||
app.router.add_post("/api/wfm/video/plans/save", handle_save_plan)
|
||||
app.router.add_post("/api/wfm/video/plans/delete", handle_delete_plan)
|
||||
app.router.add_post("/api/wfm/video/index-image/save-to-output", handle_save_index_to_output)
|
||||
|
||||
app.router.add_get("/api/wfm/video/edit/projects", handle_list_edit_projects)
|
||||
app.router.add_get("/api/wfm/video/edit/projects/content", handle_get_edit_project_content)
|
||||
app.router.add_post("/api/wfm/video/edit/projects/save", handle_save_edit_project)
|
||||
app.router.add_post("/api/wfm/video/edit/projects/delete", handle_delete_edit_project)
|
||||
|
||||
|
||||
async def handle_save_frame(request: web.Request) -> web.Response:
|
||||
@@ -57,3 +75,135 @@ async def handle_to_gif(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error converting video to GIF: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_probe_video(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
subfolder = request.rel_url.query.get("subfolder", "")
|
||||
type_ = request.rel_url.query.get("type", "input")
|
||||
if not filename:
|
||||
return web.json_response({"error": "filename required"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.probe_video, filename, subfolder, type_)
|
||||
return web.json_response(result)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error probing video: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_list_plans(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_plan_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing video plans: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_plan_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_plan_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting video plan content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_plan(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_plan_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving video plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_plan(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_plan_service.delete_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting video plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_list_edit_projects(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_edit_project_service.list_projects)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing video edit projects: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_edit_project_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_edit_project_service.get_project, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting video edit project content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_edit_project(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
result = await asyncio.to_thread(_edit_project_service.save_project, filename, data)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving video edit project: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_edit_project(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_edit_project_service.delete_project, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting video edit project: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_index_to_output(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_plan_service.save_index_image_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving video index image to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -38,6 +39,9 @@ class GalleryMetadataStore:
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._lock = threading.RLock()
|
||||
# ディスクへの実書き込み(JSONシリアライズ+ファイルI/O)を直列化するための
|
||||
# 別ロック。_save_to_disk() を参照。
|
||||
self._write_lock = threading.Lock()
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
@@ -53,17 +57,56 @@ class GalleryMetadataStore:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
# 破損ファイルをリネームしてバックアップしておく。ここでリネームせずに
|
||||
# 空の self._data のまま次の save() が走ると、破損直前までの全メタデータ
|
||||
# (タグ/メモ/グループ登録)が跡形もなく上書きされ、二度と復元できなくなる
|
||||
# (実際に発生した不具合: _save_to_disk が非アトミック書き込みだったため、
|
||||
# 大量画像フォルダのBGインデックス処理中の頻繁な保存とComfyUIの終了タイミング
|
||||
# が重なるとファイルが不完全な状態のまま残ることがあった — _save_to_disk側は
|
||||
# os.replace()によるアトミック書き込みに修正済みだが、過去に壊れたファイルや
|
||||
# 他要因による破損に備えてここでも保護しておく)。
|
||||
try:
|
||||
backup_path = self.file_path.with_name(self.file_path.name + ".corrupt")
|
||||
self.file_path.replace(backup_path)
|
||||
logger.warning("GalleryMetadataStore: corrupt file backed up to %s", backup_path)
|
||||
except Exception as backup_err:
|
||||
logger.warning("GalleryMetadataStore: failed to back up corrupt file: %s", backup_err)
|
||||
|
||||
def _save_to_disk(self):
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド。
|
||||
# ここではself._lock配下でスナップショット(deepcopy)を取るだけに留め、実際のJSON
|
||||
# シリアライズ+ファイルI/Oはロックの外、別途self._write_lockで直列化して行う。
|
||||
# 理由: gallery_metadata.jsonは画像数が多い環境では1MBを超えることがあり、
|
||||
# self._lockを保持したままjson.dump+ファイル書き込みまで行うと、その間
|
||||
# 他スレッドのget()(検索リクエストなど)が丸ごとブロックされてしまう
|
||||
# (実際に発生した不具合: バックグラウンドインデックス処理が10枚ごとに保存する際、
|
||||
# 検索リクエストのget()呼び出しがロック待ちになり、50枚程度のフォルダに対する
|
||||
# 検索でも数秒〜十数秒かかっていた)。deepcopy自体はメモリ内操作でファイルI/Oより
|
||||
# 大幅に軽いため、ロック内に残しても実用上のブロック時間は無視できるレベルになる。
|
||||
snapshot = copy.deepcopy(self._data)
|
||||
return self._write_snapshot(snapshot)
|
||||
|
||||
def _write_snapshot(self, snapshot: dict) -> bool:
|
||||
"""self._lockの外から呼ばれる。一時ファイルに書き出してから置き換える
|
||||
(os.replace相当のPath.replace)ことで書き込みをアトミックにする。素朴な
|
||||
open(path, "w") での直接上書きだと、書き込み完了前に他プロセスが読むと
|
||||
不完全なJSONを掴む(torn read)うえ、書き込み中にComfyUIプロセスが終了すると
|
||||
ファイルが壊れたまま残る — 大量画像フォルダのBGインデックス処理で書き込み
|
||||
頻度が高い環境ほど発生しやすく、次回起動時のロード失敗→空データでの全上書き
|
||||
という形でユーザーの登録済みグループ/タグ/メモが消失する原因になっていた。
|
||||
self._write_lockで直列化し、複数スレッドの書き込みが競合して古いスナップショット
|
||||
が新しいものを上書きしないようにする。"""
|
||||
with self._write_lock:
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.file_path.with_name(self.file_path.name + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(snapshot, f, ensure_ascii=False, indent=2)
|
||||
tmp_path.replace(self.file_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
|
||||
def _normalize_path(self, image_path: str) -> str:
|
||||
return str(Path(image_path)).replace("\\", "/")
|
||||
@@ -83,6 +126,24 @@ class GalleryMetadataStore:
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
def save_batch(self, entries: dict[str, dict]) -> bool:
|
||||
"""複数画像のメタデータを1回のディスク書き込みでまとめて保存する。
|
||||
バックグラウンドインデックス処理のように多数の画像を連続して保存する場合、
|
||||
1件ずつsave()を呼ぶと画像の数だけJSONシリアライズ+ファイルI/Oが発生してしまう
|
||||
(実際に発生した不具合: gallery_metadata.jsonが1MBを超える環境で10枚ごとに
|
||||
個別save()していたため、検索リクエストが完了するまで数秒〜十数秒かかっていた)。"""
|
||||
if not entries:
|
||||
return True
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
with self._lock:
|
||||
for image_path, data in entries.items():
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
|
||||
@@ -16,8 +16,34 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子(.mp4は動画だがGalleryでは静止画と同じ一覧・配信経路を共有する)
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".mp4"}
|
||||
# サポートする画像拡張子(.mp4は動画だがGalleryでは静止画と同じ一覧・配信経路を共有する。
|
||||
# .psdはブラウザが直接レンダリングできないため、配信時に合成済みPNGへ変換する)
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".mp4", ".psd"}
|
||||
|
||||
# Canvas2D の globalCompositeOperation 文字列 -> psd_tools.constants.BlendMode 名
|
||||
# (Image Edit タブの Layer.blendMode はCanvas2Dの合成モード文字列をそのまま保持している)
|
||||
_PSD_BLEND_MODE_MAP = {
|
||||
"source-over": "NORMAL",
|
||||
"multiply": "MULTIPLY",
|
||||
"screen": "SCREEN",
|
||||
"overlay": "OVERLAY",
|
||||
"darken": "DARKEN",
|
||||
"lighten": "LIGHTEN",
|
||||
"color-dodge": "COLOR_DODGE",
|
||||
"color-burn": "COLOR_BURN",
|
||||
"hard-light": "HARD_LIGHT",
|
||||
"soft-light": "SOFT_LIGHT",
|
||||
"difference": "DIFFERENCE",
|
||||
"exclusion": "EXCLUSION",
|
||||
"hue": "HUE",
|
||||
"saturation": "SATURATION",
|
||||
"color": "COLOR",
|
||||
"luminosity": "LUMINOSITY",
|
||||
}
|
||||
# 逆引き: psd_tools.constants.BlendMode 名 -> Canvas2D 文字列(PSDインポート用)。
|
||||
# CSSに対応物が無いPSD側のブレンドモード(Dissolve, Linear Burn/Dodge, Vivid/Linear/Pin Light,
|
||||
# Hard Mix, Subtract, Divide, Darker/Lighter Color, Pass Through)は "source-over" にフォールバックする。
|
||||
_CSS_BLEND_MODE_REVERSE = {v: k for k, v in _PSD_BLEND_MODE_MAP.items()}
|
||||
|
||||
# data URL の MIME タイプ -> 拡張子(save_image_to_gallery / save_image_to_folder共用)
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
@@ -160,8 +186,14 @@ class GalleryService:
|
||||
self._folder_cache = _FolderCache()
|
||||
self._bg_cancel = threading.Event()
|
||||
self._bg_cancel.set() # 初期状態: キャンセル済み
|
||||
# ponyxlWildcardsVault形式フォールバック用キャッシュ: category_root文字列 -> (yamlシグネチャ, {leaf: tags})
|
||||
self._vault_leaf_cache: dict[str, tuple[tuple, dict]] = {}
|
||||
# ponyxlWildcardsVault形式フォールバック用キャッシュ: category_root文字列 -> (キャッシュ時刻, {leaf: tags})
|
||||
self._vault_leaf_cache: dict[str, tuple[float, dict]] = {}
|
||||
# 画像の親フォルダ文字列 -> (キャッシュ時刻, category_root or None)。
|
||||
# _find_vault_category_root は祖先ディレクトリを最大8階層×2回stat()するため、
|
||||
# 検索フィルタで全画像に対して毎回呼ぶと(特にVaultでない通常フォルダでも
|
||||
# 無駄に)遅くなる。同一フォルダ内の画像は結果が同じなのでフォルダ単位で
|
||||
# キャッシュする。
|
||||
self._vault_root_cache: dict[str, tuple[float, Path | None]] = {}
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
@@ -225,13 +257,14 @@ class GalleryService:
|
||||
batch[abs_path] = {"prompt_cache": prompt_cache}
|
||||
processed += 1
|
||||
if len(batch) >= 10:
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
# 1件ずつsave()を呼ぶとバッチ内の枚数分だけJSONシリアライズ+
|
||||
# ファイルI/Oが発生してしまう(大量画像フォルダのメタデータでは
|
||||
# 1回あたり数百ms〜になりうる)ため、まとめて1回で保存する。
|
||||
self.metadata_store.save_batch(batch)
|
||||
batch = {}
|
||||
time.sleep(0.05) # 50ms 他の処理に譲る
|
||||
if batch and not cancel.is_set():
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
self.metadata_store.save_batch(batch)
|
||||
if processed:
|
||||
logger.debug("BG index done: %s (%d indexed)", folder_path, processed)
|
||||
except Exception as e:
|
||||
@@ -312,15 +345,22 @@ class GalleryService:
|
||||
try:
|
||||
with os.scandir(folder) as it:
|
||||
for entry in it:
|
||||
if not entry.is_file(follow_symlinks=True):
|
||||
# follow_symlinks=True forces stat() to make an extra syscall per file
|
||||
# to resolve the link target even for plain files. Output folders don't
|
||||
# contain symlinked images, so False is safe and much cheaper.
|
||||
if not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=True)
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
# entry.path is already absolute+resolved because callers always
|
||||
# pass in an already-resolved `folder`. Re-resolving it here via
|
||||
# Path(entry.path).resolve() costs an extra filesystem round-trip
|
||||
# per file — on a 9786-file folder that alone was 6.7s vs 0.07s.
|
||||
entries.append((
|
||||
entry.name,
|
||||
str(Path(entry.path).resolve()).replace("\\", "/"),
|
||||
entry.path.replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
@@ -333,30 +373,45 @@ class GalleryService:
|
||||
return entries
|
||||
|
||||
def _scan_folder_recursive(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
|
||||
"""フォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
|
||||
ImagePrompt/Style Catalogギャラリーのように深い階層に画像が置かれるケース用。
|
||||
キャッシュはしない(フォルダ数が少なく、都度スキャンしても軽い想定)。
|
||||
os.walk()はDirEntryを介さずPath.stat()(follow_symlinks=True相当)を呼ぶため、
|
||||
_scan_folder()と同じ理由でフォルダ内ファイル数に対して極端に遅くなる。
|
||||
os.scandir()を自前で再帰し、follow_symlinks=Falseでstatする。
|
||||
"""
|
||||
entries = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for name in files:
|
||||
if Path(name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
full = Path(root) / name
|
||||
try:
|
||||
st = full.stat()
|
||||
entries.append((
|
||||
name,
|
||||
str(full.resolve()).replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
def _walk(d):
|
||||
try:
|
||||
with os.scandir(d) as it:
|
||||
dir_entries = list(it)
|
||||
except (PermissionError, OSError):
|
||||
return
|
||||
for entry in dir_entries:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if not entry.name.startswith("."):
|
||||
_walk(entry.path)
|
||||
continue
|
||||
if not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
# entry.path is already absolute since the top-level `folder` passed
|
||||
# in by callers is always pre-resolved — see _scan_folder() for why
|
||||
# re-resolving per file here would be costly.
|
||||
entries.append((
|
||||
entry.name,
|
||||
entry.path.replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
_walk(folder)
|
||||
return entries
|
||||
|
||||
def list_images(
|
||||
@@ -389,8 +444,12 @@ class GalleryService:
|
||||
# recursive=False(非再帰スキャン)の場合、existing_pathsにはfolder直下の
|
||||
# ファイルしか含まれないため、cleanup側にもrecursive=Falseを伝えて対象範囲を
|
||||
# 直下のみに限定する(でないとサブフォルダ内ファイルのメタデータを誤削除する)。
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
self.metadata_store.cleanup_stale_images(str(folder), existing_paths, recursive=recursive)
|
||||
# 検索文字列入力中はキー入力のたびに呼ばれるため、ここではスキップする
|
||||
# (全メタデータ走査+該当時はJSON全体のディスク書き込みを伴い重く、検索の
|
||||
# 度に繰り返す必要はない。通常の検索なしロード時に実行されれば十分)。
|
||||
if not search:
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
self.metadata_store.cleanup_stale_images(str(folder), existing_paths, recursive=recursive)
|
||||
|
||||
results = []
|
||||
for name, abs_path, size, mtime in raw_entries:
|
||||
@@ -514,32 +573,45 @@ class GalleryService:
|
||||
|
||||
def _find_vault_category_root(self, image_path: Path) -> Path | None:
|
||||
"""image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ
|
||||
(=ponyxlWildcardsVaultのカテゴリルート) を探す"""
|
||||
(=ponyxlWildcardsVaultのカテゴリルート) を探す。
|
||||
同一フォルダ内の画像は結果が同じなので、フォルダ単位でTTLキャッシュする。"""
|
||||
parent_key = str(image_path.parent)
|
||||
cached = self._vault_root_cache.get(parent_key)
|
||||
if cached is not None and time.monotonic() - cached[0] <= _CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
cur = image_path.parent
|
||||
result: Path | None = None
|
||||
for _ in range(_VAULT_ROOT_SEARCH_DEPTH):
|
||||
if any((cur / name).is_dir() for name in _VAULT_THUMB_DIR_NAMES):
|
||||
return cur
|
||||
result = cur
|
||||
break
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
return None
|
||||
|
||||
self._vault_root_cache[parent_key] = (time.monotonic(), result)
|
||||
return result
|
||||
|
||||
def _load_vault_leaves(self, category_root: Path) -> dict[str, str]:
|
||||
yaml_files = sorted(category_root.glob("*.yaml")) + sorted(category_root.glob("*.yml"))
|
||||
if not yaml_files:
|
||||
return {}
|
||||
try:
|
||||
signature = tuple(sorted((str(f), f.stat().st_mtime) for f in yaml_files))
|
||||
except OSError:
|
||||
signature = ()
|
||||
"""category_root直下のyamlをパースしたleaf辞書をTTLキャッシュする。
|
||||
以前はキャッシュの妥当性確認のため毎回 category_root.glob("*.yaml") を実行して
|
||||
いたが、category_root は「thumbnails(_option2)という名前のフォルダを直下に持つ
|
||||
祖先」というゆるい条件で決まるため、Vaultとは無関係な通常の画像出力フォルダ
|
||||
(例: 直下にComfyUIが作る空のthumbnailsキャッシュフォルダがあるだけ)が誤検出
|
||||
されることがある。そのフォルダに数千〜数万枚のファイルがあると、glob自体が
|
||||
1回数十ms〜になり、検索フィルタで画像ごとに呼ばれると数秒単位まで積み上がって
|
||||
いた(実際に発生した不具合: 1万枚超のルート直下に誤検出されたフォルダがあり、
|
||||
53枚のサブフォルダを検索するだけで約3秒かかっていた)。TTL内はglobを省略する。"""
|
||||
cache_key = str(category_root)
|
||||
cached = self._vault_leaf_cache.get(cache_key)
|
||||
if cached is not None and cached[0] == signature:
|
||||
if cached is not None and time.monotonic() - cached[0] <= _CACHE_TTL:
|
||||
return cached[1]
|
||||
yaml_files = sorted(category_root.glob("*.yaml")) + sorted(category_root.glob("*.yml"))
|
||||
leaves: dict[str, str] = {}
|
||||
for yf in yaml_files:
|
||||
leaves.update(_parse_vault_yaml_leaves(yf))
|
||||
self._vault_leaf_cache[cache_key] = (signature, leaves)
|
||||
self._vault_leaf_cache[cache_key] = (time.monotonic(), leaves)
|
||||
return leaves
|
||||
|
||||
def _read_vault_prompt(self, image_path: Path) -> str | None:
|
||||
@@ -718,6 +790,10 @@ class GalleryService:
|
||||
info["height"] = stream.height
|
||||
if container.duration:
|
||||
info["duration"] = container.duration / 1_000_000
|
||||
elif ext == ".psd":
|
||||
from psd_tools import PSDImage
|
||||
psd = PSDImage.open(path)
|
||||
info["width"], info["height"] = psd.width, psd.height
|
||||
elif ext != ".svg":
|
||||
from PIL import Image
|
||||
with Image.open(path) as img:
|
||||
@@ -789,21 +865,27 @@ class GalleryService:
|
||||
return self.metadata_store.save(str(resolved), data)
|
||||
|
||||
def toggle_favorite(self, image_path: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
# get_image_metadata()/list_images() 側はresolve()済みパスをキーにして取得する
|
||||
# ため、ここでも統一する(save_image_metaと同じ理由。キー不一致による
|
||||
# 「保存はできるが表示されない」というサイレントな食い違いを防ぐ)。
|
||||
resolved_str = str(resolved)
|
||||
meta = self.metadata_store.get(resolved_str)
|
||||
new_val = not meta.get("favorite", False)
|
||||
self.metadata_store.save(image_path, {"favorite": new_val})
|
||||
self.metadata_store.save(resolved_str, {"favorite": new_val})
|
||||
return new_val
|
||||
|
||||
def bulk_set_favorite(self, paths: list[str], value: bool) -> dict:
|
||||
"""複数画像のお気に入りを一括設定する"""
|
||||
ok = fail = 0
|
||||
for p in paths:
|
||||
if not self._check_path_allowed(Path(p).resolve()):
|
||||
resolved = Path(p).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
fail += 1
|
||||
continue
|
||||
if self.metadata_store.save(p, {"favorite": value}):
|
||||
if self.metadata_store.save(str(resolved), {"favorite": value}):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
@@ -837,21 +919,28 @@ class GalleryService:
|
||||
return self.metadata_store.delete_group(name)
|
||||
|
||||
def add_to_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
# get_image_metadata()/list_images() 側はresolve()済みパスをキーにして取得する
|
||||
# ため、ここでも統一する(save_image_metaと同じ理由。キー不一致による
|
||||
# 「グループに追加はできるが一覧に反映されない」というサイレントな食い違いを防ぐ)。
|
||||
resolved_str = str(resolved)
|
||||
meta = self.metadata_store.get(resolved_str)
|
||||
groups = meta.get("groups", [])
|
||||
if group_name not in groups:
|
||||
groups.append(group_name)
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
return self.metadata_store.save(resolved_str, {"groups": groups})
|
||||
return True
|
||||
|
||||
def remove_from_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
resolved = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
resolved_str = str(resolved)
|
||||
meta = self.metadata_store.get(resolved_str)
|
||||
groups = [g for g in meta.get("groups", []) if g != group_name]
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
return self.metadata_store.save(resolved_str, {"groups": groups})
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
all_paths = self.metadata_store.list_images_in_group(group_name)
|
||||
@@ -972,8 +1061,215 @@ class GalleryService:
|
||||
errors.append(f"{p.name}: {e}")
|
||||
return {"moved": moved, "errors": errors}
|
||||
|
||||
def build_psd_from_images(self, paths: list) -> bytes:
|
||||
"""複数画像を各1レイヤーとして1つのPSDにまとめ、バイナリを返す。
|
||||
画像サイズが異なる場合はキャンバスを最大サイズに合わせ、各レイヤーは左上(0,0)基準で配置する。
|
||||
"""
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
from psd_tools.api.layers import PixelLayer
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
layers_data = []
|
||||
for img_path in paths:
|
||||
p = Path(img_path).resolve()
|
||||
if not p.is_file() or p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
if p.suffix.lower() == ".mp4":
|
||||
continue # 動画はPSDレイヤーにできない
|
||||
if not self._check_path_allowed(p):
|
||||
continue
|
||||
try:
|
||||
im = Image.open(p).convert("RGBA")
|
||||
except Exception as e:
|
||||
logger.warning("build_psd_from_images: failed to open %s: %s", p, e)
|
||||
continue
|
||||
layers_data.append((p.stem, im))
|
||||
|
||||
if not layers_data:
|
||||
raise ValueError("No valid images to export")
|
||||
|
||||
max_w = max(im.width for _, im in layers_data)
|
||||
max_h = max(im.height for _, im in layers_data)
|
||||
|
||||
base = Image.new("RGBA", (max_w, max_h), (0, 0, 0, 0))
|
||||
psd = PSDImage.frompil(base)
|
||||
for name, im in layers_data:
|
||||
PixelLayer.frompil(im, psd, name=name, top=0, left=0)
|
||||
|
||||
buf = io.BytesIO()
|
||||
psd.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def build_psd_from_layers(self, width: int, height: int, layers: list) -> bytes:
|
||||
"""Image Edit タブのレイヤー配列(dataURL+メタデータ)から1つのPSDを組み立てる。
|
||||
各レイヤー画像は既にフロントエンド側で回転・反転・拡縮をベイクしたキャンバス全体サイズの
|
||||
PNGとして渡される前提のため位置は常に(0,0)。opacity/blendModeはPSDレイヤー属性として渡す。
|
||||
"""
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
from psd_tools.api.layers import PixelLayer
|
||||
from psd_tools.constants import BlendMode
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
from PIL import Image
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
|
||||
if not layers:
|
||||
raise ValueError("No layers to export")
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Invalid canvas size")
|
||||
|
||||
base = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
psd = PSDImage.frompil(base)
|
||||
|
||||
added = 0
|
||||
for layer_data in layers:
|
||||
data_url = layer_data.get("imageData", "")
|
||||
m = re.match(r"^data:image/\w+;base64,(.+)$", data_url)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
im = Image.open(io.BytesIO(base64.b64decode(m.group(1)))).convert("RGBA")
|
||||
except Exception as e:
|
||||
logger.warning("build_psd_from_layers: failed to decode layer image: %s", e)
|
||||
continue
|
||||
|
||||
name = str(layer_data.get("name") or "Layer")[:255]
|
||||
opacity = int(max(0.0, min(1.0, float(layer_data.get("opacity", 1.0)))) * 255)
|
||||
visible = bool(layer_data.get("visible", True))
|
||||
blend_key = _PSD_BLEND_MODE_MAP.get(layer_data.get("blendMode", "source-over"), "NORMAL")
|
||||
blend_mode = getattr(BlendMode, blend_key)
|
||||
|
||||
pixel_layer = PixelLayer.frompil(im, psd, name=name, top=0, left=0)
|
||||
pixel_layer.opacity = opacity
|
||||
pixel_layer.blend_mode = blend_mode
|
||||
pixel_layer.visible = visible
|
||||
added += 1
|
||||
|
||||
if added == 0:
|
||||
raise ValueError("No valid layer images to export")
|
||||
|
||||
buf = io.BytesIO()
|
||||
psd.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def _extract_psd_layers(self, psd) -> dict:
|
||||
"""PSDImageインスタンスからレイヤー配列(dataURL+メタデータ)を抽出する共通ロジック。
|
||||
グループ自体・ラスタ化できないレイヤー(調整レイヤー等)はスキップする。
|
||||
戻り値のlayersはフロントエンドのLayer配列規約(先頭=最前面)に合わせて並べる。
|
||||
"""
|
||||
from psd_tools.constants import BlendMode
|
||||
import base64
|
||||
import io
|
||||
|
||||
layers_out = []
|
||||
for layer in psd.descendants():
|
||||
if layer.is_group():
|
||||
continue
|
||||
try:
|
||||
im = layer.topil()
|
||||
except Exception as e:
|
||||
logger.warning("_extract_psd_layers: failed to rasterize layer %r: %s", layer.name, e)
|
||||
continue
|
||||
if im is None:
|
||||
continue
|
||||
im = im.convert("RGBA")
|
||||
|
||||
left, top, right, bottom = layer.bbox
|
||||
w, h = right - left, bottom - top
|
||||
if w <= 0 or h <= 0:
|
||||
continue
|
||||
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, format="PNG")
|
||||
data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
blend_value = layer.blend_mode
|
||||
try:
|
||||
blend_name = BlendMode(blend_value.value if hasattr(blend_value, "value") else blend_value).name
|
||||
except ValueError:
|
||||
blend_name = "NORMAL"
|
||||
css_blend = _CSS_BLEND_MODE_REVERSE.get(blend_name, "source-over")
|
||||
|
||||
layers_out.append({
|
||||
"name": layer.name or "Layer",
|
||||
"x": left,
|
||||
"y": top,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"opacity": (layer.opacity if layer.opacity is not None else 255) / 255.0,
|
||||
"visible": bool(layer.visible),
|
||||
"blendMode": css_blend,
|
||||
"imageData": data_url,
|
||||
})
|
||||
|
||||
if not layers_out:
|
||||
raise ValueError("No importable layers found in this PSD")
|
||||
|
||||
# descendants()は背面→前面(下から上)の順で返るため、
|
||||
# フロントエンドのLayer配列規約(先頭=最前面)に合わせて反転する
|
||||
layers_out.reverse()
|
||||
|
||||
return {"width": psd.width, "height": psd.height, "layers": layers_out}
|
||||
|
||||
def import_psd_layers(self, psd_bytes: bytes) -> dict:
|
||||
"""アップロードされたPSDファイル(バイト列)をレイヤー配列(dataURL+メタデータ)に分解する。"""
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
import io
|
||||
|
||||
try:
|
||||
psd = PSDImage.open(io.BytesIO(psd_bytes))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to read PSD file: {e}") from e
|
||||
|
||||
return self._extract_psd_layers(psd)
|
||||
|
||||
def get_psd_layers_from_path(self, image_path: str) -> dict:
|
||||
"""サーバー上に既にあるPSDファイル(Gallery上のパス)をレイヤー配列に分解する。
|
||||
Galleryから直接Image Editへ送る際、アップロードの往復無しで済ませるために使う。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file() or p.suffix.lower() != ".psd":
|
||||
raise ValueError("Not a PSD file")
|
||||
if not self._check_path_allowed(p):
|
||||
raise ValueError("Access denied")
|
||||
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"psd-tools is not installed. Run: "
|
||||
"python_embeded\\python.exe -m pip install psd-tools"
|
||||
) from e
|
||||
|
||||
try:
|
||||
psd = PSDImage.open(p)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to read PSD file: {e}") from e
|
||||
|
||||
return self._extract_psd_layers(psd)
|
||||
|
||||
def serve_image(self, image_path: str):
|
||||
"""画像のPathオブジェクトを返す(ルートで使用)"""
|
||||
"""画像のPathオブジェクトを返す(ルートで使用)。
|
||||
PSDはブラウザが直接レンダリングできないため、合成済みPNGにキャッシュ変換して返す。"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return None
|
||||
@@ -982,8 +1278,34 @@ class GalleryService:
|
||||
if not self._check_path_allowed(p):
|
||||
logger.warning("serve_image: path not allowed: %s", p)
|
||||
return None
|
||||
if p.suffix.lower() == ".psd":
|
||||
return self._get_psd_composite_cache(p)
|
||||
return p
|
||||
|
||||
def _get_psd_composite_cache(self, p: Path) -> Path | None:
|
||||
"""PSDの全レイヤー合成結果をフル解像度PNGとしてディスクキャッシュし、そのPathを返す。"""
|
||||
try:
|
||||
mtime = int(p.stat().st_mtime * 1000)
|
||||
except OSError:
|
||||
return None
|
||||
cache_key = hashlib.md5(f"{p}:{mtime}:full".encode()).hexdigest()
|
||||
cache_dir = self.data_dir / "thumb_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
out_path = cache_dir / f"{cache_key}_psdfull.png"
|
||||
if out_path.exists():
|
||||
return out_path
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
psd = PSDImage.open(p)
|
||||
img = psd.composite()
|
||||
if img is None:
|
||||
return None
|
||||
img.convert("RGBA").save(out_path, "PNG")
|
||||
return out_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_image: psd composite failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
def serve_thumbnail(self, image_path: str, width: int = 256) -> Path | None:
|
||||
"""縮小サムネイルのPathを返す。
|
||||
ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。
|
||||
@@ -991,6 +1313,7 @@ class GalleryService:
|
||||
Pillowが使えない場合は元ファイルにフォールバック。
|
||||
MP4はPillowで開けないためPyAVで先頭フレームを抽出しJPEGとしてキャッシュする
|
||||
(元ファイルへのフォールバックはできないため、失敗時はNoneを返す)。
|
||||
PSDはpsd_toolsでレイヤーを合成してからJPEGとしてキャッシュする(同様にフォールバック不可)。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
@@ -1041,6 +1364,24 @@ class GalleryService:
|
||||
logger.warning("serve_thumbnail: mp4 frame extraction failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
# PSDはPillowで直接開けない(内部のマージ画像がPhotoshop側の実装依存で信頼できない)ため
|
||||
# psd_toolsでレイヤーを正しく合成してからサムネイル化する。
|
||||
if p.suffix.lower() == ".psd":
|
||||
try:
|
||||
from psd_tools import PSDImage
|
||||
from PIL import Image
|
||||
psd = PSDImage.open(p)
|
||||
img = psd.composite()
|
||||
if img is None:
|
||||
return None
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((width, width), Image.LANCZOS)
|
||||
img.save(thumb_path, "JPEG", quality=85, optimize=True)
|
||||
return thumb_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_thumbnail: psd composite failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(p) as img:
|
||||
|
||||
@@ -5,6 +5,7 @@ import io
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,6 +86,20 @@ class TaggerDbService:
|
||||
conn.execute("DELETE FROM images WHERE id=?", (row_id,))
|
||||
conn.commit()
|
||||
|
||||
def delete_bulk(self, row_ids: List[int]) -> int:
|
||||
if not row_ids:
|
||||
return 0
|
||||
|
||||
placeholders = ",".join("?" for _ in row_ids)
|
||||
|
||||
with self._conn() as conn:
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM images WHERE id IN ({placeholders})",
|
||||
row_ids,
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
def export_csv(self) -> str:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -5,11 +5,14 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, PngImagePlugin
|
||||
@@ -18,6 +21,20 @@ 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
|
||||
|
||||
|
||||
class TaggerService:
|
||||
def __init__(self, models_dir: Path, settings_file: Path):
|
||||
@@ -185,19 +202,33 @@ class TaggerService:
|
||||
def _predict_onnx(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
|
||||
shape = data["input_shape"]
|
||||
fmt = "NHWC"
|
||||
size = (224, 224)
|
||||
size = 224
|
||||
if len(shape) == 4:
|
||||
if shape[1] == 3 and isinstance(shape[2], int) and shape[2] > 0:
|
||||
fmt = "NCHW"
|
||||
size = (int(shape[3]), int(shape[2]))
|
||||
size = int(shape[2])
|
||||
elif shape[3] == 3 and isinstance(shape[1], int) and shape[1] > 0:
|
||||
fmt = "NHWC"
|
||||
size = (int(shape[2]), int(shape[1]))
|
||||
size = int(shape[1])
|
||||
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
arr = np.array(img.resize(size, Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
|
||||
# WD Tagger系標準の前処理: 透過は白背景で合成 → アスペクト比を維持したまま
|
||||
# 白背景で正方形にパディング → リサイズ → RGB→BGR → 0-255のfloat32のまま(NHWC)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
canvas = Image.new("RGBA", img.size, (255, 255, 255))
|
||||
canvas.paste(img, mask=img)
|
||||
img = canvas.convert("RGB")
|
||||
|
||||
w, h = img.size
|
||||
max_dim = max(w, h)
|
||||
padded = Image.new("RGB", (max_dim, max_dim), (255, 255, 255))
|
||||
padded.paste(img, ((max_dim - w) // 2, (max_dim - h) // 2))
|
||||
if max_dim != size:
|
||||
padded = padded.resize((size, size), Image.Resampling.BICUBIC)
|
||||
|
||||
arr = np.array(padded, dtype=np.float32)
|
||||
arr = np.ascontiguousarray(arr[:, :, ::-1]) # RGB -> BGR
|
||||
if fmt == "NCHW":
|
||||
arr = arr.transpose((2, 0, 1))
|
||||
arr = np.expand_dims(arr, 0)
|
||||
@@ -223,39 +254,112 @@ class TaggerService:
|
||||
tags.append(label.replace("character:", ""))
|
||||
return tags
|
||||
|
||||
# ── Ollama VLM ────────────────────────────────────────────
|
||||
# ── VLM Tagging (Ollama / LM Studio / Lemonade / Unsloth) ──
|
||||
|
||||
def ollama_models(self, api_url: str) -> list:
|
||||
@staticmethod
|
||||
def _unsloth_api_key() -> Optional[str]:
|
||||
"""UNSLOTH_API_KEY を環境変数(.env)から取得。Unslothはローカルでも常に必須。"""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
@staticmethod
|
||||
def _strip_thinking_tags(text: str) -> str:
|
||||
text = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<thinking>[\s\S]*?</thinking>", "", text, flags=re.IGNORECASE)
|
||||
return text.strip()
|
||||
|
||||
def vlm_models(self, backend: str, api_url: str) -> list:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
if backend == "ollama":
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
|
||||
headers = {}
|
||||
if backend == "unsloth":
|
||||
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}"
|
||||
req = urllib.request.Request(api_url.rstrip("/") + "/v1/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return sorted(m["name"] for m in data.get("models", []))
|
||||
return sorted(m["id"] for m in data.get("data", []))
|
||||
except Exception as e:
|
||||
logger.error("ollama_models: %s", e)
|
||||
logger.error("vlm_models: %s", e)
|
||||
return []
|
||||
|
||||
def ollama_predict(self, image_b64: str, api_url: str, model: str, prompt: str, max_tags: int = 40) -> dict:
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096},
|
||||
}).encode("utf-8")
|
||||
def vlm_predict(self, image_b64: str, backend: str, api_url: str, model: str, prompt: str, max_tags: int = 40,
|
||||
thinking_mode: bool = False, max_tokens: int = 0) -> dict:
|
||||
backend = (backend or "ollama").lower()
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
content = data.get("message", {}).get("content", "")
|
||||
if backend == "ollama":
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt, "images": [image_b64]}],
|
||||
"stream": False,
|
||||
"think": bool(thinking_mode),
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096},
|
||||
}
|
||||
if max_tokens > 0:
|
||||
body["options"]["num_predict"] = max_tokens
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
content = data.get("message", {}).get("content", "")
|
||||
else:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if backend == "unsloth":
|
||||
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}"
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}},
|
||||
]}],
|
||||
"stream": False,
|
||||
}
|
||||
if max_tokens > 0:
|
||||
body["max_tokens"] = max_tokens
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url.rstrip("/") + "/v1/chat/completions",
|
||||
data=payload,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
content = message.get("content", "")
|
||||
# Unslothはreasoningを別フィールドで返すため<think>タグに畳んで統一的に扱う
|
||||
reasoning = message.get("reasoning_content")
|
||||
if reasoning:
|
||||
content = f"<think>{reasoning}</think>{content}"
|
||||
|
||||
if not thinking_mode:
|
||||
content = self._strip_thinking_tags(content)
|
||||
tags = [t.strip() for t in content.split(",") if t.strip()][:max_tags]
|
||||
return {"tags": ", ".join(tags), "count": len(tags)}
|
||||
except Exception as e:
|
||||
logger.error("ollama_predict: %s", e)
|
||||
logger.error("vlm_predict: %s", e)
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── ファイルメタデータ書込 ────────────────────────────────
|
||||
@@ -334,8 +438,9 @@ class TaggerService:
|
||||
self._batch_state["stop"] = True
|
||||
|
||||
def batch_start(self, folder: str, model_name: str, threshold: float, char_threshold: float,
|
||||
use_ollama: bool, ollama_api: str, ollama_model: str, ollama_prompt: str,
|
||||
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service) -> dict:
|
||||
use_ollama: bool, vlm_backend: str, vlm_api_url: str, ollama_model: str, ollama_prompt: str,
|
||||
ollama_max_tags: int, save_db: bool, write_file: bool, write_txt: bool, db_service,
|
||||
thinking_mode: bool = False, max_tokens: int = 0) -> dict:
|
||||
if self._batch_state["running"]:
|
||||
return {"error": "Batch already running"}
|
||||
folder_path = Path(folder)
|
||||
@@ -370,7 +475,8 @@ class TaggerService:
|
||||
|
||||
vlm_tags = ""
|
||||
if use_ollama and ollama_model:
|
||||
res2 = self.ollama_predict(b64, ollama_api, ollama_model, ollama_prompt, ollama_max_tags)
|
||||
res2 = self.vlm_predict(b64, vlm_backend, vlm_api_url, ollama_model, ollama_prompt, ollama_max_tags,
|
||||
thinking_mode, max_tokens)
|
||||
vlm_tags = res2.get("tags", "")
|
||||
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Video tab Edit subtab project file management (JSON timeline persistence).
|
||||
|
||||
Same CRUD shape as VideoPlanService (list/get/save/delete with a safe-path
|
||||
helper), kept as its own class rather than sharing a base because the two
|
||||
JSON schemas are unrelated (batch keyframe blocks vs. an ordered clip
|
||||
timeline) — see VideoPlanService's own docstring for the same reasoning.
|
||||
No index-image thumbnail support (unlike VideoPlanService) since the Edit
|
||||
project list is plain text/JSON only for now.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoEditProjectService:
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self.project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.project_dir / name).resolve()
|
||||
project_dir_resolved = self.project_dir.resolve()
|
||||
if resolved != project_dir_resolved and not str(resolved).startswith(str(project_dir_resolved) + "\\") and not str(resolved).startswith(str(project_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(self) -> list[dict]:
|
||||
if not self.project_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.project_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable video edit project %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", path.stem),
|
||||
"clip_count": len(data.get("clips", [])),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_project(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Project not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_project(self, filename: str, data: dict) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def delete_project(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Video tab plan file management service (JSON plan + optional PNG index thumbnail).
|
||||
|
||||
Duplicated from LabPlanService rather than shared — the two plan formats are
|
||||
unrelated (batch keyframe columns vs. sequential timeline blocks) and would
|
||||
gain nothing from a forced common base beyond coincidental line-count savings.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable video plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/video_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"block_count": len(data.get("blocks", [])),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Video_index") -> dict:
|
||||
"""Saves the contact-sheet index image into ComfyUI's own output folder,
|
||||
alongside the videos generated during the run (unlike save_index_image,
|
||||
which saves it next to the plan file in plan_dir)."""
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def delete_plan(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -57,6 +57,38 @@ class VideoService:
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def probe_video(self, filename: str, subfolder: str, type_: str) -> dict:
|
||||
"""Reads duration/dimensions/fps/audio presence via PyAV only (no ffmpeg
|
||||
subprocess) — used by the Video Edit tab right after a clip is added, so
|
||||
the timeline UI can show its length and the export-time resolution-match
|
||||
check has real numbers to compare against."""
|
||||
import av # type: ignore
|
||||
|
||||
src_path = self._resolve_media_path(filename, subfolder, type_)
|
||||
container = av.open(str(src_path))
|
||||
try:
|
||||
stream = container.streams.video[0]
|
||||
fps = float(stream.average_rate) if stream.average_rate else 0.0
|
||||
if stream.duration is not None and stream.time_base is not None:
|
||||
duration = float(stream.duration * stream.time_base)
|
||||
elif container.duration is not None:
|
||||
duration = float(container.duration) / 1_000_000
|
||||
else:
|
||||
duration = 0.0
|
||||
width = stream.codec_context.width
|
||||
height = stream.codec_context.height
|
||||
has_audio = len(container.streams.audio) > 0
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": fps,
|
||||
"has_audio": has_audio,
|
||||
}
|
||||
|
||||
def convert_to_gif(
|
||||
self,
|
||||
filename: str,
|
||||
|
||||
@@ -4,7 +4,7 @@ import jinja2
|
||||
from aiohttp import web
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR, VIDEO_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +57,22 @@ async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_video_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Video plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = VIDEO_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowStudio:
|
||||
"""Main entry point for Workflow Studio plugin."""
|
||||
|
||||
@@ -76,6 +92,9 @@ class WorkflowStudio:
|
||||
# Dynamic Lab plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
|
||||
|
||||
# Dynamic Video plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/video_plan/{filename}", serve_video_plan_file)
|
||||
|
||||
# Main page
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
|
||||
version = "0.5.2"
|
||||
license = "MIT"
|
||||
version = "0.7.2"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ piexif>=1.1.3
|
||||
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
|
||||
python-dotenv>=1.0,<2.0
|
||||
|
||||
# -- Multi-layer PSD export (Gallery tab bulk action) --
|
||||
# Pure-Python, ships prebuilt wheels (no C compiler required on Windows).
|
||||
psd-tools>=1.10
|
||||
|
||||
# -- MP4 thumbnail generation (Gallery tab) --
|
||||
# Uses PyAV (av) to decode the first frame. ComfyUI core already requires av>=17.0.0,
|
||||
# so it is normally pre-installed in python_embeded and no action is needed here.
|
||||
|
||||
@@ -311,8 +311,8 @@
|
||||
|
||||
.ie-layers-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 5px 6px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
@@ -323,7 +323,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-layers-header > span {
|
||||
.ie-layers-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ie-layers-header-row > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,14 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#wfm-tagger-db-th-select, td.wfm-tagger-db-select {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#wfm-tagger-db-th-select input, .wfm-tagger-db-select input {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================
|
||||
Video Tab (MiniMax H3専用・GenerateUIタブとは独立)
|
||||
3ペイン構成: 左=フォーム / 中央=プレビュー+編集 / 右=プロパティ
|
||||
Video Tab (MiniMax H3 / LTX-2.5 / Wan2.2対応・GenerateUIタブとは独立)
|
||||
3ペイン構成: 左=Plan/Asset/Editサブタブ / 中央=プレビュー+Video Source / 右=プロパティ
|
||||
中央・右ペインは左のサブタブ切替と無関係に常時表示される。
|
||||
各ペインは独立したカラムとして高さ100%を占め、内部でスクロールする。
|
||||
中央ペインのコンテンツ量(生成結果の有無・動画の縦横比)が変わっても
|
||||
左右のペインの位置が動かないよう、意図的に高さを共有させない構造にしている。
|
||||
@@ -20,7 +21,7 @@
|
||||
|
||||
/* ── 左ペイン: フォーム ── */
|
||||
.wfm-video-form-panel {
|
||||
width: 380px;
|
||||
width: 253px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -37,44 +38,607 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfm-video-wf-name {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-video-frame-slots {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
/* ── Plan/Asset/Edit サブタブナビ(Lab/Galleryと同型) ── */
|
||||
.wfm-video-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
margin: -16px -16px 12px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.wfm-video-frame-slot {
|
||||
.wfm-video-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-video-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-video-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
/* 中央ペイン用のタブナビ: プレビュー行の"下"に来るため、サイドバー版と違って
|
||||
上マージンは潰さない(プレビュー行と重ならないようにする)。左右のみ潰して
|
||||
center-panelのpadding分フルブリードにする。 */
|
||||
.wfm-video-center-subtab-nav {
|
||||
margin: 0 -16px 12px;
|
||||
}
|
||||
|
||||
.wfm-video-subtab-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Plan: タイムライン(各ブロックのdurationに応じてflex-growで幅配分) ── */
|
||||
.wfm-video-timeline-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-timeline-track {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-timeline-block {
|
||||
flex-basis: 0;
|
||||
min-width: 24px;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.3);
|
||||
transition: outline 0.1s;
|
||||
}
|
||||
|
||||
.wfm-video-timeline-block:last-child { border-right: none; }
|
||||
.wfm-video-timeline-block.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-timeline-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Plan: 中央下部に配置されるコンテンツ全体(Planサブタブ選択時のみ表示) ── */
|
||||
.wfm-video-plan-topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 現在ロード中のワークフロー名 + Planに記録されたワークフローのOpen Workflow行を
|
||||
同じ1行にまとめる(以前は縦に2行だった)。 */
|
||||
.wfm-video-wf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-video-wf-row .wfm-lab-plan-workflow-row { margin-top: 0; }
|
||||
|
||||
.wfm-video-plan-basesettings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 150px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Plan: 2行目の追加設定(Width/Height/Turbo Mode/Strength/Steps/Prompt Enhance)。
|
||||
basesettingsと同じ見た目だが、ワークフローによって対応する項目が変わる可変集合の
|
||||
ため別クラスに分けている ── */
|
||||
.wfm-video-plan-extrasettings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 130px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wfm-video-plan-basesettings label,
|
||||
.wfm-video-plan-extrasettings label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Noise Seed欄: 数値入力とRandomizeチェックボックスを1セル内で横並びにする */
|
||||
.wfm-video-seed-inline {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-video-seed-inline .wfm-input { flex: 1; min-width: 0; }
|
||||
|
||||
.wfm-video-seed-randomize-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Turbo Mode / Prompt Enhance欄: ラベル行の下にチェックボックスを1つだけ置く
|
||||
(他のnumber/select欄と縦位置を揃えるため、input要素の高さを合わせる) */
|
||||
.wfm-video-checkbox-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* タイムラインの下: ブロックエディタ(左、横並びの3カラム) + Run/Planタブ(右) */
|
||||
.wfm-video-plan-main-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Plan: ブロックエディタ(選択中ブロックのFirst/Last Image・Prompt。Durationはtopbarの
|
||||
basesettings行に移動済み。中央の広いエリアに配置されるため横並びの3カラムで表示する) ── */
|
||||
.wfm-video-block-editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wfm-video-block-col label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-block-first-col,
|
||||
.wfm-video-block-last-col {
|
||||
width: 190px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-video-resolution-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
.wfm-video-block-prompt-col {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-video-seed-randomize-wrap {
|
||||
.wfm-video-block-prompt-col textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.wfm-video-block-color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 6px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-video-color-swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
/* ── Plan: Run/Planタブ(実行系・プラン管理系を切替表示するコンパクトなウィジェット) ── */
|
||||
.wfm-video-runplan-panel {
|
||||
width: 260px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.wfm-video-runplan-tabnav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-video-runplan-tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 4px;
|
||||
cursor: pointer;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-video-runplan-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-video-runplan-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Asset: 中央下部サブタブ。左=一覧(サムネイル/テーブル切替) / 右=詳細 ── */
|
||||
.wfm-video-asset-content { padding: 0 16px 16px; }
|
||||
|
||||
.wfm-video-asset-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wfm-video-asset-list-col {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-detail-col {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-video-asset-view-btn.active {
|
||||
background: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-video-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table-wrap {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table th,
|
||||
.wfm-video-asset-table td {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--wfm-bg-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-asset-row { cursor: pointer; }
|
||||
.wfm-video-asset-row:hover { background: var(--wfm-bg-hover); }
|
||||
.wfm-video-asset-row.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-asset-card {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
transition: outline 0.1s;
|
||||
}
|
||||
|
||||
.wfm-video-asset-card.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-asset-card-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
}
|
||||
|
||||
.wfm-video-asset-card-name {
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-asset-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Edit: クリップタイムライン(Planタブと同じ横トラック+共有ツールバー方式)・
|
||||
トリムパネル・書き出しパネル ──
|
||||
Planの.wfm-video-timeline-trackはブロックをflex-grow(duration比率)で幅一杯に
|
||||
引き伸ばすのに対し、Editのブロックは実秒数×px/秒の固定幅で「左詰め」に並べる
|
||||
(空きトラックはそのまま空く)ため、専用クラスに分けている。 */
|
||||
.wfm-video-edit-panel { padding: 0 16px 16px; }
|
||||
|
||||
.wfm-video-edit-timeline-track {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 48px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
flex: none; /* stay a fixed-height full-width bar; never shrink/stretch with clip content */
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-block {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
background: var(--wfm-bg-hover, rgba(255, 255, 255, 0.04));
|
||||
cursor: grab;
|
||||
overflow: hidden;
|
||||
transition: outline 0.1s;
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-block:active { cursor: grabbing; }
|
||||
.wfm-video-edit-timeline-block.selected { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-edit-timeline-block-name {
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-edit-timeline-block-meta {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-edit-main-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 160px));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-row label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 開始/終了の入力欄+「現在位置を使用」ボタンを縦積みにする。以前は横並びで
|
||||
ボタンのラベルがはみ出し、かつ入力欄とボタンの見分けがつきにくかった。 */
|
||||
.wfm-video-edit-trim-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-trim-field input { width: 100%; box-sizing: border-box; }
|
||||
|
||||
.wfm-video-edit-playhead-btn {
|
||||
align-self: flex-start;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.wfm-video-edit-total-duration {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── トリムスクラバー: clipchamp風の時間目盛り+シークバー+トリムハンドル。
|
||||
すべて%位置指定(clip.duration基準)にすることでpx/秒換算を持たずに済ませている。 */
|
||||
.wfm-video-trim-scrubber {
|
||||
margin-top: 10px;
|
||||
/* start/end/playheadのバッジ(track上端から浮く)が上のクリップ名や
|
||||
目盛りと重ならないよう、あらかじめ専用の余白を確保しておく。
|
||||
playheadバッジはstart/endバッジより高い位置に2段目として出すため
|
||||
(再生位置がトリム開始/終了に近いとき同じ高さだと文字が重なるため)、
|
||||
その分も含めた高さを確保する。 */
|
||||
padding-top: 44px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wfm-video-trim-ruler {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-trim-ruler-tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-trim-track {
|
||||
position: relative;
|
||||
height: 52px;
|
||||
margin-top: 4px;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-video-trim-range {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(70, 130, 230, 0.3);
|
||||
border-top: 2px solid var(--wfm-primary);
|
||||
border-bottom: 2px solid var(--wfm-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-video-trim-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
margin-left: -5px;
|
||||
background: var(--wfm-primary);
|
||||
border-radius: 2px;
|
||||
cursor: ew-resize;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.wfm-video-trim-playhead {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
background: #fff;
|
||||
/* Below the trim handles (z-index:3) so a handle sitting at the same
|
||||
position (e.g. right after "Set from playhead") stays grabbable instead
|
||||
of the playhead's hit area stealing the pointer. */
|
||||
z-index: 2;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.wfm-video-trim-playhead::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wfm-video-trim-badge {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-video-trim-badge-playhead {
|
||||
background: #000;
|
||||
/* start/endバッジ(-24px)より上の2段目に出し、再生位置がトリム範囲の
|
||||
端に重なっても文字同士がぶつからないようにする */
|
||||
top: -42px;
|
||||
border: 1px solid var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-video-edit-export-panel {
|
||||
width: 220px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.wfm-video-asset-detail label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-video-asset-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-asset-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--wfm-bg-secondary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wfm-video-asset-tag button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── 中央ペイン: プレビュー + 編集 ── */
|
||||
@@ -89,8 +653,9 @@
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
/* 生成結果 or ソース選択エリアで読み込んだ動画を横並びで表示する行。
|
||||
プレビュー枠が主役、ソースパネルは選択UIのみの固定幅。 */
|
||||
/* 3カラム構成: 左=Video Source選択UI(固定幅) / 中央=Asset選択・Sourceドロップ兼用
|
||||
プレビュー / 右=生成結果専用プレビュー。.wfm-video-preview-frameは共通クラスなので
|
||||
両プレビュー枠とも自動的にflex:1で均等幅になる。 */
|
||||
.wfm-video-preview-row {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -101,18 +666,24 @@
|
||||
.wfm-video-preview-frame {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 260px;
|
||||
height: 260px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ソース動画/画像のネイティブサイズがフレームより大きい場合のみ縮小し、
|
||||
小さい場合は拡大しない(枠のheightを固定にしてPlan/Editの位置がずれないようにするため) */
|
||||
.wfm-video-preview-el {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -134,18 +705,6 @@
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
/* 将来の動画編集機能(トリミング等)用のプレースホルダー領域 */
|
||||
.wfm-video-edit-panel {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 右ペイン: プロパティ (Frame抽出 / GIF変換タブ) ── */
|
||||
.wfm-video-properties-panel {
|
||||
@@ -183,3 +742,70 @@
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 左ペイン Project サブタブ: 保存済みPlan一覧 ── */
|
||||
.wfm-video-project-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-video-project-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-video-project-item:hover { border-color: var(--wfm-primary); }
|
||||
.wfm-video-project-item.active { outline: 2px solid var(--wfm-primary); outline-offset: -2px; }
|
||||
|
||||
.wfm-video-project-item-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
}
|
||||
|
||||
.wfm-video-project-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-meta {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-delete {
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.wfm-video-project-item-delete:hover { color: var(--wfm-danger); }
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Shared AI backend settings modal (Backend / Connection / Model / Generation / Unload / Save).
|
||||
* Used by the Prompt tab and Tagger tab, each with its own localStorage key so their
|
||||
* settings stay independent — mirrors the AI TOOL tab's own settings pane (ai-tab.js).
|
||||
*/
|
||||
|
||||
import { openModal, closeModal, showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, unslothProxy, unloadAiModel } from "./util.js";
|
||||
|
||||
function loadCfg(storageKey) {
|
||||
return readJsonStorage(storageKey);
|
||||
}
|
||||
|
||||
function saveCfg(storageKey, patch) {
|
||||
const data = { ...loadCfg(storageKey), ...patch };
|
||||
localStorage.setItem(storageKey, JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
// 保存済み設定を {backend, url, model, thinkingMode, maxTokens} の形で返す
|
||||
export function getAiBackendConfig(storageKey) {
|
||||
const s = loadCfg(storageKey);
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return {
|
||||
backend,
|
||||
url,
|
||||
model: s.model || "",
|
||||
thinkingMode: !!s.thinkingMode,
|
||||
maxTokens: Math.max(0, parseInt(s.maxTokens, 10) || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function isValidBackendUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBackendModels(url, backend) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).models?.map(m => m.name) || [];
|
||||
} else if (backend === "unsloth") {
|
||||
const data = await unslothProxy(url, "/v1/models", "GET");
|
||||
return (data.data || []).map(m => m.id);
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} storageKey localStorage key holding {backend, backendUrl, model, thinkingMode, maxTokens}
|
||||
* @param {string} title modal title
|
||||
* @param {() => void} [onSaved] called after Save persists the config (e.g. to refresh a status label)
|
||||
*/
|
||||
export function openAiBackendSettingsModal(storageKey, title, onSaved) {
|
||||
const saved = loadCfg(storageKey);
|
||||
const backend = saved.backend || "ollama";
|
||||
|
||||
const html = `
|
||||
<div class="wfm-ai-settings-container">
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsBackend")}</div>
|
||||
<div class="wfm-ai-backend-row">
|
||||
${[["ollama", "Ollama"], ["lmstudio", "LM Studio"], ["lemonade", "Lemonade"], ["unsloth", "Unsloth"]].map(([v, label]) => `
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-aism-backend" value="${v}" ${v === backend ? "checked" : ""}> ${label}
|
||||
</label>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsConnection")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label">URL</label>
|
||||
<input type="text" id="wfm-aism-url" class="wfm-input wfm-ai-url-input" value="${saved.backendUrl || getAiBackendDefaultUrl(backend)}">
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<button id="wfm-aism-test-btn" class="wfm-btn">${t("aiSettingsTestBtn")}</button>
|
||||
<button id="wfm-aism-unload-btn" class="wfm-btn">${t("aiUnloadModel")}</button>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<span id="wfm-aism-test-result" class="wfm-ai-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsModelSection")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<select id="wfm-aism-model" class="wfm-select wfm-ai-model-select">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button id="wfm-aism-refresh-btn" class="wfm-btn">${t("aiSettingsRefreshBtn")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title">${t("aiSettingsGenerationTitle")}</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-aism-thinking"> ${t("aiSettingsThinkingMode")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label">${t("aiSettingsMaxTokens")}</label>
|
||||
<input type="number" id="wfm-aism-max-tokens" class="wfm-input" min="0" step="1" placeholder="${t("aiSettingsMaxTokensPlaceholder")}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<button id="wfm-aism-save-btn" class="wfm-btn wfm-btn-primary">${t("aiSettingsSaveBtn")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(title, html);
|
||||
|
||||
const urlInput = document.getElementById("wfm-aism-url");
|
||||
const modelSelect = document.getElementById("wfm-aism-model");
|
||||
const thinkingCheckbox = document.getElementById("wfm-aism-thinking");
|
||||
const maxTokensInput = document.getElementById("wfm-aism-max-tokens");
|
||||
const testResultEl = document.getElementById("wfm-aism-test-result");
|
||||
|
||||
thinkingCheckbox.checked = !!saved.thinkingMode;
|
||||
if (saved.maxTokens) maxTokensInput.value = saved.maxTokens;
|
||||
|
||||
function currentBackend() {
|
||||
return document.querySelector("input[name='wfm-aism-backend']:checked")?.value || "ollama";
|
||||
}
|
||||
|
||||
async function populateModels(selectedModel) {
|
||||
const url = urlInput.value.trim();
|
||||
try {
|
||||
const models = await fetchBackendModels(url, currentBackend());
|
||||
modelSelect.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === selectedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
} catch {
|
||||
modelSelect.innerHTML = `<option value="">${t("noModelsFound")}</option>`;
|
||||
}
|
||||
}
|
||||
populateModels(saved.model || "");
|
||||
|
||||
document.querySelectorAll("input[name='wfm-aism-backend']").forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
testResultEl.textContent = "";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-refresh-btn")?.addEventListener("click", () => populateModels(modelSelect.value));
|
||||
|
||||
document.getElementById("wfm-aism-test-btn")?.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!isValidBackendUrl(url)) {
|
||||
testResultEl.textContent = t("aiToastInvalidUrlInput");
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
return;
|
||||
}
|
||||
testResultEl.textContent = t("aiStatusConnecting");
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-working";
|
||||
try {
|
||||
const models = await fetchBackendModels(url, currentBackend());
|
||||
testResultEl.textContent = `${t("aiStatusConnectOk")} (${models.length} ${t("aiModels")})`;
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-ok";
|
||||
await populateModels(modelSelect.value);
|
||||
} catch (err) {
|
||||
testResultEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
testResultEl.className = "wfm-ai-test-result wfm-ai-status-error";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-unload-btn")?.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
const model = modelSelect.value;
|
||||
if (!model) { showToast(t("aiUnloadNoModel"), "error"); return; }
|
||||
try {
|
||||
await unloadAiModel(url, currentBackend(), model);
|
||||
showToast(t("aiUnloadDone"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message === "UNSUPPORTED" ? t("aiUnloadUnsupported") : t("aiUnloadFailed") + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-aism-save-btn")?.addEventListener("click", () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
saveCfg(storageKey, {
|
||||
backend: currentBackend(),
|
||||
backendUrl: url,
|
||||
model: modelSelect.value,
|
||||
thinkingMode: !!thinkingCheckbox.checked,
|
||||
maxTokens: Math.max(0, parseInt(maxTokensInput.value, 10) || 0),
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
closeModal();
|
||||
if (onSaved) onSaved();
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml, unloadAiModel } from "./util.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
@@ -456,6 +456,20 @@ function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Unload model
|
||||
document.getElementById("wfm-ai-unload-btn")?.addEventListener("click", async () => {
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
const url = urlInput?.value?.trim() || "";
|
||||
const model = document.getElementById("wfm-ai-model-select")?.value || "";
|
||||
if (!model) { showToast(t("aiUnloadNoModel"), "error"); return; }
|
||||
try {
|
||||
await unloadAiModel(url, backend, model);
|
||||
showToast(t("aiUnloadDone"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message === "UNSUPPORTED" ? t("aiUnloadUnsupported") : t("aiUnloadFailed") + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Model refresh
|
||||
document.getElementById("wfm-ai-model-refresh-btn")?.addEventListener("click", () => refreshModels());
|
||||
|
||||
|
||||
@@ -245,6 +245,10 @@ function applyI18nToHtml() {
|
||||
if (galleryBulkMove) galleryBulkMove.textContent = t("galleryBulkMoveTo");
|
||||
const galleryBulkExport = document.getElementById("wfm-gallery-bulk-export");
|
||||
if (galleryBulkExport) galleryBulkExport.textContent = t("galleryBulkExport");
|
||||
const galleryBulkPsd = document.getElementById("wfm-gallery-bulk-psd");
|
||||
if (galleryBulkPsd) galleryBulkPsd.textContent = t("galleryBulkPsd");
|
||||
const galleryBulkEditLayers = document.getElementById("wfm-gallery-bulk-edit-layers");
|
||||
if (galleryBulkEditLayers) galleryBulkEditLayers.textContent = t("galleryBulkEditLayers");
|
||||
const galleryBulkDelete = document.getElementById("wfm-gallery-bulk-delete");
|
||||
if (galleryBulkDelete) galleryBulkDelete.textContent = t("galleryBulkDelete");
|
||||
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
|
||||
@@ -319,10 +323,8 @@ function applyI18nToHtml() {
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
const presetsHeader = document.querySelector(".wfm-prompt-split-right .wfm-prompt-split-header");
|
||||
if (presetsHeader) presetsHeader.textContent = t("presetsSubtab");
|
||||
const ollamaRefreshBtn = document.getElementById("wfm-ollama-refresh-btn");
|
||||
if (ollamaRefreshBtn) ollamaRefreshBtn.textContent = t("refresh");
|
||||
const ollamaTestBtn = document.getElementById("wfm-ollama-test-btn");
|
||||
if (ollamaTestBtn) ollamaTestBtn.textContent = t("test");
|
||||
const ollamaSettingsBtn = document.getElementById("wfm-ollama-settings-btn");
|
||||
if (ollamaSettingsBtn) ollamaSettingsBtn.title = t("settings");
|
||||
const ollamaAttachBtn = document.getElementById("wfm-ollama-attach-btn");
|
||||
if (ollamaAttachBtn) ollamaAttachBtn.textContent = t("attachImage");
|
||||
const ollamaClearBtn = document.getElementById("wfm-ollama-clear-btn");
|
||||
@@ -487,6 +489,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-28": "helpGen28",
|
||||
"wfm-help-gen-29": "helpGen29",
|
||||
"wfm-help-gen-30": "helpGen30",
|
||||
"wfm-help-gen-31": "helpGen31",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
@@ -575,6 +578,8 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gallery-22": "helpGallery22", "wfm-help-gallery-23": "helpGallery23",
|
||||
"wfm-help-gallery-24": "helpGallery24", "wfm-help-gallery-25": "helpGallery25",
|
||||
"wfm-help-gallery-26": "helpGallery26",
|
||||
"wfm-help-gallery-28": "helpGallery28", "wfm-help-gallery-29": "helpGallery29",
|
||||
"wfm-help-gallery-30": "helpGallery30",
|
||||
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
|
||||
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
|
||||
"wfm-help-tagger-title": "helpTaggerTitle",
|
||||
@@ -630,6 +635,8 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
|
||||
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
|
||||
"wfm-help-imageedit-30": "helpImageEdit30",
|
||||
"wfm-help-imageedit-31": "helpImageEdit31", "wfm-help-imageedit-32": "helpImageEdit32",
|
||||
"wfm-help-imageedit-33": "helpImageEdit33", "wfm-help-imageedit-34": "helpImageEdit34",
|
||||
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
|
||||
"wfm-help-imageedit-8b": "helpImageEdit8b",
|
||||
@@ -736,7 +743,9 @@ function applyI18nToHtml() {
|
||||
"wfm-help-video-1": "helpVideo1", "wfm-help-video-2": "helpVideo2",
|
||||
"wfm-help-video-3": "helpVideo3", "wfm-help-video-4": "helpVideo4",
|
||||
"wfm-help-video-5": "helpVideo5", "wfm-help-video-6": "helpVideo6",
|
||||
"wfm-help-video-7": "helpVideo7",
|
||||
"wfm-help-video-7": "helpVideo7", "wfm-help-video-8": "helpVideo8",
|
||||
"wfm-help-video-9": "helpVideo9", "wfm-help-video-10": "helpVideo10",
|
||||
"wfm-help-video-11": "helpVideo11", "wfm-help-video-12": "helpVideo12",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
|
||||
@@ -1446,6 +1446,12 @@ export const comfyEditor = {
|
||||
<label>Batch Size</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-batch" value="${latent.batch_size ?? 1}" min="1" max="64">
|
||||
</div>
|
||||
${latent.isVideoLatent ? `
|
||||
<div class="wfm-form-group">
|
||||
<label>Length (frames)</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-latent-length" value="${latent.length ?? ""}" placeholder="${latent.length === undefined ? "linked" : ""}" step="1" min="1">
|
||||
</div>
|
||||
` : ""}
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-latent-apply">Apply</button>
|
||||
${latent.width === undefined || latent.height === undefined ? `<p class="wfm-placeholder" style="margin-top:6px;">Width/Height is linked to another node (e.g. auto-follows the input image) — leave blank to keep it linked.</p>` : ""}
|
||||
<div style="margin-top:10px;border-top:1px solid var(--wfm-border);padding-top:10px;">
|
||||
@@ -1502,6 +1508,8 @@ export const comfyEditor = {
|
||||
if (wEl?.value !== "") inputs.width = parseInt(wEl.value) || 512;
|
||||
if (hEl?.value !== "") inputs.height = parseInt(hEl.value) || 512;
|
||||
inputs.batch_size = parseInt(document.getElementById("wfm-settings-batch")?.value) || 1;
|
||||
const lenEl = document.getElementById("wfm-settings-latent-length");
|
||||
if (lenEl && lenEl.value !== "") inputs.length = parseInt(lenEl.value) || 1;
|
||||
_syncRawJson();
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,35 @@ async function _loadObjectInfo() {
|
||||
* Get ordered widget (non-link) input names for a node class from object_info.
|
||||
* These correspond to widgets_values in UI-format nodes.
|
||||
*/
|
||||
// Scalar widget types object_info can declare for a required/optional input.
|
||||
const _WIDGET_TYPE_SET = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", "COMFY_DYNAMICCOMBO_V3"]);
|
||||
|
||||
/**
|
||||
* Resolve a possibly-compound object_info type spec (e.g. LTXVEmptyLatentAudio's frame_rate:
|
||||
* "FLOAT,INT", a MultiType declaration meaning "widget defaults to FLOAT but also accepts an INT
|
||||
* link") down to the single scalar widget type it actually occupies a widgets_values slot as.
|
||||
* Preferring the explicit widgetType hint some MultiType specs carry, else the first comma-
|
||||
* separated segment. Returns null for link-only types (MODEL, CLIP, LATENT, etc.) or arrays that
|
||||
* aren't combo option lists.
|
||||
*
|
||||
* Without this, a plain `upper === "INT" || ...` exact-match check silently drops any
|
||||
* comma-joined MultiType widget from the name/type lists entirely — which corrupts every widget
|
||||
* declared after it in the node's schema order in _simulateWidgetValues (see its own docstring),
|
||||
* exactly the class of bug that produced e.g. LTXVEmptyLatentAudio's batch_size reading
|
||||
* frame_rate's stale value instead of its own.
|
||||
*/
|
||||
function _resolveWidgetType(type, spec) {
|
||||
if (Array.isArray(type)) return "COMBO";
|
||||
if (typeof type !== "string") return null;
|
||||
const explicit = spec?.[1]?.widgetType;
|
||||
if (typeof explicit === "string" && _WIDGET_TYPE_SET.has(explicit.toUpperCase())) return explicit.toUpperCase();
|
||||
for (const part of type.split(",")) {
|
||||
const upper = part.trim().toUpperCase();
|
||||
if (_WIDGET_TYPE_SET.has(upper)) return upper;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _getWidgetInputNames(objectInfo, classType) {
|
||||
const info = objectInfo[classType];
|
||||
if (!info) return [];
|
||||
@@ -89,31 +118,16 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// (e.g. CLIPTextEncodeEditPlus's text1/text2). Never has a widgets_values entry.
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
// Link types are strings like "MODEL", "CLIP", "CONDITIONING", "LATENT", "IMAGE", etc.
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", or an array of choices
|
||||
if (Array.isArray(type)) {
|
||||
// Combo/enum widget
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
|
||||
}
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", an array of choices, or a
|
||||
// comma-joined MultiType union of those (e.g. "FLOAT,INT") — see _resolveWidgetType.
|
||||
if (_resolveWidgetType(type, spec)) names.push(name);
|
||||
}
|
||||
|
||||
// Optional inputs that are widgets
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
if (_resolveWidgetType(type, spec)) names.push(name);
|
||||
}
|
||||
|
||||
return names;
|
||||
@@ -133,26 +147,14 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
const resolved = _resolveWidgetType(type, spec);
|
||||
if (resolved) types.push(resolved);
|
||||
}
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
const resolved = _resolveWidgetType(type, spec);
|
||||
if (resolved) types.push(resolved);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
@@ -614,6 +616,18 @@ export const comfyWorkflow = {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
// Forces the next convertUiToApi() call to refetch /object_info instead of
|
||||
// reusing the cached snapshot. Needed whenever a file gets uploaded mid-session
|
||||
// after the cache was already populated (e.g. Video Plan's block-chaining
|
||||
// uploads an extracted frame between batch iterations) — a stale cache's
|
||||
// LoadImage "image" COMBO choices wouldn't include the just-uploaded filename
|
||||
// yet, so the COMBO-mismatch fallback a few lines below (silently substituting
|
||||
// choices[0] when a value isn't in the known list — see the file-level comment
|
||||
// at the top) would replace it with an unrelated pre-existing file instead.
|
||||
invalidateObjectInfoCache() {
|
||||
_objectInfoCache = null;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
@@ -962,6 +976,14 @@ export const comfyWorkflow = {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// LTXVDualCFGGuider (LTX-2.5's video+audio guider) — positive/negative both
|
||||
// commonly point at the *same* LTXVConditioning node (different output slots),
|
||||
// so this only seeds the role on that node; LTXVConditioning below splits it
|
||||
// back out per-slot onto its own positive/negative inputs.
|
||||
if (ct === "LTXVDualCFGGuider") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
|
||||
@@ -1038,6 +1060,13 @@ export const comfyWorkflow = {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// LTXVConditioning (LTX-2.5) — same shape as InstructPixToPixConditioning:
|
||||
// separate positive/negative inputs feeding separate outputs consumed by
|
||||
// LTXVDualCFGGuider, so each role must route only to its own input.
|
||||
if (ct === "LTXVConditioning") {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — used by Ernie
|
||||
// Image's prompt-enhancement toggle to pick between the raw PrimitiveStringMultiline
|
||||
// text and an LLM-generated (TextGenerate) rewrite. The rewrite can't be known
|
||||
@@ -1363,6 +1392,21 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Video latent nodes (Wan2.2's EmptyHunyuanLatentVideo, LTX-2.5's
|
||||
// EmptyLTXVLatentVideo) — same width/height/batch_size shape as EmptyLatentImage
|
||||
// plus a frame-count "length" input, usually linked to a duration*fps expression
|
||||
// (see video-tab.js's own Wan2.2/LTX-2.5 handling) rather than a literal number —
|
||||
// left undefined in that case so the settings-tab UI shows "linked".
|
||||
if (ct === "EmptyHunyuanLatentVideo" || ct === "EmptyLTXVLatentVideo") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title, isVideoLatent: true,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
length: typeof inputs.length === "number" ? inputs.length : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ResolutionSelector (ComfyUI core, comfy_extras.nodes_resolution): typically
|
||||
// feeds an EmptyLatentImage's width/height rather than being a latent node
|
||||
// itself. aspect_ratio is a fixed-choice COMBO (e.g. "3:2 (Photo)") — its exact
|
||||
@@ -1537,7 +1581,12 @@ export const comfyWorkflow = {
|
||||
// generation timeout (video generation runs far longer than a still image) — see
|
||||
// generate-tab.js _coreGenerate and lab-tab.js _runLabBatch.
|
||||
isVideoWorkflow(analysis) {
|
||||
return (analysis?.prompt_nodes || []).some((n) => /ImageToVideo|TextToVideo/.test(n.type));
|
||||
if ((analysis?.prompt_nodes || []).some((n) => /ImageToVideo|TextToVideo/.test(n.type))) return true;
|
||||
// LTX-2.5/Wan2.2: no all-in-one *ToVideo node — detected instead via their
|
||||
// EmptyHunyuanLatentVideo/EmptyLTXVLatentVideo latent node (see analyzeWorkflow)
|
||||
// or a SaveVideo/CreateVideo node anywhere in the flattened graph.
|
||||
if ((analysis?.latent_nodes || []).some((n) => n.isVideoLatent)) return true;
|
||||
return (analysis?.all_nodes || []).some((n) => n.type === "SaveVideo" || n.type === "CreateVideo");
|
||||
},
|
||||
|
||||
applyParams(workflow, params) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { callLLM, loadAiSettings, isValidBackendUrl } from "./ai-tab.js";
|
||||
import { initImagePromptTab, activateImagePromptTab } from "./image-prompt-tab.js";
|
||||
import { initStyleCatalogTab, activateStyleCatalogTab } from "./style-catalog-tab.js";
|
||||
import { applyStoredVideoVolume } from "./settings-tab.js";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,13 +43,24 @@ const API = {
|
||||
imagesDelete: `/wfm/gallery/images/delete`,
|
||||
imagesMove: `/wfm/gallery/images/move`,
|
||||
imagesExportZip: `/wfm/gallery/images/export-zip`,
|
||||
imagesExportPsd: `/wfm/gallery/images/export-psd`,
|
||||
};
|
||||
|
||||
export const FEEDER_GROUP = "__Feeder__";
|
||||
export const VIDEO_GROUP = "__Video Assets__";
|
||||
// Video Planを実行するたびに生成された動画が自動的に追加される作業用グループ
|
||||
// (Plan保存の有無に関わらず常にここへ入る)。__Video Assets__(ユーザーが手動で
|
||||
// キュレーションする素材グループ)への整理はユーザー任意のタイミングで行う。
|
||||
export const VTEMP_GROUP = "__Video Temp__";
|
||||
|
||||
const _RESERVED_GROUPS = [FEEDER_GROUP, VIDEO_GROUP, VTEMP_GROUP];
|
||||
function _isReservedGroup(name) {
|
||||
return _RESERVED_GROUPS.includes(name);
|
||||
}
|
||||
|
||||
// 拡張子から動画ファイル(mp4)かどうかを判定する。
|
||||
// 一覧アイテムは path/filename、詳細メタ(get_image_metadata)は ext フィールドを持つため両対応。
|
||||
function isVideoFile(img) {
|
||||
export function isVideoFile(img) {
|
||||
const source = img?.ext || img?.filename || img?.path || "";
|
||||
return source.toLowerCase().endsWith(".mp4");
|
||||
}
|
||||
@@ -221,6 +233,9 @@ window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, work
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
let _scrollObserver = null;
|
||||
let _loadImagesAbortController = null;
|
||||
let _searchDebounceTimer = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// ── 状態 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -322,6 +337,30 @@ async function exportSelectedImagesToZip(paths) {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSelectedImagesToPsd(paths) {
|
||||
if (paths.length === 0) return;
|
||||
try {
|
||||
const res = await fetch(API.imagesExportPsd, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ paths }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `gallery_export_${Date.now()}.psd`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t("psdExportCompleted"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
function _getExpandedPaths() {
|
||||
@@ -467,6 +506,16 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
|
||||
// root フォルダはデフォルトで展開状態にする(フォルダスキャン高速化により
|
||||
// サブフォルダの内容もすぐ確認できるようになったため、折りたたみ初期状態だと
|
||||
// 気付きにくい)
|
||||
if (isRoot) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
@@ -477,6 +526,16 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
|
||||
// 前回のリクエストがまだ処理中なら中断する。中断せず並行させると、後から
|
||||
// 返ってきた古いレスポンス(例: クリア直前に打っていた検索文字列の結果)が
|
||||
// 最新の表示を上書きしてしまい、「クリアしても検索結果のまま/読み込み中の
|
||||
// まま」に見える不具合の原因になっていた。
|
||||
if (_loadImagesAbortController) {
|
||||
_loadImagesAbortController.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
_loadImagesAbortController = controller;
|
||||
|
||||
const grid = document.getElementById("wfm-gallery-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
@@ -488,18 +547,29 @@ async function loadImages() {
|
||||
if (state.favoriteOnly) params.favorite = "true";
|
||||
if (state.tagFilter) params.tag = state.tagFilter;
|
||||
|
||||
// グループフィルタはサーバーサイドで処理
|
||||
if (state.groupFilter) params.group = state.groupFilter;
|
||||
// グループフィルタはサーバーサイドで処理。グループのメンバーは現在のフォルダの
|
||||
// サブフォルダに散らばっている可能性がある(例: 動画は"video"サブフォルダ配下)ため、
|
||||
// 通常のフォルダ単位ブラウズ(非再帰)とは異なり、フィルタ時はフォルダ配下を再帰的に
|
||||
// 検索する。
|
||||
if (state.groupFilter) {
|
||||
params.group = state.groupFilter;
|
||||
params.recursive = "true";
|
||||
}
|
||||
|
||||
try {
|
||||
const images = (await apiFetch(API.images(params))).images || [];
|
||||
const images = (await apiFetch(API.images(params), { signal: controller.signal })).images || [];
|
||||
state.images = images;
|
||||
state.lastSelectionIndex = -1;
|
||||
document.getElementById("wfm-gallery-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
updateTagFilter(state.images);
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") return;
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
} finally {
|
||||
if (_loadImagesAbortController === controller) {
|
||||
_loadImagesAbortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,7 +980,9 @@ async function loadImageDetail(img) {
|
||||
${mediaHtml}
|
||||
</div>
|
||||
`;
|
||||
preview.querySelector(isVideo ? "video" : "img").addEventListener("dblclick", () => openLightbox(img));
|
||||
const previewMediaEl = preview.querySelector(isVideo ? "video" : "img");
|
||||
previewMediaEl.addEventListener("dblclick", () => openLightbox(img));
|
||||
if (isVideo) applyStoredVideoVolume(previewMediaEl);
|
||||
|
||||
// ファイル名
|
||||
document.getElementById("wfm-gallery-detail-filename").textContent = img.filename;
|
||||
@@ -1122,6 +1194,33 @@ export async function ensureFeederGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
/** __Video Assets__ と __Video Temp__ の両方が存在しない場合に作成する。add_to_groupは
|
||||
* 対象グループがレジストリに登録されているかを確認せず画像側にグループ名を書き込むだけの
|
||||
* ため、一度レジストリから消える(例: 予約保護が効く前の版で削除された)と、以後の手動追加は
|
||||
* 画像には記録されてもグループ一覧・フィルタには一切出てこなくなる。VideoタブのAsset
|
||||
* タブ初期化時に呼び、レジストリを確実に存在させる。
|
||||
* (__Video Temp__側もここでensureする — 以前はensureVideoGroupがVIDEO_GROUPしか
|
||||
* 保証しておらず、__Video Temp__のレジストリが何らかの理由で消えると誰も復活させられない
|
||||
* バグがあった。バッチRun開始時のensure(video-plan-tab.jsの_ensureVideoAssetGroups)は
|
||||
* Runするまで呼ばれないため、それだけでは不十分。) */
|
||||
export async function ensureVideoGroup() {
|
||||
try {
|
||||
await fetch(API.groupEnsure, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: VIDEO_GROUP }),
|
||||
});
|
||||
await fetch(API.groupEnsure, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: VTEMP_GROUP }),
|
||||
});
|
||||
await loadGroups();
|
||||
} catch (e) {
|
||||
console.warn("[Gallery] ensureVideoGroup error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Feederグループ内の全画像を除外する(FC ボタン) */
|
||||
async function clearFeederGroup() {
|
||||
try {
|
||||
@@ -1136,6 +1235,19 @@ async function clearFeederGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
/** __Video Temp__グループ内の全画像を除外する(詳細パネルの「Clear __Video Temp__」ボタン) */
|
||||
async function clearVtempGroup() {
|
||||
try {
|
||||
await fetch(API.groupClear(VTEMP_GROUP), { method: "POST" });
|
||||
showToast(t("vtempGroupCleared"), "success");
|
||||
if (state.groupFilter === VTEMP_GROUP) {
|
||||
await loadImages();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** ツールバーのグループフィルタと一括バーのセレクトを更新 */
|
||||
function _updateGroupSelects() {
|
||||
// ツールバー: グループフィルタ
|
||||
@@ -1215,15 +1327,16 @@ function renderDetailGroup(img) {
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map(g => {
|
||||
const label = g === FEEDER_GROUP ? `🔒 ${escapeHtml(g)}` : escapeHtml(g);
|
||||
const label = _isReservedGroup(g) ? `🔒 ${escapeHtml(g)}` : escapeHtml(g);
|
||||
return `<option value="${escapeHtml(g)}">${label}</option>`;
|
||||
}).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-gallery-grp-rename-btn"
|
||||
${allGroups.length === 0 || allGroups[0] === FEEDER_GROUP ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
${allGroups.length === 0 || _isReservedGroup(allGroups[0]) ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-gallery-grp-delete-btn"
|
||||
${allGroups.length === 0 || allGroups[0] === FEEDER_GROUP ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
${allGroups.length === 0 || _isReservedGroup(allGroups[0]) ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-gallery-vtemp-clear-btn" style="width:100%;margin-top:6px;" title="${t("vtempClearBtnTitle")}">${t("vtempClearBtnLabel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1302,15 +1415,18 @@ function renderDetailGroup(img) {
|
||||
}
|
||||
});
|
||||
|
||||
// 管理セレクト変更時: __Feeder__ は rename/delete を無効化
|
||||
// 管理セレクト変更時: 予約グループ(__Feeder__/__Video Assets__/__Video Temp__)は rename/delete を無効化
|
||||
el.querySelector("#wfm-gallery-grp-manage-sel")?.addEventListener("change", (e) => {
|
||||
const isReserved = e.target.value === FEEDER_GROUP;
|
||||
const isReserved = _isReservedGroup(e.target.value);
|
||||
const renameBtn = el.querySelector("#wfm-gallery-grp-rename-btn");
|
||||
const deleteBtn = el.querySelector("#wfm-gallery-grp-delete-btn");
|
||||
if (renameBtn) renameBtn.disabled = isReserved;
|
||||
if (deleteBtn) deleteBtn.disabled = isReserved;
|
||||
});
|
||||
|
||||
// Clear __Video Temp__ ボタン: __Video Temp__ グループをクリア(Video Plan実行時に自動登録される生成物置き場)
|
||||
el.querySelector("#wfm-gallery-vtemp-clear-btn")?.addEventListener("click", clearVtempGroup);
|
||||
|
||||
// グループ名変更
|
||||
el.querySelector("#wfm-gallery-grp-rename-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-gallery-grp-manage-sel");
|
||||
@@ -1647,6 +1763,7 @@ function openLightbox(img) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
if (isVideoFile(img)) applyStoredVideoVolume(overlay.querySelector("video"));
|
||||
}
|
||||
|
||||
// ── 画像比較ライトボックス ─────────────────────────────────────
|
||||
@@ -1682,6 +1799,7 @@ function openCompare(paths) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelectorAll("video").forEach(applyStoredVideoVolume);
|
||||
}
|
||||
|
||||
// ── outputパス取得 ────────────────────────────────────────────
|
||||
@@ -1761,17 +1879,32 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
// 選択画像を Image Edit タブへ送信
|
||||
document.getElementById("wfm-gallery-send-image-edit-btn")?.addEventListener("click", () => {
|
||||
document.getElementById("wfm-gallery-send-image-edit-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedImage) {
|
||||
showToast("Please select an image first", "info");
|
||||
return;
|
||||
}
|
||||
const url = API.serveImage(state.selectedImage.path);
|
||||
if (!window._wfmImageEditTab) return;
|
||||
const path = state.selectedImage.path;
|
||||
const name = (state.selectedImage.filename || "gallery-image").replace(/\.[^.]+$/, "");
|
||||
if (window._wfmImageEditTab) {
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
window._wfmImageEditTab.loadFromUrl(url, name);
|
||||
|
||||
// PSDはフラット画像1枚ではなくレイヤー分解して送る(Open PSDボタンと同じ経路)
|
||||
if (/\.psd$/i.test(path)) {
|
||||
try {
|
||||
const r = await fetch(`/wfm/gallery/image/psd-layers?path=${encodeURIComponent(path)}`);
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
|
||||
const data = await r.json();
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
await window._wfmImageEditTab.loadPsdLayersData(data, name);
|
||||
} catch (err) {
|
||||
showToast(`PSD import failed: ${err.message}`, "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const url = API.serveImage(path);
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
window._wfmImageEditTab.loadFromUrl(url, name);
|
||||
});
|
||||
|
||||
// 選択画像を GenerateUI Image タブへ送信
|
||||
@@ -1853,6 +1986,7 @@ function bindEvents() {
|
||||
return;
|
||||
}
|
||||
const url = API.serveImage(state.selectedImage.path);
|
||||
const isVideo = isVideoFile(state.selectedImage);
|
||||
|
||||
// I2I送信元がImageタブだった場合はそちらへ画像を読み込ませる(_ccI2ITargetModeはComic Creater側が
|
||||
// 「I2Iへ送る」ボタン押下時にセットするフラグ。undefined/'layout'なら従来通りコマ/オーバーレイへ挿入)
|
||||
@@ -1863,6 +1997,11 @@ function bindEvents() {
|
||||
targetMode = null;
|
||||
}
|
||||
if (targetMode === "image") {
|
||||
// Imageタブは動画未対応(Comic Creator側の動画ツールはレイアウトタブのみ対応)
|
||||
if (isVideo) {
|
||||
showToast(t("galleryVideoNotSupportedInImageTab"), "info");
|
||||
return;
|
||||
}
|
||||
let imageTab = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent._ccImageTab?.loadFromUrl === "function") {
|
||||
@@ -1880,6 +2019,26 @@ function bindEvents() {
|
||||
return;
|
||||
}
|
||||
|
||||
// レイアウトタブのコマ/オーバーレイへ(デフォルト経路)。動画(.mp4)はComic Creator側の
|
||||
// 動画ツール専用の受け口(insertVideoFromUrl)へ、それ以外は従来通り画像として送る
|
||||
if (isVideo) {
|
||||
let insertVideoFn = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent.insertVideoFromUrl === "function") {
|
||||
insertVideoFn = window.parent.insertVideoFromUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
insertVideoFn = null;
|
||||
}
|
||||
if (!insertVideoFn) {
|
||||
showToast(t("galleryCCNotAvailable"), "info");
|
||||
return;
|
||||
}
|
||||
insertVideoFn(url, state.selectedImage.filename || "video.mp4");
|
||||
showToast(t("gallerySentCC"), "success");
|
||||
return;
|
||||
}
|
||||
|
||||
let insertFn = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent.insertImageFromUrl === "function") {
|
||||
@@ -1897,12 +2056,15 @@ function bindEvents() {
|
||||
});
|
||||
}
|
||||
|
||||
// 検索
|
||||
// 検索(連続キー入力のたびにリクエストを飛ばすと、50枚程度のフォルダでも
|
||||
// ファイルI/Oの重いサーバー処理が積み重なって読み込みが遅延するためdebounceする)
|
||||
document.getElementById("wfm-gallery-search")?.addEventListener("input", (e) => {
|
||||
state.search = e.target.value;
|
||||
loadImages();
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
_searchDebounceTimer = setTimeout(loadImages, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
setupSearchClearBtn("wfm-gallery-search", "wfm-gallery-search-clear-btn", () => {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
@@ -2056,6 +2218,36 @@ function bindEvents() {
|
||||
exportSelectedImagesToZip([...state.selectedImages]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-gallery-bulk-psd")?.addEventListener("click", () => {
|
||||
if (state.selectedImages.size === 0) return;
|
||||
exportSelectedImagesToPsd([...state.selectedImages]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-gallery-bulk-edit-layers")?.addEventListener("click", () => {
|
||||
if (state.selectedImages.size === 0) return;
|
||||
if (!window._wfmImageEditTab) return;
|
||||
|
||||
const allPaths = [...state.selectedImages];
|
||||
const paths = allPaths.filter(p => !/\.mp4$/i.test(p));
|
||||
if (paths.length === 0) {
|
||||
showToast(t("editLayersNoImages"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const items = paths.map(p => {
|
||||
const img = state.images.find(i => i.path === p);
|
||||
const filename = img?.filename || p.split(/[\\/]/).pop() || "layer";
|
||||
return { url: API.serveImage(p), name: filename.replace(/\.[^.]+$/, "") };
|
||||
});
|
||||
|
||||
document.querySelector('[data-tab="image-edit"]')?.click();
|
||||
window._wfmImageEditTab.loadLayersFromUrls(items);
|
||||
|
||||
if (paths.length < allPaths.length) {
|
||||
showToast(t("editLayersSkippedVideo", allPaths.length - paths.length), "info");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-gallery-bulk-delete")?.addEventListener("click", () => {
|
||||
if (state.selectedImages.size === 0) return;
|
||||
const count = state.selectedImages.size;
|
||||
@@ -2150,6 +2342,7 @@ function bindEvents() {
|
||||
if (galleryClearBtn) {
|
||||
galleryClearBtn.textContent = t("clearFilters");
|
||||
galleryClearBtn.addEventListener("click", () => {
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
state.favoriteOnly = false;
|
||||
state.tagFilter = "";
|
||||
|
||||
@@ -293,6 +293,13 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
|
||||
refreshFeederNodeList();
|
||||
|
||||
// Lab subtab may already be the active one (e.g. it was active when this workflow
|
||||
// was loaded) — the subtab-click handler that normally triggers this refresh never
|
||||
// fires in that case, leaving Setting 1's live-reflected cells showing the previous
|
||||
// workflow until the user clicks a cell or revisits the subtab.
|
||||
const activeSubtab = document.querySelector(".wfm-gen-subtab-btn.active")?.dataset.subtab;
|
||||
if (activeSubtab === "lab") refreshLabLiveDefaults();
|
||||
|
||||
if (ckptWarning) {
|
||||
showToast(ckptWarning, "error");
|
||||
} else {
|
||||
@@ -2284,10 +2291,11 @@ export async function initGenerateTab() {
|
||||
});
|
||||
searchOverlay.innerHTML = html + "\n";
|
||||
|
||||
// Scroll editor to current match
|
||||
// Scroll editor to current match. フォーカス/selectionRangeは意図的に設定しない
|
||||
// (テキストエリアにカーソルが当たった状態になり、検索直後のキー入力が選択範囲を
|
||||
// 上書きして不意にJSONを編集してしまうため)。ハイライトはsearchOverlayの<mark>
|
||||
// 表示のみで行い、編集はユーザーが明示的にエディタをクリックした場合に限る。
|
||||
const pos = matchPositions[currentMatchIndex];
|
||||
editor.focus();
|
||||
editor.setSelectionRange(pos, pos + term.length);
|
||||
const lineNum = (text.slice(0, pos).match(/\n/g) || []).length;
|
||||
const lineH = parseFloat(getComputedStyle(editor).lineHeight) || 18;
|
||||
editor.scrollTop = Math.max(0, lineH * lineNum - editor.clientHeight / 2);
|
||||
|
||||
@@ -186,6 +186,9 @@ const LANGUAGES = {
|
||||
feederGalNoNode: "No WFS_GalleryFeeder node selected",
|
||||
feederGalEmptyGroup: "No images in the selected group",
|
||||
feederGroupCleared: "Feeder group cleared",
|
||||
vtempGroupCleared: "__Video Temp__ group cleared",
|
||||
vtempClearBtnLabel: "Clear __Video Temp__",
|
||||
vtempClearBtnTitle: "Clear the __Video Temp__ group (video generations land here automatically; organize keepers into __Video Assets__ manually anytime)",
|
||||
addedToFeeder: "Added to Feeder",
|
||||
removedFromFeeder: "Removed from Feeder",
|
||||
appliedToWorkflow: "Applied to workflow",
|
||||
@@ -329,6 +332,8 @@ const LANGUAGES = {
|
||||
textSizeLabel: "Text Size",
|
||||
textSizeTextarea: "Prompt / Chat Textarea",
|
||||
textSizeHint: "Applies to: Generate UI prompts, AI Assistant chat, Preset prompts, Wildcard textarea, Metadata prompt preview",
|
||||
videoVolumeLabel: "Video Volume",
|
||||
videoVolumeHint: "Applies to: Video tab preview, Gallery detail/lightbox/compare video players — the level you set here (or adjust via any video's own controls) carries over to the next video opened anywhere in the app.",
|
||||
jsonColorsSection: "RAW JSON Colors",
|
||||
jsonColorBase: "Base text",
|
||||
jsonColorYellow: "Names / Scheduler",
|
||||
@@ -581,6 +586,7 @@ const LANGUAGES = {
|
||||
sendToLiNodeSuccess: (filename) => `Sent "${filename}" to LI node`,
|
||||
galleryCCNotAvailable: "Open this Gallery from ComfyUI Comic Creator to use Send CC",
|
||||
gallerySentCC: "Sent to ComfyUI Comic Creator",
|
||||
galleryVideoNotSupportedInImageTab: "Sending a video to the Image tab is not supported yet",
|
||||
galleryNoEmbeddedWorkflow: "No ComfyUI workflow embedded in this image.",
|
||||
galleryTabOutput: "Output",
|
||||
galleryTabImagePrompt: "ImagePrompt",
|
||||
@@ -619,6 +625,8 @@ const LANGUAGES = {
|
||||
galleryBulkCompare: "Compare",
|
||||
galleryBulkMoveTo: "Move To...",
|
||||
galleryBulkExport: "Export",
|
||||
galleryBulkPsd: "Create PSD",
|
||||
galleryBulkEditLayers: "Edit as Layers",
|
||||
galleryBulkDelete: "Delete File",
|
||||
nodesBulkSelected: "selected",
|
||||
nodesBulkDeselect: "Deselect All",
|
||||
@@ -631,6 +639,9 @@ const LANGUAGES = {
|
||||
nodesBulkUnfavAll: "☆ Unfavorite All",
|
||||
downloadStarted: "Download started",
|
||||
exportCompleted: "Export completed. ZIP file is downloading.",
|
||||
psdExportCompleted: "PSD created. File is downloading.",
|
||||
editLayersNoImages: "No editable images in selection (video files are not supported)",
|
||||
editLayersSkippedVideo: (n) => `Skipped ${n} video file(s)`,
|
||||
|
||||
tabAi: "AI TOOL",
|
||||
tabTagger: "Tagger",
|
||||
@@ -646,7 +657,7 @@ const LANGUAGES = {
|
||||
taggerThreshold: "Threshold",
|
||||
taggerCharThreshold: "Char Thr.",
|
||||
taggerGenerateTags: "Generate Tags",
|
||||
taggerOllamaEnable: "Ollama VLM",
|
||||
taggerOllamaEnable: "VLM Tagging",
|
||||
taggerOllamaUrl: "API URL",
|
||||
taggerOllamaModel: "Model",
|
||||
taggerOllamaPrompt: "Prompt",
|
||||
@@ -686,6 +697,9 @@ const LANGUAGES = {
|
||||
taggerWrittenToFile: "Written to file",
|
||||
taggerSavedToDB: "Saved to DB",
|
||||
taggerSelectGalleryImage: "Select an image in the Gallery tab",
|
||||
taggerDbNoSelection: "Please select rows to delete",
|
||||
taggerDbDeleteConfirm: (count) => `Delete ${count} database records?`,
|
||||
taggerDbDeletedCount: (count) => `${count} database records deleted`,
|
||||
|
||||
// -- AI Tab --
|
||||
aiSubTabTranslate: "Translate",
|
||||
@@ -715,6 +729,12 @@ const LANGUAGES = {
|
||||
aiSettingsInputLang: "Input",
|
||||
aiSettingsOutputLang: "Output",
|
||||
aiSettingsSaveBtn: "Save Settings",
|
||||
aiUnloadModel: "Unload Model",
|
||||
aiUnloading: "Unloading...",
|
||||
aiUnloadDone: "Model unloaded",
|
||||
aiUnloadFailed: "Unload failed: ",
|
||||
aiUnloadUnsupported: "This backend does not support unloading via API",
|
||||
aiUnloadNoModel: "Select a model first",
|
||||
aiToastNoText: "Please enter text",
|
||||
aiToastNoModel: "Please select a model in the Settings tab",
|
||||
aiToastInvalidUrl: "URL must start with http:// or https://",
|
||||
@@ -785,7 +805,8 @@ const LANGUAGES = {
|
||||
helpGen5: "Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones",
|
||||
helpGen28: "Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab",
|
||||
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)",
|
||||
helpGen31: "LTX-2.5 and Wan2.2 video workflows also work when loaded as regular workflows in this tab (not just the dedicated Video tab): Positive/Negative Prompt detection follows LTX-2.5's LTXVDualCFGGuider / LTXVConditioning nodes to find the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's EmptyHunyuanLatentVideo and LTX-2.5's EmptyLTXVLatentVideo nodes (with an added Length (frames) field) instead of showing \"No EmptyLatentImage node found\"; generating from these workflows also gets the same extended timeout as MiniMax H3 video generations",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself); the search bar highlights matches and scrolls to them without moving the cursor into the editor, so typing right after a search can't accidentally overwrite the JSON — click into the editor first to actually edit",
|
||||
helpGen8: "Seed control: random, fixed, increment, decrement",
|
||||
helpGen9: "Generate, progress display, stop, and view results",
|
||||
helpGen10: "Eagle integration: auto-save generated images to Eagle (SVG output supported too — both the standard SaveSVGNode and comfyui-tosvg's Save SVG String node)",
|
||||
@@ -867,7 +888,7 @@ const LANGUAGES = {
|
||||
helpLabResults3: "Loading a saved plan restores its Results grid from the image list recorded in the plan file",
|
||||
|
||||
helpSearchPlaceholder: "Search help...",
|
||||
helpPrompt1: "AI assistant (Ollama / LM Studio / Lemonade): model selection, chat for prompt consultation, image attachment (vision models); backend and URL configured in Settings tab",
|
||||
helpPrompt1: "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",
|
||||
helpPrompt2: "Apply AI responses to GenerateUI prompts",
|
||||
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
|
||||
helpPrompt4: "Preset management: create, edit, positive/negative prompt clipboard copy (PP/NP)",
|
||||
@@ -897,11 +918,11 @@ const LANGUAGES = {
|
||||
helpSettings2: "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)",
|
||||
helpSettings3: "Workflow data folder path",
|
||||
helpSettings4: "ComfyUI connection URL and test",
|
||||
helpSettings5: "AI Assistant (Prompt Tab): backend (Ollama / LM Studio / Lemonade), API URL, default model, and connection test",
|
||||
helpSettings5: "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",
|
||||
helpSettings6: "Default workflow setting",
|
||||
helpSettings7: "Eagle integration URL and auto-save toggle",
|
||||
helpSettings8: "Data Management: export core settings, metadata, and prompts to a JSON file, or import to restore. After importing, restart ComfyUI.",
|
||||
helpSettings15: "Full Backup (ZIP): unlike the JSON export above, also includes folder-based data (AI Skills, Lab Plans, Style presets) and the Tagger database — everything the JSON export can't represent. Restoring overwrites existing files; restart ComfyUI afterward.",
|
||||
helpSettings15: "Full Backup (ZIP): unlike the JSON export above, also includes folder-based data (AI Skills, Lab Plans, Video Plans, Style presets) and the Tagger database — everything the JSON export can't represent. Restoring overwrites existing files; restart ComfyUI afterward.",
|
||||
helpSettings16: "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.",
|
||||
helpSettings9: "All sections are collapsible accordion panels",
|
||||
helpSettings10: "Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (directory junction on Windows / symlink on other OS); existing files migrated automatically",
|
||||
@@ -934,17 +955,20 @@ const LANGUAGES = {
|
||||
helpGallery3: "Thumbnail and Table view modes; double-click any image to open fullscreen lightbox",
|
||||
helpGallery4: "Toolbar filters: favorites (★), text search (filename / tag / prompt), tag filter, group filter, sort by date/name",
|
||||
helpGallery5: "Folder tree: click to load images, expand with ▶; '+ New' creates a subfolder inside the selected folder, 'Del' deletes it with all contents",
|
||||
helpGallery6: "Multi-select: Ctrl+click to toggle individual images; Shift+click to range-select from the last clicked item — bulk bar: select group → 'Add to Group' / 'Remove from Group'; 'Favorite All' / 'Unfavorite All'; 'Compare' (2–4 images); 'Move To...'; 'Delete File'",
|
||||
helpGallery6: "Multi-select: Ctrl+click to toggle individual images; Shift+click to range-select from the last clicked item — bulk bar: 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' (combines the selected images into one layered PSD file); 'Edit as Layers' (opens the selected images in the Image Edit tab, each as its own layer); 'Delete File'",
|
||||
helpGallery7: "Detail panel — Info tab: filename, size, dimensions (image width×height) or video duration, date, tags (add/remove), memo; 'Move To...' moves the image to another folder, 'Delete File' removes it permanently",
|
||||
helpGallery8: "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 sends the workflow directly to the ComfyUI canvas (requires Workflow Studio to be opened from the ComfyUI toolbar; UI-format only falls back to title drag if opened from a bookmark)",
|
||||
helpGallery9: "Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image",
|
||||
helpGallery10: "Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the JSON tab",
|
||||
helpGallery11: "Supports PNG (tEXt/iTXt), JPEG, WebP, GIF, MP4; workflow extraction reads ComfyUI prompt/workflow keys embedded in PNG metadata or in MP4 container tags (as written by SaveVideo, e.g. MiniMax H3 output)",
|
||||
helpGallery11: "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)",
|
||||
helpGallery28: "Create PSD 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",
|
||||
helpGallery29: "Edit as Layers 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",
|
||||
helpGallery30: "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",
|
||||
helpGallery12: "Metadata button (green, in the detail panel tab bar): opens the selected image in the Metadata tab for full embedded-data inspection; Alt+click on any image thumbnail has the same effect",
|
||||
helpGallery13: "Load GenUI button (blue, in the detail panel tab bar): loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab and switches to it; shows a warning toast if no workflow is embedded or the format is not supported",
|
||||
helpGallery14: "Tagger button (purple, in the detail panel action row): opens the selected image directly in the Tagger tab's Single mode for instant tagging",
|
||||
helpGallery15: "F button (cyan overlay, top-left of each thumbnail): toggle the image in/out of the __Feeder__ group with a single click; cyan and always visible when in group, visible on hover only when not; quickly queue images for the Feeder tab Gallery mode",
|
||||
helpGallery16: "Image Edit 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",
|
||||
helpGallery16: "Image Edit 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",
|
||||
helpGallery17: "Send GenUI Image 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",
|
||||
helpGallery27: "Send to LI node 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",
|
||||
helpGallery18: "Second sub-tab next to Output (top of the Gallery tab) for browsing a visual prompt library — manages the ws_image_prompt folder inside the ComfyUI output directory (created automatically)",
|
||||
@@ -959,13 +983,13 @@ const LANGUAGES = {
|
||||
|
||||
// -- Tagger Tab --
|
||||
helpTaggerTitle: "Tagger Tab",
|
||||
helpTagger1: "Automatic image tagging using WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and Ollama VLM — 3 sub-tabs: Single / Batch / DB",
|
||||
helpTagger1: "Automatic image tagging using WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and VLM (Ollama / LM Studio / Lemonade / Unsloth) — 3 sub-tabs: Single / Batch / DB",
|
||||
helpTagger2: "Model placement: put each model in its own subfolder under ComfyUI/models/tagger/<model-name>/; the folder must contain a .onnx file + selected_tags.csv (or .h5 + label file for DeepDanbooru)",
|
||||
helpTagger3: "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",
|
||||
helpTagger4: "Threshold slider: minimum confidence score for general tags (default 0.35); Character Threshold: separate threshold for character: prefix tags (default 0.85)",
|
||||
helpTagger5: "Ollama VLM: enable the checkbox to run a vision model alongside WD Tagger; set API URL, select model (click ↻ to refresh), and customize the prompt; results from both models are merged",
|
||||
helpTagger5: "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",
|
||||
helpTagger6: "Result tags can be sent to: (1) GenUI:P — appended to the GenerateUI tab's positive prompt textarea and applied to the loaded workflow immediately; (2) Send to Prompt — appended to the Prompt tab's positive textarea; (3) Save to Gallery — saved to Gallery image metadata (requires image opened from Gallery); (4) Write to File — embedded into JPEG EXIF (ImageDescription) or PNG tEXt (Tags key); (5) Save to DB — stored in the internal SQLite database",
|
||||
helpTagger7: "Batch tab: enter a folder path, configure WD Tagger and Ollama settings; output options: Save to DB (default on), Write to File (EXIF/PNG metadata), Write .txt (creates <filename>.txt alongside each image); click Start Batch to process all images in the folder; progress bar and log are updated in real time; Stop cancels after the current image",
|
||||
helpTagger7: "Batch tab: enter a folder path, configure WD Tagger and enable VLM tagging (uses the same ⚙ Settings as the Single tab); output options: Save to DB (default on), Write to File (EXIF/PNG metadata), Write .txt (creates <filename>.txt alongside each image); click Start Batch to process all images in the folder; progress bar and log are updated in real time; Stop cancels after the current image",
|
||||
helpTagger8: "DB tab: searchable database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records as a CSV file",
|
||||
helpTagger9: "Install dependencies into ComfyUI's embedded Python: python_embeded\\python.exe -m pip install -r requirements.txt; for GPU ONNX inference install onnxruntime-gpu instead of onnxruntime; TensorFlow (for DeepDanbooru) is commented out by default due to size",
|
||||
|
||||
@@ -992,7 +1016,7 @@ const LANGUAGES = {
|
||||
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
|
||||
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image (\"generate a cat picture\", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the \"Allow image generation\" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; generate_image also accepts optional generation parameters the model can fill in from the request — steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size (number of images produced in that one call), and aspect_ratio (a ratio preset like \"3:2\", applied only if the workflow has a Resolution Selector node feeding width/height — ignored otherwise); anything the model doesn't supply keeps whatever the loaded workflow already has set; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button",
|
||||
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model; Unsloth always requires an API key even for local access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI, the key is read server-side and never sent to the browser; Unsloth also requires \"Auto model switching (OpenAI API)\" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a think flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage",
|
||||
helpAi6: "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",
|
||||
helpAi7: "Chat pane — SVG: the LLM can also produce SVG graphics directly as text, with no ComfyUI workflow involved; ask it to draw something in SVG and, if the reply contains SVG code, it is rendered inline as a preview with a \"Save to Gallery\" button that writes it as a .svg file to the Gallery tab's output folder; this does not use tool calling, so it works with any model regardless of tool-calling support",
|
||||
helpAi8: "Chat pane — Skills: pick a skill from the dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); use the pencil button to open the skill manager and create, edit, or delete .md skill files, stored in user/default/Workflow-Studio/ai_skills/; a file's first line block between --- markers can define name/description shown in the dropdown; two starter skills are included by default: \"SVG Icon Generator\" (decomposes the subject into parts before drawing, for more recognizable SVG output) and \"Skill Creator\" (interviews you conversationally and drafts a new skill file, ready to save); when a reply contains a ```skill code block, a \"Save as new skill\" button appears below it that opens the manager with the draft pre-filled",
|
||||
helpAi9: "Tips for writing your own skills: give a clear frontmatter name/description so it reads well in the dropdown and tooltip; state the exact output format you want and, if there is a deliverable to extract later (like SVG or another skill file), wrap it in a distinctive fenced code block; write concrete, imperative instructions rather than vague ones - smaller local models follow explicit rules and a short worked example far better than general guidance; state hard constraints (forbidden content, security, length limits) explicitly rather than assuming they are implied; when in doubt, select the \"Skill Creator\" skill in the dropdown and describe what you want - it will interview you and draft the file for you",
|
||||
@@ -1066,7 +1090,9 @@ const LANGUAGES = {
|
||||
helpTroubleCardTagger: "Tagger & Model Issues",
|
||||
helpImageEditTitle: "Image Edit Tab",
|
||||
helpImageEdit1: "Layer-based image editor — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite",
|
||||
helpImageEdit2: "Loading images: drag & drop onto the canvas, Upload button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas",
|
||||
helpImageEdit2: "Loading images: drag & drop onto the canvas, File Open button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas",
|
||||
helpImageEdit31: "Open PSD button (next to File Open): loads a layered PSD file from disk and decomposes it into individual layers — position, size, visibility, opacity, and blend mode are restored for each layer; groups and non-raster layers (adjustment layers, etc.) are flattened into their rendered appearance",
|
||||
helpImageEdit32: "Close button (next to Open PSD): closes the current document after a confirmation prompt, discarding all layers and unsaved edits and returning to the empty placeholder view",
|
||||
helpImageEdit3: "New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers and automatically adds an empty Layer 1",
|
||||
helpImageEditToolsTitle: "Tools",
|
||||
helpImageEdit4: "Select (V): click to select a layer; drag to move; drag corner handles to resize; drag the circle above the top edge to rotate; double-click a text layer to re-edit its content; Tool Options bar shows Flip H / Flip V / Rotate field",
|
||||
@@ -1078,7 +1104,6 @@ const LANGUAGES = {
|
||||
helpImageEdit9: "👁 / 🚫 button: toggle layer visibility",
|
||||
helpImageEdit10: "🔒 / 🔓 button: toggle layer lock — a locked layer cannot be moved, resized, or rotated (orange bounding box is shown); Layer 1 is locked automatically when the first image is loaded to prevent accidental moves",
|
||||
helpImageEdit11: "Opacity slider at the bottom adjusts the active layer's transparency",
|
||||
helpImageEdit12: "Type icons: 🖼 = image layer, T = text layer, ✏ = draw layer",
|
||||
helpImageEdit13: "Delete key: removes the selected layer (only when 2+ layers exist)",
|
||||
helpImageEditTextTitle: "Text Object Quality",
|
||||
helpImageEdit14: "Text layers are sized exactly to the measured text bounding box — no extra whitespace",
|
||||
@@ -1087,6 +1112,7 @@ const LANGUAGES = {
|
||||
helpImageEditExportTitle: "Export & Navigation",
|
||||
helpImageEdit17: "Save PNG: composites all visible layers and downloads the result as a PNG file",
|
||||
helpImageEdit18: "Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root",
|
||||
helpImageEdit34: "Save PSD: downloads a layered PSD file with every layer preserved separately — position, rotation, flip, and scale are baked into each layer's pixels (PSD layers cannot store a rotation angle), while opacity, blend mode, and visibility are kept as real, editable PSD layer attributes; clipping masks are merged into their target layer since PSD's native layer-mask mechanism is not used",
|
||||
helpImageEdit19b: "Send to LI node: uploads the composite PNG to ComfyUI's input folder via /upload/image and writes the resulting filename into the image widget of the currently selected node in the ComfyUI graph, falling back to the first LoadImage-type node found; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable",
|
||||
helpImageEdit20: "Canvas navigation: scroll wheel to zoom, Space + drag to pan",
|
||||
helpImageEdit21: "Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)",
|
||||
@@ -1102,12 +1128,13 @@ const LANGUAGES = {
|
||||
helpImageEdit30: "Double-click (Select tool on text layer): Re-edit text",
|
||||
helpImageEdit7b: "Blur (≈): apply Gaussian blur or mosaic to the active layer — whole-layer or region-based (drag a rectangle); see the Blur Tool card below for details",
|
||||
helpImageEdit7c: "BG Remove (⬚): remove the background from the active layer using Lightweight (@imgly, runs in browser) or BiRefNet (requires Mask Editor One + birefnet.safetensors in ComfyUI/models/background_removal/); see the BG Remove Tool card below for details",
|
||||
helpImageEdit7d: "Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click \"M\" in the Layers header to add a mask layer; see the Mask Tool card below for details",
|
||||
helpImageEdit7d: "Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click \"Mask\" (second row of the Layers header) to add a mask layer; see the Mask Tool card below for details",
|
||||
helpImageEdit7e: "Inpaint (🩹): composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like VAEEncodeForInpaint); enter Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), and Denoise (applied to KSampler) in the properties panel, then click Run to upload and generate without leaving the Image Edit tab — the result is shown back in the properties panel when done; requires a Mask layer to be painted first and an inpaint-capable workflow loaded in GenerateUI; also works with workflows built around the Mask Editor One custom node (comfyui-mask-editor-one) instead of LoadImage — image/mask are pushed to it via its node_id-keyed server cache and layer_data widget; \"Use dedicated workflow\" checkbox: when off, the workflow currently loaded in GenerateUI is used (unchanged); when on, pick a saved workflow from the dropdown — it is loaded, edited, and generated entirely in the background without disturbing the workflow shown in GenerateUI",
|
||||
helpImageEdit8b: "\"M\" button in the Layers header: adds a new mask layer above the current layer and automatically switches to the Mask tool",
|
||||
helpImageEdit8b: "The Layers header has two rows: the top row has +, ⧉ (Duplicate), −, ↑, ↓; the \"Mask\" button sits alone on the second row — it adds a new mask layer above the current layer and automatically switches to the Mask tool",
|
||||
helpImageEdit33: "⧉ (Duplicate layer) button: copies the active layer — including its pixels, position, size, opacity, blend mode, and visibility — and inserts the copy directly above the original as the new active layer; works on any layer type (image, draw, text, mask)",
|
||||
helpImageEdit12: "Type icons: 🖼 = image layer, T = text layer, ✏ = draw layer, ⬚ = mask layer",
|
||||
helpImageEditMaskTitle: "Mask Tool (🎭)",
|
||||
helpImageEditMask1: "Add a mask layer: click the \"M\" button in the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically",
|
||||
helpImageEditMask1: "Add a mask layer: click the \"Mask\" button on the second row of the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically",
|
||||
helpImageEditMask2: "Clicking a mask layer in the layer list automatically switches to the Mask tool",
|
||||
helpImageEditMask3: "Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge; the overlay is only shown for the currently active mask layer — inactive mask layers stay hidden until selected",
|
||||
helpImageEditMask4: "Properties pane — Paint mode: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); MASK EDITOR ONE section (always visible — header text is green when ABR brushes are available, gray when none are imported yet): Select button opens the ABR brush library (disabled until at least one brush exists) — choosing a brush replaces the default circle brush; ✕ clears the brush back to Circle; when an image brush is active, Hardness is disabled and additional controls appear: Spacing (stamp interval 5–100%), Angle (rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random rotation per stamp on/off)",
|
||||
@@ -1218,7 +1245,7 @@ const LANGUAGES = {
|
||||
importError: "Import failed",
|
||||
exportFullBackup: "Full Backup (ZIP)",
|
||||
importFullBackup: "Restore Full Backup (ZIP)",
|
||||
fullBackupHint: "Full Backup (ZIP) additionally includes folder-based data (AI Skills, Lab Plans, Style presets) and the Tagger database — everything the JSON export above can't represent. Restoring overwrites existing files; restart ComfyUI afterward.",
|
||||
fullBackupHint: "Full Backup (ZIP) additionally includes folder-based data (AI Skills, Lab Plans, Video Plans, Style presets) and the Tagger database — everything the JSON export above can't represent. Restoring overwrites existing files; restart ComfyUI afterward.",
|
||||
fullBackupFilesUnit: "files",
|
||||
fullBackupIncludeWorkflows: "Include default workflows folder (user/default/workflows)",
|
||||
fullBackupIncludeWildcard: "Include Impact Pack wildcard folder (if present)",
|
||||
@@ -1315,31 +1342,64 @@ const LANGUAGES = {
|
||||
helpMetadata5: "Supported: ComfyUI PNG/WebP/MP4/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image subgraph workflows; MP4 reads the workflow/prompt container tags written by SaveVideo, including all-in-one video nodes like MiniMax H3 ImageToVideo), SD WebUI, SD Forge, Fooocus.",
|
||||
helpMetadata6: "This is now a sub-tab inside the Gallery tab (to the right of Style_Catalog), rather than its own top-level tab — everything else about it works exactly the same",
|
||||
|
||||
// -- Video Tab (MiniMax H3, independent of GenerateUI) --
|
||||
// -- Video Tab (MiniMax H3 / LTX-2.5 / Wan2.2, independent of GenerateUI) --
|
||||
tabVideo: "Video",
|
||||
loadInVideo: "Load in Video",
|
||||
videoTitle: "Video (MiniMax H3)",
|
||||
videoFirstFrame: "First Frame (optional)",
|
||||
videoLastFrame: "Last Frame (optional)",
|
||||
videoDropOrClick: "Drop image or click to select",
|
||||
videoPrompt: "Prompt",
|
||||
videoAspectRatio: "Aspect Ratio",
|
||||
videoMegapixels: "Megapixels",
|
||||
videoMultiple: "Multiple",
|
||||
videoDuration: "Duration (seconds)",
|
||||
videoNoiseSeed: "Noise Seed",
|
||||
videoRandomizeSeed: "Randomize",
|
||||
videoGenerate: "Generate",
|
||||
videoEditPlaceholder: "Editing tools coming soon",
|
||||
videoEditNoClipsHint: "No clips yet — select an Asset or drop a video into Video Source above, then \"Add to Edit\"",
|
||||
videoEditNoClips: "Add at least one clip first",
|
||||
videoEditStillProbing: "Please wait for clip analysis to finish",
|
||||
videoEditResolutionMismatch: (a, b) => `Clip resolutions don't match (${a} vs ${b}). Trim/crop to the same size before exporting.`,
|
||||
videoEditProbing: "Analyzing...",
|
||||
videoEditSelectClipHint: "Select a clip to trim",
|
||||
videoEditImageDuration: "Duration (s)",
|
||||
videoEditTrimStart: "Start (s)",
|
||||
videoEditTrimEnd: "End (s)",
|
||||
videoEditSetFromPlayhead: "Use current position",
|
||||
videoEditMoveLeft: "Move left",
|
||||
videoEditMoveRight: "Move right",
|
||||
videoEditClearBtn: "Clear",
|
||||
videoEditTotalDuration: (time) => `Total: ${time}`,
|
||||
videoEditPreviewPlay: "▶ Preview",
|
||||
videoEditPreviewStop: "■ Stop",
|
||||
videoEditConfirmClear: "Clear the entire edit timeline?",
|
||||
videoSourceAddToEdit: "Add to Edit",
|
||||
videoEditDuplicate: "Duplicate",
|
||||
videoEditDelete: "Delete",
|
||||
videoEditExportBtn: "Export",
|
||||
videoEditExporting: "Exporting...",
|
||||
videoEditExportDone: (name) => `Exported: ${name}`,
|
||||
videoEditSendToEdit: "Send to Edit",
|
||||
videoEditClipSent: (name) => `Added "${name}" to Edit timeline`,
|
||||
videoAssetSetAsFirst: "Set as First Image",
|
||||
videoAssetSetAsLast: "Set as Last Image",
|
||||
videoAssetSetAsFirstDone: (name) => `Set "${name}" as First Image`,
|
||||
videoAssetSetAsLastDone: (name) => `Set "${name}" as Last Image`,
|
||||
videoNoBlockSelected: "No block selected in Plan",
|
||||
videoBlockImageUploadFailed: "Failed to upload image to Plan block",
|
||||
videoNoWorkflowLoaded: "No workflow loaded",
|
||||
videoUnsupportedWorkflow: "This workflow does not contain a MiniMax H3 Image-to-Video node",
|
||||
videoUnsupportedWorkflow: "This workflow does not contain a supported video model node (MiniMax H3, LTX-2.5 Text/Image/First&Last-Frame to Video, or Wan2.2 Text to Video)",
|
||||
videoWorkflowLoadFailed: "Failed to load workflow",
|
||||
videoConfirmLoadWorkflow: (name) => `Load "${name}" as this plan's workflow?`,
|
||||
videoPlanMinOneBlock: "A plan needs at least one block",
|
||||
videoPlanEnterName: "Enter a name for this plan",
|
||||
videoPlanSaved: "Plan saved",
|
||||
videoPlanSaveFailed: "Failed to save plan",
|
||||
videoPlanLoaded: "Plan loaded",
|
||||
videoPlanLoadFailed: "Failed to load plan",
|
||||
videoEditProjectEnterName: "Enter a name for this project",
|
||||
videoEditProjectSaved: "Project saved",
|
||||
videoEditProjectSaveFailed: "Failed to save project",
|
||||
videoEditProjectLoaded: "Project loaded",
|
||||
videoEditProjectLoadFailed: "Failed to load project",
|
||||
videoConfirmClearPlan: "Clear the current plan?",
|
||||
videoPlanPause: "Pause",
|
||||
videoPlanResume: "Resume",
|
||||
videoWorkflowLoaded: (name) => `Workflow loaded: ${name}`,
|
||||
videoGenerateSuccess: "Video generated",
|
||||
videoDoneCount: (n) => `Done (${n} file${n === 1 ? "" : "s"})`,
|
||||
videoSourceLabel: "Video Source",
|
||||
videoSourceHint: "Load any video here to use Frame/GIF tools without generating one.",
|
||||
videoSourceDropLabel: "Drop video or click to select",
|
||||
videoNoVideo: "No video",
|
||||
videoSourcePreviewPlaceholder: "Select an asset or drop a video",
|
||||
videoResultPreviewPlaceholder: "No video generated yet",
|
||||
videoPropFrame: "Frame",
|
||||
videoPropGif: "GIF",
|
||||
videoFrameHint: "Play/seek the preview video to the position you want, then capture.",
|
||||
@@ -1356,15 +1416,35 @@ const LANGUAGES = {
|
||||
videoGifDoneCount: (n) => `Done (${n} frame${n === 1 ? "" : "s"})`,
|
||||
videoGifSaved: (name) => `GIF saved: ${name}`,
|
||||
videoNoSourceLoaded: "Load a video first (generate one, or drop a file into Video Source)",
|
||||
videoPropProject: "Project",
|
||||
videoProjectHint: "Saved plans",
|
||||
videoProjectEmpty: "No saved plans yet",
|
||||
videoProjectBlockCount: (n) => `${n} block${n === 1 ? "" : "s"}`,
|
||||
videoProjectDeleteConfirm: (name) => `Delete plan "${name}"?`,
|
||||
videoProjectDeleted: "Plan deleted",
|
||||
videoProjectDeleteFailed: "Failed to delete plan",
|
||||
videoProjectLoadListFailed: "Failed to load plan list",
|
||||
videoEditProjectHint: "Saved edit projects",
|
||||
videoEditProjectEmpty: "No saved edit projects yet",
|
||||
videoEditProjectClipCount: (n) => `${n} clip${n === 1 ? "" : "s"}`,
|
||||
videoEditProjectDeleteConfirm: (name) => `Delete project "${name}"?`,
|
||||
videoEditProjectDeleted: "Project deleted",
|
||||
videoEditProjectDeleteFailed: "Failed to delete project",
|
||||
videoEditProjectLoadListFailed: "Failed to load project list",
|
||||
helpVideoTitle: "Video Tab",
|
||||
helpVideoDesc: "A dedicated video generation tab for the MiniMax H3 (Image to Video) workflow, independent of the GenerateUI tab.",
|
||||
helpVideo1: "Set first_frame (optional) / last_frame (optional) images, prompt, aspect_ratio / megapixels / multiple (resolution), duration (seconds), and noise_seed, then click Generate. Leaving first_frame empty runs the workflow text-only (bypassed), and workflows with no LoadImage node at all are also supported.",
|
||||
helpVideo2: "Model selection (UNet / CLIP / VAE / Audio VAE) is fixed to the loaded workflow's defaults and cannot be changed from this tab.",
|
||||
helpVideo3: "Use the \"Load in Video\" button in the Workflow tab's detail modal to load a workflow containing a MiniMax H3 Image-to-Video node. Unsupported workflows show an error instead.",
|
||||
helpVideo4: "Generated videos (mp4) can also be browsed and played back in the Gallery tab.",
|
||||
helpVideo5: "The Video Source panel next to the preview lets you drop or pick any local video (not just a generated one) to use the Frame/GIF tools below without running MiniMax H3 first; Clear resets it back to empty.",
|
||||
helpVideo6: "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.",
|
||||
helpVideoDesc: "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).",
|
||||
helpVideo1: "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.",
|
||||
helpVideo2: "A plan with a single block behaves exactly like the old one-shot form. Model selection (UNet / CLIP / VAE / Audio VAE) is fixed to the loaded workflow's defaults. For workflows with no ResolutionSelector node (e.g. Wan2.2, LTX-2.5 First & Last Frame to Video), the Aspect Ratio / Megapixels / Multiple fields are disabled and the workflow's own Width / Height become directly editable instead. Turbo Mode / Strength / Steps only apply to MiniMax H3's turbo LoRA, and Prompt Enhance only to LTX-2.5 — both stay disabled for other model families. Noise Seed is disabled for the one workflow that doesn't expose it: Wan2.2 Text-to-Video.",
|
||||
helpVideo3: "Use the \"Load in Video\" button in the Workflow tab's detail modal to load a workflow containing a MiniMax H3 Image-to-Video node, an LTX-2.5 Text/Image/First&Last-Frame-to-Video node, or a Wan2.2 Text-to-Video node — this starts a fresh single-block plan seeded from the workflow's own current values. Unsupported workflows show an error instead.",
|
||||
helpVideo4: "Generated videos (mp4) automatically appear in the Asset subtab's Video Temp group, and can also be browsed and played back in the Gallery tab. Organizing keepers into Video Assets is entirely up to you, on your own schedule.",
|
||||
helpVideo5: "The center panel shows two previews: the left one shows either the video you select in the Asset subtab or whatever you drop/pick in the Video Source panel on its left (Clear resets it to empty), letting you use the Frame/GIF tools without running the loaded video workflow first; the right one always shows the most recently generated clip.",
|
||||
helpVideo6: "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.",
|
||||
helpVideo7: "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.",
|
||||
helpVideo8: "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.",
|
||||
helpVideo9: "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.",
|
||||
helpVideo10: "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.",
|
||||
helpVideo11: "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. The export panel's Save/Save As/Load buttons persist the timeline itself (clip order, trim points, which uploaded file each clip refers to) as a JSON project file on the server — Load re-fetches each clip from its saved server reference and re-probes it, so the original files must still exist. These saved projects also show up in the sidebar's Project tab whenever the Edit subtab is active (see helpVideo9).",
|
||||
helpVideo12: "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).",
|
||||
},
|
||||
|
||||
ja: {
|
||||
@@ -1547,6 +1627,9 @@ const LANGUAGES = {
|
||||
feederGalNoNode: "WFS_GalleryFeederノードが選択されていません",
|
||||
feederGalEmptyGroup: "選択グループに画像がありません",
|
||||
feederGroupCleared: "Feederグループをクリアしました",
|
||||
vtempGroupCleared: "__Video Temp__グループをクリアしました",
|
||||
vtempClearBtnLabel: "Clear __Video Temp__",
|
||||
vtempClearBtnTitle: "__Video Temp__グループをクリアします(動画生成物は自動的にここへ入ります。__Video Assets__への整理は任意のタイミングで手動で行ってください)",
|
||||
addedToFeeder: "Feederに追加しました",
|
||||
removedFromFeeder: "Feederから除外しました",
|
||||
appliedToWorkflow: "ワークフローに適用しました",
|
||||
@@ -1690,6 +1773,8 @@ const LANGUAGES = {
|
||||
textSizeLabel: "文字サイズ",
|
||||
textSizeTextarea: "プロンプト / チャット入力欄",
|
||||
textSizeHint: "適用対象: 生成UIプロンプト、AIアシスタントチャット、プリセットプロンプト、ワイルドカード編集、Metadataプロンプトプレビュー",
|
||||
videoVolumeLabel: "動画の音量",
|
||||
videoVolumeHint: "適用対象: Videoタブのプレビュー、Galleryタブの詳細/ライトボックス/比較動画プレーヤー。ここで設定した音量(または各動画自身のコントロールで変更した音量)は、アプリ内のどこで次に動画を開いても引き継がれます。",
|
||||
jsonColorsSection: "RAW JSON 文字色",
|
||||
jsonColorBase: "通常テキスト",
|
||||
jsonColorYellow: "名前 / スケジューラ",
|
||||
@@ -1942,6 +2027,7 @@ const LANGUAGES = {
|
||||
sendToLiNodeSuccess: (filename) => `"${filename}" をLIノードへ送信しました`,
|
||||
galleryCCNotAvailable: "Send CCはComfyUI Comic Creator内のギャラリーから開いた場合のみ使用できます",
|
||||
gallerySentCC: "ComfyUI Comic Creatorへ送信しました",
|
||||
galleryVideoNotSupportedInImageTab: "動画のImageタブへの送信は未対応です",
|
||||
galleryNoEmbeddedWorkflow: "この画像にはComfyUIワークフローが埋め込まれていません。",
|
||||
galleryTabOutput: "Output",
|
||||
galleryTabImagePrompt: "ImagePrompt",
|
||||
@@ -1980,6 +2066,8 @@ const LANGUAGES = {
|
||||
galleryBulkCompare: "比較",
|
||||
galleryBulkMoveTo: "移動先...",
|
||||
galleryBulkExport: "エクスポート",
|
||||
galleryBulkPsd: "PSD作成",
|
||||
galleryBulkEditLayers: "レイヤーとして編集",
|
||||
galleryBulkDelete: "ファイル削除",
|
||||
nodesBulkSelected: "件選択中",
|
||||
nodesBulkDeselect: "選択解除",
|
||||
@@ -1992,6 +2080,9 @@ const LANGUAGES = {
|
||||
nodesBulkUnfavAll: "☆ お気に入り解除(全件)",
|
||||
downloadStarted: "ダウンロード開始",
|
||||
exportCompleted: "エクスポート完了。ZIPファイルをダウンロード中です。",
|
||||
psdExportCompleted: "PSDを作成しました。ファイルをダウンロード中です。",
|
||||
editLayersNoImages: "選択範囲に編集可能な画像がありません(動画ファイルは非対応)",
|
||||
editLayersSkippedVideo: (n) => `動画ファイルを${n}件スキップしました`,
|
||||
|
||||
tabAi: "AI TOOL",
|
||||
tabTagger: "Tagger",
|
||||
@@ -2007,7 +2098,7 @@ const LANGUAGES = {
|
||||
taggerThreshold: "閾値",
|
||||
taggerCharThreshold: "キャラ閾値",
|
||||
taggerGenerateTags: "タグ生成",
|
||||
taggerOllamaEnable: "Ollama VLM",
|
||||
taggerOllamaEnable: "VLMタグ付け",
|
||||
taggerOllamaUrl: "API URL",
|
||||
taggerOllamaModel: "モデル",
|
||||
taggerOllamaPrompt: "プロンプト",
|
||||
@@ -2047,6 +2138,9 @@ const LANGUAGES = {
|
||||
taggerWrittenToFile: "ファイルに書き込みました",
|
||||
taggerSavedToDB: "DBに保存しました",
|
||||
taggerSelectGalleryImage: "Galleryタブで画像を選択してください",
|
||||
taggerDbNoSelection: "削除する行を選択してください",
|
||||
taggerDbDeleteConfirm: (count) => `${count}件のDBレコードを削除しますか?`,
|
||||
taggerDbDeletedCount: (count) => `${count}件のDBレコードを削除しました`,
|
||||
|
||||
// -- AI Tab --
|
||||
aiSubTabTranslate: "翻訳",
|
||||
@@ -2076,6 +2170,12 @@ const LANGUAGES = {
|
||||
aiSettingsInputLang: "入力言語",
|
||||
aiSettingsOutputLang: "翻訳言語",
|
||||
aiSettingsSaveBtn: "設定を保存",
|
||||
aiUnloadModel: "モデルをアンロード",
|
||||
aiUnloading: "アンロード中...",
|
||||
aiUnloadDone: "モデルをアンロードしました",
|
||||
aiUnloadFailed: "アンロード失敗: ",
|
||||
aiUnloadUnsupported: "このバックエンドはAPI経由のアンロードに未対応です",
|
||||
aiUnloadNoModel: "先にモデルを選択してください",
|
||||
aiToastNoText: "テキストを入力してください",
|
||||
aiToastNoModel: "設定タブでモデルを選択してください",
|
||||
aiToastInvalidUrl: "URLは http:// または https:// で始まる必要があります",
|
||||
@@ -2146,7 +2246,8 @@ const LANGUAGES = {
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)は常時表示;ControlNetとHypernetwork(Strength入力付き)は使用頻度が低いためデフォルトで折りたたみ — 見出しをクリックで展開;LoRA(Single/Stackタブ)は右側に独立した列を表示;すべてのセレクタにフィルタ検索付き;LoRA Stack — Applyで有効な全LoRAを対象ノードに書き込み、同時にLoRA構文とCivitAIトリガーワードをPositiveプロンプトへ挿入;再Applyでは非アクティブなLoRAを削除し、アクティブなLoRAを再追加してプロンプトを更新",
|
||||
helpGen28: "Modelタブ — ハイライト色:読み込んだワークフローに実際に該当ノードが含まれる場合、各モデル種別(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork)のラベルとLoRA列見出しが指定色(デフォルトは明るい緑)で表示される;ControlNet/Hypernetworkが折りたたまれた状態でも色は見えるため、展開しなくても使用有無が一目で分かる;色はSettingsタブでカスタマイズ可能",
|
||||
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映;読み込んだワークフローにBypass/Muteノードがある場合、検索バー上部に一覧が表示される(Bypassは除外され配線が再接続される、Muteは除外されるが再接続はされない点はComfyUI本体と同じ)",
|
||||
helpGen31: "LTX-2.5とWan2.2の動画ワークフローも、専用のVideoタブだけでなく本タブに通常ワークフローとして読み込んだ場合に対応 — Positive/Negative PromptはLTX-2.5のLTXVDualCFGGuider / LTXVConditioningノードをたどって実際のプロンプト文を検出し、SettingsタブのLatent ImageはWan2.2のEmptyHunyuanLatentVideoとLTX-2.5のEmptyLTXVLatentVideoノードを認識(Length(フレーム数)欄も追加表示)して「No EmptyLatentImage node found」にならない;これらのワークフローからの生成もMiniMax H3の動画生成と同じ延長タイムアウトが適用される",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映;読み込んだワークフローにBypass/Muteノードがある場合、検索バー上部に一覧が表示される(Bypassは除外され配線が再接続される、Muteは除外されるが再接続はされない点はComfyUI本体と同じ);検索バーはヒット箇所をハイライト表示してスクロールするだけでエディタにカーソルを移動しないため、検索直後にキー入力してもJSONが誤って書き換わることはない — 編集する際はエディタ内をクリックしてから行う",
|
||||
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
|
||||
helpGen9: "画像生成、進捗表示、生成停止、結果表示",
|
||||
helpGen10: "Eagle連携:生成画像をEagleへ自動保存(SVG出力にも対応 — 標準SaveSVGNodeおよびcomfyui-tosvgのSave SVG Stringノードの両方)",
|
||||
@@ -2228,7 +2329,7 @@ const LANGUAGES = {
|
||||
helpLabResults3: "保存済みプランを読み込むと、プランファイルに記録された画像一覧からResultsの表示が復元されます",
|
||||
|
||||
helpSearchPlaceholder: "ヘルプを検索...",
|
||||
helpPrompt1: "AIアシスタント(Ollama / LM Studio / Lemonade):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応);バックエンドとURLは設定タブで設定",
|
||||
helpPrompt1: "AIアシスタント(Ollama / LM Studio / Lemonade / Unsloth):チャットでプロンプト相談、画像添付(ビジョンモデル対応);⚙ 設定ボタンからバックエンド・接続URL・モデル・Thinking mode・Max tokensの選択とロード中モデルのアンロードが可能——設定はAI TOOLタブ・Taggerタブとは独立して保存される",
|
||||
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
|
||||
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
|
||||
helpPrompt4: "プリセット管理:作成・編集、ポジティブ/ネガティブプロンプトのクリップボードコピー(PP/NP)",
|
||||
@@ -2258,11 +2359,11 @@ const LANGUAGES = {
|
||||
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
|
||||
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
|
||||
helpSettings4: "ComfyUI接続URL設定と接続テスト",
|
||||
helpSettings5: "AI Assistant(プロンプトタブ):バックエンド(Ollama / LM Studio / Lemonade)・API URL・デフォルトモデル・接続テスト",
|
||||
helpSettings5: "AI Assistant設定はこのタブから移動しました——Promptタブ・Taggerタブ・AI TOOLタブそれぞれが独自の⚙設定ボタン(バックエンド・URL・モデル・Thinking mode・Max tokens・モデルのアンロード)を持ち、個別に保存されます",
|
||||
helpSettings6: "デフォルトワークフローの設定・解除",
|
||||
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど主要データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
|
||||
helpSettings15: "フルバックアップ (ZIP):上記のJSONエクスポートと異なり、フォルダ形式のデータ(AI Skills、Lab Plan、Styleプリセット)とTaggerデータベースも含みます。復元すると既存ファイルは上書きされます。復元後はComfyUIを再起動してください。",
|
||||
helpSettings15: "フルバックアップ (ZIP):上記のJSONエクスポートと異なり、フォルダ形式のデータ(AI Skills、Lab Plan、Video Plan、Styleプリセット)とTaggerデータベースも含みます。復元すると既存ファイルは上書きされます。復元後はComfyUIを再起動してください。",
|
||||
helpSettings16: "フルバックアップのボタン上にある2つのチェックボックスで、デフォルトワークフローフォルダーとImpact Packのwildcardフォルダーもエクスポートに含められます。どちらもエクスポート専用で自動復元はされません。必要な場合はZIP内の_external/フォルダから手動でコピーしてください。",
|
||||
helpSettings9: "各セクションはアコーディオン式で折りたたみ可能",
|
||||
helpSettings10: "Wildcard Integration:WFSのワイルドカードディレクトリをComfyUI-Impact-Packのwildcardsディレクトリへリンク(Windowsはジャンクション、他OSはシンボリックリンク)。既存ファイルは自動で移行されます",
|
||||
@@ -2295,17 +2396,20 @@ const LANGUAGES = {
|
||||
helpGallery3: "サムネイル・テーブル表示モード切替;ダブルクリックで全画面ライトボックス表示",
|
||||
helpGallery4: "ツールバーフィルタ:お気に入り(★)、テキスト検索(ファイル名・タグ・プロンプト)、タグフィルタ、グループフィルタ、日付・名前ソート",
|
||||
helpGallery5: "フォルダツリー:クリックで画像を読み込み;▶で展開;「+ New」で選択フォルダ内にサブフォルダを作成;「Del」でそのフォルダとすべての内容を削除",
|
||||
helpGallery6: "複数選択:Ctrl+クリックで個別トグル;Shift+クリックで最後にクリックした位置から範囲選択 — 一括操作バー:グループを選択して「グループに追加」/「グループから削除」;「お気に入り追加(全件)」/「お気に入り解除(全件)」;「比較」(2〜4枚選択時);「移動先...」;「ファイル削除」",
|
||||
helpGallery6: "複数選択:Ctrl+クリックで個別トグル;Shift+クリックで最後にクリックした位置から範囲選択 — 一括操作バー:グループを選択して「グループに追加」/「グループから削除」;「お気に入り追加(全件)」/「お気に入り解除(全件)」;「比較」(2〜4枚選択時);「移動先...」;「エクスポート」(選択画像をZIPファイルとしてダウンロード);「PSD作成」(選択画像を1つのレイヤー付きPSDファイルにまとめる);「レイヤーとして編集」(選択画像をImage Editタブで各画像を独立レイヤーとして開く);「ファイル削除」",
|
||||
helpGallery7: "詳細パネル — Infoタブ:ファイル名、サイズ、寸法(画像は幅×高さ、動画は再生時間)、日付、タグ(追加/削除)、メモ;「Move To...」で他フォルダへ移動;「ファイル削除」で完全削除",
|
||||
helpGallery8: "詳細パネル — JSONタブ:PNGの埋め込みデータ(prompt / workflow キー)またはGenerateUIタブで保存したメタデータからワークフローJSONを表示;「Copy & Send Canvas」ボタンでクリップボードにコピーしてComfyUIキャンバスへ直接送信(UI形式・API形式どちらも対応);ComfyUIツールバーから開いていない場合はUI形式のみタイトルドラッグのフォールバック",
|
||||
helpGallery9: "詳細パネル — Groupsタブ:画像ごとのグループ作成、割り当て、解除、名前変更、削除",
|
||||
helpGallery10: "ワークフロー自動保存:GenerateUIタブで生成した画像はワークフローが自動でギャラリーメタデータに保存され、JSONタブで確認可能",
|
||||
helpGallery11: "PNG (tEXt/iTXt)、JPEG、WebP、GIF、MP4対応;PNG形式はComfyUIの埋め込みJSON(prompt / workflow キー)、MP4形式はSaveVideoが書き込むコンテナタグ(同キー、MiniMax H3出力等)からワークフロー抽出が可能",
|
||||
helpGallery11: "PNG (tEXt/iTXt)、JPEG、WebP、GIF、MP4、PSD対応;PNG形式はComfyUIの埋め込みJSON(prompt / workflow キー)、MP4形式はSaveVideoが書き込むコンテナタグ(同キー、MiniMax H3出力等)からワークフロー抽出が可能",
|
||||
helpGallery28: "「PSD作成」一括操作ボタン:選択中の全画像を1つのレイヤー付きPSDファイルにまとめてダウンロードする — 各画像はファイル名を冠した独立レイヤーとなり、選択画像中の最大サイズに合わせたキャンバスに左上基準で配置される;最後に選択した画像が最前面になる",
|
||||
helpGallery29: "「レイヤーとして編集」一括操作ボタン:Image Editタブに切り替え、選択中の全画像を一度に開き、それぞれ独立したレイヤーとして展開する(選択中に動画ファイルが含まれる場合はスキップされる);最後に選択した画像が最前面になる",
|
||||
helpGallery30: "PSDファイル:サムネイル・フルサイズプレビューは全レイヤーを合成した結果を表示する(非表示レイヤーは除外される);元のPSDファイル自体は変更されず、Gallery上のプレビューのみが合成済みの表示になる",
|
||||
helpGallery12: "Metadataボタン(緑、詳細パネルのタブバー内):選択画像をMetadataタブで開いて埋め込みデータを詳細確認。画像サムネイルのAlt+クリックでも同じ操作が可能",
|
||||
helpGallery13: "Load GenUIボタン(青、詳細パネルのタブバー内):選択画像に埋め込まれたComfyUIワークフローをGenerateUIタブへ直接読み込んで切り替え。ワークフローが埋め込まれていない場合や非対応形式の場合は警告トーストを表示",
|
||||
helpGallery14: "Taggerボタン(紫、詳細パネルのアクション行):選択画像をTaggerタブのSingleモードで直接開き、即座にタグ付けを開始できる",
|
||||
helpGallery15: "Fボタン(シアン色オーバーレイ、各サムネイル左上):クリック一つで画像を __Feeder__ グループに追加/削除;グループ内の場合は常にシアン色で表示、非グループ時はホバー時のみ表示;FeederタブのGalleryモードへ素早く画像を追加できる",
|
||||
helpGallery16: "Image Editボタン(ツールバー、Refreshボタン横):選択中の画像をImage Editタブに送信;既にレイヤーが存在する場合はキャンバスに合わせてスケールして新規レイヤーとして追加、存在しない場合はLayer 1になる",
|
||||
helpGallery16: "Image Editボタン(ツールバー、Refreshボタン横):選択中の画像をImage Editタブに送信;既にレイヤーが存在する場合はキャンバスに合わせてスケールして新規レイヤーとして追加、存在しない場合はLayer 1になる;PSDファイルの場合は1枚に統合せず元のレイヤー構成のまま完全に分解して開く",
|
||||
helpGallery17: "Send GenUI Imageボタン(ツールバー、Image Editボタン横):選択画像をComfyUIにアップロードし、GenerateUIタブの最初のLoadImageノードへ入力として設定;GenerateUI → Input → Imageへ自動切り替え;事前にLoadImageノードを含むワークフローの読み込みが必要",
|
||||
helpGallery27: "Send to LI nodeボタン(ツールバー、Send GenUI Imageボタン横):選択画像をアップロードし、そのファイル名をComfyUIキャンバス上で選択中のノード(無ければ最初に見つかったLoadImage系ノード)のimageウィジェットへ書き込む — Image EditタブのSend to LI nodeと同じ送信先;ComfyUIのトップメニューボタンからWorkflow Studioを起動している必要がある",
|
||||
helpGallery18: "GalleryタブのOutputと並ぶ2つ目のサブタブ。視覚的なプロンプトライブラリを閲覧できる — ComfyUI outputフォルダ内のws_image_promptフォルダを管理(未作成時は自動生成)",
|
||||
@@ -2315,17 +2419,18 @@ const LANGUAGES = {
|
||||
helpGallery22: "Galleryタブ先頭にある3つ目のサブタブ。登録済みStyleをプレビュー画像から視覚的に選べる — ComfyUI outputフォルダ内のws_style_catalogフォルダを管理(未作成時は自動生成)。GenerateUIタブの「カタログ作成」ボタンで生成される(GenerateUIタブのヘルプ参照)",
|
||||
helpGallery23: "同じ3カラムレイアウト:フォルダツリー(左)|サムネイル一覧(中央)|Positive/Negativeプロンプトパネル(右)。サムネイルクリックで、その生成画像に実際に埋め込まれているPositive/Negativeプロンプトを表示、それぞれ個別のCopyボタン付き",
|
||||
helpGallery24: "Select as Styleボタン:選択中の画像のファイル名(拡張子除く)と同名の登録済みStyleを探し、GenerateUIタブのStyleドロップダウンをそれに切り替える — 既存Styleシステムへの視覚的なショートカットであり、埋め込みプロンプトをその場限りで適用するものではない(カタログ画像作成後にStyleがリネーム・削除されていた場合は「一致するスタイルが見つかりません」トーストを表示)",
|
||||
helpGallery25: "Load in GenerateUI / Open in Metadata Tabボタン:カタログ画像自体に埋め込まれたワークフロー(カタログのコピーは元の出力画像と同じPNGメタデータを保持している)を読み取り、GenerateUIエディタへ直接読み込むか、Metadataタブで画像を開いて確認できる — Outputフォルダ内で元ファイルを探す必要がない",
|
||||
helpGallery26: "Galleryタブ上部の4つのサブタブ:Output(このブラウザ)・ImagePrompt・Style_Catalog・Metadata — 最後のMetadataは独立したMetadataタブ(該当ヘルプページ参照)をそのまま内包しているため、Galleryを離れずに画像をドロップして埋め込みモデル/プロンプト情報を確認できます",
|
||||
|
||||
// -- Tagger Tab --
|
||||
helpTaggerTitle: "Taggerタブ",
|
||||
helpTagger1: "WD Tagger(ONNX)・SwinV2・DeepDanbooru(.h5)・Ollama VLMによる自動タグ付け — サブタブ: Single / Batch / DB",
|
||||
helpTagger1: "WD Tagger(ONNX)・SwinV2・DeepDanbooru(.h5)・VLM(Ollama / LM Studio / Lemonade / Unsloth)による自動タグ付け — サブタブ: Single / Batch / DB",
|
||||
helpTagger2: "モデル配置:ComfyUI/models/tagger/<モデル名>/ 以下に各モデルのフォルダを作成し、.onnx + selected_tags.csv(DeepDanbooruは .h5 + ラベルファイル)を格納",
|
||||
helpTagger3: "Singleタブ:プレビューエリアへ画像をドラッグ&ドロップ、またはUploadボタンでファイルを選択;GalleryタブのTaggerボタンで選択画像を直接開くことも可能",
|
||||
helpTagger4: "閾値スライダー:一般タグの信頼スコア下限(デフォルト0.35);キャラ閾値:character: プレフィックス付きタグ専用の閾値(デフォルト0.85)",
|
||||
helpTagger5: "Ollama VLM:チェックボックスをオンにするとWD Taggerと並行してビジョンモデルを実行;API URLとモデルを設定(↻で一覧更新)、プロンプトを編集可能;両モデルの結果はマージされる",
|
||||
helpTagger5: "VLMタグ付け:チェックボックスをオンにするとWD Taggerと並行してビジョンモデルを実行;⚙設定ボタンからバックエンド(Ollama / LM Studio / Lemonade / Unsloth)・URL・モデル・Thinking mode・Max tokensの選択とモデルのアンロードが可能——Promptタブ独自のAI設定とは別管理;プロンプトとMax Tagsはインラインで編集可能;両モデルの結果はマージされる",
|
||||
helpTagger6: "生成タグの送信先:(1) GenUI:P — 生成UIタブのポジティブプロンプトに追記し、読み込み中のワークフローへ即時適用;(2) プロンプトに送信 — Promptタブの正プロンプトテキストエリアに追記;(3) Galleryに保存 — ギャラリーの画像メタデータに保存(Galleryから開いた画像のみ有効);(4) ファイルに書込 — JPEGはEXIF(ImageDescription)、PNGはtEXt(Tagsキー)に書き込み;(5) DBに保存 — 内蔵SQLiteデータベースに保存",
|
||||
helpTagger7: "Batchタブ:処理対象フォルダパスを入力してWD Tagger・Ollamaの設定を行い、出力オプション(DBに保存〈デフォルトON〉・ファイルに書込〈EXIF/PNGメタデータ〉・.txt出力〈画像と同フォルダに <ファイル名>.txt を生成〉)を選択;バッチ開始で指定フォルダ内の全画像を処理;進行状況バーとログがリアルタイム更新;停止ボタンは現在の画像処理完了後にキャンセル",
|
||||
helpTagger7: "Batchタブ:処理対象フォルダパスを入力してWD Taggerを設定し、VLMタグ付けを有効化(Singleタブと同じ⚙設定を使用);出力オプション(DBに保存〈デフォルトON〉・ファイルに書込〈EXIF/PNGメタデータ〉・.txt出力〈画像と同フォルダに <ファイル名>.txt を生成〉)を選択;バッチ開始で指定フォルダ内の全画像を処理;進行状況バーとログがリアルタイム更新;停止ボタンは現在の画像処理完了後にキャンセル",
|
||||
helpTagger8: "DBタブ:タグ付けした画像の検索可能データベース;行をクリックすると編集パネルでWDタグ・VLMタグを編集可能;保存で更新、削除でレコード削除;CSV出力で全レコードをダウンロード",
|
||||
helpTagger9: "依存パッケージのインストール:python_embeded\\python.exe -m pip install -r requirements.txt;GPU推論にはonnxruntimeの代わりにonnxruntime-gpuをインストール;TensorFlow(DeepDanbooru用)はサイズ大のためデフォルトではコメントアウト",
|
||||
|
||||
@@ -2352,7 +2457,7 @@ const LANGUAGES = {
|
||||
helpAi2: "翻訳ペイン:Ollama、LM Studio、Lemonade、または Unsloth を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット;モデルがtool callingに対応している場合、「猫の画像を作って」のように頼むとGenerateUIに読み込み中のワークフローで生成を実行し、結果をチャット内に表示する;ペインヘッダーの「画像生成を許可する」チェックボックスでこの機能のON/OFFを切り替えられる;画像が添付されている場合(TOOLSペインにドロップした画像)はLLMにもビジョン入力として送信され、generate_image呼び出し時はT2Iの代わりにI2I(image-to-image)の元画像として使われる;generate_image呼び出しにはsteps・cfg・sampler_name・scheduler・denoise・seed、width・height、batch_size(その1回の呼び出しで生成する枚数)、aspect_ratio(\"3:2\"のような比率プリセット。ワークフローにwidth/heightを供給するResolution Selectorノードがある場合のみ適用され、それ以外は無視される)といった任意の生成パラメータも指定できる;モデルが指定しなかった項目は読み込み中のワークフローの現在の設定がそのまま使われる;tool callingとビジョンの動作を確認済みのモデル: Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成;ドロップした画像はChatペインの添付としても共有され(Chat側にも表示、I2I生成に使用)、✕ボタンでクリアするまで保持される",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade / Unsloth)の選択、APIのURL設定、接続テスト、モデル選択;Unslothはローカルアクセスでも常にAPIキー認証が必須——プラグインフォルダの .env.example を .env にコピーして UNSLOTH_API_KEY を設定し ComfyUI を再起動する(キーはサーバー側でのみ読み込まれ、ブラウザには一切送信されない);Unsloth Desktopアプリ側の設定画面で「モデル自動切り替え (OpenAI API)」もONにする必要がある——OFFのままだと、Unsloth Studio上で事前にモデルを手動ロードしていない限り HTTP 404 で接続に失敗する;Unslothのreasoningモデルは思考内容をインラインの<think>タグではなく別のreasoning_contentフィールドで返すため、Max tokensが少ないとモデルが思考だけでトークンを使い切り、空の返信になることがある——返信が空になる場合はMax tokensを増やす(2048以上を推奨);Generationセクション — Thinking modeトグル(デフォルトOFF;OllamaにはthinkフラグとしてOFF/ONを送信、バックエンドを問わずOFF時は出力中の<think>...</think>ブロックをクライアント側で除去するため、推論モデルの思考過程がTranslation/Chat/TOOLSの結果に混入しない)とMax tokens(Ollama: num_predict、LM Studio/Lemonade/Unsloth: max_tokens;0で無制限);Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade / Unsloth)の選択、APIのURL設定、接続テスト、モデル選択、および「Test connection」の右隣にある「Unload Model」ボタンでロード中のモデルをVRAM/メモリから解放——Ollamaは keep_alive:0、LM Studioは /api/v1/models/unload、Lemonadeは /api/v1/unload を使用;Unslothにはアンロード用APIが存在しないため未対応メッセージが表示される;Unslothはローカルアクセスでも常にAPIキー認証が必須——プラグインフォルダの .env.example を .env にコピーして UNSLOTH_API_KEY を設定し ComfyUI を再起動する(キーはサーバー側でのみ読み込まれ、ブラウザには一切送信されない);Unsloth Desktopアプリ側の設定画面で「モデル自動切り替え (OpenAI API)」もONにする必要がある——OFFのままだと、Unsloth Studio上で事前にモデルを手動ロードしていない限り HTTP 404 で接続に失敗する;Unslothのreasoningモデルは思考内容をインラインの<think>タグではなく別のreasoning_contentフィールドで返すため、Max tokensが少ないとモデルが思考だけでトークンを使い切り、空の返信になることがある——返信が空になる場合はMax tokensを増やす(2048以上を推奨);Generationセクション — Thinking modeトグル(デフォルトOFF;OllamaにはthinkフラグとしてOFF/ONを送信、バックエンドを問わずOFF時は出力中の<think>...</think>ブロックをクライアント側で除去するため、推論モデルの思考過程がTranslation/Chat/TOOLSの結果に混入しない)とMax tokens(Ollama: num_predict、LM Studio/Lemonade/Unsloth: max_tokens;0で無制限);Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi7: "Chat ペイン — SVG:LLMはComfyUIワークフローを一切使わず、SVGグラフィックをテキストとして直接生成することもできる;「SVGで〜を描いて」のように頼み、応答にSVGコードが含まれていれば自動でインラインプレビュー表示され、「Galleryに保存」ボタンでGalleryタブの出力フォルダに.svgファイルとして保存できる;tool calling(function calling)を使用しないため、tool calling非対応のモデルでも動作する",
|
||||
helpAi8: "Chat ペイン — スキル:メッセージ一覧上部のドロップダウンでスキルを選ぶと、その指示内容が system prompt として会話の先頭に付加される(表示上のチャット履歴には含まれない);鉛筆ボタンでスキル管理パネルを開き、.mdスキルファイルを作成・編集・削除できる(保存先: user/default/Workflow-Studio/ai_skills/);ファイル先頭の---で囲まれた部分にname/descriptionを書くとドロップダウンの表示に反映される;初期状態で2つのスキルを同梱: 「SVG Icon Generator」(描く前に対象を部位分解することで認識しやすいSVGを生成)と「Skill Creator」(対話形式で質問しながら新しいスキルファイルを下書きする);応答に```skillコードブロックが含まれる場合、メッセージ下部に「スキルとして保存」ボタンが表示され、内容が入った状態で管理パネルが開く",
|
||||
helpAi9: "スキル作成のヒント:ドロップダウンやツールチップで分かりやすいよう、frontmatterのname/descriptionは明確に書く;期待する出力形式を具体的に指示し、SVGや別のスキルファイルのように後で抽出したい成果物がある場合は専用のコードフェンスで囲むよう指示する;曖昧な指示より具体的で命令形の指示の方がローカルの小型モデルには効きやすく、短い実例を1つ添えるとさらに効果的;禁止事項(不適切な内容、セキュリティ、文字数制限など)は暗黙に期待せず明示的に書く;迷ったらドロップダウンで「Skill Creator」スキルを選び、作りたいスキルの概要を伝えれば対話形式で下書きしてくれる",
|
||||
@@ -2426,7 +2531,9 @@ const LANGUAGES = {
|
||||
helpTroubleCardTagger: "Tagger・モデルの問題",
|
||||
helpImageEditTitle: "Image Edit タブ",
|
||||
helpImageEdit1: "レイヤーベースの画像エディタ — 複数のレイヤー(画像・テキスト・描画・マスク)を重ね合わせて画像を合成・エクスポート",
|
||||
helpImageEdit2: "画像の読み込み:キャンバスへのドラッグ&ドロップ、Uploadボタン、またはGalleryタブのImage Editボタンから送信;最初の画像はLayer 1として自動ロック;以降の画像はキャンバスサイズに合わせてスケールされ新規レイヤーとして追加",
|
||||
helpImageEdit2: "画像の読み込み:キャンバスへのドラッグ&ドロップ、File Openボタン、またはGalleryタブのImage Editボタンから送信;最初の画像はLayer 1として自動ロック;以降の画像はキャンバスサイズに合わせてスケールされ新規レイヤーとして追加",
|
||||
helpImageEdit31: "Open PSDボタン(File Openの隣):ディスク上のレイヤー付きPSDファイルを読み込み、各レイヤーに分解する — 位置・サイズ・表示状態・不透明度・ブレンドモードはレイヤーごとに復元される;グループやラスタ化できないレイヤー(調整レイヤー等)はその見た目のままフラット化される",
|
||||
helpImageEdit32: "Closeボタン(Open PSDの隣):確認ダイアログの後、現在のドキュメントを閉じる — 全レイヤーと未保存の変更を破棄し、空のプレースホルダー表示に戻る",
|
||||
helpImageEdit3: "Newボタン:空白キャンバスを作成 — W×Hサイズを入力(例: 512x768);既存レイヤーは全て削除し、空のLayer 1を自動追加",
|
||||
helpImageEditToolsTitle: "ツール",
|
||||
helpImageEdit4: "Select (V):クリックでレイヤーを選択、ドラッグで移動、コーナーハンドルでリサイズ、上辺の円ハンドルで回転;テキストレイヤーをダブルクリックすると再編集可能;ツールオプションバーにFlip H / Flip V / 回転角度入力を表示",
|
||||
@@ -2447,6 +2554,7 @@ const LANGUAGES = {
|
||||
helpImageEditExportTitle: "エクスポートとナビゲーション",
|
||||
helpImageEdit17: "Save PNG:全可視レイヤーを合成してPNGファイルとしてダウンロード",
|
||||
helpImageEdit18: "Save to Gallery:ファイル名を入力(デフォルト: wfs-image-YYYYMMDDHHmmss)してギャラリーのルートフォルダに合成PNGを保存(設定 > Gallery Output Folderで設定);Galleryタブを一度開いてルートフォルダを設定する必要あり",
|
||||
helpImageEdit34: "Save PSD:全レイヤーを個別に保持したPSDファイルをダウンロードする — 位置・回転・反転・拡縮は各レイヤーのピクセルにベイクされる(PSDのレイヤーは回転角度を保持できないため)一方、不透明度・ブレンドモード・表示状態は編集可能な実際のPSDレイヤー属性として保持される;クリッピングマスクはPSD本来のレイヤーマスク機構を使わないため、対象レイヤーと合成した状態で書き出される",
|
||||
helpImageEdit19b: "Send to LI node:合成PNGをComfyUIのinputフォルダに /upload/image でアップロードし、さらにそのファイル名をComfyUIグラフ内で選択中のノード(無ければ最初に見つかったLoadImage系ノード)のimageウィジェットへ書き込む;ComfyUIのトップメニューボタンからWorkflow Studioを起動している必要がある",
|
||||
helpImageEdit20: "キャンバスナビゲーション:スクロールホイールでズーム、Space + ドラッグでパン",
|
||||
helpImageEdit21: "Undo / Redo:Ctrl+Z / Ctrl+Y(またはツールバーの ↩ ↪ ボタン)",
|
||||
@@ -2462,11 +2570,12 @@ const LANGUAGES = {
|
||||
helpImageEdit30: "ダブルクリック(Selectツールでテキストレイヤー):テキストを再編集",
|
||||
helpImageEdit7b: "Blur (≈):アクティブレイヤーにガウスぼかしまたはモザイクを適用 — 全体またはドラッグで矩形範囲指定;詳細は下の「Blur Tool」カードを参照",
|
||||
helpImageEdit7c: "BG Remove (⬚):Lightweight(@imgly、ブラウザ内処理)またはBiRefNet(Mask Editor One + ComfyUI/models/background_removal/にbirefnet.safetensors必須)でアクティブレイヤーの背景を除去;詳細は下の「BG Remove Tool」カードを参照",
|
||||
helpImageEdit7d: "Mask (🎭):6つのサブツールでマスクレイヤーにマスクを描画 — Paint(フリーハンド)・Color(色類似度フラッドセレクト)・Alpha(画像の透過チャンネル抽出)・Text(テキストスタンプ)・Vector(Catmull-Romスプライン多角形)・Shape(矩形/楕円);Mask Editor OneインストールでSAM3テキストプロンプトセグメンテーションも利用可能;レイヤーヘッダーの「M」でマスクレイヤーを追加;詳細は下の「Mask Tool」カードを参照",
|
||||
helpImageEdit7d: "Mask (🎭):6つのサブツールでマスクレイヤーにマスクを描画 — Paint(フリーハンド)・Color(色類似度フラッドセレクト)・Alpha(画像の透過チャンネル抽出)・Text(テキストスタンプ)・Vector(Catmull-Romスプライン多角形)・Shape(矩形/楕円);Mask Editor OneインストールでSAM3テキストプロンプトセグメンテーションも利用可能;レイヤーヘッダー2段目の「Mask」ボタンでマスクレイヤーを追加;詳細は下の「Mask Tool」カードを参照",
|
||||
helpImageEdit7e: "Inpaint (🩹):表示中のレイヤーとマスクレイヤーを1枚のRGBA画像に合成(マスクをアルファチャンネルに焼き込み)し、GenerateUIで読み込み中のワークフロー内でインペイント対応の最初のLoadImageノード(MASK出力がVAEEncodeForInpaint等に接続されているノード)へ適用;プロパティパネルでポジ/ネガプロンプト、Grow Mask By(VAE Encode (for Inpainting)ノードへ反映)、Denoise(KSamplerへ反映)を入力してRunを押すと、Image Editタブに留まったままアップロード・生成を実行;完了後は結果画像がプロパティパネルに表示される;事前にマスクレイヤーの描画とインペイント対応ワークフローの読み込みが必要;LoadImageの代わりにMask Editor Oneカスタムノード(comfyui-mask-editor-one)を使ったワークフローにも対応(node_idキーのサーバーキャッシュとlayer_dataウィジェット経由で画像/マスクを反映);「Use dedicated workflow」チェックボックス:OFF時はGenerateUIで読み込み中のワークフローを使用(従来通り)、ON時はドロップダウンから保存済みワークフローを選択すると、GenerateUIの表示状態には触れずバックグラウンドで読み込み・編集・生成を実行",
|
||||
helpImageEdit8b: "レイヤーヘッダーの「M」ボタン:アクティブレイヤーの上に新しいマスクレイヤーを追加し、自動的にMaskツールに切り替える",
|
||||
helpImageEdit8b: "レイヤーヘッダーは2段構成:1段目に +、⧉(複製)、−、↑、↓;2段目には「Mask」ボタンのみを配置 — アクティブレイヤーの上に新しいマスクレイヤーを追加し、自動的にMaskツールに切り替える",
|
||||
helpImageEdit33: "⧉(レイヤー複製)ボタン:アクティブレイヤーをピクセル・位置・サイズ・不透明度・ブレンドモード・表示状態ごと複製し、元のレイヤーの直前(重なり順で1つ上)に新しいアクティブレイヤーとして挿入する;画像・描画・テキスト・マスクいずれのレイヤー種別でも動作する",
|
||||
helpImageEditMaskTitle: "Mask ツール (🎭)",
|
||||
helpImageEditMask1: "マスクレイヤーの追加:レイヤーパネルヘッダーの「M」ボタンをクリック;新しいマスクレイヤーはアクティブレイヤーの上に挿入され、Maskツールが自動的に有効化される",
|
||||
helpImageEditMask1: "マスクレイヤーの追加:レイヤーパネルヘッダー2段目の「Mask」ボタンをクリック;新しいマスクレイヤーはアクティブレイヤーの上に挿入され、Maskツールが自動的に有効化される",
|
||||
helpImageEditMask2: "レイヤーリストでマスクレイヤーをクリックすると自動的にMaskツールに切り替わる",
|
||||
helpImageEditMask3: "ツールオプションバー — サブツール:Paint / Color / Alpha / Text / Vector / Shape(全てMask Editor One不要)/ SAM3(Mask Editor One必須・未インストール時は無効);Invertチェックボックスでオーバーレイプレビューを反転;Overlayカラーピッカーでプレビューカラーを変更(デフォルト赤);Blurスライダー(0〜50 px)でオーバーレイ輪郭をぼかす;オーバーレイは選択中(アクティブ)のマスクレイヤーのみ表示され、非アクティブなマスクは選択するまで非表示",
|
||||
helpImageEditMask4: "プロパティペイン — Paintモード:Mode(Add / Erase)・Size(1〜200 px)・Hardness(0〜100%);MASK EDITOR ONEセクション(常時表示 — ABRブラシが利用可能な場合は見出しが緑、未インポートの場合はグレー):Selectボタンでブラシピッカーを開く(ブラシが1つも無い間は無効)(選択したブラシがデフォルト円形に代わる);✕でCircle(デフォルト)に戻す;画像ブラシ選択中はHardnessが無効になり追加コントロールが表示:Spacing(スタンプ間隔 5〜100%)・Angle(回転角 0〜359°)・Sz Jitter(スタンプごとのサイズランダム変動 0〜100%)・Rot. Jitter(スタンプごとの角度ランダム変動 on/off)",
|
||||
@@ -2577,7 +2686,7 @@ const LANGUAGES = {
|
||||
importError: "インポート失敗",
|
||||
exportFullBackup: "フルバックアップ (ZIP)",
|
||||
importFullBackup: "フルバックアップを復元 (ZIP)",
|
||||
fullBackupHint: "フルバックアップ(ZIP)は上記のJSONエクスポートでは表現できないフォルダ形式のデータ(AI Skills、Lab Plan、Styleプリセット)とTaggerデータベースも含みます。復元すると既存ファイルは上書きされます。復元後はComfyUIを再起動してください。",
|
||||
fullBackupHint: "フルバックアップ(ZIP)は上記のJSONエクスポートでは表現できないフォルダ形式のデータ(AI Skills、Lab Plan、Video Plan、Styleプリセット)とTaggerデータベースも含みます。復元すると既存ファイルは上書きされます。復元後はComfyUIを再起動してください。",
|
||||
fullBackupFilesUnit: "件",
|
||||
fullBackupIncludeWorkflows: "デフォルトワークフローフォルダーを含める (user/default/workflows)",
|
||||
fullBackupIncludeWildcard: "Impact Packのwildcardフォルダーを含める(存在する場合)",
|
||||
@@ -2674,31 +2783,64 @@ const LANGUAGES = {
|
||||
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/MP4/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image サブグラフ形式;MP4はSaveVideoが書き込むworkflow/promptコンテナタグを読み取り、MiniMax H3 ImageToVideoのようなオールインワン動画ノードにも対応)・SD WebUI・SD Forge・Fooocus。",
|
||||
helpMetadata6: "現在は Gallery タブ内のサブタブ(Style_Catalog の右隣)になっており、独立したトップレベルタブではありません — それ以外の動作は全く同じです",
|
||||
|
||||
// -- Video タブ(MiniMax H3専用、GenerateUIタブとは独立) --
|
||||
// -- Video タブ(MiniMax H3 / LTX-2.5 / Wan2.2対応、GenerateUIタブとは独立) --
|
||||
tabVideo: "Video",
|
||||
loadInVideo: "Videoに読み込み",
|
||||
videoTitle: "Video (MiniMax H3)",
|
||||
videoFirstFrame: "First Frame(任意)",
|
||||
videoLastFrame: "Last Frame(任意)",
|
||||
videoDropOrClick: "画像をドロップまたはクリックして選択",
|
||||
videoPrompt: "Prompt",
|
||||
videoAspectRatio: "Aspect Ratio",
|
||||
videoMegapixels: "Megapixels",
|
||||
videoMultiple: "Multiple",
|
||||
videoDuration: "Duration(秒)",
|
||||
videoNoiseSeed: "Noise Seed",
|
||||
videoRandomizeSeed: "ランダム化",
|
||||
videoGenerate: "Generate",
|
||||
videoEditPlaceholder: "編集機能は今後追加予定",
|
||||
videoEditNoClipsHint: "クリップがありません — 上の動画ソースでAssetを選択/動画をドロップし「Editに追加」してください",
|
||||
videoEditNoClips: "まずクリップを1つ以上追加してください",
|
||||
videoEditStillProbing: "クリップの解析が完了するまでお待ちください",
|
||||
videoEditResolutionMismatch: (a, b) => `クリップの解像度が一致しません(${a} と ${b})。書き出し前に同じサイズにトリム/クロップしてください。`,
|
||||
videoEditProbing: "解析中...",
|
||||
videoEditSelectClipHint: "トリムするクリップを選択してください",
|
||||
videoEditImageDuration: "表示時間(秒)",
|
||||
videoEditTrimStart: "開始(秒)",
|
||||
videoEditTrimEnd: "終了(秒)",
|
||||
videoEditSetFromPlayhead: "現在位置を使用",
|
||||
videoEditMoveLeft: "左へ移動",
|
||||
videoEditMoveRight: "右へ移動",
|
||||
videoEditClearBtn: "クリア",
|
||||
videoEditTotalDuration: (time) => `合計: ${time}`,
|
||||
videoEditPreviewPlay: "▶ プレビュー",
|
||||
videoEditPreviewStop: "■ 停止",
|
||||
videoEditConfirmClear: "編集タイムラインを全てクリアしますか?",
|
||||
videoSourceAddToEdit: "Editに追加",
|
||||
videoEditDuplicate: "複製",
|
||||
videoEditDelete: "削除",
|
||||
videoEditExportBtn: "書き出し",
|
||||
videoEditExporting: "書き出し中...",
|
||||
videoEditExportDone: (name) => `書き出し完了: ${name}`,
|
||||
videoEditSendToEdit: "Editへ送る",
|
||||
videoEditClipSent: (name) => `"${name}" をEditタイムラインに追加しました`,
|
||||
videoAssetSetAsFirst: "First Imageに設定",
|
||||
videoAssetSetAsLast: "Last Imageに設定",
|
||||
videoAssetSetAsFirstDone: (name) => `"${name}" をFirst Imageに設定しました`,
|
||||
videoAssetSetAsLastDone: (name) => `"${name}" をLast Imageに設定しました`,
|
||||
videoNoBlockSelected: "Planでブロックが選択されていません",
|
||||
videoBlockImageUploadFailed: "Planブロックへの画像アップロードに失敗しました",
|
||||
videoNoWorkflowLoaded: "ワークフローが読み込まれていません",
|
||||
videoUnsupportedWorkflow: "このワークフローにはMiniMax H3のImage to Videoノードが含まれていません",
|
||||
videoUnsupportedWorkflow: "このワークフローには対応する動画モデルノード(MiniMax H3、LTX-2.5のText/Image/First&Last-Frame to Video、またはWan2.2のText to Video)が含まれていません",
|
||||
videoWorkflowLoadFailed: "ワークフローの読み込みに失敗しました",
|
||||
videoConfirmLoadWorkflow: (name) => `「${name}」をこのPlanのワークフローとして読み込みますか?`,
|
||||
videoPlanMinOneBlock: "Planには最低1つのブロックが必要です",
|
||||
videoPlanEnterName: "Planの名前を入力してください",
|
||||
videoPlanSaved: "Planを保存しました",
|
||||
videoPlanSaveFailed: "Planの保存に失敗しました",
|
||||
videoPlanLoaded: "Planを読み込みました",
|
||||
videoPlanLoadFailed: "Planの読み込みに失敗しました",
|
||||
videoEditProjectEnterName: "プロジェクトの名前を入力してください",
|
||||
videoEditProjectSaved: "プロジェクトを保存しました",
|
||||
videoEditProjectSaveFailed: "プロジェクトの保存に失敗しました",
|
||||
videoEditProjectLoaded: "プロジェクトを読み込みました",
|
||||
videoEditProjectLoadFailed: "プロジェクトの読み込みに失敗しました",
|
||||
videoConfirmClearPlan: "現在のPlanをクリアしますか?",
|
||||
videoPlanPause: "一時停止",
|
||||
videoPlanResume: "再開",
|
||||
videoWorkflowLoaded: (name) => `ワークフローを読み込みました: ${name}`,
|
||||
videoGenerateSuccess: "動画を生成しました",
|
||||
videoDoneCount: (n) => `完了(${n}件)`,
|
||||
videoSourceLabel: "動画ソース",
|
||||
videoSourceHint: "生成せずにFrame/GIF機能を使いたい場合はここに任意の動画を読み込めます。",
|
||||
videoSourceDropLabel: "動画をドロップまたはクリックして選択",
|
||||
videoNoVideo: "動画なし",
|
||||
videoSourcePreviewPlaceholder: "アセットを選択するか動画をドロップしてください",
|
||||
videoResultPreviewPlaceholder: "生成された動画はまだありません",
|
||||
videoPropFrame: "Frame",
|
||||
videoPropGif: "GIF",
|
||||
videoFrameHint: "プレビュー動画を再生・シークして止めたい位置で「Capture Frame」を押してください。",
|
||||
@@ -2715,15 +2857,35 @@ const LANGUAGES = {
|
||||
videoGifDoneCount: (n) => `完了(${n}フレーム)`,
|
||||
videoGifSaved: (name) => `GIFを保存しました: ${name}`,
|
||||
videoNoSourceLoaded: "先に動画を用意してください(生成するか、動画ソースにファイルをドロップ)",
|
||||
videoPropProject: "Project",
|
||||
videoProjectHint: "保存済みのPlan",
|
||||
videoProjectEmpty: "保存済みのPlanはまだありません",
|
||||
videoProjectBlockCount: (n) => `${n}ブロック`,
|
||||
videoProjectDeleteConfirm: (name) => `Plan「${name}」を削除しますか?`,
|
||||
videoProjectDeleted: "Planを削除しました",
|
||||
videoProjectDeleteFailed: "Planの削除に失敗しました",
|
||||
videoProjectLoadListFailed: "Plan一覧の読み込みに失敗しました",
|
||||
videoEditProjectHint: "保存済みのEditプロジェクト",
|
||||
videoEditProjectEmpty: "保存済みのEditプロジェクトはまだありません",
|
||||
videoEditProjectClipCount: (n) => `${n}クリップ`,
|
||||
videoEditProjectDeleteConfirm: (name) => `プロジェクト「${name}」を削除しますか?`,
|
||||
videoEditProjectDeleted: "プロジェクトを削除しました",
|
||||
videoEditProjectDeleteFailed: "プロジェクトの削除に失敗しました",
|
||||
videoEditProjectLoadListFailed: "プロジェクト一覧の読み込みに失敗しました",
|
||||
helpVideoTitle: "Video タブ",
|
||||
helpVideoDesc: "MiniMax H3 (Image to Video) ワークフロー専用の動画生成タブ。GenerateUIタブとは独立して動作します。",
|
||||
helpVideo1: "first_frame(任意)/last_frame(任意)の画像、prompt、aspect_ratio/megapixels/multiple(解像度)、duration(秒)、noise_seedを指定してGenerateをクリックします。first_frameを未指定にするとテキストのみでバイパス実行され、LoadImageノード自体が無いワークフローにも対応しています。",
|
||||
helpVideo2: "モデル選択(UNet/CLIP/VAE/Audio VAE)は読み込んだワークフローの既定値に固定され、このタブでは変更できません。",
|
||||
helpVideo3: "Workflowタブの詳細モーダルにある「Videoに読み込み」ボタンから、MiniMax H3のImage to Videoノードを含むワークフローを読み込めます。対応していないワークフローはエラー表示になります。",
|
||||
helpVideo4: "生成した動画(mp4)はGalleryタブでも閲覧・再生できます。",
|
||||
helpVideo5: "プレビューの横にある「Video Source」パネルでは、生成した動画に限らず任意のローカル動画をドロップ/選択して、下のFrame/GIF機能をMiniMax H3を実行せずに使えます。Clearで選択をリセットできます。",
|
||||
helpVideo6: "Frameタブ(右パネル): プレビュー動画を再生・シークして止めたい位置で「Capture Frame」を押すとその場でフレームをキャプチャし、「Save to Output」でPNGとしてComfyUIのOutputフォルダに保存します。",
|
||||
helpVideoDesc: "MiniMax H3 (Image to Video)、LTX-2.5 (Text / Image / First & Last Frame to Video)、Wan2.2 (Text to Video) ワークフロー対応の動画生成タブ。GenerateUIタブとは独立して動作します。中央パネルのPlan/Edit/Assetサブタブがバッチ生成・クリップ編集・生成済み動画の閲覧を扱い、左サイドバーのProjectサブタブは中央がPlan選択中なら保存済みPlan一覧、Edit選択中なら保存済みEditプロジェクト一覧を扱います。",
|
||||
helpVideo1: "Planサブタブ: 上部の設定行にAspect Ratio/Megapixels/Multiple/Duration(秒)/Noise SeedがこのPlanの基本設定として並びます——Durationは常に現在選択中のブロックの値を表示・編集します(各ブロックが独自の値を持つ)が、それ以外はPlan全体で共通の値です。その下の2行目にはWidth/Height/Turbo Mode/Strength/Steps/Prompt Enhanceが並び、読み込んだワークフローが実際に対応する項目だけ入力可能になります(詳細はhelpVideo2)。「+ Split」「+ Block」「Delete」ボタンでタイムラインをブロックに分割すると、各ブロックは独自のPrompt・Duration・First/Last Imageを持ちます(First Imageは「なし」「画像を指定」「前ブロックの最終フレームを使用」から選択可能。前ブロックに明示的なLast Imageが設定されていれば、フレーム抽出よりそちらが優先されます)。Runを押すと全ブロックを順番に生成します。実行中もPause/Cancelが使えます。",
|
||||
helpVideo2: "ブロックが1個だけのPlanは、以前の単発生成フォームと同じ動作になります。モデル選択(UNet/CLIP/VAE/Audio VAE)は読み込んだワークフローの既定値に固定されます。ResolutionSelectorノードを持たないワークフロー(Wan2.2、LTX-2.5のFirst & Last Frame to Videoなど)はAspect Ratio/Megapixels/Multiple欄が無効化される代わりに、ワークフロー自体のWidth/Heightを直接編集できるようになります。Turbo Mode/Strength/StepsはMiniMax H3のturbo LoRA専用、Prompt EnhanceはLTX-2.5専用で、他のモデルファミリーでは無効のままです。Noise SeedはWan2.2のText to Videoのみ公開入力を持たないため無効化されます。",
|
||||
helpVideo3: "Workflowタブの詳細モーダルにある「Videoに読み込み」ボタンから、MiniMax H3のImage to Videoノード、LTX-2.5のText/Image/First&Last-Frame to Videoノード、またはWan2.2のText to Videoノードを含むワークフローを読み込めます。この操作でワークフローの現在値を反映したブロック1個の新規Planが作成されます。対応していないワークフローはエラー表示になります。",
|
||||
helpVideo4: "生成した動画(mp4)はAssetサブタブのVideo Tempグループに自動的に反映されるほか、Galleryタブでも閲覧・再生できます。残したい動画をVideo Assetsへ整理するタイミングはユーザー任意です。",
|
||||
helpVideo5: "中央パネルには2つのプレビューがあります。左側はAssetサブタブで選択した動画、または左隣の「Video Source」パネルにドロップ/選択した任意のローカル動画を表示し(Clearでリセット)、読み込んだワークフローを実行せずにFrame/GIF機能を使えます。右側は常に直近で生成されたクリップを表示します。",
|
||||
helpVideo6: "Frameタブ(右パネル): プレビュー動画を再生・シークして止めたい位置で「Capture Frame」を押すとその場でフレームをキャプチャし、「Save to Output」でPNGとしてComfyUIのOutputフォルダに保存します。保存前に「Add to Video Assets group」にチェックを入れると、保存したフレームをGalleryのVideo Assetsグループにも自動登録できます。",
|
||||
helpVideo7: "GIFタブ(右パネル): Start/End(秒)・FPS・Max Width(任意)を指定して「Convert to GIF」を押すと、サーバー側で動画をデコードしアニメーションGIFとしてComfyUIのOutputフォルダに保存します。",
|
||||
helpVideo8: "チェイン機能: ブロックのFirst Imageを「前ブロックの最終フレームを使用」に設定すると、前のクリップから連続した映像になります。前ブロックに明示的なLast Imageが設定されていればそれがそのまま使われ、無ければ生成された動画から実際の最終フレームが自動抽出されます。",
|
||||
helpVideo9: "Projectタブ(左サイドバー): 中央がPlanサブタブのときは保存済みPlan一覧、Editサブタブのときは保存済みEditプロジェクト一覧(サムネイル/ブロック数またはクリップ数・更新日時)を表示します。クリックで読み込み、×ボタンで削除できます。同じタブをもう一度押すとパネルを閉じます。",
|
||||
helpVideo10: "ハイライト表示: 読み込んだワークフローが実際にサポートしているフィールドに応じて、Prompt/Duration/First Image/Last Image/Aspect Ratio/Megapixels/Multiple/Noise Seed/Width/Height/Turbo Mode/Strength/Steps/Prompt Enhanceのラベルが、GenerateUI ModelタブのハイライトとSettingsタブで共通設定できる色に変わります。例えばWan2.2ワークフロー(ResolutionSelectorノードが無い)ではAspect Ratio/Megapixels/Multipleはハイライトされない代わりに、Width/Heightがハイライトされます。",
|
||||
helpVideo11: "Editサブタブ: 生成とは独立して動画/画像クリップをトリム・結合できる簡易タイムラインです。Video Sourceパネルの「Editに追加」ボタン、またはAssetサブタブの「Editへ送る」ボタンでクリップを追加し、ブロックをドラッグ(または◀/▶ツールバーボタン)で並べ替え、選択中クリップをDuplicate/Delete、Clearでタイムラインを空にできます。選択中の動画クリップのトリムパネルには時間目盛り・Sourceプレビューと同期するドラッグ可能な再生ヘッド・時刻バッジ付きのドラッグ可能なイン/アウトハンドルが表示されます(画像クリップは代わりにシンプルな保持時間の入力欄)。「▶ プレビュー」は準備済みの全クリップをイン/アウト点でトリムして右側の結果プレビューで連続再生し、「書き出し」は実際のComfyUIグラフ(トリム→2クリップ以上ならConcatenate→保存)を構築・実行して、結果をVideo Tempグループへ自動登録します。複数クリップの書き出しは現状音声が失われます(ComfyUIの結合ノードが全入力で音声形式の統一を要求するため)。書き出しパネルのSave/Save As/Loadボタンは、タイムラインそのもの(クリップ順序・トリム区間・各クリップが参照するアップロード済みファイル)をサーバー上にJSONプロジェクトとして保存します。Loadは保存されたサーバー参照から各クリップを再取得・再プローブするため、元のファイルが存在している必要があります。保存済みプロジェクトは、中央がEditサブタブのときサイドバーのProjectタブにも一覧表示されます(helpVideo9参照)。",
|
||||
helpVideo12: "Assetサブタブ(中央パネル、Plan/Editの隣): グループ切替(または「All Video Assets」で両方まとめて)でVideo AssetsまたはVideo Tempに登録された動画/画像を一覧表示します。検索欄はファイル名でフィルタし、▦/☰ボタンでサムネイルグリッドとテーブル表示(ファイル名・更新日時・タグ)を切り替えられます。項目を選択するとタグ・メモが表示され中央プレビューに読み込まれ、「Editへ送る」(Editサブタブのクリップとして追加)に加え、画像の場合のみ「First Imageに設定」「Last Imageに設定」(選択中のPlanブロックへ直接アップロードし、Planサブタブへ切り替え)が使えます。",
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -2906,6 +3068,9 @@ const LANGUAGES = {
|
||||
feederGalNoNode: "未选择WFS_GalleryFeeder节点",
|
||||
feederGalEmptyGroup: "所选组中没有图片",
|
||||
feederGroupCleared: "已清空Feeder组",
|
||||
vtempGroupCleared: "已清空__Video Temp__组",
|
||||
vtempClearBtnLabel: "Clear __Video Temp__",
|
||||
vtempClearBtnTitle: "清空__Video Temp__组(视频生成结果会自动进入此组,整理到__Video Assets__由用户自行选择时机手动完成)",
|
||||
addedToFeeder: "已添加到Feeder",
|
||||
removedFromFeeder: "已从Feeder移除",
|
||||
appliedToWorkflow: "已应用到工作流",
|
||||
@@ -3049,6 +3214,8 @@ const LANGUAGES = {
|
||||
textSizeLabel: "文字大小",
|
||||
textSizeTextarea: "提示词 / 聊天输入框",
|
||||
textSizeHint: "适用于:生成UI提示词、AI助手聊天、预设提示词、通配符编辑、Metadata提示词预览",
|
||||
videoVolumeLabel: "视频音量",
|
||||
videoVolumeHint: "适用于:Video 标签页预览、Gallery 标签页详情/灯箱/对比视频播放器。在此设置的音量(或在任意视频自身控件中调整的音量)会在应用内下次打开任意视频时保留。",
|
||||
jsonColorsSection: "RAW JSON 文字颜色",
|
||||
jsonColorBase: "普通文本",
|
||||
jsonColorYellow: "名称 / 调度器",
|
||||
@@ -3301,6 +3468,7 @@ const LANGUAGES = {
|
||||
sendToLiNodeSuccess: (filename) => `已将"${filename}"发送到LI节点`,
|
||||
galleryCCNotAvailable: "仅当从ComfyUI Comic Creator内的图库打开时才能使用Send CC",
|
||||
gallerySentCC: "已发送至ComfyUI Comic Creator",
|
||||
galleryVideoNotSupportedInImageTab: "暂不支持将视频发送到Image标签",
|
||||
galleryNoEmbeddedWorkflow: "此图像中未嵌入ComfyUI工作流。",
|
||||
galleryTabOutput: "Output",
|
||||
galleryTabImagePrompt: "ImagePrompt",
|
||||
@@ -3339,6 +3507,8 @@ const LANGUAGES = {
|
||||
galleryBulkCompare: "对比",
|
||||
galleryBulkMoveTo: "移动到...",
|
||||
galleryBulkExport: "导出",
|
||||
galleryBulkPsd: "创建PSD",
|
||||
galleryBulkEditLayers: "作为图层编辑",
|
||||
galleryBulkDelete: "删除文件",
|
||||
nodesBulkSelected: "项已选择",
|
||||
nodesBulkDeselect: "取消全选",
|
||||
@@ -3351,6 +3521,9 @@ const LANGUAGES = {
|
||||
nodesBulkUnfavAll: "☆ 全部取消收藏",
|
||||
downloadStarted: "下载开始",
|
||||
exportCompleted: "导出完成。正在下载 ZIP 文件。",
|
||||
psdExportCompleted: "PSD 已创建。正在下载文件。",
|
||||
editLayersNoImages: "所选内容中没有可编辑的图片(不支持视频文件)",
|
||||
editLayersSkippedVideo: (n) => `已跳过 ${n} 个视频文件`,
|
||||
|
||||
tabAi: "AI TOOL",
|
||||
tabTagger: "Tagger",
|
||||
@@ -3366,7 +3539,7 @@ const LANGUAGES = {
|
||||
taggerThreshold: "阈值",
|
||||
taggerCharThreshold: "角色阈值",
|
||||
taggerGenerateTags: "生成标签",
|
||||
taggerOllamaEnable: "Ollama VLM",
|
||||
taggerOllamaEnable: "VLM打标",
|
||||
taggerOllamaUrl: "API URL",
|
||||
taggerOllamaModel: "模型",
|
||||
taggerOllamaPrompt: "提示词",
|
||||
@@ -3406,6 +3579,9 @@ const LANGUAGES = {
|
||||
taggerWrittenToFile: "已写入文件",
|
||||
taggerSavedToDB: "已保存到DB",
|
||||
taggerSelectGalleryImage: "请在图库标签中选择图像",
|
||||
taggerDbNoSelection: "请选择要删除的行",
|
||||
taggerDbDeleteConfirm: (count) => `确定要删除 ${count} 条数据库记录吗?`,
|
||||
taggerDbDeletedCount: (count) => `已删除 ${count} 条数据库记录`,
|
||||
|
||||
// -- AI Tab --
|
||||
aiSubTabTranslate: "翻译",
|
||||
@@ -3435,6 +3611,12 @@ const LANGUAGES = {
|
||||
aiSettingsInputLang: "输入语言",
|
||||
aiSettingsOutputLang: "翻译语言",
|
||||
aiSettingsSaveBtn: "保存设置",
|
||||
aiUnloadModel: "卸载模型",
|
||||
aiUnloading: "卸载中...",
|
||||
aiUnloadDone: "模型已卸载",
|
||||
aiUnloadFailed: "卸载失败: ",
|
||||
aiUnloadUnsupported: "此后端不支持通过API卸载",
|
||||
aiUnloadNoModel: "请先选择模型",
|
||||
aiToastNoText: "请输入文字",
|
||||
aiToastNoModel: "请在设置标签中选择模型",
|
||||
aiToastInvalidUrl: "URL必须以 http:// 或 https:// 开头",
|
||||
@@ -3505,7 +3687,8 @@ const LANGUAGES = {
|
||||
helpGen5: "Model标签:Checkpoint/VAE/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)始终显示;ControlNet和Hypernetwork(带Strength输入)因使用频率较低默认折叠 — 点击标题展开;LoRA(Single/Stack标签)拥有独立的列;所有选择器均含筛选;LoRA Stack — 点击Apply会将所有启用的LoRA写入目标节点,并同时将LoRA语法和CivitAI触发词插入Positive提示词;再次Apply会移除未启用的LoRA并重新添加启用的LoRA以更新提示词",
|
||||
helpGen28: "Model标签 — 高亮颜色:当前工作流实际包含某类型节点时(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork),对应标签及LoRA列标题会以指定颜色(默认亮绿色)显示;即使ControlNet/Hypernetwork处于折叠状态该颜色依然可见,无需展开即可判断是否在使用;颜色可在Settings标签中自定义",
|
||||
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流;如果加载的工作流包含Bypass或Mute节点,搜索栏上方会显示列表(Bypass节点会被排除但其连线会重新连接;Mute节点会被排除且不重新连接,与ComfyUI本体行为一致)",
|
||||
helpGen31: "LTX-2.5和Wan2.2视频工作流在本标签中作为普通工作流加载时(不仅限于专用的Video标签)也可正常使用 — Positive/Negative Prompt会沿LTX-2.5的LTXVDualCFGGuider / LTXVConditioning节点追溯找到实际提示词文本,Settings标签的Latent Image部分能识别Wan2.2的EmptyHunyuanLatentVideo和LTX-2.5的EmptyLTXVLatentVideo节点(并新增Length(帧数)字段),不再显示\"No EmptyLatentImage node found\";从这些工作流生成时也会应用与MiniMax H3视频生成相同的延长超时",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流;如果加载的工作流包含Bypass或Mute节点,搜索栏上方会显示列表(Bypass节点会被排除但其连线会重新连接;Mute节点会被排除且不重新连接,与ComfyUI本体行为一致);搜索栏只会高亮匹配项并滚动到该位置,不会将光标移入编辑器,因此搜索后立即按键不会意外覆盖JSON — 需要先点击编辑器内部才能实际编辑",
|
||||
helpGen8: "种子管理:随机/固定/递增/递减",
|
||||
helpGen9: "生成图片、进度显示、停止生成、查看结果",
|
||||
helpGen10: "Eagle集成:自动保存生成图片到Eagle(也支持SVG输出 — 标准SaveSVGNode节点和comfyui-tosvg的Save SVG String节点均可)",
|
||||
@@ -3587,7 +3770,7 @@ const LANGUAGES = {
|
||||
helpLabResults3: "加载已保存的方案时,会根据方案文件中记录的图片列表恢复Results网格",
|
||||
|
||||
helpSearchPlaceholder: "搜索帮助...",
|
||||
helpPrompt1: "AI助手(Ollama / LM Studio / Lemonade):模型选择、聊天咨询提示词、图片附件(视觉模型);后端和URL在设置标签页配置",
|
||||
helpPrompt1: "AI助手(Ollama / LM Studio / Lemonade / Unsloth):聊天咨询提示词、图片附件(视觉模型);点击⚙设置按钮可选择后端、连接URL、模型、Thinking mode、Max tokens,并卸载已加载的模型——设置与AI TOOL标签页、Tagger标签页相互独立",
|
||||
helpPrompt2: "将AI回复应用到生成UI的提示词",
|
||||
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
|
||||
helpPrompt4: "预设管理:创建、编辑、正面/负面提示词剪贴板复制(PP/NP)",
|
||||
@@ -3617,11 +3800,11 @@ const LANGUAGES = {
|
||||
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
|
||||
helpSettings3: "工作流数据文件夹路径更改/重置",
|
||||
helpSettings4: "ComfyUI连接URL设置和测试",
|
||||
helpSettings5: "AI助手(提示词标签页):后端(Ollama / LM Studio / Lemonade)、API URL、默认模型、连接测试",
|
||||
helpSettings5: "AI助手设置已从此标签页移出——Prompt标签页、Tagger标签页、AI TOOL标签页现在各自拥有独立的⚙设置按钮(后端、URL、模型、Thinking mode、Max tokens、卸载模型),分别单独保存",
|
||||
helpSettings6: "默认工作流设置/取消",
|
||||
helpSettings7: "Eagle集成URL、自动保存开关",
|
||||
helpSettings8: "数据管理:将设置、元数据、提示词等主要数据导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
|
||||
helpSettings15: "完整备份 (ZIP):与上方的JSON导出不同,还包含文件夹型数据(AI Skills、Lab Plan、Style预设)以及Tagger数据库。恢复会覆盖现有文件,恢复后请重启ComfyUI。",
|
||||
helpSettings15: "完整备份 (ZIP):与上方的JSON导出不同,还包含文件夹型数据(AI Skills、Lab Plan、Video Plan、Style预设)以及Tagger数据库。恢复会覆盖现有文件,恢复后请重启ComfyUI。",
|
||||
helpSettings16: "完整备份按钮上方的两个复选框可将ComfyUI默认工作流文件夹和Impact Pack的wildcard文件夹一并导出。两者均仅用于导出,不会自动恢复。如需要,请从ZIP中的_external/文件夹手动复制回去。",
|
||||
helpSettings9: "各部分可折叠展开",
|
||||
helpSettings10: "Wildcard Integration:将WFS通配符目录链接到ComfyUI-Impact-Pack的wildcards目录(Windows为目录联结,其他OS为符号链接);现有文件自动迁移",
|
||||
@@ -3654,17 +3837,20 @@ const LANGUAGES = {
|
||||
helpGallery3: "缩略图和表格视图模式;双击任何图像可在全屏灯箱中打开",
|
||||
helpGallery4: "工具栏过滤器:收藏 (★)、文本搜索(文件名・标签・提示词)、标签过滤、分组过滤、按日期/名称排序",
|
||||
helpGallery5: "文件夹树:点击加载图像,▶展开;'+ New'在选定文件夹内创建子文件夹;'Del'删除该文件夹及其所有内容",
|
||||
helpGallery6: "多选:Ctrl+点击切换单张图像;Shift+点击从上次点击位置进行范围选择 — 批量操作栏:选择分组 → 「添加到分组」/「从分组中删除」;全部收藏/取消收藏;「对比」(2–4张);移动到...;删除文件",
|
||||
helpGallery6: "多选:Ctrl+点击切换单张图像;Shift+点击从上次点击位置进行范围选择 — 批量操作栏:选择分组 → 「添加到分组」/「从分组中删除」;全部收藏/取消收藏;「对比」(2–4张);移动到...;「导出」(将所选图像下载为ZIP文件);「创建PSD」(将所选图像合并为一个带图层的PSD文件);「作为图层编辑」(在Image Edit标签中打开所选图像,每张作为独立图层);删除文件",
|
||||
helpGallery7: "详情面板 — 信息(Info)标签:文件名、大小、尺寸(图片为宽×高,视频为时长)、日期、标签(添加/移除)、备注;'移动到...'将图像移至另一文件夹;'删除文件'永久删除",
|
||||
helpGallery8: "详情面板 — JSON标签:显示来自PNG嵌入数据(prompt / workflow键)或Generate UI标签保存的工作流JSON;「Copy & Send Canvas」按钮将工作流复制到剪贴板并直接发送到ComfyUI画布(UI和API格式均支持);若Workflow Studio不是从ComfyUI工具栏打开,UI格式将回退到标题拖拽",
|
||||
helpGallery9: "详情面板 — 分组(Groups)标签:对每个图像进行组的创建、分配、移除、重命名和删除",
|
||||
helpGallery10: "工作流自动保存:在Generate UI标签生成的图像,其工作流会自动保存到图库元数据,并在JSON标签中显示",
|
||||
helpGallery11: "支持PNG(tEXt/iTXt)、JPEG、WebP、GIF、MP4;PNG格式读取ComfyUI嵌入的JSON(prompt / workflow键)提取工作流,MP4格式读取SaveVideo写入的容器标签(同名键,如MiniMax H3输出)",
|
||||
helpGallery11: "支持PNG(tEXt/iTXt)、JPEG、WebP、GIF、MP4、PSD;PNG格式读取ComfyUI嵌入的JSON(prompt / workflow键)提取工作流,MP4格式读取SaveVideo写入的容器标签(同名键,如MiniMax H3输出)",
|
||||
helpGallery28: "「创建PSD」批量操作按钮:将所有选中的图像合并为一个带图层的PSD文件并下载 — 每张图像成为独立图层(以文件名命名),以左上对齐方式放置在按所选最大图像尺寸创建的画布上;最后选中的图像位于最上层",
|
||||
helpGallery29: "「作为图层编辑」批量操作按钮:切换到Image Edit标签并一次性打开所有选中的图像,每张作为独立图层(所选内容中的视频文件会被跳过);最后选中的图像位于最上层",
|
||||
helpGallery30: "PSD文件:缩略图与完整预览显示所有图层合并后的效果(隐藏图层不参与合并);原始PSD文件本身不会被修改,Gallery中显示的仅是合并渲染后的预览",
|
||||
helpGallery12: "Metadata按钮(绿色,位于详情面板标签栏):在Metadata标签中打开所选图像以全面查看嵌入数据;Alt+点击任意图像缩略图效果相同",
|
||||
helpGallery13: "Load GenUI按钮(蓝色,位于详情面板标签栏):将所选图像中嵌入的ComfyUI工作流直接加载到GenerateUI标签并切换过去;如果没有嵌入工作流或格式不受支持,则显示警告提示",
|
||||
helpGallery14: "Tagger按钮(紫色,位于详情面板操作行):将所选图像直接在Tagger标签的Single模式中打开,即可立即进行标注",
|
||||
helpGallery15: "F按钮(青色悬浮按钮,每个缩略图左上角):单击即可将图像添加到/从__Feeder__组中移除;图像在组中时始终显示青色,不在组中时仅悬停时可见;可快速为Feeder标签Gallery模式添加队列图像",
|
||||
helpGallery16: "Image Edit按钮(工具栏,Refresh按钮旁):将当前选中的图像发送到Image Edit标签;如果已有图层则按比例缩放添加为新图层,否则将成为Layer 1",
|
||||
helpGallery16: "Image Edit按钮(工具栏,Refresh按钮旁):将当前选中的图像发送到Image Edit标签;如果已有图层则按比例缩放添加为新图层,否则将成为Layer 1;PSD文件会完整分解为原始图层,而不是合并为一张图像",
|
||||
helpGallery17: "Send GenUI Image按钮(工具栏,Image Edit按钮旁):将选中图像上传至ComfyUI并设置为GenerateUI标签中第一个LoadImage节点的输入;自动切换至GenerateUI → Input → Image;需要预先加载包含至少一个LoadImage节点的工作流",
|
||||
helpGallery27: "Send to LI node按钮(工具栏,Send GenUI Image按钮旁):上传选中图像并将其文件名写入ComfyUI画布上当前选中节点(若无选中节点则回退到找到的第一个LoadImage类节点)的image部件 — 与Image Edit标签的Send to LI node发送到同一目标;需要通过ComfyUI顶部菜单按钮打开Workflow Studio",
|
||||
helpGallery18: "Gallery标签页中与Output并列的第二个子标签,用于浏览可视化提示词库 — 管理ComfyUI output文件夹内的ws_image_prompt文件夹(未创建时自动生成)",
|
||||
@@ -3679,13 +3865,13 @@ const LANGUAGES = {
|
||||
|
||||
// -- Tagger Tab --
|
||||
helpTaggerTitle: "Tagger标签",
|
||||
helpTagger1: "使用WD Tagger(ONNX)、SwinV2、DeepDanbooru(.h5)和Ollama VLM进行自动图像标注 — 子标签:Single / Batch / DB",
|
||||
helpTagger1: "使用WD Tagger(ONNX)、SwinV2、DeepDanbooru(.h5)和VLM(Ollama / LM Studio / Lemonade / Unsloth)进行自动图像标注 — 子标签:Single / Batch / DB",
|
||||
helpTagger2: "模型放置:在ComfyUI/models/tagger/<模型名>/ 下为每个模型创建单独子文件夹,必须包含 .onnx + selected_tags.csv(DeepDanbooru为 .h5 + 标签文件)",
|
||||
helpTagger3: "Single标签:将图像拖放到预览区域,或点击Upload选择文件;图库详情面板的Tagger按钮可直接在此处打开所选图像",
|
||||
helpTagger4: "阈值滑块:一般标签的最低置信度(默认0.35);角色阈值:character: 前缀标签的独立阈值(默认0.85)",
|
||||
helpTagger5: "Ollama VLM:勾选复选框可与WD Tagger并行运行视觉模型;设置API URL、选择模型(点击↻刷新列表)并自定义提示词;两个模型的结果将合并",
|
||||
helpTagger5: "VLM打标:勾选复选框可与WD Tagger并行运行视觉模型;点击⚙设置按钮可选择后端(Ollama / LM Studio / Lemonade / Unsloth)、URL、模型、Thinking mode、Max tokens,并卸载模型——与Prompt标签页自身的AI设置相互独立;提示词和Max Tags可在标签页内直接编辑;两个模型的结果将合并",
|
||||
helpTagger6: "生成标签的发送目标:(1) GenUI:P — 追加到生成UI标签的正向提示词并立即应用到已加载工作流;(2) 发送到提示词 — 追加到提示词标签的正向文本区;(3) 保存到图库 — 保存到图库图像元数据(仅限从图库打开的图像);(4) 写入文件 — JPEG写入EXIF(ImageDescription)、PNG写入tEXt(Tags键);(5) 保存到DB — 存入内置SQLite数据库",
|
||||
helpTagger7: "Batch标签:输入目标文件夹路径并配置WD Tagger和Ollama设置,选择输出选项(保存DB〈默认开启〉、写入文件〈EXIF/PNG元数据〉、输出.txt〈在图像同目录生成 <文件名>.txt〉);开始批处理后将处理文件夹内所有图像;进度条和日志实时更新;停止按钮在当前图像处理完成后取消",
|
||||
helpTagger7: "Batch标签:输入目标文件夹路径并配置WD Tagger,启用VLM打标(与Single标签使用相同的⚙设置);选择输出选项(保存DB〈默认开启〉、写入文件〈EXIF/PNG元数据〉、输出.txt〈在图像同目录生成 <文件名>.txt〉);开始批处理后将处理文件夹内所有图像;进度条和日志实时更新;停止按钮在当前图像处理完成后取消",
|
||||
helpTagger8: "DB标签:所有已标注图像的可搜索数据库;点击行可在编辑面板中修改WD标签/VLM标签;保存更新记录,删除移除记录;导出CSV可下载所有记录",
|
||||
helpTagger9: "安装依赖包:python_embeded\\python.exe -m pip install -r requirements.txt;GPU推理请安装onnxruntime-gpu替代onnxruntime;TensorFlow(用于DeepDanbooru)因体积较大默认已注释",
|
||||
|
||||
@@ -3712,7 +3898,7 @@ const LANGUAGES = {
|
||||
helpAi2: "翻译面板:使用 Ollama、LM Studio、Lemonade 或 Unsloth 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史;如果模型支持 tool calling,当你要求生成图像(例如\"生成一张猫的图片\")时,会使用 GenerateUI 中当前加载的工作流执行生成,并在对话中显示结果;面板标题栏的\"允许生成图像\"复选框可开关此功能;如果附加了图像(在 TOOLS 面板中拖放的图像),该图像会作为视觉输入发送给 LLM,调用 generate_image 时会以该图像为基础执行图生图(I2I),而不是文生图;generate_image 还接受可选的生成参数——steps、cfg、sampler_name、scheduler、denoise、seed、width、height、batch_size(该次调用生成的图像数量),以及 aspect_ratio(形如\"3:2\"的比例预设,仅当工作流中存在向 width/height 供值的 Resolution Selector 节点时才会生效,否则忽略);模型未提供的参数将保留当前已加载工作流的设置;已验证可正常支持 tool calling 与视觉功能的模型:Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条);拖放的图像也会作为 Chat 面板的附件共享(在该处显示并用于图生图生成),直到点击 ✕ 按钮清除",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade / Unsloth)、设置 API URL、测试连接、选择模型;Unsloth 即使在本地访问时也始终需要 API 密钥认证——将插件文件夹中的 .env.example 复制为 .env 并设置 UNSLOTH_API_KEY,然后重启 ComfyUI(密钥仅在服务器端读取,绝不会发送到浏览器);还需要在 Unsloth Desktop 应用自身的设置界面中开启「Auto model switching (OpenAI API)」——若保持关闭,除非已在 Unsloth Studio 中手动加载过模型,否则请求会以 HTTP 404 失败;Unsloth 的推理模型会将思考内容放在单独的 reasoning_content 字段中,而不是内联的 <think> 标签里,因此 Max tokens 太小会导致模型把全部配额都用在思考上,返回空白回复——如果回复为空,请调高 Max tokens(建议 2048 以上);Generation 部分 — Thinking mode 开关(默认关闭;Ollama会收到think标志,无论使用哪个后端,关闭时都会从输出中去除<think>...</think>思考块,避免推理模型的思考过程混入Translation/Chat/TOOLS的结果)以及 Max tokens(Ollama: num_predict,LM Studio/Lemonade/Unsloth: max_tokens;0为不限);配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade / Unsloth)、设置 API URL、测试连接、选择模型,以及使用「Test connection」右侧的「Unload Model」按钮将已加载的模型从显存/内存中卸载——Ollama 使用 keep_alive:0,LM Studio 使用 /api/v1/models/unload,Lemonade 使用 /api/v1/unload;Unsloth 没有卸载 API,因此会显示不支持的提示;Unsloth 即使在本地访问时也始终需要 API 密钥认证——将插件文件夹中的 .env.example 复制为 .env 并设置 UNSLOTH_API_KEY,然后重启 ComfyUI(密钥仅在服务器端读取,绝不会发送到浏览器);还需要在 Unsloth Desktop 应用自身的设置界面中开启「Auto model switching (OpenAI API)」——若保持关闭,除非已在 Unsloth Studio 中手动加载过模型,否则请求会以 HTTP 404 失败;Unsloth 的推理模型会将思考内容放在单独的 reasoning_content 字段中,而不是内联的 <think> 标签里,因此 Max tokens 太小会导致模型把全部配额都用在思考上,返回空白回复——如果回复为空,请调高 Max tokens(建议 2048 以上);Generation 部分 — Thinking mode 开关(默认关闭;Ollama会收到think标志,无论使用哪个后端,关闭时都会从输出中去除<think>...</think>思考块,避免推理模型的思考过程混入Translation/Chat/TOOLS的结果)以及 Max tokens(Ollama: num_predict,LM Studio/Lemonade/Unsloth: max_tokens;0为不限);配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi7: "Chat 面板 — SVG:LLM 还可以直接以文本形式生成 SVG 图形,完全不涉及 ComfyUI 工作流;只需要求它用 SVG 绘制内容,如果回复中包含 SVG 代码,会自动渲染为内嵌预览,并提供\"保存到图库\"按钮,将其保存为 .svg 文件到 Gallery 标签页的输出文件夹;该功能不使用 tool calling(函数调用),因此不支持 tool calling 的模型也可以使用",
|
||||
helpAi8: "Chat 面板 — 技能:在消息列表上方的下拉菜单中选择一个技能,其内容会作为 system prompt 添加到对话开头(不会显示在可见的聊天记录中);点击铅笔按钮打开技能管理面板,可创建、编辑、删除 .md 技能文件(保存路径:user/default/Workflow-Studio/ai_skills/);文件开头 --- 之间的 name/description 字段会显示在下拉菜单中;默认内置两个技能:「SVG Icon Generator」(在绘制前先将对象拆解为多个部件,以生成更易识别的 SVG)和「Skill Creator」(通过对话向你提问并起草新的技能文件);当回复中包含```skill代码块时,消息下方会出现「保存为技能」按钮,点击后会打开管理面板并预填内容",
|
||||
helpAi9: "编写技能的小贴士:为 frontmatter 的 name/description 写清楚的内容,便于在下拉菜单和提示中阅读;明确说明期望的输出格式,如果有需要后续提取的成果物(如 SVG 或另一个技能文件),指示它用专门的代码围栏包裹;具体、祈使句式的指令比笼统的说明对本地小模型更有效,附上一个简短示例效果更佳;明确写出硬性约束(禁止内容、安全性、长度限制等),不要指望模型会自行推断;如果不确定怎么写,可以在下拉菜单中选择「Skill Creator」技能并描述你想要的技能,它会通过提问帮你起草",
|
||||
@@ -3786,7 +3972,9 @@ const LANGUAGES = {
|
||||
helpTroubleCardTagger: "Tagger 与模型问题",
|
||||
helpImageEditTitle: "图像编辑选项卡",
|
||||
helpImageEdit1: "基于图层的图像编辑器 — 通过多个图层(图像、文字、绘制、遮罩类型)合成图像并导出",
|
||||
helpImageEdit2: "加载图像:拖放到画布、点击Upload按钮、或通过Gallery标签工具栏的Image Edit按钮发送;第一张图像成为图层1(自动锁定);后续图像缩放至画布大小作为新图层添加",
|
||||
helpImageEdit2: "加载图像:拖放到画布、点击File Open按钮、或通过Gallery标签工具栏的Image Edit按钮发送;第一张图像成为图层1(自动锁定);后续图像缩放至画布大小作为新图层添加",
|
||||
helpImageEdit31: "Open PSD按钮(File Open旁):从磁盘加载带图层的PSD文件并分解为各个图层 — 每个图层的位置、尺寸、可见性、不透明度和混合模式都会被还原;分组和无法栅格化的图层(调整图层等)将按其渲染外观扁平化",
|
||||
helpImageEdit32: "Close按钮(Open PSD旁):在确认提示后关闭当前文档,丢弃所有图层和未保存的修改,返回空白占位视图",
|
||||
helpImageEdit3: "New按钮:创建空白画布 — 输入宽高(如512x768);清除所有现有图层并自动添加空白Layer 1",
|
||||
helpImageEditToolsTitle: "工具",
|
||||
helpImageEdit4: "Select (V):点击选择图层;拖动移动;拖动角点手柄调整大小;拖动顶边圆形手柄旋转;双击文字图层可重新编辑;工具选项栏显示翻转H / 翻转V / 旋转角度",
|
||||
@@ -3807,6 +3995,7 @@ const LANGUAGES = {
|
||||
helpImageEditExportTitle: "导出与导航",
|
||||
helpImageEdit17: "Save PNG:合并所有可见图层并下载为PNG文件",
|
||||
helpImageEdit18: "Save to Gallery:输入文件名(默认:wfs-image-YYYYMMDDHHmmss)将合成PNG保存到Gallery根文件夹(在设置 > Gallery Output Folder中配置);需要至少打开过一次Gallery标签以设置根目录",
|
||||
helpImageEdit34: "Save PSD:下载一个保留每个图层独立信息的PSD文件 — 位置、旋转、翻转和缩放会被烘焙进各图层的像素中(因为PSD图层无法保存旋转角度),而不透明度、混合模式和可见性则作为可编辑的真实PSD图层属性保留;由于未使用PSD原生的图层蒙版机制,剪贴蒙版会与其目标图层合并后导出",
|
||||
helpImageEdit19b: "Send to LI node:通过 /upload/image 将合成PNG上传到ComfyUI的输入文件夹,并将上传结果的文件名写入 ComfyUI 图中当前选中节点(若无选中节点则回退到找到的第一个 LoadImage 类节点)的 image 部件;需要通过 ComfyUI 顶部菜单按钮打开 Workflow Studio",
|
||||
helpImageEdit20: "画布导航:滚轮缩放,Space+拖动平移",
|
||||
helpImageEdit21: "撤销/重做:Ctrl+Z / Ctrl+Y(或工具栏的 ↩ ↪ 按钮)",
|
||||
@@ -3822,11 +4011,12 @@ const LANGUAGES = {
|
||||
helpImageEdit30: "双击(Select工具点击文字图层):重新编辑文字",
|
||||
helpImageEdit7b: "Blur (≈):对活动图层应用高斯模糊或马赛克 — 整层或拖动矩形区域;详细内容参见下方「Blur Tool」卡片",
|
||||
helpImageEdit7c: "BG Remove (⬚):使用Lightweight(@imgly,在浏览器中运行)或BiRefNet(需要Mask Editor One + ComfyUI/models/background_removal/中的birefnet.safetensors)去除活动图层背景;详细内容参见下方「BG Remove Tool」卡片",
|
||||
helpImageEdit7d: "Mask (🎭):使用6种子工具在遮罩图层上绘制遮罩 — Paint(徒手绘制)、Color(按颜色相似度泛选)、Alpha(从图像透明度提取)、Text(文字图章)、Vector(Catmull-Rom样条多边形)、Shape(矩形/椭圆);安装Mask Editor One后还可使用SAM3文本提示分割;点击图层标题栏的「M」添加遮罩图层;详细内容参见下方「Mask Tool」卡片",
|
||||
helpImageEdit7d: "Mask (🎭):使用6种子工具在遮罩图层上绘制遮罩 — Paint(徒手绘制)、Color(按颜色相似度泛选)、Alpha(从图像透明度提取)、Text(文字图章)、Vector(Catmull-Rom样条多边形)、Shape(矩形/椭圆);安装Mask Editor One后还可使用SAM3文本提示分割;点击图层标题栏第二行的「Mask」按钮添加遮罩图层;详细内容参见下方「Mask Tool」卡片",
|
||||
helpImageEdit7e: "Inpaint (🩹):将可见图层与遮罩图层合成为一张RGBA图像(遮罩烘焙进alpha通道),并应用到GenerateUI当前加载的工作流中第一个支持内绘的LoadImage节点(其MASK输出连接到VAEEncodeForInpaint等节点);在属性面板中输入正/负向提示词、Grow Mask By(应用到VAE Encode (for Inpainting)节点)、Denoise(应用到KSampler)后点击Run,即可在不离开Image Edit标签的情况下完成上传与生成;完成后结果图像会显示在属性面板中;需先绘制遮罩图层并在GenerateUI中加载支持内绘的工作流;同样支持基于Mask Editor One自定义节点(comfyui-mask-editor-one)而非LoadImage构建的工作流(通过其按node_id缓存的服务端图像与layer_data组件写入图像/遮罩);「Use dedicated workflow」复选框:关闭时使用GenerateUI当前加载的工作流(与之前相同);开启时可从下拉列表选择已保存的工作流,将在后台加载、编辑并生成,不影响GenerateUI中显示的工作流",
|
||||
helpImageEdit8b: "图层标题栏的「M」按钮:在当前活动图层上方添加新的遮罩图层,并自动切换至Mask工具",
|
||||
helpImageEdit8b: "图层标题栏分为两行:第一行为 +、⧉(复制)、−、↑、↓;第二行单独放置「Mask」按钮 — 在当前活动图层上方添加新的遮罩图层,并自动切换至Mask工具",
|
||||
helpImageEdit33: "⧉(复制图层)按钮:复制活动图层——包括像素、位置、尺寸、不透明度、混合模式和可见性——并将副本作为新的活动图层插入到原图层的正上方;适用于任意图层类型(图像、绘制、文字、遮罩)",
|
||||
helpImageEditMaskTitle: "Mask 工具 (🎭)",
|
||||
helpImageEditMask1: "添加遮罩图层:点击图层面板标题栏的「M」按钮;新遮罩图层插入到活动图层上方,Mask工具自动激活",
|
||||
helpImageEditMask1: "添加遮罩图层:点击图层面板标题栏第二行的「Mask」按钮;新遮罩图层插入到活动图层上方,Mask工具自动激活",
|
||||
helpImageEditMask2: "在图层列表中点击遮罩图层会自动切换至Mask工具",
|
||||
helpImageEditMask3: "顶部工具栏 — 子工具:Paint / Color / Alpha / Text / Vector / Shape(均无需Mask Editor One)/ SAM3(需要Mask Editor One,未安装时不可用);Invert复选框反转叠加预览;Overlay拾色器更改预览颜色(默认红色);Blur滑块(0–50 px)柔化叠加边缘;叠加显示仅在当前激活的蒙版图层上显示,未激活的蒙版图层在被选中前保持隐藏",
|
||||
helpImageEditMask4: "属性面板 — Paint模式:Mode(Add / Erase)、Size(1–200 px)、Hardness(0–100%);MASK EDITOR ONE区域(始终显示 — 有可用的ABR笔刷时标题为绿色,尚未导入笔刷时为灰色):Select按钮打开ABR笔刷库(没有任何笔刷时禁用) — 选择笔刷将替换默认圆形笔刷;✕恢复Circle(默认);选择图像笔刷后Hardness禁用,并显示额外控件:Spacing(印章间距 5–100%)、Angle(旋转角度 0–359°)、Sz Jitter(每个印章的随机大小变化 0–100%)、Rot. Jitter(每个印章的随机旋转 on/off)",
|
||||
@@ -3937,7 +4127,7 @@ const LANGUAGES = {
|
||||
importError: "导入失败",
|
||||
exportFullBackup: "完整备份 (ZIP)",
|
||||
importFullBackup: "恢复完整备份 (ZIP)",
|
||||
fullBackupHint: "完整备份(ZIP)还包含上方JSON导出无法表示的文件夹型数据(AI Skills、Lab Plan、Style预设)以及Tagger数据库。恢复会覆盖现有文件,恢复后请重启ComfyUI。",
|
||||
fullBackupHint: "完整备份(ZIP)还包含上方JSON导出无法表示的文件夹型数据(AI Skills、Lab Plan、Video Plan、Style预设)以及Tagger数据库。恢复会覆盖现有文件,恢复后请重启ComfyUI。",
|
||||
fullBackupFilesUnit: "个文件",
|
||||
fullBackupIncludeWorkflows: "包含默认工作流文件夹 (user/default/workflows)",
|
||||
fullBackupIncludeWildcard: "包含Impact Pack的wildcard文件夹(如存在)",
|
||||
@@ -4034,31 +4224,64 @@ const LANGUAGES = {
|
||||
helpMetadata5: "支持格式:ComfyUI PNG/WebP/MP4/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 子图工作流;MP4读取SaveVideo写入的workflow/prompt容器标签,也支持MiniMax H3 ImageToVideo等一体化视频节点)、SD WebUI、SD Forge、Fooocus。",
|
||||
helpMetadata6: "现在它是 Gallery 标签页内的一个子标签(位于 Style_Catalog 右侧),不再是独立的顶层标签页 — 其余操作方式完全相同",
|
||||
|
||||
// -- Video 标签页(MiniMax H3 专用,独立于 GenerateUI) --
|
||||
// -- Video 标签页(支持 MiniMax H3 / LTX-2.5 / Wan2.2,独立于 GenerateUI) --
|
||||
tabVideo: "Video",
|
||||
loadInVideo: "加载到Video",
|
||||
videoTitle: "Video (MiniMax H3)",
|
||||
videoFirstFrame: "First Frame(可选)",
|
||||
videoLastFrame: "Last Frame(可选)",
|
||||
videoDropOrClick: "拖放图片或点击选择",
|
||||
videoPrompt: "Prompt",
|
||||
videoAspectRatio: "Aspect Ratio",
|
||||
videoMegapixels: "Megapixels",
|
||||
videoMultiple: "Multiple",
|
||||
videoDuration: "Duration(秒)",
|
||||
videoNoiseSeed: "Noise Seed",
|
||||
videoRandomizeSeed: "随机化",
|
||||
videoGenerate: "Generate",
|
||||
videoEditPlaceholder: "编辑功能即将推出",
|
||||
videoEditNoClipsHint: "暂无片段 — 请在上方选择素材或拖放视频后点击“添加到Edit”",
|
||||
videoEditNoClips: "请先添加至少一个片段",
|
||||
videoEditStillProbing: "请等待片段分析完成",
|
||||
videoEditResolutionMismatch: (a, b) => `片段分辨率不一致(${a} 与 ${b})。导出前请裁剪为相同尺寸。`,
|
||||
videoEditProbing: "分析中...",
|
||||
videoEditSelectClipHint: "选择一个片段进行裁剪",
|
||||
videoEditImageDuration: "显示时长(秒)",
|
||||
videoEditTrimStart: "开始(秒)",
|
||||
videoEditTrimEnd: "结束(秒)",
|
||||
videoEditSetFromPlayhead: "使用当前位置",
|
||||
videoEditMoveLeft: "左移",
|
||||
videoEditMoveRight: "右移",
|
||||
videoEditClearBtn: "清空",
|
||||
videoEditTotalDuration: (time) => `总计: ${time}`,
|
||||
videoEditPreviewPlay: "▶ 预览",
|
||||
videoEditPreviewStop: "■ 停止",
|
||||
videoEditConfirmClear: "确定清空整个编辑时间线吗?",
|
||||
videoSourceAddToEdit: "添加到Edit",
|
||||
videoEditDuplicate: "复制",
|
||||
videoEditDelete: "删除",
|
||||
videoEditExportBtn: "导出",
|
||||
videoEditExporting: "导出中...",
|
||||
videoEditExportDone: (name) => `已导出: ${name}`,
|
||||
videoEditSendToEdit: "发送到Edit",
|
||||
videoEditClipSent: (name) => `已将 "${name}" 添加到Edit时间线`,
|
||||
videoAssetSetAsFirst: "设为First Image",
|
||||
videoAssetSetAsLast: "设为Last Image",
|
||||
videoAssetSetAsFirstDone: (name) => `已将 "${name}" 设为First Image`,
|
||||
videoAssetSetAsLastDone: (name) => `已将 "${name}" 设为Last Image`,
|
||||
videoNoBlockSelected: "Plan中未选择区块",
|
||||
videoBlockImageUploadFailed: "上传图片到Plan区块失败",
|
||||
videoNoWorkflowLoaded: "尚未加载工作流",
|
||||
videoUnsupportedWorkflow: "该工作流不包含 MiniMax H3 的 Image to Video 节点",
|
||||
videoUnsupportedWorkflow: "该工作流不包含受支持的视频模型节点(MiniMax H3、LTX-2.5 的 Text/Image/First&Last-Frame to Video,或 Wan2.2 的 Text to Video)",
|
||||
videoWorkflowLoadFailed: "工作流加载失败",
|
||||
videoConfirmLoadWorkflow: (name) => `要将“${name}”加载为该 Plan 的工作流吗?`,
|
||||
videoPlanMinOneBlock: "Plan 至少需要一个区块",
|
||||
videoPlanEnterName: "请输入该 Plan 的名称",
|
||||
videoPlanSaved: "Plan 已保存",
|
||||
videoPlanSaveFailed: "Plan 保存失败",
|
||||
videoPlanLoaded: "Plan 已加载",
|
||||
videoPlanLoadFailed: "Plan 加载失败",
|
||||
videoEditProjectEnterName: "请输入该项目的名称",
|
||||
videoEditProjectSaved: "项目已保存",
|
||||
videoEditProjectSaveFailed: "项目保存失败",
|
||||
videoEditProjectLoaded: "项目已加载",
|
||||
videoEditProjectLoadFailed: "项目加载失败",
|
||||
videoConfirmClearPlan: "要清空当前 Plan 吗?",
|
||||
videoPlanPause: "暂停",
|
||||
videoPlanResume: "继续",
|
||||
videoWorkflowLoaded: (name) => `已加载工作流: ${name}`,
|
||||
videoGenerateSuccess: "视频已生成",
|
||||
videoDoneCount: (n) => `完成(${n} 个文件)`,
|
||||
videoSourceLabel: "视频来源",
|
||||
videoSourceHint: "无需生成,也可在此加载任意视频以使用Frame/GIF工具。",
|
||||
videoSourceDropLabel: "拖放视频或点击选择",
|
||||
videoNoVideo: "暂无视频",
|
||||
videoSourcePreviewPlaceholder: "选择素材或拖放视频",
|
||||
videoResultPreviewPlaceholder: "尚未生成视频",
|
||||
videoPropFrame: "Frame",
|
||||
videoPropGif: "GIF",
|
||||
videoFrameHint: "播放/拖动预览视频到想要的位置,然后点击截取。",
|
||||
@@ -4075,15 +4298,35 @@ const LANGUAGES = {
|
||||
videoGifDoneCount: (n) => `完成(${n} 帧)`,
|
||||
videoGifSaved: (name) => `已保存GIF: ${name}`,
|
||||
videoNoSourceLoaded: "请先准备视频(生成一个,或将文件拖入视频来源)",
|
||||
videoPropProject: "Project",
|
||||
videoProjectHint: "已保存的 Plan",
|
||||
videoProjectEmpty: "暂无已保存的 Plan",
|
||||
videoProjectBlockCount: (n) => `${n} 个区块`,
|
||||
videoProjectDeleteConfirm: (name) => `确定删除 Plan「${name}」吗?`,
|
||||
videoProjectDeleted: "Plan 已删除",
|
||||
videoProjectDeleteFailed: "Plan 删除失败",
|
||||
videoProjectLoadListFailed: "Plan 列表加载失败",
|
||||
videoEditProjectHint: "已保存的 Edit 项目",
|
||||
videoEditProjectEmpty: "暂无已保存的 Edit 项目",
|
||||
videoEditProjectClipCount: (n) => `${n} 个片段`,
|
||||
videoEditProjectDeleteConfirm: (name) => `确定删除项目「${name}」吗?`,
|
||||
videoEditProjectDeleted: "项目已删除",
|
||||
videoEditProjectDeleteFailed: "项目删除失败",
|
||||
videoEditProjectLoadListFailed: "项目列表加载失败",
|
||||
helpVideoTitle: "Video 标签页",
|
||||
helpVideoDesc: "MiniMax H3(Image to Video)工作流专用的视频生成标签页,独立于 GenerateUI 标签页运行。",
|
||||
helpVideo1: "设置 first_frame(可选)/last_frame(可选)图片、prompt、aspect_ratio/megapixels/multiple(分辨率)、duration(秒)和 noise_seed,然后点击 Generate。不指定 first_frame 时将以纯文本方式绕过执行,也支持完全没有 LoadImage 节点的工作流。",
|
||||
helpVideo2: "模型选择(UNet/CLIP/VAE/Audio VAE)固定为所加载工作流的默认值,无法在此标签页中修改。",
|
||||
helpVideo3: "可通过 Workflow 标签页详情弹窗中的「加载到Video」按钮,加载包含 MiniMax H3 Image to Video 节点的工作流。不支持的工作流会显示错误。",
|
||||
helpVideo4: "生成的视频(mp4)也可以在 Gallery 标签页中浏览和播放。",
|
||||
helpVideo5: "预览旁边的「Video Source」面板可以拖放或选择任意本地视频(不仅限于生成的视频),无需先运行MiniMax H3即可使用下方的Frame/GIF工具。点击Clear可重置选择。",
|
||||
helpVideo6: "Frame标签页(右侧面板):播放/拖动预览视频到想要的位置,点击「Capture Frame」在客户端截取该帧,再点击「Save to Output」将其保存为PNG到ComfyUI的输出文件夹。",
|
||||
helpVideoDesc: "支持 MiniMax H3(Image to Video)、LTX-2.5(Text / Image / First & Last Frame to Video)、Wan2.2(Text to Video)工作流的视频生成标签页,独立于 GenerateUI 标签页运行。中央面板的 Plan/Edit/Asset 子标签负责批量生成、片段剪辑和浏览已生成的视频;侧边栏的 Project 子标签在中央选中 Plan 时显示已保存的 Plan 列表,选中 Edit 时显示已保存的 Edit 项目列表。",
|
||||
helpVideo1: "Plan 子标签:顶部设置行包含 Aspect Ratio/Megapixels/Multiple/Duration(秒)/Noise Seed,作为该 Plan 的基础设置——Duration 始终显示并可编辑当前选中区块的值(每个区块保留自己的值),其余字段则是整个 Plan 共用的。下方第二行是 Width/Height/Turbo Mode/Strength/Steps/Prompt Enhance,仅当所加载的工作流实际支持该项时才可输入(详见 helpVideo2)。用「+ Split」「+ Block」「Delete」按钮把时间线分割成多个区块后,每个区块都有自己的 Prompt、Duration 以及 First/Last Image(First Image 可选择“无”、“指定图片”或“使用前一个区块的最后一帧”——若前一个区块已明确设置了 Last Image,则优先使用该图片而非提取帧)。点击 Run 会按顺序生成所有区块,运行中也可以 Pause/Cancel。",
|
||||
helpVideo2: "只有一个区块的 Plan 与旧版单次生成表单效果完全相同。模型选择(UNet/CLIP/VAE/Audio VAE)固定为所加载工作流的默认值。没有 ResolutionSelector 节点的工作流(如 Wan2.2、LTX-2.5 的 First & Last Frame to Video)会禁用 Aspect Ratio/Megapixels/Multiple 字段,改为可以直接编辑工作流自身的 Width/Height。Turbo Mode/Strength/Steps 仅适用于 MiniMax H3 的 turbo LoRA,Prompt Enhance 仅适用于 LTX-2.5——其他模型系列中这些字段保持禁用。Noise Seed 仅在 Wan2.2 的 Text to Video 中会被禁用,因为该变体未公开此输入。",
|
||||
helpVideo3: "在 Workflow 标签页的详情弹窗中使用「加载到Video」按钮,可以加载包含 MiniMax H3 的 Image to Video 节点、LTX-2.5 的 Text/Image/First&Last-Frame to Video 节点、或 Wan2.2 的 Text to Video 节点的工作流——此操作会创建一个仅含一个区块、并以该工作流当前值为初始值的新 Plan。不受支持的工作流会显示错误提示。",
|
||||
helpVideo4: "生成的视频(mp4)会自动出现在 Asset 子标签的 Video Temp 组中,也可以在 Gallery 标签页中浏览和播放。是否整理到 Video Assets,由您自行决定时机。",
|
||||
helpVideo5: "中间面板有两个预览区:左侧显示您在 Asset 子标签中选择的视频,或拖放/选择到左边「Video Source」面板中的任意本地视频(点击 Clear 可重置),无需先运行已加载的视频工作流即可使用 Frame/GIF 工具;右侧始终显示最近一次生成的片段。",
|
||||
helpVideo6: "Frame标签页(右侧面板):播放/拖动预览视频到想要的位置,点击「Capture Frame」在客户端截取该帧,再点击「Save to Output」将其保存为PNG到ComfyUI的输出文件夹——保存前勾选「Add to Video Assets group」还可以把保存的帧同时标记进 Gallery 的 Video Assets 分组。",
|
||||
helpVideo7: "GIF标签页(右侧面板):设置Start/End(秒)、FPS和可选的Max Width,点击「Convert to GIF」后,服务器会解码视频并将动画GIF保存到ComfyUI的输出文件夹。",
|
||||
helpVideo8: "链接功能:将某个区块的 First Image 设置为“使用前一个区块的最后一帧”,即可与上一个片段流畅衔接——如果前一个区块已明确设置了 Last Image,将直接使用该图片;否则会自动从其生成的视频中提取实际的最后一帧。",
|
||||
helpVideo9: "Project标签页(左侧边栏):中央为 Plan 子标签时显示已保存的 Plan 列表,为 Edit 子标签时显示已保存的 Edit 项目列表(缩略图/区块数或片段数、最后更新时间),点击即可加载,或用 × 按钮删除;再次点击同一标签会收起面板。",
|
||||
helpVideo10: "高亮颜色:当所加载的工作流实际支持该字段时,Prompt / Duration / First Image / Last Image / Aspect Ratio / Megapixels / Multiple / Noise Seed / Width / Height / Turbo Mode / Strength / Steps / Prompt Enhance 标签会变为与 GenerateUI Model 标签页相同的高亮颜色(可在 Settings 标签页自定义)——例如 Wan2.2 工作流(没有 ResolutionSelector 节点)中,Aspect Ratio / Megapixels / Multiple 不会高亮,取而代之的是 Width / Height 会高亮。",
|
||||
helpVideo11: "Edit 子标签:独立于生成流程的轻量剪辑时间线,用于裁剪和拼接视频/图片片段。通过 Video Source 面板的「Add to Edit」按钮,或 Asset 子标签的「Send to Edit」按钮添加片段,拖动区块(或使用 ◀/▶ 工具栏按钮)重新排序,对选中片段执行 Duplicate/Delete,Clear 可清空时间线。选中视频片段的裁剪面板会显示时间刻度尺、与 Source 预览同步的可拖动播放头,以及带时间标签的可拖动入/出点手柄(图片片段则只有一个简单的保持时长输入框)。「▶ Preview」会按入/出点依次裁剪并在右侧结果预览区连续播放所有就绪片段;「Export」会构建并运行实际的 ComfyUI 图(裁剪→若多于一个片段则拼接→保存),并自动把结果标记进 Video Temp 分组。目前导出多个片段会丢失音频,因为 ComfyUI 的拼接节点要求所有输入的音频格式一致。导出面板的 Save/Save As/Load 按钮会把时间线本身(片段顺序、裁剪区间、每个片段引用的已上传文件)作为 JSON 项目保存到服务器;Load 会根据保存的服务器引用重新获取并重新探测每个片段,因此原始文件必须仍然存在。已保存的项目在中央为 Edit 子标签时也会显示在侧边栏的 Project 标签中(参见 helpVideo9)。",
|
||||
helpVideo12: "Asset 子标签(中央面板,位于 Plan/Edit 旁):浏览标记到 Video Assets 或 Video Temp 分组的所有视频/图片(通过分组下拉框选择,或选择「All Video Assets」同时浏览两者)。搜索框按文件名过滤,▦/☰ 按钮可在缩略图网格和表格视图(文件名/更新时间/标签)之间切换。选中一项会显示其标签/备注并加载到中央预览,此外还提供「Send to Edit」(作为片段加入 Edit 子标签);仅图片还额外提供「Set as First Image」「Set as Last Image」(直接上传到当前选中的 Plan 区块,并切换到 Plan 子标签)。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DrawTool } from "./image-edit/DrawTool.js";
|
||||
import { TextTool, TEXT_FONTS } from "./image-edit/TextTool.js";
|
||||
import { SelectTool } from "./image-edit/SelectTool.js";
|
||||
import { ShapeTool } from "./image-edit/ShapeTool.js";
|
||||
import { FillTool } from "./image-edit/FillTool.js";
|
||||
import { MaskTool } from "./image-edit/MaskTool.js";
|
||||
import { MaskColorTool, MaskAlphaTool, MaskTextTool, MaskVectorTool, MaskShapeTool, MASK_TEXT_FONTS } from "./image-edit/MaskEditorOneTools.js";
|
||||
import { GmicIntegration } from "./image-edit/GmicIntegration.js";
|
||||
@@ -28,6 +29,7 @@ const TOOL_DEFS = [
|
||||
{ id: "draw", icon: "✏", label: "Draw", ready: true },
|
||||
{ id: "text", icon: "T", label: "Text", ready: true },
|
||||
{ id: "shape", icon: "□", label: "Shape", ready: true },
|
||||
{ id: "fill", icon: "🪣", label: "Fill", ready: true },
|
||||
{ id: "mask", icon: "🎭", label: "Mask", ready: true },
|
||||
{ id: "blur", icon: "≈", label: "Blur", ready: true },
|
||||
{ id: "filter", icon: "★", label: "Filter", ready: true },
|
||||
@@ -63,6 +65,7 @@ class ImageEditTab {
|
||||
this._editingTextLayer = null;
|
||||
this._initialized = false;
|
||||
this._shapeTool = new ShapeTool();
|
||||
this._fillTool = new FillTool();
|
||||
// Blur ツール
|
||||
this._blurTool = new BlurTool({
|
||||
getLayerManager: () => this._layerMgr,
|
||||
@@ -241,6 +244,11 @@ class ImageEditTab {
|
||||
if (this._activeTool === "text") this._textTool?.deactivate();
|
||||
if (this._activeTool === "select") this._selectTool?.deactivate();
|
||||
if (this._activeTool === "shape") this._shapeTool?.deactivate();
|
||||
if (this._activeTool === "fill") {
|
||||
this._fillTool?.deactivate();
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.style.cursor = "";
|
||||
}
|
||||
if (this._activeTool === "mask") this._deactivateMaskSubtool();
|
||||
if (this._activeTool === "filter") {
|
||||
this._gmic.abort();
|
||||
@@ -248,8 +256,8 @@ class ImageEditTab {
|
||||
if (this._activeTool === "blur") {
|
||||
this._blurTool.deactivate();
|
||||
}
|
||||
// Draw/Mask/Inpaint以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw" && toolId !== "inpaint") {
|
||||
// Draw/Mask/Fill/Inpaint以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw" && toolId !== "fill" && toolId !== "inpaint") {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
if (pane) pane.style.display = "none";
|
||||
}
|
||||
@@ -282,6 +290,11 @@ class ImageEditTab {
|
||||
this._shapeTool.setCanvas(overlayCanvas);
|
||||
this._shapeTool.activate();
|
||||
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
|
||||
} else if (this._activeTool === "fill" && this._fillTool) {
|
||||
const activeLayer = this._layerMgr?.activeLayer;
|
||||
if (activeLayer) this._fillTool.setCanvas(activeLayer.canvas);
|
||||
this._fillTool.activate();
|
||||
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
|
||||
} else if (this._activeTool === "mask") {
|
||||
this._initMaskEditorOneTools();
|
||||
this._activateMaskSubtool();
|
||||
@@ -344,6 +357,10 @@ class ImageEditTab {
|
||||
el.innerHTML = "";
|
||||
this._renderDrawProps();
|
||||
|
||||
} else if (toolId === "fill" && this._fillTool) {
|
||||
el.innerHTML = "";
|
||||
this._renderFillProps();
|
||||
|
||||
} else if (toolId === "text" && this._textTool) {
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
@@ -968,6 +985,271 @@ class ImageEditTab {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fill Tool (bucket fill) props ─────────────────────────────────────
|
||||
|
||||
_renderFillProps() {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
const body = document.getElementById("ie-props-body");
|
||||
const title = document.getElementById("ie-props-title");
|
||||
if (!pane || !body) return;
|
||||
pane.style.display = "flex";
|
||||
if (title) title.textContent = "Fill";
|
||||
|
||||
const t = this._fillTool;
|
||||
const isGradient = t.fillMode === "gradient";
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label>Mode</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${!isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-solid" style="flex:1;">Solid</button>
|
||||
<button class="wfm-btn wfm-btn-sm${isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-gradient" style="flex:1;">Gradient</button>
|
||||
</div>
|
||||
</div>
|
||||
${!isGradient ? `
|
||||
<div class="ie-props-row">
|
||||
<label>Color</label>
|
||||
<input type="color" id="ie-fill-color" value="${t.color}"
|
||||
style="width:36px;height:24px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;flex-shrink:0;">
|
||||
</div>` : `
|
||||
<div class="ie-props-row">
|
||||
<label>Shape</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${t.gradientShape === "linear" ? " ie-opt-active" : ""}" id="ie-fill-shape-linear" style="flex:1;">Linear</button>
|
||||
<button class="wfm-btn wfm-btn-sm${t.gradientShape === "radial" ? " ie-opt-active" : ""}" id="ie-fill-shape-radial" style="flex:1;">Radial</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row" style="align-items:flex-start;">
|
||||
<label>Ramp</label>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;flex:1;min-width:0;">
|
||||
<canvas id="ie-fill-ramp" width="130" height="24" style="display:block;width:130px;height:24px;border:1px solid var(--wfm-border);border-radius:3px;cursor:pointer;"></canvas>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<input type="color" id="ie-fill-stop-color" value="${t.gradientStops[t.selectedStopIdx]?.color ?? "#ffffff"}"
|
||||
style="width:28px;height:22px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-fill-stop-add" title="Add stop">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-fill-stop-remove" title="Remove stop">-</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row" style="align-items:flex-start;">
|
||||
<label>Direction</label>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<canvas id="ie-fill-dir" width="70" height="70" style="width:70px;height:70px;border:1px solid var(--wfm-border);border-radius:3px;cursor:crosshair;"></canvas>
|
||||
<span id="ie-fill-strength-lbl" style="font-size:11px;color:var(--wfm-text-secondary);">${t.gradientStrength.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="ie-props-row">
|
||||
<label>Tolerance</label>
|
||||
<input type="range" id="ie-fill-tolerance" min="0" max="255" value="${t.tolerance}">
|
||||
<span id="ie-fill-tolerance-lbl">${t.tolerance}</span>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Opacity</label>
|
||||
<input type="range" id="ie-fill-opacity" min="1" max="100" value="${Math.round(t.opacity * 100)}">
|
||||
<span id="ie-fill-opacity-lbl">${Math.round(t.opacity * 100)}%</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("ie-fill-mode-solid")?.addEventListener("click", () => {
|
||||
t.fillMode = "solid";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-mode-gradient")?.addEventListener("click", () => {
|
||||
t.fillMode = "gradient";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-color")?.addEventListener("input", e => { t.color = e.target.value; });
|
||||
document.getElementById("ie-fill-tolerance")?.addEventListener("input", e => {
|
||||
t.tolerance = parseInt(e.target.value);
|
||||
document.getElementById("ie-fill-tolerance-lbl").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-fill-opacity")?.addEventListener("input", e => {
|
||||
t.opacity = parseInt(e.target.value) / 100;
|
||||
document.getElementById("ie-fill-opacity-lbl").textContent = e.target.value + "%";
|
||||
});
|
||||
|
||||
if (isGradient) {
|
||||
document.getElementById("ie-fill-shape-linear")?.addEventListener("click", () => {
|
||||
t.gradientShape = "linear";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-shape-radial")?.addEventListener("click", () => {
|
||||
t.gradientShape = "radial";
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-color")?.addEventListener("input", e => {
|
||||
t.gradientStops[t.selectedStopIdx].color = e.target.value;
|
||||
this._drawFillGradientRamp();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-add")?.addEventListener("click", () => {
|
||||
t.addStop();
|
||||
this._renderFillProps();
|
||||
});
|
||||
document.getElementById("ie-fill-stop-remove")?.addEventListener("click", () => {
|
||||
t.removeStop();
|
||||
this._renderFillProps();
|
||||
});
|
||||
this._setupFillGradientRamp();
|
||||
this._setupFillGradientDir();
|
||||
}
|
||||
}
|
||||
|
||||
/** カラーランプ(複数ストップの線形プレビュー)の描画とドラッグ操作。particle_widget.jsのカラーランプUIを移植。 */
|
||||
_drawFillGradientRamp() {
|
||||
const canvas = document.getElementById("ie-fill-ramp");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const t = this._fillTool;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const barH = h - 10; // 下部にハンドル用の余白を確保
|
||||
const grad = ctx.createLinearGradient(0, 0, w, 0);
|
||||
[...t.gradientStops].sort((a, b) => a.pos - b.pos).forEach(s => grad.addColorStop(s.pos, s.color));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, barH);
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.strokeRect(0.5, 0.5, w - 1, barH - 1);
|
||||
|
||||
t.gradientStops.forEach((s, i) => {
|
||||
// ハンドル(三角形)がcanvas端で切れて見えなくならないようクランプする
|
||||
const x = Math.max(5, Math.min(w - 5, s.pos * w));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, h);
|
||||
ctx.lineTo(x - 5, barH);
|
||||
ctx.lineTo(x + 5, barH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = i === t.selectedStopIdx ? "#0077ff" : "#999";
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
_setupFillGradientRamp() {
|
||||
const canvas = document.getElementById("ie-fill-ramp");
|
||||
if (!canvas) return;
|
||||
this._drawFillGradientRamp();
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
const t = this._fillTool;
|
||||
const hitTestStop = mx => {
|
||||
let best = -1, bestDist = 9;
|
||||
t.gradientStops.forEach((s, i) => {
|
||||
const d = Math.abs(s.pos * canvas.width - mx);
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
});
|
||||
return best;
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", e => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||
const idx = hitTestStop(mx);
|
||||
if (idx < 0) return;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
t.selectStop(idx);
|
||||
// ドラッグ中onMove/onUpが参照し続けるcanvas要素が入れ替わらないよう、
|
||||
// DOM全体を再構築する_renderFillProps()は使わず、選択状態の見た目だけを部分更新する
|
||||
this._drawFillGradientRamp();
|
||||
const colorInput = document.getElementById("ie-fill-stop-color");
|
||||
if (colorInput) colorInput.value = t.gradientStops[idx].color;
|
||||
|
||||
const onMove = ev => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const x = (ev.clientX - r.left) * (canvas.width / r.width);
|
||||
t.gradientStops[t.selectedStopIdx].pos = Math.max(0, Math.min(1, x / canvas.width));
|
||||
this._drawFillGradientRamp();
|
||||
};
|
||||
const onUp = () => {
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
/** グラデーション方向(linear)/半径(radial)を指定するミニコンパスUI。particle_widget.jsの矢印方向ハンドルを移植。 */
|
||||
_drawFillGradientDir() {
|
||||
const canvas = document.getElementById("ie-fill-dir");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
|
||||
const maxR = Math.min(w, h) / 2 - 6;
|
||||
const t = this._fillTool;
|
||||
const distFrac = (Math.max(0.2, Math.min(3.0, t.gradientStrength)) - 0.2) / 2.8;
|
||||
const dist = 6 + distFrac * (maxR - 6);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = "#888";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 4, cy); ctx.lineTo(cx + 4, cy);
|
||||
ctx.moveTo(cx, cy - 4); ctx.lineTo(cx, cy + 4);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.strokeStyle = "#0077ff";
|
||||
if (t.gradientShape === "radial") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dist, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
this._fillDirHandlePos = { x: cx + dist, y: cy };
|
||||
} else {
|
||||
const rad = t.gradientAngleDeg * Math.PI / 180;
|
||||
const ex = cx + Math.cos(rad) * dist, ey = cy + Math.sin(rad) * dist;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy); ctx.lineTo(ex, ey);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
this._fillDirHandlePos = { x: ex, y: ey };
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(this._fillDirHandlePos.x, this._fillDirHandlePos.y, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ffcc00";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
_setupFillGradientDir() {
|
||||
const canvas = document.getElementById("ie-fill-dir");
|
||||
if (!canvas) return;
|
||||
this._drawFillGradientDir();
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
const t = this._fillTool;
|
||||
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
|
||||
const maxR = Math.min(w, h) / 2 - 6;
|
||||
|
||||
const applyDrag = (mx, my) => {
|
||||
const dx = mx - cx, dy = my - cy;
|
||||
const dist = Math.max(6, Math.min(maxR, Math.hypot(dx, dy)));
|
||||
t.gradientStrength = 0.2 + ((dist - 6) / (maxR - 6)) * 2.8;
|
||||
if (t.gradientShape === "linear") t.gradientAngleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
this._drawFillGradientDir();
|
||||
const lbl = document.getElementById("ie-fill-strength-lbl");
|
||||
if (lbl) lbl.textContent = t.gradientStrength.toFixed(1);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", e => {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
applyDrag((e.clientX - rect.left) * (w / rect.width), (e.clientY - rect.top) * (h / rect.height));
|
||||
const onMove = ev => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
applyDrag((ev.clientX - r.left) * (w / r.width), (ev.clientY - r.top) * (h / r.height));
|
||||
};
|
||||
const onUp = () => {
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mask Editor One 追加ツール: 初期化・切り替え ──────────────────────────
|
||||
|
||||
_initMaskEditorOneTools() {
|
||||
@@ -1107,11 +1389,19 @@ class ImageEditTab {
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
document.getElementById("ie-open-psd-input")?.addEventListener("change", e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) this._loadPsdFile(file);
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
document.getElementById("ie-new-btn")?.addEventListener("click", () => this._newCanvas());
|
||||
document.getElementById("ie-close-btn")?.addEventListener("click", () => this._closeDocument());
|
||||
document.getElementById("ie-undo-btn")?.addEventListener("click", () => this._undo());
|
||||
document.getElementById("ie-redo-btn")?.addEventListener("click", () => this._redo());
|
||||
document.getElementById("ie-save-btn")?.addEventListener("click", () => this._fileExport.savePng());
|
||||
document.getElementById("ie-save-gallery-btn")?.addEventListener("click", () => this._fileExport.saveToGallery());
|
||||
document.getElementById("ie-save-psd-btn")?.addEventListener("click", () => this._fileExport.saveAsPsd());
|
||||
document.getElementById("ie-send-workflow-btn")?.addEventListener("click", () => this._fileExport.sendToWorkflow());
|
||||
document.getElementById("ie-zoom-fit")?.addEventListener("click", () => this._fitToView());
|
||||
document.getElementById("ie-zoom-100")?.addEventListener("click", () => {
|
||||
@@ -1140,7 +1430,7 @@ class ImageEditTab {
|
||||
this._setZoom(this._zoom * (e.deltaY > 0 ? 0.9 : 1.1));
|
||||
}, { passive: false });
|
||||
|
||||
wrap.addEventListener("mousedown", e => {
|
||||
wrap.addEventListener("pointerdown", e => {
|
||||
if (e.button === 1 || (e.button === 0 && this._spaceDown)) {
|
||||
e.preventDefault();
|
||||
this._panning = true;
|
||||
@@ -1159,7 +1449,7 @@ class ImageEditTab {
|
||||
if (!this._layerMgr || !drawCanvas) return;
|
||||
this._onToolMouseDown(e, drawCanvas);
|
||||
});
|
||||
window.addEventListener("mousemove", e => {
|
||||
window.addEventListener("pointermove", e => {
|
||||
if (this._panning) {
|
||||
this._panOffset.x = e.clientX - this._panStart.x;
|
||||
this._panOffset.y = e.clientY - this._panStart.y;
|
||||
@@ -1178,7 +1468,7 @@ class ImageEditTab {
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener("mouseup", e => {
|
||||
window.addEventListener("pointerup", e => {
|
||||
if (this._panning && (e.button === 1 || e.button === 0)) {
|
||||
this._panning = false;
|
||||
wrap.style.cursor = this._spaceDown ? "grab" : "";
|
||||
@@ -1191,22 +1481,24 @@ class ImageEditTab {
|
||||
}
|
||||
});
|
||||
|
||||
// draw / text 用 mousedown
|
||||
// draw / text 用 pointerdown(ペンタブレット筆圧対応のため Pointer Events を使用)
|
||||
const drawCanvas = document.getElementById("ie-canvas-draw");
|
||||
if (drawCanvas) {
|
||||
drawCanvas.addEventListener("mousedown", e => this._onToolMouseDown(e, drawCanvas));
|
||||
drawCanvas.addEventListener("mousemove", e => this._onToolMouseMove(e, drawCanvas));
|
||||
drawCanvas.addEventListener("mouseup", e => this._onToolMouseUp(e));
|
||||
drawCanvas.addEventListener("mouseleave", () => this._onToolMouseLeave());
|
||||
drawCanvas.style.touchAction = "none";
|
||||
drawCanvas.addEventListener("pointerdown", e => this._onToolMouseDown(e, drawCanvas));
|
||||
drawCanvas.addEventListener("pointermove", e => this._onToolMouseMove(e, drawCanvas));
|
||||
drawCanvas.addEventListener("pointerup", e => this._onToolMouseUp(e));
|
||||
drawCanvas.addEventListener("pointerleave", () => this._onToolMouseLeave());
|
||||
}
|
||||
|
||||
// select 用 mousedown(overlayCanvas)
|
||||
// select 用 pointerdown(overlayCanvas)
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) {
|
||||
overlay.addEventListener("mousedown", e => this._onToolMouseDown(e, overlay));
|
||||
overlay.addEventListener("mousemove", e => this._onToolMouseMove(e, overlay));
|
||||
overlay.addEventListener("mouseup", e => this._onToolMouseUp(e));
|
||||
overlay.addEventListener("mouseleave", () => this._onToolMouseLeave());
|
||||
overlay.style.touchAction = "none";
|
||||
overlay.addEventListener("pointerdown", e => this._onToolMouseDown(e, overlay));
|
||||
overlay.addEventListener("pointermove", e => this._onToolMouseMove(e, overlay));
|
||||
overlay.addEventListener("pointerup", e => this._onToolMouseUp(e));
|
||||
overlay.addEventListener("pointerleave", () => this._onToolMouseLeave());
|
||||
// テキストオブジェクトのダブルクリックで再編集
|
||||
overlay.addEventListener("dblclick", e => this._onOverlayDblClick(e, overlay));
|
||||
}
|
||||
@@ -1214,16 +1506,27 @@ class ImageEditTab {
|
||||
|
||||
_onToolMouseDown(e, refCanvas) {
|
||||
if (!this._layerMgr || e.button !== 0 || this._spaceDown) return;
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
// PointerEvent.pressure: pen -> actual pressure, mouse -> 0.5 while a button is held.
|
||||
const pressure = e.pressure || 0.5;
|
||||
|
||||
if (this._activeTool === "draw" && this._drawTool) {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer) return;
|
||||
this._saveUndo();
|
||||
this._drawTool.setCanvas(activeLayer.canvas);
|
||||
this._drawTool.onMouseDown(pos.x, pos.y);
|
||||
this._drawTool.onMouseDown(pos.x, pos.y, pressure);
|
||||
this._updateCompositeView();
|
||||
|
||||
} else if (this._activeTool === "fill" && this._fillTool) {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer) return;
|
||||
this._saveUndo();
|
||||
this._fillTool.setCanvas(activeLayer.canvas);
|
||||
this._fillTool.onMouseDown(pos.x, pos.y);
|
||||
this._updateCompositeView();
|
||||
this._refreshLayerList();
|
||||
|
||||
} else if (this._activeTool === "mask") {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer || activeLayer.type !== "mask") {
|
||||
@@ -1234,7 +1537,7 @@ class ImageEditTab {
|
||||
if (sub === "paint" && this._maskTool) {
|
||||
this._saveUndo();
|
||||
this._maskTool.setCanvas(activeLayer.canvas);
|
||||
this._maskTool.onMouseDown(pos.x, pos.y);
|
||||
this._maskTool.onMouseDown(pos.x, pos.y, pressure);
|
||||
this._updateCompositeView();
|
||||
} else if (sub === "color" && this._maskColorTool) {
|
||||
this._saveUndo();
|
||||
@@ -1279,15 +1582,16 @@ class ImageEditTab {
|
||||
|
||||
_onToolMouseMove(e, refCanvas) {
|
||||
if (!this._layerMgr) return;
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pos = DrawTool.getCanvasPos(refCanvas, e);
|
||||
const pressure = e.pressure || 0.5;
|
||||
if (this._activeTool === "draw") {
|
||||
this._drawTool?.onMouseMove(pos.x, pos.y);
|
||||
this._drawTool?.onMouseMove(pos.x, pos.y, pressure);
|
||||
if (this._drawTool?._drawing) this._updateCompositeView();
|
||||
}
|
||||
if (this._activeTool === "mask") {
|
||||
const sub = this._maskSubtool;
|
||||
if (sub === "paint") {
|
||||
this._maskTool?.onMouseMove(pos.x, pos.y);
|
||||
this._maskTool?.onMouseMove(pos.x, pos.y, pressure);
|
||||
if (this._maskTool?._drawing) this._updateCompositeView();
|
||||
} else if (sub === "vector") {
|
||||
this._maskVectorTool?.onMouseMove(pos.x, pos.y);
|
||||
@@ -1401,6 +1705,102 @@ class ImageEditTab {
|
||||
showToast(`Loaded: ${img.width}×${img.height}`, "success");
|
||||
}
|
||||
|
||||
// PSDファイルをアップロードし、レイヤーを分解して新規ドキュメントとして読み込む。
|
||||
async _loadPsdFile(file) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/import-psd-layers", { method: "POST", body: form });
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
data = await r.json();
|
||||
} catch (err) {
|
||||
showToast(`PSD import failed: ${err.message}`, "error");
|
||||
return;
|
||||
}
|
||||
await this.loadPsdLayersData(data, file.name.replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
|
||||
/** バックエンドの import-psd-layers / psd-layers が返すレイヤー配列(dataURL+メタデータ)を
|
||||
* 新規ドキュメントとして読み込む。グループ・調整レイヤー等はバックエンド側で既にスキップ
|
||||
* 済みのフラットな画像レイヤー配列が渡される想定(ギャラリーからの直接送信でも使う共通経路)。
|
||||
*/
|
||||
async loadPsdLayersData(data, baseName = "psd-image") {
|
||||
const { width, height, layers } = data || {};
|
||||
if (!width || !height || !layers?.length) {
|
||||
showToast("No importable layers found in this PSD", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
this._canvasW = width;
|
||||
this._canvasH = height;
|
||||
this._baseName = baseName;
|
||||
this._initCanvases();
|
||||
|
||||
// レイヤーはバックエンド側で先頭=最前面の順に並べ替え済み
|
||||
const loaded = await Promise.all(layers.map(ld => new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const layer = new Layer(ld.name || "Layer", "image", ld.width, ld.height);
|
||||
layer.x = ld.x;
|
||||
layer.y = ld.y;
|
||||
layer.displayW = ld.width;
|
||||
layer.displayH = ld.height;
|
||||
layer.opacity = ld.opacity;
|
||||
layer.blendMode = ld.blendMode;
|
||||
layer.visible = ld.visible;
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
resolve(layer);
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = ld.imageData;
|
||||
})));
|
||||
|
||||
this._layerMgr.layers = loaded.filter(Boolean);
|
||||
this._layerMgr.activeIndex = 0;
|
||||
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._setActiveTool("select");
|
||||
this._selectTool?.setLayer(this._layerMgr.activeLayer);
|
||||
this._refreshLayerList();
|
||||
this._updateCompositeView();
|
||||
this._fitToView();
|
||||
|
||||
document.getElementById("ie-placeholder").style.display = "none";
|
||||
showToast(`PSD imported: ${this._layerMgr.layers.length} layer(s)`, "success");
|
||||
}
|
||||
|
||||
// 現在のドキュメントを閉じ、未保存の変更を破棄してプレースホルダー表示に戻す
|
||||
_closeDocument() {
|
||||
if (!this._layerMgr) return;
|
||||
if (!confirm("Close the current document? Unsaved changes will be lost.")) return;
|
||||
|
||||
this._layerMgr = null;
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._canvasW = 512;
|
||||
this._canvasH = 512;
|
||||
this._baseName = "image";
|
||||
this._zoom = 1.0;
|
||||
this._panOffset = { x: 0, y: 0 };
|
||||
|
||||
const drawCanvas = document.getElementById("ie-canvas-draw");
|
||||
const overlayCanvas = document.getElementById("ie-canvas-overlay");
|
||||
drawCanvas?.getContext("2d").clearRect(0, 0, drawCanvas.width, drawCanvas.height);
|
||||
overlayCanvas?.getContext("2d").clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
||||
|
||||
const layerList = document.getElementById("ie-layer-list");
|
||||
if (layerList) layerList.innerHTML = "";
|
||||
|
||||
const placeholder = document.getElementById("ie-placeholder");
|
||||
if (placeholder) placeholder.style.display = "";
|
||||
}
|
||||
|
||||
_newCanvas() {
|
||||
const current = `${this._canvasW || 512}x${this._canvasH || 512}`;
|
||||
const input = prompt("Canvas size (WxH):", current);
|
||||
@@ -1539,6 +1939,8 @@ class ImageEditTab {
|
||||
this._refreshLayerList();
|
||||
});
|
||||
|
||||
this._fillTool = new FillTool();
|
||||
|
||||
this._compositeMode = false;
|
||||
}
|
||||
|
||||
@@ -1750,6 +2152,45 @@ class ImageEditTab {
|
||||
|
||||
// ── レイヤーパネル ────────────────────────────
|
||||
|
||||
// 指定レイヤーをアクティブ化し、種別・現在のツールに応じてキャンバス/ツールを同期する
|
||||
// (レイヤーリストのクリック選択・複製直後のアクティブ化などで共用)
|
||||
_activateLayerById(id) {
|
||||
if (!this._layerMgr) return;
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._layerMgr.setActive(id);
|
||||
const layer = this._layerMgr.activeLayer;
|
||||
if (layer?.type === "mask") {
|
||||
if (this._activeTool !== "mask") {
|
||||
this._setActiveTool("mask");
|
||||
} else {
|
||||
// すでにマスクツール選択中 → canvas だけ切り替え
|
||||
this._maskTool?.setCanvas(layer.canvas);
|
||||
this._maskTool?.activate();
|
||||
}
|
||||
// operation に合わせて MaskTool の mode を同期
|
||||
if (this._maskTool) {
|
||||
this._maskTool.mode = layer.operation === "subtract" ? "erase" : "paint";
|
||||
this._renderToolOptions("mask");
|
||||
}
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "draw" && layer) {
|
||||
this._drawTool?.setCanvas(layer.canvas);
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "select" && layer) {
|
||||
this._selectTool?.setLayer(layer);
|
||||
this._updateCompositeView();
|
||||
} else {
|
||||
this._updateCompositeView();
|
||||
}
|
||||
this._refreshLayerList();
|
||||
if (layer) {
|
||||
const sl = document.getElementById("ie-layer-opacity");
|
||||
const lb = document.getElementById("ie-layer-opacity-label");
|
||||
if (sl) sl.value = Math.round(layer.opacity * 100);
|
||||
if (lb) lb.textContent = Math.round(layer.opacity * 100) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
_setupLayerPanel() {
|
||||
document.getElementById("ie-add-layer-btn")?.addEventListener("click", () => {
|
||||
if (!this._layerMgr) return;
|
||||
@@ -1764,6 +2205,24 @@ class ImageEditTab {
|
||||
this._activateCurrentTool();
|
||||
});
|
||||
|
||||
document.getElementById("ie-dup-layer-btn")?.addEventListener("click", async () => {
|
||||
if (!this._layerMgr) return;
|
||||
const active = this._layerMgr.activeLayer;
|
||||
if (!active) return;
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._saveUndo();
|
||||
|
||||
const json = active.toJSON();
|
||||
json.name = `${active.name} copy`;
|
||||
const dup = await Layer.fromJSON(json);
|
||||
dup.id = crypto.randomUUID();
|
||||
|
||||
const idx = this._layerMgr.layers.indexOf(active);
|
||||
this._layerMgr.layers.splice(idx, 0, dup);
|
||||
|
||||
this._activateLayerById(dup.id);
|
||||
});
|
||||
|
||||
document.getElementById("ie-add-mask-btn")?.addEventListener("click", () => {
|
||||
if (!this._layerMgr) { showToast("Open an image first", "info"); return; }
|
||||
this._syncActiveLayerFromCanvas();
|
||||
@@ -1873,39 +2332,7 @@ class ImageEditTab {
|
||||
if (sel?.id === id) this._selectTool?.setLayer(sel);
|
||||
this._refreshLayerList();
|
||||
} else if (action === "select") {
|
||||
this._syncActiveLayerFromCanvas();
|
||||
this._layerMgr.setActive(id);
|
||||
const layer = this._layerMgr.activeLayer;
|
||||
if (layer?.type === "mask") {
|
||||
if (this._activeTool !== "mask") {
|
||||
this._setActiveTool("mask");
|
||||
} else {
|
||||
// すでにマスクツール選択中 → canvas だけ切り替え
|
||||
this._maskTool?.setCanvas(layer.canvas);
|
||||
this._maskTool?.activate();
|
||||
}
|
||||
// operation に合わせて MaskTool の mode を同期
|
||||
if (this._maskTool) {
|
||||
this._maskTool.mode = layer.operation === "subtract" ? "erase" : "paint";
|
||||
this._renderToolOptions("mask");
|
||||
}
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "draw" && layer) {
|
||||
this._drawTool?.setCanvas(layer.canvas);
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "select" && layer) {
|
||||
this._selectTool?.setLayer(layer);
|
||||
this._updateCompositeView();
|
||||
} else {
|
||||
this._updateCompositeView();
|
||||
}
|
||||
this._refreshLayerList();
|
||||
if (layer) {
|
||||
const sl = document.getElementById("ie-layer-opacity");
|
||||
const lb = document.getElementById("ie-layer-opacity-label");
|
||||
if (sl) sl.value = Math.round(layer.opacity * 100);
|
||||
if (lb) lb.textContent = Math.round(layer.opacity * 100) + "%";
|
||||
}
|
||||
this._activateLayerById(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1962,6 +2389,60 @@ class ImageEditTab {
|
||||
}
|
||||
}
|
||||
|
||||
/** ギャラリーなど外部から複数画像URLをまとめてレイヤーとして新規ドキュメントに読み込む
|
||||
* @param {Array<{url: string, name?: string}>} items 先頭=最背面, 末尾=最前面として重ねる
|
||||
*/
|
||||
async loadLayersFromUrls(items) {
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
let imgs;
|
||||
try {
|
||||
imgs = await Promise.all(items.map(({ url }) => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load: ${url}`));
|
||||
img.src = url;
|
||||
})));
|
||||
} catch (err) {
|
||||
showToast(err.message || "Failed to load images", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const w = Math.max(...imgs.map(i => i.width));
|
||||
const h = Math.max(...imgs.map(i => i.height));
|
||||
|
||||
this._canvasW = w;
|
||||
this._canvasH = h;
|
||||
this._baseName = "gallery-layers";
|
||||
this._initCanvases();
|
||||
|
||||
const layers = imgs.map((img, i) => {
|
||||
const fit = fitToCanvas(img.width, img.height, w, h);
|
||||
const layer = new Layer(items[i].name || `Layer ${i + 1}`, "image", img.width, img.height);
|
||||
layer.displayW = fit.w;
|
||||
layer.displayH = fit.h;
|
||||
layer.x = Math.round((w - fit.w) / 2);
|
||||
layer.y = Math.round((h - fit.h) / 2);
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
return layer;
|
||||
});
|
||||
|
||||
// items は先頭=最背面の順で渡される想定のため、Layer配列規約(先頭=最前面)に合わせて反転する
|
||||
this._layerMgr.layers = layers.reverse();
|
||||
this._layerMgr.activeIndex = 0;
|
||||
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._setActiveTool("select");
|
||||
this._selectTool?.setLayer(this._layerMgr.activeLayer);
|
||||
this._refreshLayerList();
|
||||
this._updateCompositeView();
|
||||
this._fitToView();
|
||||
|
||||
document.getElementById("ie-placeholder").style.display = "none";
|
||||
showToast(`Loaded ${layers.length} image(s) as layers`, "success");
|
||||
}
|
||||
|
||||
// ── キーボードショートカット ──────────────────
|
||||
|
||||
_setupKeyboard() {
|
||||
@@ -1983,6 +2464,7 @@ class ImageEditTab {
|
||||
if (e.key === "b") this._setActiveTool("draw");
|
||||
if (e.key === "t") this._setActiveTool("text");
|
||||
if (e.key === "s") this._setActiveTool("shape");
|
||||
if (e.key === "g") this._setActiveTool("fill");
|
||||
// Delete/Backspaceで選択オブジェクト削除
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && this._activeTool === "select") {
|
||||
const layer = this._selectTool?.getSelectedLayer();
|
||||
|
||||
@@ -20,6 +20,11 @@ export class DrawTool {
|
||||
this.sizeJitterAmount = 0.5;
|
||||
this.rotationJitter = false;
|
||||
|
||||
// Pen pressure (0–1, from PointerEvent.pressure; mouse reports 0.5).
|
||||
// pressure=0.5 maps to a 1.0x multiplier so mouse behavior is unchanged.
|
||||
this.pressureEnabled = true;
|
||||
this._lastPressure = 0.5;
|
||||
|
||||
// Image brush from ABR library (null = default circle brush)
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
@@ -80,29 +85,31 @@ export class DrawTool {
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
onMouseDown(x, y, pressure = 0.5) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._paintToStroke(x, y, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
this._paint(x, y, pressure);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
onMouseMove(x, y, pressure = 0.5) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
this._paintLine(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
@@ -117,6 +124,20 @@ export class DrawTool {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Pressure response ──────────────────────────────────────────────
|
||||
|
||||
_effectiveSize(pressure) {
|
||||
if (!this.pressureEnabled) return this.brushSize;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(1, this.brushSize * (0.3 + p * 1.4)); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
_effectiveOpacity(pressure) {
|
||||
if (!this.pressureEnabled) return this.opacity;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(0, Math.min(1, this.opacity * (0.4 + p * 1.2))); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
@@ -155,54 +176,48 @@ export class DrawTool {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
_paintToStroke(x, y, pressure = 0.5) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
let size = this._effectiveSize(pressure);
|
||||
if (this.sizeJitter) {
|
||||
size = Math.max(1, size * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
|
||||
if (this.rotationJitter) {
|
||||
const savedAngle = this.angle;
|
||||
this.angle = Math.random() * 360;
|
||||
this._paintImageBrush(x, y, size);
|
||||
this.angle = savedAngle;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
this._paintImageBrush(x, y, size);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
_paintLineToStroke(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const step = Math.max(1, this.brushSize * this.spacing);
|
||||
const steps = Math.ceil(dist / step);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
_getStamp(size) {
|
||||
size = Math.max(1, Math.round(size));
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampSize === size &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampColor === this.color
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
@@ -225,11 +240,12 @@ export class DrawTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
_paint(x, y, pressure = 0.5) {
|
||||
const size = this._effectiveSize(pressure);
|
||||
const stamp = this._getStamp(size);
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = this.opacity;
|
||||
this.ctx.globalAlpha = this._effectiveOpacity(pressure);
|
||||
if (this.mode === "erase") {
|
||||
this.ctx.globalCompositeOperation = "destination-out";
|
||||
}
|
||||
@@ -237,13 +253,13 @@ export class DrawTool {
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
_paintLine(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const spacing = Math.max(1, this.brushSize * 0.2);
|
||||
const steps = Math.max(1, Math.ceil(dist / spacing));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,8 +335,8 @@ export class DrawTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
_paintImageBrush(x, y, size = this.brushSize) {
|
||||
const stamp = this._getImageStamp(Math.round(size));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -63,6 +63,92 @@ export class FileExport {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用レイヤー配列を構築: 各レイヤーを個別のcanvas(ドキュメント全体サイズ)に
|
||||
// 回転・反転・拡縮のみベイクして描画する。opacity/blendModeはピクセルに焼き込まず
|
||||
// PSDレイヤー属性として渡すためメタデータのまま残す。
|
||||
// maskApply=true のクリッピングマスクは対象レイヤーと合成して1枚にベイクする
|
||||
// (PSDの本物のレイヤーマスクとしては書き出さない簡略化)。
|
||||
_buildLayerExportList(layerMgr, w, h) {
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const exportLayers = [];
|
||||
// 背面→前面の順(PSD側で後から追加したレイヤーほど上に重なるため、この順で送る)
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (maskedIndices.has(i)) continue; // マスク側の処理でまとめて扱う
|
||||
|
||||
if (layer.type === "mask") {
|
||||
if (!(layer.maskApply && layer.visible && i + 1 < layers.length)) continue;
|
||||
const target = layers[i + 1];
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
this._cb.renderMaskedLayer(canvas.getContext("2d"), canvas, layer, target, false);
|
||||
exportLayers.push({
|
||||
name: target.name || "Layer",
|
||||
visible: target.visible,
|
||||
opacity: 1,
|
||||
blendMode: "source-over",
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.save();
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
exportLayers.push({
|
||||
name: layer.name || "Layer",
|
||||
visible: layer.visible,
|
||||
opacity: layer.opacity,
|
||||
blendMode: layer.blendMode,
|
||||
imageData: canvas.toDataURL("image/png"),
|
||||
});
|
||||
}
|
||||
return exportLayers;
|
||||
}
|
||||
|
||||
async saveAsPsd() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const exportLayers = this._buildLayerExportList(layerMgr, w, h);
|
||||
if (exportLayers.length === 0) { showToast("No layers to export", "error"); return; }
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/export-psd-layers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width: w, height: h, layers: exportLayers }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + ".psd";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("PSD saved", "success");
|
||||
} catch (err) {
|
||||
showToast(`PSD save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
savePng() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Image Edit Tab - Fill Tool (bucket fill)
|
||||
* Flood-fills a contiguous same-color region on the active layer canvas,
|
||||
* either with a solid color or a linear/radial gradient.
|
||||
* Gradient ramp math (evalGradient / hex<->rgb) is ported from
|
||||
* comfyUI-particle-pixijs's particle_engine.js.
|
||||
*/
|
||||
|
||||
function _hexToRgb01(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || "#000000");
|
||||
if (!m) return { r: 0, g: 0, b: 0 };
|
||||
return { r: parseInt(m[1], 16) / 255, g: parseInt(m[2], 16) / 255, b: parseInt(m[3], 16) / 255 };
|
||||
}
|
||||
|
||||
function _rgb01ToHex({ r, g, b }) {
|
||||
const h = v => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0");
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
function _lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
export class FillTool {
|
||||
constructor() {
|
||||
// fill settings
|
||||
this.tolerance = 32; // 0-255
|
||||
this.fillMode = "solid"; // "solid" | "gradient"
|
||||
this.color = "#ff0000"; // solid fill color
|
||||
this.opacity = 1.0;
|
||||
|
||||
// gradient settings
|
||||
this.gradientShape = "linear"; // "linear" | "radial"
|
||||
this.gradientAngleDeg = 0; // linear direction
|
||||
this.gradientStrength = 1.0; // 0.2-3.0, length/radius multiplier
|
||||
this.gradientStops = [
|
||||
{ pos: 0.0, color: "#ffffff" },
|
||||
{ pos: 1.0, color: "#888888" },
|
||||
];
|
||||
this.selectedStopIdx = 0;
|
||||
|
||||
// internal state
|
||||
this._active = false;
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas ? canvas.getContext("2d") : null;
|
||||
}
|
||||
|
||||
onChange(cb) { this._onChange = cb; }
|
||||
|
||||
activate() { this._active = true; }
|
||||
deactivate() { this._active = false; }
|
||||
|
||||
// ── color ramp evaluation (ported from particle_engine.js evalGradient) ──
|
||||
|
||||
static evalGradient(stops, t) {
|
||||
if (!stops || stops.length === 0) return { r: 0, g: 0, b: 0 };
|
||||
const sorted = [...stops].sort((a, b) => a.pos - b.pos);
|
||||
if (t <= sorted[0].pos) return _hexToRgb01(sorted[0].color);
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (t >= last.pos) return _hexToRgb01(last.color);
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i], b = sorted[i + 1];
|
||||
if (t >= a.pos && t <= b.pos) {
|
||||
const span = b.pos - a.pos;
|
||||
const localT = span > 0 ? (t - a.pos) / span : 0;
|
||||
const ca = _hexToRgb01(a.color), cb = _hexToRgb01(b.color);
|
||||
return {
|
||||
r: _lerp(ca.r, cb.r, localT),
|
||||
g: _lerp(ca.g, cb.g, localT),
|
||||
b: _lerp(ca.b, cb.b, localT),
|
||||
};
|
||||
}
|
||||
}
|
||||
return _hexToRgb01(last.color);
|
||||
}
|
||||
|
||||
/** Preview swatch color (hex) of the ramp at position t. */
|
||||
evalGradientHex(t) {
|
||||
return _rgb01ToHex(FillTool.evalGradient(this.gradientStops, t));
|
||||
}
|
||||
|
||||
// ── stop editing helpers (ported from particle_widget.js addStop/removeStop/selectStop) ──
|
||||
|
||||
addStop() {
|
||||
const sorted = [...this.gradientStops].sort((a, b) => a.pos - b.pos);
|
||||
let bestGapStart = 0, bestGapSize = 0;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].pos - sorted[i].pos;
|
||||
if (gap > bestGapSize) { bestGapSize = gap; bestGapStart = sorted[i].pos; }
|
||||
}
|
||||
const pos = sorted.length < 2 ? 0.5 : bestGapStart + bestGapSize / 2;
|
||||
const color = _rgb01ToHex(FillTool.evalGradient(this.gradientStops, pos));
|
||||
this.gradientStops.push({ pos, color });
|
||||
this.selectedStopIdx = this.gradientStops.length - 1;
|
||||
}
|
||||
|
||||
removeStop(idx = this.selectedStopIdx) {
|
||||
if (this.gradientStops.length <= 1) return;
|
||||
this.gradientStops.splice(idx, 1);
|
||||
this.selectedStopIdx = Math.max(0, Math.min(this.selectedStopIdx, this.gradientStops.length - 1));
|
||||
}
|
||||
|
||||
selectStop(idx) {
|
||||
this.selectedStopIdx = idx;
|
||||
}
|
||||
|
||||
// ── flood fill ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flood-fills the contiguous region matching the pixel at (x, y).
|
||||
* (x, y) are canvas-pixel coordinates (already converted via DrawTool.getCanvasPos).
|
||||
*/
|
||||
onMouseDown(x, y) {
|
||||
if (!this._active || !this._ctx) return;
|
||||
const w = this._canvas.width, h = this._canvas.height;
|
||||
const px = Math.floor(x), py = Math.floor(y);
|
||||
if (px < 0 || py < 0 || px >= w || py >= h) return;
|
||||
|
||||
const imgData = this._ctx.getImageData(0, 0, w, h);
|
||||
const data = imgData.data;
|
||||
|
||||
const mask = this._buildFloodMask(data, w, h, px, py, this.tolerance);
|
||||
if (!mask) return;
|
||||
|
||||
if (this.fillMode === "gradient") {
|
||||
this._fillMaskWithGradient(data, w, h, mask.pixels, mask.bbox);
|
||||
} else {
|
||||
this._fillMaskWithSolid(data, mask.pixels, this.color, this.opacity);
|
||||
}
|
||||
|
||||
this._ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
/** Scanline stack-based flood fill. Returns { pixels: Uint8Array(w*h), bbox } or null. */
|
||||
_buildFloodMask(data, w, h, startX, startY, tolerance) {
|
||||
const idx0 = (startY * w + startX) * 4;
|
||||
const tr = data[idx0], tg = data[idx0 + 1], tb = data[idx0 + 2], ta = data[idx0 + 3];
|
||||
const tol2 = tolerance * tolerance;
|
||||
|
||||
const matches = (i) => {
|
||||
const dr = data[i] - tr, dg = data[i + 1] - tg, db = data[i + 2] - tb, da = data[i + 3] - ta;
|
||||
return (dr * dr + dg * dg + db * db + da * da) <= tol2 * 4;
|
||||
};
|
||||
|
||||
const pixels = new Uint8Array(w * h);
|
||||
const stack = [[startX, startY]];
|
||||
let minX = startX, maxX = startX, minY = startY, maxY = startY;
|
||||
|
||||
while (stack.length) {
|
||||
let [x, y] = stack.pop();
|
||||
while (x >= 0 && matches((y * w + x) * 4)) x--;
|
||||
x++;
|
||||
let spanUp = false, spanDown = false;
|
||||
while (x < w && matches((y * w + x) * 4) && !pixels[y * w + x]) {
|
||||
pixels[y * w + x] = 1;
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
|
||||
if (y > 0) {
|
||||
const above = matches((( y - 1) * w + x) * 4) && !pixels[(y - 1) * w + x];
|
||||
if (above && !spanUp) { stack.push([x, y - 1]); spanUp = true; }
|
||||
else if (!above) spanUp = false;
|
||||
}
|
||||
if (y < h - 1) {
|
||||
const below = matches(((y + 1) * w + x) * 4) && !pixels[(y + 1) * w + x];
|
||||
if (below && !spanDown) { stack.push([x, y + 1]); spanDown = true; }
|
||||
else if (!below) spanDown = false;
|
||||
}
|
||||
x++;
|
||||
}
|
||||
}
|
||||
return { pixels, bbox: { minX, minY, maxX, maxY } };
|
||||
}
|
||||
|
||||
_fillMaskWithSolid(data, pixels, colorHex, opacity) {
|
||||
const { r, g, b } = _hexToRgb01(colorHex);
|
||||
const cr = r * 255, cg = g * 255, cb = b * 255;
|
||||
const alpha = Math.max(0, Math.min(1, opacity));
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
if (!pixels[i]) continue;
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], cr, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], cg, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], cb, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable with any {pixels, bbox} mask (flood fill today; rect/ellipse selections in the future). */
|
||||
_fillMaskWithGradient(data, w, h, pixels, bbox) {
|
||||
const cx = (bbox.minX + bbox.maxX) / 2, cy = (bbox.minY + bbox.maxY) / 2;
|
||||
const diagW = bbox.maxX - bbox.minX, diagH = bbox.maxY - bbox.minY;
|
||||
const diag = Math.max(1, Math.hypot(diagW, diagH));
|
||||
const strength = Math.max(0.2, Math.min(3.0, this.gradientStrength));
|
||||
const alpha = Math.max(0, Math.min(1, this.opacity));
|
||||
|
||||
const angleRad = this.gradientAngleDeg * Math.PI / 180;
|
||||
const dirX = Math.cos(angleRad), dirY = Math.sin(angleRad);
|
||||
const halfLen = (diag / 2) * strength;
|
||||
const radius = (diag / 2) * strength;
|
||||
|
||||
for (let y = bbox.minY; y <= bbox.maxY; y++) {
|
||||
for (let x = bbox.minX; x <= bbox.maxX; x++) {
|
||||
const i = y * w + x;
|
||||
if (!pixels[i]) continue;
|
||||
const dx = x - cx, dy = y - cy;
|
||||
let t;
|
||||
if (this.gradientShape === "radial") {
|
||||
t = Math.hypot(dx, dy) / radius;
|
||||
} else {
|
||||
t = (dx * dirX + dy * dirY) / (halfLen * 2) + 0.5;
|
||||
}
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const c = FillTool.evalGradient(this.gradientStops, t);
|
||||
const di = i * 4;
|
||||
data[di] = _lerp(data[di], c.r * 255, alpha);
|
||||
data[di + 1] = _lerp(data[di + 1], c.g * 255, alpha);
|
||||
data[di + 2] = _lerp(data[di + 2], c.b * 255, alpha);
|
||||
data[di + 3] = Math.max(data[di + 3], alpha * 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export class MaskTool {
|
||||
this.sizeJitterAmount = 0.5; // 0–1: fraction of brushSize that can be removed
|
||||
this.rotationJitter = false;
|
||||
|
||||
// Pen pressure (0–1, from PointerEvent.pressure; mouse reports 0.5).
|
||||
// pressure=0.5 maps to a 1.0x multiplier so mouse behavior is unchanged.
|
||||
this.pressureEnabled = true;
|
||||
this._lastPressure = 0.5;
|
||||
|
||||
this._drawing = false;
|
||||
this._lastX = 0;
|
||||
this._lastY = 0;
|
||||
@@ -80,29 +85,31 @@ export class MaskTool {
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
onMouseDown(x, y, pressure = 0.5) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._paintToStroke(x, y, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
this._paint(x, y, pressure);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
onMouseMove(x, y, pressure = 0.5) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
this._paintLine(this._lastX, this._lastY, x, y, this._lastPressure, pressure);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._lastPressure = pressure;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
@@ -117,6 +124,14 @@ export class MaskTool {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Pressure response ──────────────────────────────────────────────
|
||||
|
||||
_effectiveSize(pressure) {
|
||||
if (!this.pressureEnabled) return this.brushSize;
|
||||
const p = Math.max(0, Math.min(1, pressure ?? 0.5));
|
||||
return Math.max(1, this.brushSize * (0.3 + p * 1.4)); // p=0.5 -> 1.0x
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
@@ -149,54 +164,48 @@ export class MaskTool {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
_paintToStroke(x, y, pressure = 0.5) {
|
||||
const saved = this.ctx;
|
||||
this.ctx = this._strokeCtx;
|
||||
|
||||
if (this.sizeJitter || this.rotationJitter) {
|
||||
const savedSize = this.brushSize;
|
||||
const savedAngle = this.angle;
|
||||
const savedStamp = this._imgStamp;
|
||||
const savedStampSize = this._imgStampSize;
|
||||
if (this.sizeJitter) {
|
||||
this.brushSize = Math.max(1, savedSize * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
if (this.rotationJitter) {
|
||||
this.angle = Math.random() * 360;
|
||||
}
|
||||
this._paintImageBrush(x, y);
|
||||
this.brushSize = savedSize;
|
||||
this.angle = savedAngle;
|
||||
this._imgStamp = savedStamp;
|
||||
this._imgStampSize = savedStampSize;
|
||||
let size = this._effectiveSize(pressure);
|
||||
if (this.sizeJitter) {
|
||||
size = Math.max(1, size * (1 - Math.random() * this.sizeJitterAmount));
|
||||
}
|
||||
|
||||
if (this.rotationJitter) {
|
||||
const savedAngle = this.angle;
|
||||
this.angle = Math.random() * 360;
|
||||
this._paintImageBrush(x, y, size);
|
||||
this.angle = savedAngle;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
this._paintImageBrush(x, y, size);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
_paintLineToStroke(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const step = Math.max(1, this.brushSize * this.spacing);
|
||||
const steps = Math.ceil(dist / step);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paintToStroke(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
_getStamp(size) {
|
||||
size = Math.max(1, Math.round(size));
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
this._stampSize === size &&
|
||||
this._stampHard === this.hardness &&
|
||||
this._stampMode === this.mode
|
||||
) return this._stamp;
|
||||
|
||||
const size = Math.max(1, Math.round(this.brushSize));
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = size;
|
||||
sc.height = size;
|
||||
@@ -222,8 +231,9 @@ export class MaskTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
_paint(x, y, pressure = 0.5) {
|
||||
const size = this._effectiveSize(pressure);
|
||||
const stamp = this._getStamp(size);
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
@@ -233,13 +243,13 @@ export class MaskTool {
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
_paintLine(x0, y0, x1, y1, p0 = 0.5, p1 = 0.5) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||
const spacing = Math.max(1, this.brushSize * 0.2);
|
||||
const steps = Math.max(1, Math.ceil(dist / spacing));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
|
||||
this._paint(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, p0 + (p1 - p0) * t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,8 +317,8 @@ export class MaskTool {
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
_paintImageBrush(x, y, size = this.brushSize) {
|
||||
const stamp = this._getImageStamp(Math.round(size));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Prompt Tab - AI Assistant (Ollama / LM Studio chat, translate, apply to GenerateUI)
|
||||
* Prompt Tab - AI Assistant (Ollama / LM Studio / Lemonade / Unsloth chat, translate, apply to GenerateUI)
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { getAiBackendDefaultUrl } from "./util.js";
|
||||
import { unslothProxy } from "./util.js";
|
||||
import { openAiBackendSettingsModal, getAiBackendConfig } from "./ai-settings-modal.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -17,64 +18,73 @@ const ollamaState = {
|
||||
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
|
||||
function loadPromptAiSettings() {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
}
|
||||
|
||||
// バックエンド変更検知用: モデル一覧を取得した時点のバックエンド名を記録
|
||||
let _promptAiBackendForModels = null;
|
||||
|
||||
export function getPromptAiConfig() {
|
||||
const s = loadPromptAiSettings();
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return { backend, url };
|
||||
return getAiBackendConfig(PROMPT_AI_KEY);
|
||||
}
|
||||
|
||||
export async function fetchAiModels() {
|
||||
const { backend, url } = getPromptAiConfig();
|
||||
// Ollama-style messages (with .images) → OpenAI互換 content 配列に変換
|
||||
function _toOpenAiMessages(messages) {
|
||||
return messages.map(msg => {
|
||||
if (msg.images?.length) {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: [
|
||||
{ type: "text", text: msg.content },
|
||||
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { role: msg.role, content: msg.content };
|
||||
});
|
||||
}
|
||||
|
||||
function _stripThinkingTags(text) {
|
||||
return (text || "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Ollamaは `think` (bool) と `options.num_predict`、OpenAI互換は `max_tokens` で制御
|
||||
function _applyGenOptions(body, backend, cfg) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ((await res.json()).data || []).map(m => m.id);
|
||||
body.think = !!cfg.thinkingMode;
|
||||
if (cfg.maxTokens > 0) body.options = { ...(body.options || {}), num_predict: cfg.maxTokens };
|
||||
} else if (cfg.maxTokens > 0) {
|
||||
body.max_tokens = cfg.maxTokens;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function chatWithAi(model, messages) {
|
||||
const { backend, url } = getPromptAiConfig();
|
||||
const cfg = getPromptAiConfig();
|
||||
const { backend, url } = cfg;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, messages, stream: false }, backend, cfg)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
const text = (await res.json()).message?.content || "";
|
||||
return cfg.thinkingMode ? text : _stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: _toOpenAiMessages(messages), stream: false }, backend, cfg);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const msg = data.choices?.[0]?.message;
|
||||
// Unslothはreasoningを別フィールドで返すため<think>タグに畳んで表示に混ぜる
|
||||
const text = (msg?.reasoning_content ? `<think>${msg.reasoning_content}</think>` : "") + (msg?.content || "");
|
||||
return cfg.thinkingMode ? text : _stripThinkingTags(text);
|
||||
} else {
|
||||
// LM Studio / Lemonade (OpenAI-compatible): convert Ollama-style messages (with .images) to OpenAI content arrays
|
||||
const openAiMessages = messages.map(msg => {
|
||||
if (msg.images?.length) {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: [
|
||||
{ type: "text", text: msg.content },
|
||||
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { role: msg.role, content: msg.content };
|
||||
});
|
||||
// LM Studio / Lemonade (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages: openAiMessages, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, messages: _toOpenAiMessages(messages), stream: false }, backend, cfg)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
const text = (await res.json()).choices?.[0]?.message?.content || "";
|
||||
return cfg.thinkingMode ? text : _stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,37 +202,30 @@ function updateAttachmentDisplay() {
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Status label (設定モーダルで選択中のbackend/modelを表示)
|
||||
// ============================================
|
||||
|
||||
function _updateStatusLabel() {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (!status) return;
|
||||
const { backend, model } = getPromptAiConfig();
|
||||
status.textContent = model ? `${backend}: ${model}` : `${backend}: ${t("selectModelFirst")}`;
|
||||
status.className = model ? "wfm-ollama-status connected" : "wfm-ollama-status error";
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Send Message
|
||||
// ============================================
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById("wfm-ollama-input");
|
||||
const modelSelect = document.getElementById("wfm-ollama-model");
|
||||
if (!input || !modelSelect) return;
|
||||
if (!input) return;
|
||||
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
// バックエンドが変更されていたらモデル一覧を自動更新
|
||||
const { backend } = getPromptAiConfig();
|
||||
if (backend !== _promptAiBackendForModels) {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = `Switching to ${backend}...`; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
modelSelect.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) { status.textContent = `${models.length} models [${backend}]`; status.className = "wfm-ollama-status connected"; }
|
||||
} catch {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
}
|
||||
|
||||
const model = modelSelect.value;
|
||||
const { model } = getPromptAiConfig();
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
return;
|
||||
@@ -259,13 +262,12 @@ async function sendMessage() {
|
||||
|
||||
async function sendTranslate(direction) {
|
||||
const input = document.getElementById("wfm-ollama-input");
|
||||
const modelSelect = document.getElementById("wfm-ollama-model");
|
||||
if (!input || !modelSelect) return;
|
||||
if (!input) return;
|
||||
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const model = modelSelect.value;
|
||||
const { model } = getPromptAiConfig();
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
return;
|
||||
@@ -318,48 +320,11 @@ function applyToGenerateUI(text) {
|
||||
// ============================================
|
||||
|
||||
export function initAiChatUI() {
|
||||
// AI model refresh
|
||||
async function refreshModels() {
|
||||
const select = document.getElementById("wfm-ollama-model");
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
const { backend } = getPromptAiConfig();
|
||||
try {
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
if (select) {
|
||||
select.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
}
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) {
|
||||
status.textContent = `${models.length} models [${backend}]`;
|
||||
status.className = "wfm-ollama-status connected";
|
||||
}
|
||||
} catch {
|
||||
if (status) {
|
||||
status.textContent = t("failedConnect");
|
||||
status.className = "wfm-ollama-status error";
|
||||
}
|
||||
}
|
||||
}
|
||||
_updateStatusLabel();
|
||||
|
||||
// Auto-load models on init
|
||||
refreshModels();
|
||||
|
||||
document.getElementById("wfm-ollama-refresh-btn")?.addEventListener("click", refreshModels);
|
||||
|
||||
// Test connection
|
||||
document.getElementById("wfm-ollama-test-btn")?.addEventListener("click", async () => {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = "Testing..."; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
await fetchAiModels();
|
||||
if (status) { status.textContent = t("connectedCheck"); status.className = "wfm-ollama-status connected"; }
|
||||
await refreshModels();
|
||||
} catch {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
// Settings modal
|
||||
document.getElementById("wfm-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(PROMPT_AI_KEY, t("settings"), _updateStatusLabel);
|
||||
});
|
||||
|
||||
// Send message
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
|
||||
|
||||
import { getSettings, readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { getSettings, readJsonStorage } from "./util.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
@@ -336,6 +336,34 @@ export function applyTextareaFontSize(size) {
|
||||
el.textContent = `${TEXTAREA_FONT_SIZE_TARGETS} { font-size: ${px}px !important; }`;
|
||||
}
|
||||
|
||||
// Video playback volume — shared by every <video> player in the app (Video tab preview,
|
||||
// Gallery detail/lightbox/compare). The browser default for a freshly created <video> element
|
||||
// is always 100%, and Gallery's players are recreated from scratch each time a different video
|
||||
// is opened, so without this the volume silently jumps back to max on every video switch.
|
||||
const VIDEO_VOLUME_DEFAULT = 1;
|
||||
|
||||
export function getVideoVolume() {
|
||||
const v = loadLocalSettings().videoVolume;
|
||||
return typeof v === "number" && v >= 0 && v <= 1 ? v : VIDEO_VOLUME_DEFAULT;
|
||||
}
|
||||
|
||||
function setVideoVolume(volume) {
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
saveLocalSettings({ videoVolume: clamped });
|
||||
return clamped;
|
||||
}
|
||||
|
||||
// Applies the saved volume to a <video> element, and keeps it in sync with any further change
|
||||
// the user makes via that element's own native controls — so adjusting the volume on any video
|
||||
// player anywhere in the app is remembered for the next video opened anywhere else.
|
||||
export function applyStoredVideoVolume(videoEl) {
|
||||
if (!videoEl) return;
|
||||
videoEl.volume = getVideoVolume();
|
||||
if (videoEl.dataset.wfmVolumeWired) return;
|
||||
videoEl.dataset.wfmVolumeWired = "1";
|
||||
videoEl.addEventListener("volumechange", () => setVideoVolume(videoEl.volume));
|
||||
}
|
||||
|
||||
// JSON highlight color settings
|
||||
const JSON_COLOR_STYLE_ID = "wfm-json-color-style";
|
||||
|
||||
@@ -378,7 +406,7 @@ export function applyModelTabActiveColor(color) {
|
||||
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active { color: ${val}; }`;
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active,\n.wfm-video-plan-content label.wfm-model-label-active { color: ${val}; }`;
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
@@ -575,6 +603,24 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Video Volume -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("videoVolumeLabel") || "Video Volume"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>${t("videoVolumeLabel") || "Video Volume"}</span>
|
||||
<span id="wfm-video-volume-val" style="font-size:12px;color:var(--wfm-text-secondary);">${Math.round(getVideoVolume() * 100)}%</span>
|
||||
</label>
|
||||
<input type="range" class="wfm-range" id="wfm-video-volume"
|
||||
min="0" max="100" step="1"
|
||||
value="${Math.round(getVideoVolume() * 100)}"
|
||||
style="width:100%;margin-top:4px;">
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("videoVolumeHint") || "Applies to: Video tab preview, Gallery detail/lightbox/compare video players"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- RAW JSON Colors -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("jsonColorsSection") || "RAW JSON Colors"}</summary>
|
||||
@@ -691,44 +737,6 @@ export async function initSettingsTab() {
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-default-ckpt-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- AI Assistant Settings (Prompt Tab) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("aiSettingsBackend")}</label>
|
||||
<div class="wfm-ai-backend-row">
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="ollama"> Ollama
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaUrl")}</label>
|
||||
<input type="text" class="wfm-input" id="wfm-settings-prompt-ai-url"
|
||||
placeholder="http://localhost:11434">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaDefaultModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-prompt-ai-model" style="flex:1;">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-refresh">${t("refresh")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-prompt-ai-save">${t("saveOllama")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-prompt-ai-test">${t("testConnection")}</button>
|
||||
<span id="wfm-settings-prompt-ai-status" style="font-size:12px;line-height:28px;"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- CivitAI Settings -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("civitaiApiKeySetting")}</summary>
|
||||
@@ -1158,92 +1166,6 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- AI Assistant (Prompt Tab) settings ---
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
const loadPromptAiCfg = () => {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
};
|
||||
const savePromptAiCfg = (patch) => {
|
||||
const data = { ...loadPromptAiCfg(), ...patch };
|
||||
localStorage.setItem(PROMPT_AI_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Restore saved values into UI
|
||||
const promptAiSaved = loadPromptAiCfg();
|
||||
const promptAiBackendSaved = promptAiSaved.backend || "ollama";
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.checked = r.value === promptAiBackendSaved;
|
||||
});
|
||||
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
|
||||
if (promptAiUrlEl) {
|
||||
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
|
||||
promptAiUrlEl.placeholder = getAiBackendDefaultUrl(promptAiBackendSaved);
|
||||
}
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
|
||||
});
|
||||
});
|
||||
|
||||
// Load models directly from the selected backend
|
||||
async function loadPromptAiModels() {
|
||||
const select = document.getElementById("wfm-settings-prompt-ai-model");
|
||||
if (!select) return;
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
try {
|
||||
let models = [];
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
models = (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
models = ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
const saved = loadPromptAiCfg().model || "";
|
||||
select.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === saved ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
} catch {
|
||||
select.innerHTML = `<option value="">${t("failedLoadModels")}</option>`;
|
||||
}
|
||||
}
|
||||
await loadPromptAiModels();
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-refresh")?.addEventListener("click", loadPromptAiModels);
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
|
||||
try {
|
||||
let count = 0;
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
count = ((await res.json()).models || []).length;
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
count = ((await res.json()).data || []).length;
|
||||
}
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaConnected")} (${count} models)`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
await loadPromptAiModels();
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaFailed")}: ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-settings-prompt-ai-save")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = promptAiUrlEl?.value.trim() || "";
|
||||
const model = document.getElementById("wfm-settings-prompt-ai-model")?.value || "";
|
||||
savePromptAiCfg({ backend, backendUrl: url, model });
|
||||
showToast(t("ollamaSaved"), "success");
|
||||
});
|
||||
|
||||
// CivitAI host select
|
||||
document.getElementById("wfm-settings-civitai-host")?.addEventListener("change", async (e) => {
|
||||
const host = e.target.value;
|
||||
@@ -1361,6 +1283,16 @@ export async function initSettingsTab() {
|
||||
applyTextareaFontSize(px);
|
||||
});
|
||||
|
||||
// --- Video volume slider (auto-saves immediately, no "Save" click needed — a dragged
|
||||
// slider that silently didn't persist would defeat the whole point of this setting) ---
|
||||
document.getElementById("wfm-video-volume")?.addEventListener("input", (e) => {
|
||||
const pct = parseInt(e.target.value) || 0;
|
||||
const valEl = document.getElementById("wfm-video-volume-val");
|
||||
if (valEl) valEl.textContent = `${pct}%`;
|
||||
const volume = setVideoVolume(pct / 100);
|
||||
document.querySelectorAll("video").forEach((v) => { v.volume = volume; });
|
||||
});
|
||||
|
||||
// --- JSON highlight color pickers ---
|
||||
document.getElementById("wfm-json-color-grid")?.addEventListener("input", (e) => {
|
||||
if (e.target.type !== "color") return;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Tagger Tab
|
||||
* Handles WD Tagger / DeepDanbooru + Ollama VLM single/batch tagging.
|
||||
* Handles WD Tagger / DeepDanbooru + VLM (Ollama/LM Studio/Lemonade/Unsloth) single/batch tagging.
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { openAiBackendSettingsModal, getAiBackendConfig } from "./ai-settings-modal.js";
|
||||
|
||||
const TAGGER_AI_KEY = "wfm_tagger_ai_settings";
|
||||
function getTaggerAiConfig() {
|
||||
return getAiBackendConfig(TAGGER_AI_KEY);
|
||||
}
|
||||
|
||||
// ── 状態 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,7 +38,7 @@ export function initTaggerTab() {
|
||||
_setupBatchActions();
|
||||
_setupDbActions();
|
||||
_loadModels();
|
||||
_loadSettings();
|
||||
_updateVlmBackendIndicators();
|
||||
}
|
||||
|
||||
/** GalleryタブからTaggerタブへ画像を渡す */
|
||||
@@ -74,8 +80,6 @@ function _applyI18n() {
|
||||
"wfm-tagger-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-generate-btn": "taggerGenerateTags",
|
||||
"wfm-tagger-ollama-header-label": "taggerOllamaEnable",
|
||||
"wfm-tagger-ollama-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-ollama-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-ollama-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-ollama-max-label": "taggerOllamaMaxTags",
|
||||
"wfm-tagger-result-header": "taggerResult",
|
||||
@@ -87,8 +91,7 @@ function _applyI18n() {
|
||||
"wfm-tagger-batch-folder-header": "taggerBatchFolder",
|
||||
"wfm-tagger-batch-threshold-label": "taggerThreshold",
|
||||
"wfm-tagger-batch-char-threshold-label": "taggerCharThreshold",
|
||||
"wfm-tagger-batch-url-label": "taggerOllamaUrl",
|
||||
"wfm-tagger-batch-model-label": "taggerOllamaModel",
|
||||
"wfm-tagger-batch-ollama-header-label": "taggerOllamaEnable",
|
||||
"wfm-tagger-batch-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-batch-output-header": "taggerBatchOutput",
|
||||
"wfm-tagger-batch-save-db-label": "taggerSaveToDB",
|
||||
@@ -107,12 +110,18 @@ function _applyI18n() {
|
||||
"wfm-tagger-db-edit-vlm-label": "taggerDbVlm",
|
||||
"wfm-tagger-db-save-btn": "taggerDbSave",
|
||||
"wfm-tagger-db-delete-btn": "taggerDbDelete",
|
||||
"wfm-tagger-db-delete-selected-btn": "taggerDbDelete"
|
||||
};
|
||||
for (const [id, key] of Object.entries(map)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
["wfm-tagger-ollama-settings-btn", "wfm-tagger-batch-ollama-settings-btn"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = t("settings");
|
||||
});
|
||||
|
||||
// サブタブボタン
|
||||
const subtabKeys = ["taggerSingleTab", "taggerBatchTab", "taggerDbTab"];
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach((btn, i) => {
|
||||
@@ -168,6 +177,24 @@ function _setupOllamaToggle() {
|
||||
});
|
||||
wrap.style.display = chk.checked ? "" : "none";
|
||||
}
|
||||
|
||||
document.getElementById("wfm-tagger-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(TAGGER_AI_KEY, t("settings"), _updateVlmBackendIndicators);
|
||||
});
|
||||
document.getElementById("wfm-tagger-batch-ollama-settings-btn")?.addEventListener("click", () => {
|
||||
openAiBackendSettingsModal(TAGGER_AI_KEY, t("settings"), _updateVlmBackendIndicators);
|
||||
});
|
||||
}
|
||||
|
||||
// ── VLMバックエンド表示 (Taggerタブ独自のAI設定を参照) ────────────
|
||||
|
||||
function _updateVlmBackendIndicators() {
|
||||
const { backend, model } = getTaggerAiConfig();
|
||||
const label = `${backend}: ${model || t("taggerNoModel")}`;
|
||||
const singleEl = document.getElementById("wfm-tagger-ollama-backend-value");
|
||||
if (singleEl) singleEl.textContent = label;
|
||||
const batchEl = document.getElementById("wfm-tagger-batch-backend-value");
|
||||
if (batchEl) batchEl.textContent = label;
|
||||
}
|
||||
|
||||
// ── モデル一覧ロード ─────────────────────────────────────────────
|
||||
@@ -188,61 +215,6 @@ async function _loadModels() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ollamaモデル一覧ロード ───────────────────────────────────────
|
||||
|
||||
async function _loadOllamaModels(urlId, selId) {
|
||||
const urlEl = document.getElementById(urlId);
|
||||
const selEl = document.getElementById(selId);
|
||||
if (!urlEl || !selEl) return;
|
||||
const api = urlEl.value || "http://127.0.0.1:11434";
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/ollama/models?api_url=${encodeURIComponent(api)}`);
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
if (models.length === 0) {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
} else {
|
||||
selEl.innerHTML = models.map(m => `<option value="${m}">${m}</option>`).join("");
|
||||
}
|
||||
} catch {
|
||||
selEl.innerHTML = `<option value="">(${t("taggerNoModel")})</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 設定の読み込み/保存 ─────────────────────────────────────────
|
||||
|
||||
async function _loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/settings");
|
||||
const data = await res.json();
|
||||
if (data.ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-ollama-url");
|
||||
if (el) el.value = data.ollama_url;
|
||||
}
|
||||
if (data.batch_ollama_url) {
|
||||
const el = document.getElementById("wfm-tagger-batch-ollama-url");
|
||||
if (el) el.value = data.batch_ollama_url;
|
||||
}
|
||||
} catch {
|
||||
// 初回は無視
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveSettings() {
|
||||
try {
|
||||
await fetch("/wfm/tagger/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ollama_url: document.getElementById("wfm-tagger-ollama-url")?.value || "",
|
||||
batch_ollama_url: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "",
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// サイレントに失敗
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single サブタブ ─────────────────────────────────────────────
|
||||
|
||||
function _setupSingleActions() {
|
||||
@@ -279,11 +251,6 @@ function _setupSingleActions() {
|
||||
// タグ生成
|
||||
document.getElementById("wfm-tagger-generate-btn")?.addEventListener("click", _generateTags);
|
||||
|
||||
// Ollamaリフレッシュ
|
||||
document.getElementById("wfm-tagger-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-ollama-url", "wfm-tagger-ollama-model");
|
||||
});
|
||||
|
||||
// 結果アクション
|
||||
document.getElementById("wfm-tagger-send-genui-btn")?.addEventListener("click", _sendToGenUI);
|
||||
document.getElementById("wfm-tagger-send-prompt-btn")?.addEventListener("click", _sendToPrompt);
|
||||
@@ -321,8 +288,7 @@ async function _generateTags() {
|
||||
const threshold = parseFloat(document.getElementById("wfm-tagger-threshold")?.value || "0.35");
|
||||
const charThreshold = parseFloat(document.getElementById("wfm-tagger-char-threshold")?.value || "0.85");
|
||||
const useOllama = document.getElementById("wfm-tagger-ollama-enable")?.checked || false;
|
||||
const ollamaUrl = document.getElementById("wfm-tagger-ollama-url")?.value || "http://127.0.0.1:11434";
|
||||
const ollamaModel = document.getElementById("wfm-tagger-ollama-model")?.value || "";
|
||||
const { backend: vlmBackend, url: vlmUrl, model: ollamaModel, thinkingMode, maxTokens } = getTaggerAiConfig();
|
||||
const ollamaPrompt = document.getElementById("wfm-tagger-ollama-prompt")?.value || "";
|
||||
const maxTags = parseInt(document.getElementById("wfm-tagger-ollama-max-tags")?.value || "40");
|
||||
|
||||
@@ -344,12 +310,13 @@ async function _generateTags() {
|
||||
}
|
||||
|
||||
if (useOllama && ollamaModel) {
|
||||
const res2 = await fetch("/wfm/tagger/ollama/predict", {
|
||||
const res2 = await fetch("/wfm/tagger/vlm/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
image_b64: state.imageB64, api_url: ollamaUrl,
|
||||
image_b64: state.imageB64, backend: vlmBackend, api_url: vlmUrl,
|
||||
model: ollamaModel, prompt: ollamaPrompt, max_tags: maxTags,
|
||||
thinking_mode: thinkingMode, max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
@@ -361,7 +328,6 @@ async function _generateTags() {
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
document.getElementById("wfm-tagger-result").value = tags;
|
||||
showToast(t("taggerDone"), "success");
|
||||
_saveSettings();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
} finally {
|
||||
@@ -453,10 +419,6 @@ async function _saveToDb() {
|
||||
// ── Batch サブタブ ───────────────────────────────────────────────
|
||||
|
||||
function _setupBatchActions() {
|
||||
document.getElementById("wfm-tagger-batch-ollama-refresh-btn")?.addEventListener("click", () => {
|
||||
_loadOllamaModels("wfm-tagger-batch-ollama-url", "wfm-tagger-batch-ollama-model");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-tagger-batch-start-btn")?.addEventListener("click", _batchStart);
|
||||
document.getElementById("wfm-tagger-batch-stop-btn")?.addEventListener("click", _batchStop);
|
||||
}
|
||||
@@ -465,15 +427,19 @@ async function _batchStart() {
|
||||
const folder = document.getElementById("wfm-tagger-batch-folder")?.value || "";
|
||||
if (!folder) { showToast(t("taggerBatchFolder"), "warning"); return; }
|
||||
|
||||
const { backend: vlmBackend, url: vlmUrl, model: vlmModel, thinkingMode, maxTokens } = getTaggerAiConfig();
|
||||
const body = {
|
||||
folder,
|
||||
model: document.getElementById("wfm-tagger-batch-model")?.value || "",
|
||||
threshold: parseFloat(document.getElementById("wfm-tagger-batch-threshold")?.value || "0.35"),
|
||||
char_threshold: parseFloat(document.getElementById("wfm-tagger-batch-char-threshold")?.value || "0.85"),
|
||||
use_ollama: document.getElementById("wfm-tagger-batch-ollama-enable")?.checked || false,
|
||||
ollama_api: document.getElementById("wfm-tagger-batch-ollama-url")?.value || "http://127.0.0.1:11434",
|
||||
ollama_model: document.getElementById("wfm-tagger-batch-ollama-model")?.value || "",
|
||||
vlm_backend: vlmBackend,
|
||||
vlm_api_url: vlmUrl,
|
||||
ollama_model: vlmModel,
|
||||
ollama_prompt: document.getElementById("wfm-tagger-batch-ollama-prompt")?.value || "",
|
||||
thinking_mode: thinkingMode,
|
||||
max_tokens: maxTokens,
|
||||
save_db: document.getElementById("wfm-tagger-batch-save-db")?.checked ?? true,
|
||||
write_file: document.getElementById("wfm-tagger-batch-write-file")?.checked || false,
|
||||
write_txt: document.getElementById("wfm-tagger-batch-write-txt")?.checked || false,
|
||||
@@ -549,6 +515,8 @@ function _setupDbActions() {
|
||||
});
|
||||
document.getElementById("wfm-tagger-db-save-btn")?.addEventListener("click", _dbSave);
|
||||
document.getElementById("wfm-tagger-db-delete-btn")?.addEventListener("click", _dbDelete);
|
||||
document.getElementById("wfm-tagger-db-select").addEventListener("click", _dbToggleSelection);
|
||||
document.getElementById("wfm-tagger-db-delete-selected-btn").addEventListener("click", _dbDeleteSelected);
|
||||
}
|
||||
|
||||
async function _dbLoad() {
|
||||
@@ -576,12 +544,20 @@ async function _dbSearch() {
|
||||
function _renderDbTable(rows) {
|
||||
const tbody = document.getElementById("wfm-tagger-db-tbody");
|
||||
if (!tbody) return;
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;padding:20px;color:var(--wfm-text-secondary)">${t("taggerDbEmpty")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(row => `
|
||||
<tr data-id="${row.id}" title="${_esc(row.all_tags)}">
|
||||
<td class="wfm-tagger-db-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-id="${row.id}"
|
||||
>
|
||||
</td>
|
||||
<td>${row.id}</td>
|
||||
<td>${_esc(row.filename)}</td>
|
||||
<td>${_esc(_truncate(row.interrogator_tags, 60))}</td>
|
||||
@@ -591,9 +567,13 @@ function _renderDbTable(rows) {
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(tr => {
|
||||
tr.addEventListener("click", () => {
|
||||
tr.addEventListener("click", e => {
|
||||
// チェックボックスをクリックした場合は行選択を発生させない
|
||||
if (e.target.closest(".wfm-tagger-db-select")) return;
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(r => r.classList.remove("selected"));
|
||||
tr.classList.add("selected");
|
||||
|
||||
const row = rows.find(r => r.id === parseInt(tr.dataset.id));
|
||||
if (row) _openDbEdit(row);
|
||||
});
|
||||
@@ -655,6 +635,55 @@ function _dbExport() {
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function _dbDeleteSelected() {
|
||||
const ids = _getSelectedDbIds();
|
||||
|
||||
if (!ids.length) {
|
||||
showToast(t("taggerDbNoSelection"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(t("taggerDbDeleteConfirm", ids.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/db", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
showToast(t("taggerDbDeletedCount", data.count), "success");
|
||||
_dbLoad();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _getSelectedDbIds() {
|
||||
return [...document.querySelectorAll(".wfm-tagger-db-select input:checked")]
|
||||
.map(el => parseInt(el.dataset.id))
|
||||
.filter(Number.isInteger);
|
||||
}
|
||||
|
||||
function _dbToggleSelection(e) {
|
||||
const checked = e.target.checked;
|
||||
const checkboxes = document.querySelectorAll(
|
||||
'#wfm-tagger-db-tbody input[type="checkbox"]'
|
||||
);
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
// ── ユーティリティ ───────────────────────────────────────────────
|
||||
|
||||
function _esc(str) {
|
||||
|
||||
@@ -109,6 +109,52 @@ export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
// Unslothはローカルでも常にAPIキーが必要なため、フロントから直接叩かず
|
||||
// サーバー側プロキシ(/api/wfm/unsloth/proxy)経由でキーを付与して中継する
|
||||
// (see py/routes/unsloth_routes.py)
|
||||
export async function unslothProxy(baseUrl, path, method, payload) {
|
||||
const res = await fetch("/api/wfm/unsloth/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, path, method, payload }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ローカルVLM/LLMバックエンドからモデルをVRAM/メモリからアンロードする。
|
||||
// 公式APIが存在しない場合(Unsloth)は "UNSUPPORTED" エラーを投げる。
|
||||
export async function unloadAiModel(url, backend, model) {
|
||||
if (!model) throw new Error("No model selected");
|
||||
if (backend === "ollama") {
|
||||
// keep_alive:0 + 空プロンプトが公式に文書化されたアンロード方法
|
||||
// (専用エンドポイントは存在しない) https://docs.ollama.com/api
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt: "", keep_alive: 0 }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else if (backend === "lmstudio") {
|
||||
const res = await fetch(`${url}/api/v1/models/unload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ instance_id: model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else if (backend === "lemonade") {
|
||||
const res = await fetch(`${url}/api/v1/unload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: model }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else {
|
||||
throw new Error("UNSUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Video Tab - Asset subtab: browses the videos a Plan run has generated.
|
||||
*
|
||||
* Deliberately thin — it reuses Gallery's existing backend (groups, tags/memo
|
||||
* sidecar, thumbnailing) exactly like feeder-tab.js's own Gallery mode does,
|
||||
* rather than re-implementing folder browsing, bulk selection, etc. Anything
|
||||
* beyond basic browsing/tagging (move, export, delete, multi-select) is left
|
||||
* to the real Gallery tab via the "Open in Gallery" button.
|
||||
*/
|
||||
|
||||
import { VIDEO_GROUP, VTEMP_GROUP, ensureVideoGroup, isVideoFile } from "./gallery-tab.js";
|
||||
import { setSourcePreview } from "./video-preview.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { addClipFromFile } from "./video-edit-tab.js";
|
||||
import { setBlockImageFromFile } from "./video-plan-tab.js";
|
||||
|
||||
// Sentinel for the "All Video Assets" option — not a real backend group, since
|
||||
// Gallery's group filter only ever matches one group at a time. Selecting it
|
||||
// fetches VIDEO_GROUP and VTEMP_GROUP separately and merges them (see _loadImages).
|
||||
const ALL_GROUPS_VALUE = "__video_asset_all__";
|
||||
|
||||
// Display names are now identical to the underlying group names (minus the
|
||||
// "__...__" wrapping) — no more per-plan "VideoPlan:<name>" sub-groups, so the
|
||||
// dropdown is always exactly these three fixed entries.
|
||||
const _GROUPS = [ALL_GROUPS_VALUE, VIDEO_GROUP, VTEMP_GROUP];
|
||||
|
||||
const _s = {
|
||||
outputDir: "",
|
||||
group: ALL_GROUPS_VALUE,
|
||||
images: [],
|
||||
selectedPath: null,
|
||||
loaded: false, // becomes true once the Asset subtab has been shown at least once
|
||||
searchQuery: "",
|
||||
viewMode: "grid", // "grid" | "table"
|
||||
kindFilter: "all", // "all" | "video" | "image"
|
||||
};
|
||||
|
||||
function _formatDate(mtime) {
|
||||
return new Date(mtime * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function _filteredImages() {
|
||||
const q = _s.searchQuery.trim().toLowerCase();
|
||||
let list = _s.images;
|
||||
if (_s.kindFilter === "video") list = list.filter((img) => isVideoFile(img));
|
||||
else if (_s.kindFilter === "image") list = list.filter((img) => !isVideoFile(img));
|
||||
if (q) list = list.filter((img) => img.filename.toLowerCase().includes(q));
|
||||
return list;
|
||||
}
|
||||
|
||||
async function _fetchOutputDir() {
|
||||
if (_s.outputDir) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_s.outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
function _groupLabel(g) {
|
||||
if (g === ALL_GROUPS_VALUE) return "All Video Assets";
|
||||
if (g === VIDEO_GROUP) return "Video Assets";
|
||||
if (g === VTEMP_GROUP) return "Video Temp";
|
||||
return g;
|
||||
}
|
||||
|
||||
function _renderGroupSelect() {
|
||||
const sel = document.getElementById("wfm-video-asset-group");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = _GROUPS
|
||||
.map((g) => `<option value="${g}"${g === _s.group ? " selected" : ""}>${_groupLabel(g)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function _fetchGroupImages(group) {
|
||||
const params = new URLSearchParams({ folder: _s.outputDir, group, recursive: "true", sort: "date_desc" });
|
||||
const res = await fetch(`/wfm/gallery/images?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
return data.images || [];
|
||||
}
|
||||
|
||||
async function _loadImages() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const statusEl = document.getElementById("wfm-video-asset-status");
|
||||
if (!grid) return;
|
||||
await _fetchOutputDir();
|
||||
if (!_s.outputDir) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No output folder configured</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = `<div class="wfm-placeholder">Loading...</div>`;
|
||||
try {
|
||||
let images;
|
||||
if (_s.group === ALL_GROUPS_VALUE) {
|
||||
// "All Video Assets" = the union of the curated __Video Assets__
|
||||
// group and __Video Temp__ (auto-populated by every plan run,
|
||||
// organized into __Video Assets__ by the user on their own
|
||||
// schedule) — not a single backend group filter, so fetch both
|
||||
// and merge.
|
||||
const [curated, temp] = await Promise.all([
|
||||
_fetchGroupImages(VIDEO_GROUP),
|
||||
_fetchGroupImages(VTEMP_GROUP),
|
||||
]);
|
||||
const byPath = new Map();
|
||||
for (const img of [...curated, ...temp]) byPath.set(img.path, img);
|
||||
images = Array.from(byPath.values()).sort((a, b) => b.mtime - a.mtime);
|
||||
} else {
|
||||
images = await _fetchGroupImages(_s.group);
|
||||
}
|
||||
_s.images = images;
|
||||
_renderList();
|
||||
if (statusEl) statusEl.textContent = `${_s.images.length} video(s)`;
|
||||
} catch (err) {
|
||||
_s.images = [];
|
||||
grid.innerHTML = `<div class="wfm-placeholder" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Renders whichever of grid/table is active for the current viewMode — called
|
||||
// on load, on search input, and after switching view modes.
|
||||
function _renderList() {
|
||||
if (_s.viewMode === "table") _renderTable();
|
||||
else _renderGrid();
|
||||
}
|
||||
|
||||
function _renderGrid() {
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
if (!grid) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-placeholder">No videos yet</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
for (const img of images) grid.appendChild(_makeCard(img));
|
||||
}
|
||||
|
||||
function _renderTable() {
|
||||
const body = document.getElementById("wfm-video-asset-table-body");
|
||||
if (!body) return;
|
||||
const images = _filteredImages();
|
||||
if (images.length === 0) {
|
||||
body.innerHTML = `<tr><td colspan="3" class="wfm-placeholder">No videos yet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
for (const img of images) body.appendChild(_makeRow(img));
|
||||
}
|
||||
|
||||
function _makeRow(img) {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "wfm-video-asset-row" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
row.dataset.path = img.path;
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.textContent = img.filename;
|
||||
nameCell.title = img.filename;
|
||||
|
||||
const dateCell = document.createElement("td");
|
||||
dateCell.textContent = _formatDate(img.mtime);
|
||||
|
||||
const tagsCell = document.createElement("td");
|
||||
tagsCell.textContent = (img.tags || []).join(", ");
|
||||
|
||||
row.append(nameCell, dateCell, tagsCell);
|
||||
row.addEventListener("click", () => _selectImage(img));
|
||||
return row;
|
||||
}
|
||||
|
||||
function _makeCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-video-asset-card" + (img.path === _s.selectedPath ? " selected" : "");
|
||||
card.dataset.path = img.path;
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "wfm-video-asset-card-img";
|
||||
thumb.loading = "lazy";
|
||||
thumb.src = `/wfm/gallery/image/thumb?path=${encodeURIComponent(img.path)}&w=200`;
|
||||
thumb.onerror = () => { thumb.style.display = "none"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-video-asset-card-name";
|
||||
name.textContent = img.filename;
|
||||
name.title = img.filename;
|
||||
|
||||
card.append(thumb, name);
|
||||
card.addEventListener("click", () => _selectImage(img));
|
||||
return card;
|
||||
}
|
||||
|
||||
function _selectImage(img) {
|
||||
_s.selectedPath = img.path;
|
||||
document.querySelectorAll(".wfm-video-asset-card, .wfm-video-asset-row").forEach((c) => {
|
||||
c.classList.toggle("selected", c.dataset.path === img.path);
|
||||
});
|
||||
_renderDetail(img);
|
||||
_loadIntoSourcePreview(img);
|
||||
}
|
||||
|
||||
// Feeds the selected asset's media into the center panel's Asset/Source preview
|
||||
// pane (see video-preview.js) so it's visible without switching tabs, and so the
|
||||
// Frame/GIF property tools can operate on it just like a Video Source drop.
|
||||
// Fetched as a Blob and wrapped as a "local" source rather than trying to map
|
||||
// Gallery's arbitrary absolute path onto ComfyUI's filename/subfolder/type
|
||||
// triple — that keeps the GIF tool's existing upload-on-first-use path working
|
||||
// unchanged for both cases. Still/animated images (this group isn't exclusively
|
||||
// videos — anything tagged into it shows up here) preview via the pane's <img>
|
||||
// companion element instead of trying to play them as a <video>.
|
||||
async function _loadIntoSourcePreview(img) {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const isVideo = isVideoFile(img);
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideo ? "video/mp4" : "image/png") });
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file }, isVideo ? "video" : "image");
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] failed to load preview:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDetail(img) {
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (!panel) return;
|
||||
// Filename/memo are untrusted (filesystem names, user-entered text) — the
|
||||
// markup skeleton is static, and those two values are assigned afterward
|
||||
// via textContent/.value, never interpolated into the HTML string itself.
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-video-asset-name" id="wfm-video-asset-name"></div>
|
||||
<label>Tags</label>
|
||||
<div class="wfm-video-asset-tags" id="wfm-video-asset-tags"></div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<input type="text" id="wfm-video-asset-tag-input" class="wfm-input" placeholder="Add tag" style="flex:1;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-tag-add">Add</button>
|
||||
</div>
|
||||
<label style="margin-top:10px;">Memo</label>
|
||||
<textarea id="wfm-video-asset-memo" class="wfm-textarea" rows="3"></textarea>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-memo-save" style="margin-top:6px;">Save Memo</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-send-to-edit" style="width:100%;margin-top:12px;">${t("videoEditSendToEdit")}</button>
|
||||
<div id="wfm-video-asset-set-plan-image-row" style="display:${isVideoFile(img) ? "none" : "flex"};gap:6px;margin-top:6px;">
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-first" style="flex:1;">${t("videoAssetSetAsFirst")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-last" style="flex:1;">${t("videoAssetSetAsLast")}</button>
|
||||
</div>
|
||||
<button type="button" class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-video-asset-open-gallery" style="width:100%;margin-top:6px;">Open in Gallery</button>
|
||||
`;
|
||||
|
||||
const nameEl = document.getElementById("wfm-video-asset-name");
|
||||
if (nameEl) { nameEl.textContent = img.filename; nameEl.title = img.filename; }
|
||||
const memoEl = document.getElementById("wfm-video-asset-memo");
|
||||
if (memoEl) memoEl.value = img.memo || "";
|
||||
|
||||
_renderTags(img);
|
||||
|
||||
panel.querySelector("#wfm-video-asset-tag-add")?.addEventListener("click", async () => {
|
||||
const input = panel.querySelector("#wfm-video-asset-tag-input");
|
||||
const tag = input?.value.trim();
|
||||
if (!tag) return;
|
||||
const tags = Array.from(new Set([...(img.tags || []), tag]));
|
||||
await _saveMeta(img.path, { tags });
|
||||
img.tags = tags;
|
||||
input.value = "";
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-memo-save")?.addEventListener("click", async () => {
|
||||
const memo = panel.querySelector("#wfm-video-asset-memo")?.value || "";
|
||||
await _saveMeta(img.path, { memo });
|
||||
img.memo = memo;
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-video-asset-send-to-edit")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || (isVideoFile(img) ? "video/mp4" : "image/png") });
|
||||
addClipFromFile(file, img.filename);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoEditClipSent", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const setAsPlanImage = async (which) => {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
await setBlockImageFromFile(which, file);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="plan"]')?.click();
|
||||
showToast(t(which === "first" ? "videoAssetSetAsFirstDone" : "videoAssetSetAsLastDone", img.filename), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
};
|
||||
panel.querySelector("#wfm-video-asset-set-first")?.addEventListener("click", () => setAsPlanImage("first"));
|
||||
panel.querySelector("#wfm-video-asset-set-last")?.addEventListener("click", () => setAsPlanImage("last"));
|
||||
|
||||
panel.querySelector("#wfm-video-asset-open-gallery")?.addEventListener("click", () => {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
const filterSel = document.getElementById("wfm-gallery-group-filter");
|
||||
if (filterSel) {
|
||||
// ALL_GROUPS_VALUE is a virtual union with no Gallery-side equivalent
|
||||
// (Gallery's own filter only ever matches one real group) — fall back
|
||||
// to the curated group, the closer of the two to "everything worth keeping".
|
||||
filterSel.value = _s.group === ALL_GROUPS_VALUE ? VIDEO_GROUP : _s.group;
|
||||
filterSel.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _renderTags(img) {
|
||||
const container = document.getElementById("wfm-video-asset-tags");
|
||||
if (!container) return;
|
||||
const tags = img.tags || [];
|
||||
container.innerHTML = "";
|
||||
if (tags.length === 0) {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "wfm-placeholder";
|
||||
placeholder.textContent = "No tags";
|
||||
container.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
// Built via DOM APIs (not innerHTML) since tag text is user-entered —
|
||||
// textContent keeps it inert regardless of what characters it contains.
|
||||
for (const tag of tags) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "wfm-video-asset-tag";
|
||||
pill.append(document.createTextNode(`${tag} `));
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", async () => {
|
||||
const newTags = tags.filter((t2) => t2 !== tag);
|
||||
await _saveMeta(img.path, { tags: newTags });
|
||||
img.tags = newTags;
|
||||
_renderTags(img);
|
||||
});
|
||||
|
||||
pill.appendChild(removeBtn);
|
||||
container.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveMeta(path, data) {
|
||||
try {
|
||||
await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, ...data }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[VideoAsset] saveMeta failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Called whenever the Asset subtab becomes active (see video-tab.js's subtab
|
||||
// toggle) — always refetches rather than caching, so a Plan run's newly
|
||||
// generated videos show up as soon as the user switches over to look.
|
||||
export async function refreshVideoAssetTab() {
|
||||
if (!_s.loaded) {
|
||||
_renderGroupSelect();
|
||||
_s.loaded = true;
|
||||
}
|
||||
await _loadImages();
|
||||
}
|
||||
|
||||
function _setViewMode(mode) {
|
||||
_s.viewMode = mode;
|
||||
document.getElementById("wfm-video-asset-view-grid")?.classList.toggle("active", mode === "grid");
|
||||
document.getElementById("wfm-video-asset-view-table")?.classList.toggle("active", mode === "table");
|
||||
const grid = document.getElementById("wfm-video-asset-grid");
|
||||
const tableWrap = document.getElementById("wfm-video-asset-table-wrap");
|
||||
if (grid) grid.style.display = mode === "grid" ? "" : "none";
|
||||
if (tableWrap) tableWrap.style.display = mode === "table" ? "" : "none";
|
||||
_renderList();
|
||||
}
|
||||
|
||||
export function initVideoAssetTab() {
|
||||
document.getElementById("wfm-video-asset-group")?.addEventListener("change", (e) => {
|
||||
_s.group = e.target.value;
|
||||
_s.selectedPath = null;
|
||||
const panel = document.getElementById("wfm-video-asset-detail");
|
||||
if (panel) panel.innerHTML = `<span class="wfm-placeholder">Select a video</span>`;
|
||||
_loadImages();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-refresh")?.addEventListener("click", () => _loadImages());
|
||||
document.getElementById("wfm-video-asset-kind")?.addEventListener("change", (e) => {
|
||||
_s.kindFilter = e.target.value;
|
||||
_renderList();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-search")?.addEventListener("input", (e) => {
|
||||
_s.searchQuery = e.target.value;
|
||||
_renderList();
|
||||
});
|
||||
document.getElementById("wfm-video-asset-view-grid")?.addEventListener("click", () => _setViewMode("grid"));
|
||||
document.getElementById("wfm-video-asset-view-table")?.addEventListener("click", () => _setViewMode("table"));
|
||||
|
||||
// Neither reserved group is ever auto-created by a batch run itself (see
|
||||
// video-plan-tab.js's _ensureVideoAssetGroups, which only ensures
|
||||
// __Video Temp__ right before a run starts), so a fresh install or a group
|
||||
// deleted via Gallery's Manage Groups would otherwise never come back on
|
||||
// its own — add_to_group() happily records membership on individual images
|
||||
// without ever re-registering the group itself. ensureVideoGroup() ensures
|
||||
// both __Video Assets__ and __Video Temp__ every time the Asset subtab inits.
|
||||
ensureVideoGroup();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Video Tab - shared state for the two center-panel preview panes:
|
||||
* "source" (#wfm-video-source-preview-video — an Asset selection or a
|
||||
* locally-dropped file) and "result" (#wfm-video-preview-video — the latest
|
||||
* generated clip). video-asset-tab.js (Asset selection), video-plan-tab.js
|
||||
* (batch run results) and video-tab.js (the Video Source drop zone, Frame/GIF
|
||||
* tools) all need to read or write these. Pulling this into its own module
|
||||
* lets all three depend on it without needing to import each other —
|
||||
* video-tab.js already imports both of the others for init/refresh, so a
|
||||
* reverse import from either back into video-tab.js would create a cycle.
|
||||
*
|
||||
* Each pane also has a companion <img> element (wfm-video-*-preview-img) so a
|
||||
* still/animated image (from Asset or a Video Source drop) can be previewed
|
||||
* without forcing it through a <video> tag, which can't render image formats
|
||||
* at all. mediaType controls which element is shown; Frame/GIF's
|
||||
* getActivePreviewVideoElement() returns null while a pane is showing an
|
||||
* image, since those tools only make sense against real video.
|
||||
*/
|
||||
|
||||
const _PANES = {
|
||||
source: { video: "wfm-video-source-preview-video", img: "wfm-video-source-preview-img", placeholder: "wfm-video-source-preview-placeholder" },
|
||||
result: { video: "wfm-video-preview-video", img: "wfm-video-preview-img", placeholder: "wfm-video-preview-placeholder" },
|
||||
};
|
||||
|
||||
const _objectUrls = { source: null, result: null };
|
||||
|
||||
// Whichever pane most recently received media is what the Frame/GIF property
|
||||
// tabs act on (see getActivePreviewSource/getActivePreviewVideoElement below) —
|
||||
// an Asset selection or Video Source drop activates "source", a batch run
|
||||
// finishing a block (or the Edit tab's export/preview) activates "result".
|
||||
let _activePane = null; // "source" | "result" | null
|
||||
let _activeSource = null;
|
||||
let _activeMediaType = null; // "video" | "image" | null — mirrors _activePane's current content
|
||||
|
||||
function _setPreview(pane, url, source, mediaType = "video") {
|
||||
const ids = _PANES[pane];
|
||||
const video = document.getElementById(ids.video);
|
||||
const img = document.getElementById(ids.img);
|
||||
const placeholder = document.getElementById(ids.placeholder);
|
||||
if (!video || !placeholder) return;
|
||||
|
||||
if (_objectUrls[pane]) {
|
||||
URL.revokeObjectURL(_objectUrls[pane]);
|
||||
_objectUrls[pane] = null;
|
||||
}
|
||||
if (url && url.startsWith("blob:")) _objectUrls[pane] = url;
|
||||
|
||||
if (url) {
|
||||
if (mediaType === "image") {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.style.display = "none";
|
||||
if (img) { img.src = url; img.style.display = "block"; }
|
||||
} else {
|
||||
if (img) { img.removeAttribute("src"); img.style.display = "none"; }
|
||||
video.src = url;
|
||||
video.style.display = "block";
|
||||
}
|
||||
placeholder.style.display = "none";
|
||||
_activePane = pane;
|
||||
_activeSource = source;
|
||||
_activeMediaType = mediaType;
|
||||
} else {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.style.display = "none";
|
||||
if (img) { img.removeAttribute("src"); img.style.display = "none"; }
|
||||
placeholder.style.display = "";
|
||||
if (_activePane === pane) { _activePane = null; _activeSource = null; _activeMediaType = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// Sets (or clears, with url=null) the Asset-selection/Video-Source-drop pane.
|
||||
// mediaType: "video" (default) or "image".
|
||||
export function setSourcePreview(url, source, mediaType = "video") { _setPreview("source", url, source, mediaType); }
|
||||
|
||||
// Sets (or clears) the generated-result pane — called once per block as a
|
||||
// batch run's video output comes in, and by the Edit tab's timeline preview
|
||||
// (which may show either a video clip or a held still image).
|
||||
export function setResultPreview(url, source, mediaType = "video") { _setPreview("result", url, source, mediaType); }
|
||||
|
||||
// What the Frame/GIF property tabs operate on: { kind: "output"|"input", filename,
|
||||
// subfolder, type } for anything already on the server, or { kind: "local", file }
|
||||
// for a picked/dropped file not yet uploaded. null if neither pane has media.
|
||||
export function getActivePreviewSource() { return _activeSource; }
|
||||
|
||||
// The GIF tab uploads a still-local source on first use and needs to remember
|
||||
// the resulting server-side reference so a repeat conversion doesn't re-upload —
|
||||
// this rewrites the active source in place without touching either <video> element.
|
||||
export function updateActivePreviewSourceRef(source) { _activeSource = source; }
|
||||
|
||||
// The <video> element the Frame/GIF tools should read/capture from, or null if
|
||||
// neither pane currently has a video loaded (including when the active pane is
|
||||
// showing a still image instead).
|
||||
export function getActivePreviewVideoElement() {
|
||||
if (!_activePane || _activeMediaType === "image") return null;
|
||||
return document.getElementById(_PANES[_activePane].video);
|
||||
}
|
||||
|
||||
// Both panes' <video> elements share the same persistent volume setting.
|
||||
export function getAllPreviewVideoElements() {
|
||||
return Object.values(_PANES).map((ids) => document.getElementById(ids.video)).filter(Boolean);
|
||||
}
|
||||
|
||||
// Direct access to the "result" pane's <video> element — used by
|
||||
// video-edit-tab.js's timeline preview, which needs to drive playback
|
||||
// (seek to a clip's trim-in, listen for the trim-out point, swap to the
|
||||
// next clip) rather than just set a src via setResultPreview().
|
||||
export function getResultPreviewVideoElement() {
|
||||
return document.getElementById(_PANES.result.video);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Video Tab - Sidebar "Project" subtab (next to Asset): browses saved Video
|
||||
* Plan files (ws_videoplan_*.json in video_plan/) OR saved Video Edit
|
||||
* projects (ws_videoeditproj_*.json in video_edit_project/), depending on
|
||||
* which center subtab (Plan/Edit) is currently active — video-tab.js passes
|
||||
* that mode in on every refresh (Asset stays on whichever of the two was
|
||||
* last active, since it has no saved-list concept of its own).
|
||||
*
|
||||
* Deliberately thin, mirroring video-asset-tab.js's own scope — clicking an
|
||||
* item hands off to video-plan-tab.js's openSavedVideoPlan() or
|
||||
* video-edit-tab.js's openSavedVideoEditProject() rather than
|
||||
* re-implementing plan/project loading here.
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { openSavedVideoPlan } from "./video-plan-tab.js";
|
||||
import { openSavedVideoEditProject } from "./video-edit-tab.js";
|
||||
|
||||
// Per-mode config: which endpoints to hit, which loader/toast strings to use,
|
||||
// and how to read each item's count field — keeps _renderList/_makeItem/etc.
|
||||
// mode-agnostic instead of branching throughout.
|
||||
const _MODES = {
|
||||
plan: {
|
||||
listUrl: "/api/wfm/video/plans",
|
||||
deleteUrl: "/api/wfm/video/plans/delete",
|
||||
open: (filename) => openSavedVideoPlan(filename),
|
||||
countOf: (item) => item.block_count ?? 0,
|
||||
countLabel: (n) => t("videoProjectBlockCount", n),
|
||||
hintKey: "videoProjectHint",
|
||||
emptyKey: "videoProjectEmpty",
|
||||
deleteConfirmKey: "videoProjectDeleteConfirm",
|
||||
deletedKey: "videoProjectDeleted",
|
||||
deleteFailedKey: "videoProjectDeleteFailed",
|
||||
loadListFailedKey: "videoProjectLoadListFailed",
|
||||
},
|
||||
edit: {
|
||||
listUrl: "/api/wfm/video/edit/projects",
|
||||
deleteUrl: "/api/wfm/video/edit/projects/delete",
|
||||
open: (filename) => openSavedVideoEditProject(filename),
|
||||
countOf: (item) => item.clip_count ?? 0,
|
||||
countLabel: (n) => t("videoEditProjectClipCount", n),
|
||||
hintKey: "videoEditProjectHint",
|
||||
emptyKey: "videoEditProjectEmpty",
|
||||
deleteConfirmKey: "videoEditProjectDeleteConfirm",
|
||||
deletedKey: "videoEditProjectDeleted",
|
||||
deleteFailedKey: "videoEditProjectDeleteFailed",
|
||||
loadListFailedKey: "videoEditProjectLoadListFailed",
|
||||
},
|
||||
};
|
||||
|
||||
const _s = {
|
||||
mode: "plan",
|
||||
plans: [],
|
||||
activeFilename: null,
|
||||
loaded: false, // becomes true once the Project subtab has been shown at least once
|
||||
};
|
||||
|
||||
function _formatUpdatedAt(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function _renderList() {
|
||||
const list = document.getElementById("wfm-video-project-list");
|
||||
if (!list) return;
|
||||
const cfg = _MODES[_s.mode];
|
||||
if (_s.plans.length === 0) {
|
||||
list.innerHTML = `<span class="wfm-placeholder">${t(cfg.emptyKey)}</span>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const plan of _s.plans) list.appendChild(_makeItem(plan));
|
||||
}
|
||||
|
||||
function _makeItem(plan) {
|
||||
const cfg = _MODES[_s.mode];
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-video-project-item" + (plan.filename === _s.activeFilename ? " active" : "");
|
||||
item.dataset.filename = plan.filename;
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "wfm-video-project-item-thumb";
|
||||
thumb.loading = "lazy";
|
||||
if (plan.thumbnail) thumb.src = plan.thumbnail;
|
||||
thumb.onerror = () => { thumb.style.visibility = "hidden"; };
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "wfm-video-project-item-info";
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-video-project-item-name";
|
||||
name.textContent = plan.name || plan.filename;
|
||||
name.title = plan.filename;
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "wfm-video-project-item-meta";
|
||||
const countLabel = cfg.countLabel(cfg.countOf(plan));
|
||||
const updated = _formatUpdatedAt(plan.updated_at);
|
||||
meta.textContent = updated ? `${countLabel} · ${updated}` : countLabel;
|
||||
meta.title = plan.note || "";
|
||||
|
||||
info.append(name, meta);
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.className = "wfm-video-project-item-delete";
|
||||
deleteBtn.textContent = "×";
|
||||
deleteBtn.title = t("delete");
|
||||
deleteBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_deletePlan(plan);
|
||||
});
|
||||
|
||||
item.append(thumb, info, deleteBtn);
|
||||
item.addEventListener("click", () => _openPlan(plan));
|
||||
return item;
|
||||
}
|
||||
|
||||
async function _openPlan(plan) {
|
||||
_s.activeFilename = plan.filename;
|
||||
_renderList();
|
||||
await _MODES[_s.mode].open(plan.filename);
|
||||
}
|
||||
|
||||
async function _deletePlan(plan) {
|
||||
const cfg = _MODES[_s.mode];
|
||||
if (!window.confirm(t(cfg.deleteConfirmKey, plan.name || plan.filename))) return;
|
||||
try {
|
||||
const res = await fetch(cfg.deleteUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: plan.filename }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
if (_s.activeFilename === plan.filename) _s.activeFilename = null;
|
||||
showToast(t(cfg.deletedKey), "success");
|
||||
await refreshVideoProjectTab(_s.mode);
|
||||
} catch (err) {
|
||||
showToast(`${t(cfg.deleteFailedKey)}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Called whenever the Project subtab becomes active (see video-tab.js's prop-tab
|
||||
// toggle) — always refetches rather than caching, so a plan/project saved
|
||||
// elsewhere (Plan pane's Save/Save As, Edit's Save/Save As) shows up as soon
|
||||
// as the user switches over to look. `mode` ("plan"|"edit") is video-tab.js's
|
||||
// record of which center subtab is currently active; defaults to "plan" so
|
||||
// direct calls (e.g. from initVideoProjectTab) keep the original behavior.
|
||||
export async function refreshVideoProjectTab(mode = "plan") {
|
||||
const list = document.getElementById("wfm-video-project-list");
|
||||
if (!list) return;
|
||||
_s.mode = mode === "edit" ? "edit" : "plan";
|
||||
_s.activeFilename = null;
|
||||
const cfg = _MODES[_s.mode];
|
||||
const hintEl = document.getElementById("wfm-video-project-hint");
|
||||
if (hintEl) hintEl.textContent = t(cfg.hintKey);
|
||||
try {
|
||||
const res = await fetch(cfg.listUrl);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
_s.plans = await res.json();
|
||||
_renderList();
|
||||
} catch (err) {
|
||||
list.innerHTML = `<span class="wfm-placeholder" style="color:var(--wfm-danger)">${escapeHtml(t(cfg.loadListFailedKey))}: ${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
_s.loaded = true;
|
||||
}
|
||||
|
||||
export function initVideoProjectTab() {
|
||||
document.getElementById("wfm-video-project-refresh")?.addEventListener("click", () => refreshVideoProjectTab());
|
||||
}
|
||||
@@ -1,187 +1,100 @@
|
||||
/**
|
||||
* Video Tab - MiniMax H3 (Image to Video) 専用の動画生成UI
|
||||
* GenerateUIタブとは完全に独立。UI形式ワークフローJSONを直接編集し、
|
||||
* comfyWorkflow.convertUiToApi() でAPI形式に変換して実行する。
|
||||
* 状態は「テンプレートJSON+フォーム値」のみ保持し、生成の都度
|
||||
* structuredClone() したコピーに書き込む(複数回生成をまたいだID不整合を防ぐ)。
|
||||
* Video Tab - top-level orchestration for the Plan / Asset / Edit subtabs, plus
|
||||
* the Frame-capture / GIF-conversion / Video-Source tools (kept exactly as they
|
||||
* were — they don't depend on which subtab is active and stay visible across
|
||||
* all three; see video-tab.css's persistent-panel layout).
|
||||
*
|
||||
* Batch generation (the former single-shot form) now lives in
|
||||
* video-plan-tab.js, and per-block workflow manipulation in video-workflow.js.
|
||||
* This file only re-exports loadWorkflowIntoVideoEditor so workflow-tab.js's
|
||||
* "Load in Video" button needs no changes.
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { applyStoredVideoVolume } from "./settings-tab.js";
|
||||
import { initVideoPlanTab, loadWorkflowIntoVideoEditor } from "./video-plan-tab.js";
|
||||
import { initVideoAssetTab, refreshVideoAssetTab } from "./video-asset-tab.js";
|
||||
import { initVideoProjectTab, refreshVideoProjectTab } from "./video-project-tab.js";
|
||||
import { initVideoEditTab, addClipFromFile } from "./video-edit-tab.js";
|
||||
import { VIDEO_GROUP, ensureVideoGroup } from "./gallery-tab.js";
|
||||
import {
|
||||
setSourcePreview, getActivePreviewSource, updateActivePreviewSourceRef,
|
||||
getActivePreviewVideoElement, getAllPreviewVideoElements,
|
||||
} from "./video-preview.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_FILENAME = "minimax_test.json";
|
||||
|
||||
const state = {
|
||||
templateWorkflow: null,
|
||||
templateFilename: null,
|
||||
firstFrameFilename: null,
|
||||
lastFrameFilename: null,
|
||||
generating: false,
|
||||
// Whatever's currently shown in the center preview frame — a just-generated
|
||||
// result, or a video dropped/picked in the Video Source panel. Used by the
|
||||
// Frame/GIF property tabs so they work on any video, not just generated ones.
|
||||
// { kind: "output"|"input", filename, subfolder, type } for anything already on
|
||||
// the server, or { kind: "local", file } for a picked file not yet uploaded.
|
||||
previewSource: null,
|
||||
};
|
||||
export { loadWorkflowIntoVideoEditor };
|
||||
|
||||
// ============================================
|
||||
// ワークフロー取得
|
||||
// Subtab switching — two INDEPENDENT groups sharing the same .wfm-video-subtab-*
|
||||
// look, kept deliberately uncoupled: the sidebar's Project button collapses its
|
||||
// panel entirely when re-clicked (nothing needs to always be visible there —
|
||||
// Video Source covers the "load something to work with" case), while the center
|
||||
// Plan/Edit/Asset trio is a "always exactly one visible" tab group. Querying each
|
||||
// scoped to its own container (rather than one global querySelectorAll) is
|
||||
// what keeps clicking one from affecting the other.
|
||||
// ============================================
|
||||
|
||||
async function getRawWorkflow(filename) {
|
||||
const res = await fetch(`/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
// Which of the center Plan/Edit subtabs was last active — the sidebar
|
||||
// Project panel uses this to decide whether to list saved Video Plans or
|
||||
// saved Video Edit projects (Asset has no saved-list concept, so selecting
|
||||
// it leaves this unchanged rather than resetting to "plan").
|
||||
let _centerSubtabMode = "plan";
|
||||
|
||||
function _isProjectPanelOpen() {
|
||||
const panel = document.getElementById("wfm-video-subtab-project");
|
||||
return !!panel && panel.style.display !== "none";
|
||||
}
|
||||
|
||||
function _initCenterSubtabToggle() {
|
||||
const scope = document.querySelector(".wfm-video-center-panel");
|
||||
if (!scope) return;
|
||||
scope.querySelectorAll(".wfm-video-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.videoSubtab;
|
||||
scope.querySelectorAll(".wfm-video-subtab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
scope.querySelectorAll(".wfm-video-subtab-panel").forEach((p) => {
|
||||
p.style.display = p.dataset.videoSubtabPanel === target ? "" : "none";
|
||||
});
|
||||
if (target === "asset") refreshVideoAssetTab();
|
||||
if (target === "plan" || target === "edit") {
|
||||
_centerSubtabMode = target;
|
||||
// Project panel may already be open (e.g. switching Plan<->Edit
|
||||
// while it's pinned) — keep its list in sync with the new mode.
|
||||
if (_isProjectPanelOpen()) refreshVideoProjectTab(_centerSubtabMode);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar Project button: clicking it shows its panel; clicking it again while
|
||||
// active collapses it, letting the video preview reclaim the sidebar's width
|
||||
// when it isn't needed.
|
||||
function _initSidebarSubtabToggle() {
|
||||
const sidebar = document.querySelector(".wfm-video-form-panel");
|
||||
if (!sidebar) return;
|
||||
const buttons = sidebar.querySelectorAll(".wfm-video-subtab-btn");
|
||||
buttons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.videoSubtab;
|
||||
const panel = document.getElementById(`wfm-video-subtab-${target}`);
|
||||
const willShow = !!panel && panel.style.display === "none";
|
||||
buttons.forEach((b) => b.classList.remove("active"));
|
||||
sidebar.querySelectorAll(".wfm-video-subtab-panel").forEach((p) => { p.style.display = "none"; });
|
||||
if (willShow) {
|
||||
btn.classList.add("active");
|
||||
panel.style.display = "";
|
||||
if (target === "project") refreshVideoProjectTab(_centerSubtabMode);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MiniMax H3ノードの動的特定(ノードIDをハードコードしない)
|
||||
// ============================================
|
||||
|
||||
// ノードのinputs[]のうちwidgetキーを持つものだけを抽出した並び順で、名前からインデックスを引く。
|
||||
// widgets_values配列はこの並び順と1:1対応する(link接続されていてもwidgetキーを持つ入力は
|
||||
// widgets_valuesにスロットを持つ「legacy full」形式のため)。
|
||||
function _widgetIndex(node, name) {
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.findIndex((inp) => inp.name === name);
|
||||
}
|
||||
|
||||
function locateMiniMaxNodes(workflow) {
|
||||
if (!Array.isArray(workflow?.nodes) || !Array.isArray(workflow?.links)) return null;
|
||||
|
||||
const sgDef = (workflow.definitions?.subgraphs || [])
|
||||
.find((sg) => (sg.nodes || []).some((n) => n.type === "MiniMaxH3ImageToVideo"));
|
||||
if (!sgDef) return null;
|
||||
|
||||
const instanceNode = workflow.nodes.find((n) => n.type === sgDef.id);
|
||||
if (!instanceNode || !Array.isArray(instanceNode.widgets_values)) return null;
|
||||
|
||||
const firstFrameSlot = instanceNode.inputs.findIndex((inp) => inp.name === "first_frame");
|
||||
const lastFrameSlot = instanceNode.inputs.findIndex((inp) => inp.name === "last_frame");
|
||||
const widthSlot = instanceNode.inputs.findIndex((inp) => inp.name === "width");
|
||||
if (firstFrameSlot === -1 || lastFrameSlot === -1 || widthSlot === -1) return null;
|
||||
|
||||
const findLinkedNode = (targetSlot) => {
|
||||
const link = workflow.links.find((l) => l[3] === instanceNode.id && l[4] === targetSlot);
|
||||
if (!link) return null;
|
||||
return workflow.nodes.find((n) => n.id === link[1]) || null;
|
||||
};
|
||||
|
||||
// first_frameはoptional入力のため、LoadImageノードが接続されていないワークフロー
|
||||
// (テキストのみで動作するT2V構成など)もサポート対象とする。未接続の場合は
|
||||
// loadImageNode: null のまま返し、必要ならrunGeneration側で動的にノードを注入する。
|
||||
const rawLoadImageNode = findLinkedNode(firstFrameSlot);
|
||||
const loadImageNode = (rawLoadImageNode && rawLoadImageNode.type === "LoadImage") ? rawLoadImageNode : null;
|
||||
const resolutionNode = findLinkedNode(widthSlot);
|
||||
if (!resolutionNode || resolutionNode.type !== "ResolutionSelector") return null;
|
||||
|
||||
const promptIdx = _widgetIndex(instanceNode, "prompt");
|
||||
const durationIdx = _widgetIndex(instanceNode, "value_1");
|
||||
if (promptIdx === -1 || durationIdx === -1) return null;
|
||||
|
||||
return {
|
||||
sgDef, instanceNode, loadImageNode, resolutionNode,
|
||||
firstFrameSlot, lastFrameSlot, promptIdx, durationIdx,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ワークフロー読み込み
|
||||
// ============================================
|
||||
|
||||
function _showFramePreview(which, filename) {
|
||||
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
|
||||
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
|
||||
if (!previewImg || !wrap || !filename) return;
|
||||
previewImg.src = `${comfyUI.baseUrl}/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||||
wrap.style.display = "";
|
||||
}
|
||||
|
||||
export async function loadWorkflowIntoVideoEditor(workflow, filename) {
|
||||
const nodes = locateMiniMaxNodes(workflow);
|
||||
if (!nodes) {
|
||||
showToast(t("videoUnsupportedWorkflow"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
state.templateWorkflow = workflow;
|
||||
state.templateFilename = filename || "";
|
||||
state.firstFrameFilename = null;
|
||||
state.lastFrameFilename = null;
|
||||
|
||||
const nameEl = document.getElementById("wfm-video-wf-name");
|
||||
if (nameEl) nameEl.textContent = filename || "Loaded Workflow";
|
||||
|
||||
const { instanceNode, resolutionNode, loadImageNode, promptIdx, durationIdx } = nodes;
|
||||
|
||||
const promptEl = document.getElementById("wfm-video-prompt");
|
||||
if (promptEl) promptEl.value = instanceNode.widgets_values[promptIdx] ?? "";
|
||||
|
||||
const durationEl = document.getElementById("wfm-video-duration");
|
||||
if (durationEl) durationEl.value = instanceNode.widgets_values[durationIdx] ?? 2;
|
||||
|
||||
const arIdx = _widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = _widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = _widgetIndex(resolutionNode, "multiple");
|
||||
const aspectEl = document.getElementById("wfm-video-aspect-ratio");
|
||||
if (aspectEl && arIdx !== -1 && resolutionNode.widgets_values[arIdx] != null) {
|
||||
aspectEl.value = resolutionNode.widgets_values[arIdx];
|
||||
}
|
||||
const mpEl = document.getElementById("wfm-video-megapixels");
|
||||
if (mpEl && mpIdx !== -1) mpEl.value = resolutionNode.widgets_values[mpIdx] ?? mpEl.value;
|
||||
const multEl = document.getElementById("wfm-video-multiple");
|
||||
if (multEl && multIdx !== -1) multEl.value = resolutionNode.widgets_values[multIdx] ?? multEl.value;
|
||||
|
||||
const fnIdx = _widgetIndex(loadImageNode, "image");
|
||||
if (fnIdx !== -1 && loadImageNode.widgets_values[fnIdx]) {
|
||||
state.firstFrameFilename = loadImageNode.widgets_values[fnIdx];
|
||||
_showFramePreview("first", state.firstFrameFilename);
|
||||
}
|
||||
|
||||
showToast(t("videoWorkflowLoaded", filename || ""), "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadDefaultWorkflow() {
|
||||
try {
|
||||
const wf = await getRawWorkflow(DEFAULT_WORKFLOW_FILENAME);
|
||||
await loadWorkflowIntoVideoEditor(wf, DEFAULT_WORKFLOW_FILENAME);
|
||||
} catch {
|
||||
// 初回自動ロード失敗は静かに無視する(環境によってファイルが存在しないこともある)。
|
||||
// Workflowタブの「Load in Video」ボタンから明示的に読み込める。
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Aspect Ratio 選択肢(object_infoから動的取得、ハードコードしない)
|
||||
// ============================================
|
||||
|
||||
async function _populateAspectRatioOptions() {
|
||||
const select = document.getElementById("wfm-video-aspect-ratio");
|
||||
if (!select) return;
|
||||
try {
|
||||
const info = await comfyUI.fetchObjectInfo("ResolutionSelector");
|
||||
const inputDef = info?.ResolutionSelector?.input?.required?.aspect_ratio;
|
||||
if (!inputDef) return;
|
||||
const first = inputDef[0];
|
||||
let values = null;
|
||||
if (Array.isArray(first)) values = first;
|
||||
else if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) values = inputDef[1].values;
|
||||
else if (typeof first === "string" && Array.isArray(inputDef[1]?.options)) values = inputDef[1].options;
|
||||
if (!values?.length) return;
|
||||
select.innerHTML = values.map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
|
||||
} catch {
|
||||
// ResolutionSelectorが未導入の環境などは選択肢が空のまま(手入力不可のselectなので実質何もできないが、
|
||||
// Videoタブ自体の初期化は継続させる)。
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// first_frame / last_frame アップロード
|
||||
// first_frame / last_frame アップロード(Video Sourceパネル・Frame/GIFタブと共有する
|
||||
// 汎用ドロップゾーン配線ヘルパー。video-plan-tab.jsのブロック画像アップロードとは別物)
|
||||
// ============================================
|
||||
|
||||
function _wireDropZone(dropZoneId, fileInputId, onFile) {
|
||||
@@ -200,279 +113,6 @@ function _wireDropZone(dropZoneId, fileInputId, onFile) {
|
||||
});
|
||||
}
|
||||
|
||||
async function _handleFrameUpload(which, file) {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const statusEl = document.getElementById(`wfm-video-${which}-frame-status`);
|
||||
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
|
||||
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
|
||||
if (previewImg) previewImg.src = URL.createObjectURL(file);
|
||||
if (wrap) wrap.style.display = "";
|
||||
if (statusEl) statusEl.textContent = "Uploading...";
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (which === "first") state.firstFrameFilename = result.name;
|
||||
else state.lastFrameFilename = result.name;
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `✓ ${result.name}`;
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `✗ ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _clearFrame(which) {
|
||||
if (which === "first") state.firstFrameFilename = null;
|
||||
else state.lastFrameFilename = null;
|
||||
const wrap = document.getElementById(`wfm-video-${which}-frame-wrap`);
|
||||
const previewImg = document.getElementById(`wfm-video-${which}-frame-preview`);
|
||||
const statusEl = document.getElementById(`wfm-video-${which}-frame-status`);
|
||||
const fileInput = document.getElementById(`wfm-video-${which}-frame-file`);
|
||||
if (wrap) wrap.style.display = "none";
|
||||
if (previewImg) previewImg.src = "";
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
if (fileInput) fileInput.value = "";
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// フォーム値の読み取り・ワークフローへの反映
|
||||
// ============================================
|
||||
|
||||
function _readFormValues() {
|
||||
return {
|
||||
prompt: document.getElementById("wfm-video-prompt")?.value || "",
|
||||
aspectRatio: document.getElementById("wfm-video-aspect-ratio")?.value || "",
|
||||
megapixels: Number(document.getElementById("wfm-video-megapixels")?.value) || 0.2,
|
||||
multiple: Number(document.getElementById("wfm-video-multiple")?.value) || 32,
|
||||
duration: Number(document.getElementById("wfm-video-duration")?.value) || 2,
|
||||
};
|
||||
}
|
||||
|
||||
function _applyFormToWorkflow(nodes, form) {
|
||||
const { instanceNode, resolutionNode, loadImageNode, promptIdx, durationIdx } = nodes;
|
||||
|
||||
instanceNode.widgets_values[promptIdx] = form.prompt;
|
||||
instanceNode.widgets_values[durationIdx] = form.duration;
|
||||
|
||||
const arIdx = _widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = _widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = _widgetIndex(resolutionNode, "multiple");
|
||||
if (arIdx !== -1) resolutionNode.widgets_values[arIdx] = form.aspectRatio;
|
||||
if (mpIdx !== -1) resolutionNode.widgets_values[mpIdx] = form.megapixels;
|
||||
if (multIdx !== -1) resolutionNode.widgets_values[multIdx] = form.multiple;
|
||||
|
||||
// first_frameは既存のLoadImageノードが接続されている場合のみここで書き換える。
|
||||
// 接続が無い場合(loadImageNode: null)は、first_frameが新規指定された時だけ
|
||||
// _injectFrameNode()でノードごと注入する(未指定ならバイパス=未接続のまま実行)。
|
||||
if (loadImageNode) {
|
||||
const fnIdx = _widgetIndex(loadImageNode, "image");
|
||||
if (fnIdx !== -1) loadImageNode.widgets_values[fnIdx] = state.firstFrameFilename;
|
||||
}
|
||||
}
|
||||
|
||||
// first_frame/last_frameが未接続のワークフローに対して、画像が指定された時だけ
|
||||
// LoadImageノードを新規注入する共通ヘルパー。ノードID/リンクIDはclone内の実際の
|
||||
// 最大値+1で採番するため、重複ID/リンクID不整合は起きない
|
||||
// (CLAUDE.md記載の既知の落とし穴への対策)。未指定時は何もしない
|
||||
// (テンプレート由来のlink:nullのまま=クリーンな状態を毎回cloneするので残留の心配がない=バイパス扱い)。
|
||||
function _injectFrameNode(clone, instanceNode, targetSlot, filename) {
|
||||
const maxNodeId = Math.max(clone.last_node_id || 0, ...clone.nodes.map((n) => n.id));
|
||||
const maxLinkId = Math.max(clone.last_link_id || 0, ...clone.links.map((l) => l[0]));
|
||||
const newNodeId = maxNodeId + 1;
|
||||
const newLinkId = maxLinkId + 1;
|
||||
|
||||
clone.nodes.push({
|
||||
id: newNodeId,
|
||||
type: "LoadImage",
|
||||
pos: [(instanceNode.pos?.[0] || 0) - 260, (instanceNode.pos?.[1] || 0) + 40],
|
||||
size: [280, 314],
|
||||
flags: {},
|
||||
order: clone.nodes.length,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ localized_name: "image", name: "image", type: "COMBO", widget: { name: "image" }, link: null },
|
||||
{ localized_name: "choose file to upload", name: "upload", type: "IMAGEUPLOAD", widget: { name: "upload" }, link: null },
|
||||
],
|
||||
outputs: [
|
||||
{ localized_name: "IMAGE", name: "IMAGE", type: "IMAGE", links: [newLinkId] },
|
||||
{ localized_name: "MASK", name: "MASK", type: "MASK", links: null },
|
||||
],
|
||||
properties: { "Node name for S&R": "LoadImage" },
|
||||
widgets_values: [filename, "image"],
|
||||
});
|
||||
|
||||
clone.links.push([newLinkId, newNodeId, 0, instanceNode.id, targetSlot, "IMAGE"]);
|
||||
const targetInput = instanceNode.inputs[targetSlot];
|
||||
if (targetInput) targetInput.link = newLinkId;
|
||||
|
||||
clone.last_node_id = newNodeId;
|
||||
clone.last_link_id = newLinkId;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 実行
|
||||
// ============================================
|
||||
|
||||
async function runGeneration() {
|
||||
if (state.generating) return;
|
||||
if (!state.templateWorkflow) {
|
||||
showToast(t("videoNoWorkflowLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = structuredClone(state.templateWorkflow);
|
||||
const nodes = locateMiniMaxNodes(clone);
|
||||
if (!nodes) {
|
||||
showToast(t("videoUnsupportedWorkflow"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
_applyFormToWorkflow(nodes, _readFormValues());
|
||||
// first_frame: 既存のLoadImageノードが無いワークフロー(T2V構成等)で、
|
||||
// ユーザーが新たに画像を指定した場合のみノードを注入する。未指定ならバイパス(未接続のまま実行)。
|
||||
if (!nodes.loadImageNode && state.firstFrameFilename) {
|
||||
_injectFrameNode(clone, nodes.instanceNode, nodes.firstFrameSlot, state.firstFrameFilename);
|
||||
}
|
||||
if (state.lastFrameFilename) {
|
||||
_injectFrameNode(clone, nodes.instanceNode, nodes.lastFrameSlot, state.lastFrameFilename);
|
||||
}
|
||||
|
||||
const genBtn = document.getElementById("wfm-video-generate-btn");
|
||||
const progressBar = document.getElementById("wfm-video-progress-bar");
|
||||
const progressText = document.getElementById("wfm-video-progress-text");
|
||||
|
||||
state.generating = true;
|
||||
if (genBtn) genBtn.disabled = true;
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = "Converting workflow...";
|
||||
|
||||
try {
|
||||
const apiWorkflow = await comfyWorkflow.convertUiToApi(clone);
|
||||
|
||||
const seedValue = Number(document.getElementById("wfm-video-noise-seed")?.value) || 0;
|
||||
const seedMode = document.getElementById("wfm-video-seed-randomize")?.checked ? "random" : "fixed";
|
||||
|
||||
const { images, seed } = await comfyUI.generate(apiWorkflow, {
|
||||
seedMode,
|
||||
seedValue,
|
||||
timeoutMs: 30 * 60 * 1000, // 動画生成は静止画より長時間になりうる
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
});
|
||||
|
||||
const seedEl = document.getElementById("wfm-video-noise-seed");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
const outputMedia = images.filter((img) => img.type !== "temp");
|
||||
if (progressText) progressText.textContent = t("videoDoneCount", outputMedia.length);
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
_renderResult(outputMedia);
|
||||
await _saveGeneratedVideoMeta(outputMedia, apiWorkflow);
|
||||
showToast(t("videoGenerateSuccess"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
if (progressText) progressText.textContent = "Error";
|
||||
} finally {
|
||||
state.generating = false;
|
||||
if (genBtn) genBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 結果表示
|
||||
// ============================================
|
||||
|
||||
// Sets (or clears) the center preview frame's video and records where it came from —
|
||||
// `source` is what the Frame/GIF property tabs act on (see state.previewSource above).
|
||||
// Revokes the previous blob: URL (if any) so picking a series of local files doesn't
|
||||
// leak memory.
|
||||
let _previewObjectUrl = null;
|
||||
|
||||
function _setPreviewMedia(url, source) {
|
||||
state.previewSource = source;
|
||||
const video = document.getElementById("wfm-video-preview-video");
|
||||
const placeholder = document.getElementById("wfm-video-preview-placeholder");
|
||||
if (!video || !placeholder) return;
|
||||
|
||||
if (_previewObjectUrl) {
|
||||
URL.revokeObjectURL(_previewObjectUrl);
|
||||
_previewObjectUrl = null;
|
||||
}
|
||||
if (url && url.startsWith("blob:")) _previewObjectUrl = url;
|
||||
|
||||
if (url) {
|
||||
video.src = url;
|
||||
video.style.display = "block";
|
||||
placeholder.style.display = "none";
|
||||
} else {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.style.display = "none";
|
||||
placeholder.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _renderResult(images) {
|
||||
// MiniMax H3 always emits a single VIDEO output — but guard generically (prefer
|
||||
// an mp4 if present) in case a future video workflow's SaveVideo/PreviewVideo
|
||||
// ordering differs.
|
||||
const media = (images || []).find((img) => img.filename.toLowerCase().endsWith(".mp4")) || images?.[0];
|
||||
if (!media) {
|
||||
_setPreviewMedia(null, null);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ filename: media.filename, subfolder: media.subfolder || "", type: media.type || "output" });
|
||||
const src = `${comfyUI.baseUrl}/view?${params}`;
|
||||
_setPreviewMedia(src, {
|
||||
kind: media.type === "input" ? "input" : "output",
|
||||
filename: media.filename,
|
||||
subfolder: media.subfolder || "",
|
||||
type: media.type || "output",
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Gallery連携(生成結果のワークフローをサイドカーメタとして保存)
|
||||
// ============================================
|
||||
|
||||
let _outputDir = "";
|
||||
|
||||
async function _fetchOutputDir() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function _saveGeneratedVideoMeta(images, workflow) {
|
||||
if (!_outputDir) await _fetchOutputDir();
|
||||
if (!_outputDir) return;
|
||||
for (const img of images) {
|
||||
if (img.type !== "output") continue;
|
||||
const parts = [_outputDir];
|
||||
if (img.subfolder) parts.push(img.subfolder);
|
||||
parts.push(img.filename);
|
||||
const path = parts.join("/");
|
||||
try {
|
||||
await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, workflow }),
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// i18nの適用(app.jsのapplyI18nToHtml()と同じ「個別要素にt()を適用」方式)
|
||||
// ============================================
|
||||
@@ -482,29 +122,27 @@ function _applyVideoI18n() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
};
|
||||
setText("wfm-video-title-label", "videoTitle");
|
||||
setText("wfm-video-first-frame-label", "videoFirstFrame");
|
||||
setText("wfm-video-last-frame-label", "videoLastFrame");
|
||||
setText("wfm-video-first-frame-drop-label", "videoDropOrClick");
|
||||
setText("wfm-video-last-frame-drop-label", "videoDropOrClick");
|
||||
setText("wfm-video-first-frame-clear", "clear");
|
||||
setText("wfm-video-last-frame-clear", "clear");
|
||||
setText("wfm-video-prompt-label", "videoPrompt");
|
||||
setText("wfm-video-aspect-ratio-label", "videoAspectRatio");
|
||||
setText("wfm-video-megapixels-label", "videoMegapixels");
|
||||
setText("wfm-video-multiple-label", "videoMultiple");
|
||||
setText("wfm-video-duration-label", "videoDuration");
|
||||
setText("wfm-video-noise-seed-label", "videoNoiseSeed");
|
||||
setText("wfm-video-randomize-label", "videoRandomizeSeed");
|
||||
setText("wfm-video-generate-btn", "videoGenerate");
|
||||
setText("wfm-video-edit-placeholder-label", "videoEditPlaceholder");
|
||||
const setTitle = (id, key) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = t(key);
|
||||
};
|
||||
setText("wfm-video-edit-export-btn", "videoEditExportBtn");
|
||||
setTitle("wfm-video-edit-move-left-btn", "videoEditMoveLeft");
|
||||
setTitle("wfm-video-edit-move-right-btn", "videoEditMoveRight");
|
||||
setText("wfm-video-edit-duplicate-btn", "videoEditDuplicate");
|
||||
setText("wfm-video-edit-delete-btn", "videoEditDelete");
|
||||
setText("wfm-video-edit-clear-btn", "videoEditClearBtn");
|
||||
setText("wfm-video-source-add-to-edit", "videoSourceAddToEdit");
|
||||
setText("wfm-video-source-label", "videoSourceLabel");
|
||||
setText("wfm-video-source-hint", "videoSourceHint");
|
||||
setText("wfm-video-source-drop-label", "videoSourceDropLabel");
|
||||
setText("wfm-video-source-clear", "clear");
|
||||
setText("wfm-video-preview-placeholder", "videoNoVideo");
|
||||
setText("wfm-video-source-preview-placeholder", "videoSourcePreviewPlaceholder");
|
||||
setText("wfm-video-preview-placeholder", "videoResultPreviewPlaceholder");
|
||||
setText("wfm-video-prop-tab-frame", "videoPropFrame");
|
||||
setText("wfm-video-prop-tab-gif", "videoPropGif");
|
||||
setText("wfm-video-subtab-btn-project", "videoPropProject");
|
||||
setText("wfm-video-project-hint", "videoProjectHint");
|
||||
setText("wfm-video-frame-hint", "videoFrameHint");
|
||||
setText("wfm-video-frame-capture-btn", "videoCaptureFrame");
|
||||
setText("wfm-video-frame-save-btn", "videoSaveToOutput");
|
||||
@@ -517,19 +155,47 @@ function _applyVideoI18n() {
|
||||
|
||||
// ============================================
|
||||
// Video Source panel — loads any local video into the preview frame, independent of
|
||||
// generation, so Frame/GIF tools work without running MiniMax H3 first.
|
||||
// generation, so Frame/GIF tools work without running the loaded video workflow first.
|
||||
// ============================================
|
||||
|
||||
function _wireVideoSourcePanel() {
|
||||
_wireDropZone("wfm-video-source-drop", "wfm-video-source-file", (file) => {
|
||||
if (!file.type.startsWith("video/")) return;
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
const isImage = file.type.startsWith("image/");
|
||||
if (!isVideo && !isImage) return;
|
||||
const statusEl = document.getElementById("wfm-video-source-status");
|
||||
_setPreviewMedia(URL.createObjectURL(file), { kind: "local", file });
|
||||
setSourcePreview(URL.createObjectURL(file), { kind: "local", file }, isVideo ? "video" : "image");
|
||||
if (statusEl) { statusEl.textContent = file.name; statusEl.style.color = ""; }
|
||||
});
|
||||
|
||||
// "Add to Edit" — explicit, user-triggered handoff from whatever is loaded
|
||||
// in the Source pane (a plain drop, an Asset selection, a Frame/GIF-tool
|
||||
// source) into the Edit subtab's clip timeline. Deliberately NOT automatic
|
||||
// on every Source drop — Frame/GIF is a single-video tool the user reaches
|
||||
// for far more often than multi-clip editing, so auto-adding would clutter
|
||||
// the Edit timeline with videos the user never meant to combine.
|
||||
document.getElementById("wfm-video-source-add-to-edit")?.addEventListener("click", async () => {
|
||||
const ref = getActivePreviewSource();
|
||||
if (!ref) { showToast(t("videoNoSourceLoaded"), "error"); return; }
|
||||
try {
|
||||
let file;
|
||||
if (ref.kind === "local") {
|
||||
file = ref.file;
|
||||
} else {
|
||||
const blob = await comfyUI.getImageBlob(ref);
|
||||
const isVideoExt = /\.(mp4|webm|mkv|mov)$/i.test(ref.filename || "");
|
||||
file = new File([blob], ref.filename, { type: blob.type || (isVideoExt ? "video/mp4" : "image/png") });
|
||||
}
|
||||
addClipFromFile(file);
|
||||
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
|
||||
showToast(t("videoEditClipSent", file.name), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-video-source-clear")?.addEventListener("click", () => {
|
||||
_setPreviewMedia(null, null);
|
||||
setSourcePreview(null, null);
|
||||
const statusEl = document.getElementById("wfm-video-source-status");
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
const fileInput = document.getElementById("wfm-video-source-file");
|
||||
@@ -561,6 +227,7 @@ function _initPropTabs() {
|
||||
// ============================================
|
||||
|
||||
let _capturedFrameBlob = null;
|
||||
let _frameOutputDir = "";
|
||||
|
||||
function _blobToDataUrl(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -571,6 +238,44 @@ function _blobToDataUrl(blob) {
|
||||
});
|
||||
}
|
||||
|
||||
// Same small output-dir lookup each of video-asset-tab.js/video-plan-tab.js/
|
||||
// video-edit-tab.js keeps its own copy of — needed to build the absolute path
|
||||
// Gallery's group-tagging API expects.
|
||||
async function _fetchFrameOutputDir() {
|
||||
if (_frameOutputDir) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
_frameOutputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
// Opt-in tagging of a saved frame into Gallery's curated __Video Assets__
|
||||
// group (see the "Add to Video Assets group" checkbox) — mirrors
|
||||
// video-edit-tab.js's _addOutputToVideoTemp, but targets the curated group
|
||||
// directly since a manually-captured frame is a deliberate keep, not the
|
||||
// every-run scratch output __Video Temp__ exists for.
|
||||
async function _addFrameToVideoAssets(filename, subfolder) {
|
||||
await _fetchFrameOutputDir();
|
||||
if (!_frameOutputDir) return;
|
||||
const parts = [_frameOutputDir];
|
||||
if (subfolder) parts.push(subfolder);
|
||||
parts.push(filename);
|
||||
const path = parts.join("/");
|
||||
try {
|
||||
await ensureVideoGroup();
|
||||
await fetch(`/wfm/gallery/groups/${encodeURIComponent(VIDEO_GROUP)}/add`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[VideoTab] failed to tag frame into video group:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _initFrameTab() {
|
||||
const captureBtn = document.getElementById("wfm-video-frame-capture-btn");
|
||||
const saveBtn = document.getElementById("wfm-video-frame-save-btn");
|
||||
@@ -579,7 +284,7 @@ function _initFrameTab() {
|
||||
const statusEl = document.getElementById("wfm-video-frame-status");
|
||||
|
||||
captureBtn?.addEventListener("click", () => {
|
||||
const video = document.getElementById("wfm-video-preview-video");
|
||||
const video = getActivePreviewVideoElement();
|
||||
if (!video || video.style.display === "none" || !video.videoWidth) {
|
||||
showToast(t("videoNoSourceLoaded"), "error");
|
||||
return;
|
||||
@@ -617,6 +322,9 @@ function _initFrameTab() {
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
if (statusEl) { statusEl.textContent = `✓ ${json.filename}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
showToast(t("videoFrameSaved", json.filename), "success");
|
||||
if (document.getElementById("wfm-video-frame-add-to-assets")?.checked) {
|
||||
await _addFrameToVideoAssets(json.filename, json.subfolder);
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
@@ -641,7 +349,7 @@ function _initGifTab() {
|
||||
const statusEl = document.getElementById("wfm-video-gif-status");
|
||||
|
||||
convertBtn?.addEventListener("click", async () => {
|
||||
if (!state.previewSource) {
|
||||
if (!getActivePreviewSource()) {
|
||||
showToast(t("videoNoSourceLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
@@ -651,12 +359,12 @@ function _initGifTab() {
|
||||
if (progressText) progressText.textContent = "Uploading...";
|
||||
|
||||
try {
|
||||
let ref = state.previewSource;
|
||||
let ref = getActivePreviewSource();
|
||||
if (ref.kind === "local") {
|
||||
const result = await comfyUI.uploadImage(ref.file, ref.file.name);
|
||||
ref = { kind: "input", filename: result.name, subfolder: result.subfolder || "", type: "input" };
|
||||
// Cache the uploaded reference so a repeat conversion doesn't re-upload.
|
||||
state.previewSource = ref;
|
||||
updateActivePreviewSourceRef(ref);
|
||||
}
|
||||
|
||||
const start = Number(document.getElementById("wfm-video-gif-start")?.value) || 0;
|
||||
@@ -707,19 +415,20 @@ function _initGifTab() {
|
||||
|
||||
export function initVideoTab() {
|
||||
_applyVideoI18n();
|
||||
_populateAspectRatioOptions();
|
||||
|
||||
_wireDropZone("wfm-video-first-frame-drop", "wfm-video-first-frame-file", (file) => _handleFrameUpload("first", file));
|
||||
_wireDropZone("wfm-video-last-frame-drop", "wfm-video-last-frame-file", (file) => _handleFrameUpload("last", file));
|
||||
|
||||
document.getElementById("wfm-video-first-frame-clear")?.addEventListener("click", () => _clearFrame("first"));
|
||||
document.getElementById("wfm-video-last-frame-clear")?.addEventListener("click", () => _clearFrame("last"));
|
||||
document.getElementById("wfm-video-generate-btn")?.addEventListener("click", runGeneration);
|
||||
_initCenterSubtabToggle();
|
||||
_initSidebarSubtabToggle();
|
||||
initVideoPlanTab();
|
||||
initVideoAssetTab();
|
||||
initVideoProjectTab();
|
||||
initVideoEditTab();
|
||||
|
||||
_wireVideoSourcePanel();
|
||||
_initPropTabs();
|
||||
_initFrameTab();
|
||||
_initGifTab();
|
||||
|
||||
loadDefaultWorkflow();
|
||||
// 保存済みの音量を適用し、以降ユーザーがネイティブコントロールで変更した音量も
|
||||
// 自動保存する(要素自体は永続的なので初期化時に一度呼べば十分)。両方のプレビュー
|
||||
// ペイン(Asset/Source用・生成結果用)の<video>要素に適用する。
|
||||
getAllPreviewVideoElements().forEach((video) => applyStoredVideoVolume(video));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Video workflow helpers — model-family detection and node manipulation for
|
||||
* MiniMax H3 / LTX-2.5 (Text・Image・First&Last-Frame to Video) / Wan2.2 (Text
|
||||
* to Video) workflows. Pure functions operating on UI-format workflow JSON,
|
||||
* with no DOM/state dependency, so both the Plan tab's per-block batch loop
|
||||
* and (previously) the single-shot form can share the exact same logic.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 動画モデルノードの動的特定(ノードIDをハードコードしない)
|
||||
// サブグラフの内部ノード型(Wan2.2のみサブグラフ名)で対象ワークフローの種類を判定し、
|
||||
// 以降はサブグラフが公開する入力のlabel(無ければname)で意味的にスロットを特定する。
|
||||
// これによりLTX-2.5の3バリアント間で公開入力のname(text/value/value_1...)が揺れて
|
||||
// いても、label(prompt/duration/width...)は共通しているため同じロジックで処理できる。
|
||||
// ============================================
|
||||
|
||||
// ノードのinputs[]のうちwidgetキーを持つものだけを抽出した並び順で、指定した入力定義の
|
||||
// インデックスを引く。widgets_values配列はこの並び順と1:1対応する(link接続されていても
|
||||
// widgetキーを持つ入力はwidgets_valuesにスロットを持つ「legacy full」形式のため)。
|
||||
function _widgetIndexOf(node, inputDef) {
|
||||
if (!inputDef) return -1;
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.indexOf(inputDef);
|
||||
}
|
||||
|
||||
// 上と同じ並び順ルールで、名前から直接インデックスを引く版(ResolutionSelectorや
|
||||
// LoadImageなど、モデルファミリー間で入力名が揺れないノードに使う)。
|
||||
export function widgetIndex(node, name) {
|
||||
const widgetInputs = (node?.inputs || []).filter((inp) => inp.widget);
|
||||
return widgetInputs.findIndex((inp) => inp.name === name);
|
||||
}
|
||||
|
||||
// サブグラフが公開する入力は、labelがあればlabel、無ければnameが意味的な役割を表す
|
||||
// (例: MiniMax H3の"width"はlabel無しでname自体が"width"、LTX-2.5の"value_2"はlabelが
|
||||
// "width")。
|
||||
function _findInputByLabel(node, label) {
|
||||
return (node?.inputs || []).find((inp) => (inp.label || inp.name) === label) || null;
|
||||
}
|
||||
|
||||
// noise_seedはLTX-2.5のText to Videoバリアントだけlabel="seed"を持つ(他は無label)ため、
|
||||
// 意味的ラベルではなくname自体(全バリアントで"noise_seed"に統一)で検索する。
|
||||
function _findInputByName(node, name) {
|
||||
return (node?.inputs || []).find((inp) => inp.name === name) || null;
|
||||
}
|
||||
|
||||
function _detectVideoSubgraph(workflow) {
|
||||
if (!Array.isArray(workflow?.nodes) || !Array.isArray(workflow?.links)) return null;
|
||||
const subgraphs = workflow.definitions?.subgraphs || [];
|
||||
|
||||
const minimax = subgraphs.find((sg) => (sg.nodes || []).some((n) => n.type === "MiniMaxH3ImageToVideo"));
|
||||
if (minimax) return { family: "minimax", sgDef: minimax };
|
||||
|
||||
// LTX-2.5のText/Image/First&Last-Frame to Videoはいずれも内部にLTXV*系ノードを持つ
|
||||
// サブグラフとして配布される。バリアントごとにサブグラフの公開入力nameが異なるため、
|
||||
// 内部ノード型のプレフィックスで検出する。
|
||||
const ltx = subgraphs.find((sg) => (sg.nodes || []).some((n) => typeof n.type === "string" && n.type.startsWith("LTXV")));
|
||||
if (ltx) return { family: "ltx25", sgDef: ltx };
|
||||
|
||||
// Wan2.2はUNETLoader/KSamplerAdvanced/EmptyHunyuanLatentVideoなど汎用ノードのみで
|
||||
// 構成されており、MiniMax H3/LTX-2.5のような固有ノード型による検出ができない。
|
||||
// そのためサブグラフ自体の名前(公式テンプレート由来の "...(Wan2.2)" 表記)で検出する。
|
||||
const wan22 = subgraphs.find((sg) => /wan\s*2\.2/i.test(sg.name || ""));
|
||||
if (wan22) return { family: "wan22", sgDef: wan22 };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function locateVideoModelNodes(workflow) {
|
||||
const detected = _detectVideoSubgraph(workflow);
|
||||
if (!detected) return null;
|
||||
const { family, sgDef } = detected;
|
||||
|
||||
const instanceNode = workflow.nodes.find((n) => n.type === sgDef.id);
|
||||
if (!instanceNode || !Array.isArray(instanceNode.widgets_values)) return null;
|
||||
|
||||
// Wan2.2の公開入力はプロンプトが label 無しの name="text" のまま(MiniMax H3の"prompt"や
|
||||
// LTX-2.5の label="prompt" と異なる)ため、"prompt" が見つからない場合は "text" にフォール
|
||||
// バックする。
|
||||
const promptInput = _findInputByLabel(instanceNode, "prompt") || _findInputByLabel(instanceNode, "text");
|
||||
const durationInput = _findInputByLabel(instanceNode, "duration");
|
||||
const widthInput = _findInputByLabel(instanceNode, "width");
|
||||
if (!promptInput || !durationInput || !widthInput) return null;
|
||||
|
||||
const promptIdx = _widgetIndexOf(instanceNode, promptInput);
|
||||
const durationIdx = _widgetIndexOf(instanceNode, durationInput);
|
||||
if (promptIdx === -1 || durationIdx === -1) return null;
|
||||
|
||||
const widthSlot = instanceNode.inputs.indexOf(widthInput);
|
||||
|
||||
// ワークフローにより有無・意味が異なる任意項目。見つからなければ全て-1を返し、
|
||||
// 呼び出し側(UI)はその項目を無効化・非ハイライトにする。
|
||||
const heightInput = _findInputByLabel(instanceNode, "height");
|
||||
const noiseSeedInput = _findInputByName(instanceNode, "noise_seed");
|
||||
// MiniMax H3は"turbo_mode"、Wan2.2は"enable_turbo_mode" — 同じ意味のトグルをUI上は
|
||||
// 1つのフィールドとして兼用するため、どちらか見つかった方を使う。
|
||||
const turboModeInput = _findInputByLabel(instanceNode, "turbo_mode") || _findInputByLabel(instanceNode, "enable_turbo_mode");
|
||||
const turboStrengthInput = _findInputByLabel(instanceNode, "turbo_model_strength");
|
||||
const turboStepsInput = _findInputByLabel(instanceNode, "turbo_steps");
|
||||
const promptEnhanceInput = _findInputByLabel(instanceNode, "prompt_enhance");
|
||||
const noiseSeedIdx = _widgetIndexOf(instanceNode, noiseSeedInput);
|
||||
const turboModeIdx = _widgetIndexOf(instanceNode, turboModeInput);
|
||||
const turboStrengthIdx = _widgetIndexOf(instanceNode, turboStrengthInput);
|
||||
const turboStepsIdx = _widgetIndexOf(instanceNode, turboStepsInput);
|
||||
const promptEnhanceIdx = _widgetIndexOf(instanceNode, promptEnhanceInput);
|
||||
|
||||
const findLinkedNode = (targetSlot) => {
|
||||
if (targetSlot === -1) return null;
|
||||
const link = workflow.links.find((l) => l[3] === instanceNode.id && l[4] === targetSlot);
|
||||
if (!link) return null;
|
||||
return workflow.nodes.find((n) => n.id === link[1]) || null;
|
||||
};
|
||||
|
||||
// 一部のLTX-2.5バリアント(First & Last Frame to Video等)はResolutionSelectorを介さず、
|
||||
// width/heightをサブグラフの直接ウィジェット値として持つ。その場合はresolutionNode: null
|
||||
// を返し、以降の処理ではaspect_ratio等の操作をスキップする(テンプレートのwidth/height値が
|
||||
// そのまま使われる)。
|
||||
const rawResolutionNode = findLinkedNode(widthSlot);
|
||||
const resolutionNode = (rawResolutionNode && rawResolutionNode.type === "ResolutionSelector") ? rawResolutionNode : null;
|
||||
|
||||
// first_frame/last_frameはoptional入力のため、対応するLoadImageノードが接続されて
|
||||
// いないワークフロー(テキストのみで動作するT2V構成など)もサポート対象とする。
|
||||
// 未接続の場合はnullのまま返し、必要ならapplyBlockToWorkflow側で動的にノードを注入する。
|
||||
const firstFrameInput = _findInputByLabel(instanceNode, "first_frame");
|
||||
const lastFrameInput = _findInputByLabel(instanceNode, "last_frame");
|
||||
const firstFrameSlot = firstFrameInput ? instanceNode.inputs.indexOf(firstFrameInput) : -1;
|
||||
const lastFrameSlot = lastFrameInput ? instanceNode.inputs.indexOf(lastFrameInput) : -1;
|
||||
|
||||
const rawLoadImageNode = findLinkedNode(firstFrameSlot);
|
||||
const loadImageNode = (rawLoadImageNode && rawLoadImageNode.type === "LoadImage") ? rawLoadImageNode : null;
|
||||
const rawLastLoadImageNode = findLinkedNode(lastFrameSlot);
|
||||
const lastLoadImageNode = (rawLastLoadImageNode && rawLastLoadImageNode.type === "LoadImage") ? rawLastLoadImageNode : null;
|
||||
|
||||
// width/heightはResolutionSelectorがあればその出力に接続されており(widthSlotの
|
||||
// link経由)、その場合は直接ウィジェット値を書き換えても実行時にリンク元の出力で
|
||||
// 上書きされ意味がない。resolutionNodeがnullのワークフロー(LTX-2.5 First&Last-Frame,
|
||||
// Wan2.2全種)でのみ、instanceNode自身のwidth/heightウィジェットを直接編集できる。
|
||||
const widthIdx = resolutionNode ? -1 : _widgetIndexOf(instanceNode, widthInput);
|
||||
const heightIdx = resolutionNode ? -1 : _widgetIndexOf(instanceNode, heightInput);
|
||||
|
||||
return {
|
||||
family, sgDef, instanceNode, loadImageNode, lastLoadImageNode, resolutionNode,
|
||||
firstFrameSlot, lastFrameSlot, promptIdx, durationIdx,
|
||||
widthIdx, heightIdx, noiseSeedIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
};
|
||||
}
|
||||
|
||||
// Reads the current values a freshly-loaded template workflow carries, so a UI
|
||||
// (the Plan tab's first block, previously the single-shot form) can seed its
|
||||
// fields from whatever the workflow file already had.
|
||||
export function readTemplateDefaults(nodes) {
|
||||
const {
|
||||
instanceNode, resolutionNode, loadImageNode, lastLoadImageNode, promptIdx, durationIdx,
|
||||
widthIdx, heightIdx, noiseSeedIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
} = nodes;
|
||||
const arIdx = widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = widgetIndex(resolutionNode, "multiple");
|
||||
const fnIdx = widgetIndex(loadImageNode, "image");
|
||||
const lastFnIdx = widgetIndex(lastLoadImageNode, "image");
|
||||
return {
|
||||
prompt: instanceNode.widgets_values[promptIdx] ?? "",
|
||||
duration: instanceNode.widgets_values[durationIdx] ?? 2,
|
||||
hasResolution: !!resolutionNode,
|
||||
aspectRatio: (resolutionNode && arIdx !== -1) ? resolutionNode.widgets_values[arIdx] : null,
|
||||
megapixels: (resolutionNode && mpIdx !== -1) ? resolutionNode.widgets_values[mpIdx] : null,
|
||||
multiple: (resolutionNode && multIdx !== -1) ? resolutionNode.widgets_values[multIdx] : null,
|
||||
firstImageFilename: (loadImageNode && fnIdx !== -1) ? loadImageNode.widgets_values[fnIdx] : null,
|
||||
lastImageFilename: (lastLoadImageNode && lastFnIdx !== -1) ? lastLoadImageNode.widgets_values[lastFnIdx] : null,
|
||||
// width/heightは resolutionNode がある場合は widthIdx/heightIdx が -1 になる
|
||||
// (locateVideoModelNodes参照) ため、常にnullが返り直接入力欄は無効化される。
|
||||
hasDirectResolution: widthIdx !== -1 && heightIdx !== -1,
|
||||
width: widthIdx !== -1 ? instanceNode.widgets_values[widthIdx] : null,
|
||||
height: heightIdx !== -1 ? instanceNode.widgets_values[heightIdx] : null,
|
||||
hasNoiseSeed: noiseSeedIdx !== -1,
|
||||
noiseSeed: noiseSeedIdx !== -1 ? instanceNode.widgets_values[noiseSeedIdx] : null,
|
||||
hasTurboMode: turboModeIdx !== -1,
|
||||
turboMode: turboModeIdx !== -1 ? instanceNode.widgets_values[turboModeIdx] : null,
|
||||
hasTurboStrength: turboStrengthIdx !== -1,
|
||||
turboStrength: turboStrengthIdx !== -1 ? instanceNode.widgets_values[turboStrengthIdx] : null,
|
||||
hasTurboSteps: turboStepsIdx !== -1,
|
||||
turboSteps: turboStepsIdx !== -1 ? instanceNode.widgets_values[turboStepsIdx] : null,
|
||||
hasPromptEnhance: promptEnhanceIdx !== -1,
|
||||
promptEnhance: promptEnhanceIdx !== -1 ? instanceNode.widgets_values[promptEnhanceIdx] : null,
|
||||
};
|
||||
}
|
||||
|
||||
// first_frame/last_frameが未接続のワークフローに対して、画像が指定された時だけ
|
||||
// LoadImageノードを新規注入する共通ヘルパー。ノードID/リンクIDはclone内の実際の
|
||||
// 最大値+1で採番するため、重複ID/リンクID不整合は起きない
|
||||
// (CLAUDE.md記載の既知の落とし穴への対策)。
|
||||
export function injectFrameNode(clone, instanceNode, targetSlot, filename) {
|
||||
const maxNodeId = Math.max(clone.last_node_id || 0, ...clone.nodes.map((n) => n.id));
|
||||
const maxLinkId = Math.max(clone.last_link_id || 0, ...clone.links.map((l) => l[0]));
|
||||
const newNodeId = maxNodeId + 1;
|
||||
const newLinkId = maxLinkId + 1;
|
||||
|
||||
clone.nodes.push({
|
||||
id: newNodeId,
|
||||
type: "LoadImage",
|
||||
pos: [(instanceNode.pos?.[0] || 0) - 260, (instanceNode.pos?.[1] || 0) + 40],
|
||||
size: [280, 314],
|
||||
flags: {},
|
||||
order: clone.nodes.length,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ localized_name: "image", name: "image", type: "COMBO", widget: { name: "image" }, link: null },
|
||||
{ localized_name: "choose file to upload", name: "upload", type: "IMAGEUPLOAD", widget: { name: "upload" }, link: null },
|
||||
],
|
||||
outputs: [
|
||||
{ localized_name: "IMAGE", name: "IMAGE", type: "IMAGE", links: [newLinkId] },
|
||||
{ localized_name: "MASK", name: "MASK", type: "MASK", links: null },
|
||||
],
|
||||
properties: { "Node name for S&R": "LoadImage" },
|
||||
widgets_values: [filename, "image"],
|
||||
});
|
||||
|
||||
clone.links.push([newLinkId, newNodeId, 0, instanceNode.id, targetSlot, "IMAGE"]);
|
||||
const targetInput = instanceNode.inputs[targetSlot];
|
||||
if (targetInput) targetInput.link = newLinkId;
|
||||
|
||||
clone.last_node_id = newNodeId;
|
||||
clone.last_link_id = newLinkId;
|
||||
}
|
||||
|
||||
// Writes one block's (or the old single-shot form's) values onto a workflow
|
||||
// clone already located via locateVideoModelNodes(clone) — `nodes`' node
|
||||
// references point into `clone` itself. First/last frame images are only
|
||||
// touched when a filename is actually given: an existing LoadImage node gets
|
||||
// its widget overwritten, a missing one gets injected on demand, and leaving
|
||||
// both unset keeps the slot disconnected (bypassed) exactly like the
|
||||
// template's own clean state.
|
||||
export function applyBlockToWorkflow(clone, nodes, params) {
|
||||
const {
|
||||
instanceNode, resolutionNode, loadImageNode, lastLoadImageNode, promptIdx, durationIdx, firstFrameSlot, lastFrameSlot,
|
||||
widthIdx, heightIdx, turboModeIdx, turboStrengthIdx, turboStepsIdx, promptEnhanceIdx,
|
||||
} = nodes;
|
||||
|
||||
instanceNode.widgets_values[promptIdx] = params.prompt || "";
|
||||
instanceNode.widgets_values[durationIdx] = params.duration;
|
||||
|
||||
const arIdx = widgetIndex(resolutionNode, "aspect_ratio");
|
||||
const mpIdx = widgetIndex(resolutionNode, "megapixels");
|
||||
const multIdx = widgetIndex(resolutionNode, "multiple");
|
||||
if (arIdx !== -1) resolutionNode.widgets_values[arIdx] = params.aspectRatio;
|
||||
if (mpIdx !== -1) resolutionNode.widgets_values[mpIdx] = params.megapixels;
|
||||
if (multIdx !== -1) resolutionNode.widgets_values[multIdx] = params.multiple;
|
||||
|
||||
// widthIdx/heightIdxはresolutionNodeがあるワークフローでは-1固定(locateVideoModelNodes
|
||||
// 参照)なので、そちらはaspect_ratio/megapixels/multiple経由の計算値のまま変更しない。
|
||||
if (widthIdx !== -1 && params.width != null) instanceNode.widgets_values[widthIdx] = params.width;
|
||||
if (heightIdx !== -1 && params.height != null) instanceNode.widgets_values[heightIdx] = params.height;
|
||||
// noise_seedはここでは書かない — comfyUI.generate()がAPI変換後のワークフロー全体から
|
||||
// "noise_seed"/"seed"キーを持つ全ノードを一括で上書きする(applySeedToWorkflow)ため、
|
||||
// ここで書いてもその後必ず上書きされる。Plan全体のシード管理は引き続きそちら任せでよい。
|
||||
if (turboModeIdx !== -1 && params.turboMode != null) instanceNode.widgets_values[turboModeIdx] = params.turboMode;
|
||||
if (turboStrengthIdx !== -1 && params.turboStrength != null) instanceNode.widgets_values[turboStrengthIdx] = params.turboStrength;
|
||||
if (turboStepsIdx !== -1 && params.turboSteps != null) instanceNode.widgets_values[turboStepsIdx] = params.turboSteps;
|
||||
if (promptEnhanceIdx !== -1 && params.promptEnhance != null) instanceNode.widgets_values[promptEnhanceIdx] = params.promptEnhance;
|
||||
|
||||
if (params.firstImageFilename) {
|
||||
if (loadImageNode) {
|
||||
const fnIdx = widgetIndex(loadImageNode, "image");
|
||||
if (fnIdx !== -1) loadImageNode.widgets_values[fnIdx] = params.firstImageFilename;
|
||||
} else if (firstFrameSlot !== -1) {
|
||||
injectFrameNode(clone, instanceNode, firstFrameSlot, params.firstImageFilename);
|
||||
}
|
||||
}
|
||||
if (params.lastImageFilename) {
|
||||
if (lastLoadImageNode) {
|
||||
const fnIdx = widgetIndex(lastLoadImageNode, "image");
|
||||
if (fnIdx !== -1) lastLoadImageNode.widgets_values[fnIdx] = params.lastImageFilename;
|
||||
} else if (lastFrameSlot !== -1) {
|
||||
injectFrameNode(clone, instanceNode, lastFrameSlot, params.lastImageFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const state = {
|
||||
groupFilter: "", // empty = all groups
|
||||
currentPage: 0,
|
||||
showBatchOnly: false,
|
||||
sortBy: localStorage.getItem("wfm_workflow_sort") || "date_desc",
|
||||
};
|
||||
|
||||
|
||||
@@ -449,6 +450,26 @@ function filterWorkflows() {
|
||||
});
|
||||
}
|
||||
|
||||
function sortWorkflows(list) {
|
||||
const sorted = [...list];
|
||||
switch (state.sortBy) {
|
||||
case "date_asc":
|
||||
sorted.sort((a, b) => a.mtime - b.mtime);
|
||||
break;
|
||||
case "name_asc":
|
||||
sorted.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
break;
|
||||
case "name_desc":
|
||||
sorted.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||
break;
|
||||
case "date_desc":
|
||||
default:
|
||||
sorted.sort((a, b) => b.mtime - a.mtime);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Grid Rendering (3 view modes)
|
||||
// ============================================
|
||||
@@ -459,7 +480,7 @@ function renderGrid() {
|
||||
|
||||
grid.className = `wfm-grid wfm-view-${state.viewMode}`;
|
||||
|
||||
const filtered = filterWorkflows();
|
||||
const filtered = sortWorkflows(filterWorkflows());
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML =
|
||||
@@ -1365,6 +1386,16 @@ export function initWorkflowTab() {
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
// Toolbar: sort dropdown
|
||||
const sortSel = document.getElementById("wfm-sort");
|
||||
if (sortSel) sortSel.value = state.sortBy;
|
||||
sortSel?.addEventListener("change", (e) => {
|
||||
state.sortBy = e.target.value;
|
||||
localStorage.setItem("wfm_workflow_sort", state.sortBy);
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
// Toolbar: Load in GenerateUI button
|
||||
document.getElementById("wfm-list-load-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
@@ -1378,7 +1409,7 @@ export function initWorkflowTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar: Load in Video button (MiniMax H3専用。非対応ワークフローはloadWorkflowIntoVideoEditor内でエラートースト表示済み)
|
||||
// Toolbar: Load in Video button (MiniMax H3 / LTX-2.5 / Wan2.2対応。非対応ワークフローはloadWorkflowIntoVideoEditor内でエラートースト表示済み)
|
||||
document.getElementById("wfm-list-load-video-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
<select id="wfm-group-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Groups</option>
|
||||
</select>
|
||||
<select id="wfm-sort" class="wfm-select wfm-toolbar-select">
|
||||
<option value="date_desc">Date (Newest)</option>
|
||||
<option value="date_asc">Date (Oldest)</option>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
</select>
|
||||
<button id="wfm-list-load-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled title="Select a card first">Load in GenerateUI</button>
|
||||
<button id="wfm-list-load-video-btn" class="wfm-btn wfm-btn-sm" disabled title="Select a card first">Load in Video</button>
|
||||
<button id="wfm-list-open-comfyui-btn" class="wfm-btn wfm-btn-sm" disabled title="Select a card first">Send to Canvas</button>
|
||||
@@ -951,12 +957,8 @@
|
||||
<div class="wfm-prompt-split-header">AI Assistant</div>
|
||||
<div class="wfm-ollama-assistant">
|
||||
<div class="wfm-ollama-controls">
|
||||
<select id="wfm-ollama-model" class="wfm-select" style="flex:1;min-width:120px;">
|
||||
<option value="">Loading models...</option>
|
||||
</select>
|
||||
<button id="wfm-ollama-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button id="wfm-ollama-test-btn" class="wfm-btn wfm-btn-sm">Test</button>
|
||||
<span id="wfm-ollama-status" class="wfm-ollama-status">Disconnected</span>
|
||||
<button id="wfm-ollama-settings-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;" title="Settings">⚙</button>
|
||||
</div>
|
||||
<div class="wfm-ollama-controls" style="margin-top:6px;">
|
||||
<button id="wfm-ollama-attach-btn" class="wfm-btn wfm-btn-sm">Attach Image</button>
|
||||
@@ -1417,7 +1419,8 @@
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-28">Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)</li>
|
||||
<li id="wfm-help-gen-31">LTX-2.5 and Wan2.2 video workflows also work when loaded as regular workflows in this tab (not just the dedicated Video tab): Positive/Negative Prompt detection follows LTX-2.5's LTXVDualCFGGuider / LTXVConditioning nodes to find the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's EmptyHunyuanLatentVideo and LTX-2.5's EmptyLTXVLatentVideo nodes (with an added Length (frames) field) instead of showing "No EmptyLatentImage node found"; generating from these workflows also gets the same extended timeout as MiniMax H3 video generations</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself); the search bar highlights matches and scrolls to them without moving the cursor into the editor, so typing right after a search can't accidentally overwrite the JSON — click into the editor first to actually edit</li>
|
||||
<li id="wfm-help-gen-30">The <b>Save</b> button (right end of the subtab bar) is hidden while the <b>Batch</b> or <b>Lab</b> subtab is active, since those work on workflow copies / their own Plan files rather than the currently loaded workflow; it reappears on Input/Model/Settings/Feeder</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1571,7 +1574,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-prompt-card-ai-title">AI Assistant & Translation</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-prompt-1">AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)</li>
|
||||
<li id="wfm-help-prompt-1">AI assistant (Ollama / LM Studio / Lemonade / Unsloth): chat for prompt consultation, image attachment (vision models); click the ⚙ Settings button to choose backend, connection URL, model, Thinking mode, Max tokens, and unload the loaded model — 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>
|
||||
@@ -1666,7 +1669,7 @@
|
||||
<h3 id="wfm-help-settings-card-connections-title">Connections</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-4">ComfyUI connection URL and test</li>
|
||||
<li id="wfm-help-settings-5">Ollama URL, default model, and connection test</li>
|
||||
<li id="wfm-help-settings-5">AI Assistant settings have moved out of this tab — the Prompt tab, Tagger tab, and AI TOOL tab each have their own ⚙ Settings button (backend, URL, model, Thinking mode, Max tokens, unload model), saved independently</li>
|
||||
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1763,7 +1766,7 @@
|
||||
<h3 id="wfm-help-gallery-card-folder-title">Folder & Selection</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gallery-5">Folder tree: click to load images, expand with ▶; "+ New" creates a subfolder inside the selected folder, "Del" deletes it with all contents</li>
|
||||
<li id="wfm-help-gallery-6">Multi-select with Ctrl+click — bulk bar: select group → 'Add to Group' / 'Remove from Group'; 'Favorite All' / 'Unfavorite All'; 'Compare' (2–4 images); 'Move To...'; '<b>Export</b>' (downloads all selected images as a ZIP file); 'Delete File'</li>
|
||||
<li id="wfm-help-gallery-6">Multi-select with Ctrl+click — bulk bar: select group → 'Add to Group' / 'Remove from Group'; 'Favorite All' / 'Unfavorite All'; 'Compare' (2–4 images); 'Move To...'; '<b>Export</b>' (downloads all selected images as a ZIP file); '<b>Create PSD</b>' (combines the selected images into one layered PSD file); '<b>Edit as Layers</b>' (opens the selected images in the Image Edit tab, each as its own layer); 'Delete File'</li>
|
||||
<li id="wfm-help-gallery-15"><b>F button</b> (cyan overlay, top-left of each thumbnail): toggle the image in/out of the <b>__Feeder__</b> group with a single click; active state stays visible (cyan) when the image is already in the group; use this to quickly queue images for the Feeder tab Gallery mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1781,16 +1784,19 @@
|
||||
<li id="wfm-help-gallery-12"><b>Metadata</b> button (green, in the detail panel tab bar): opens the selected image in the Metadata tab for full embedded-data inspection; Alt+click on any image thumbnail has the same effect</li>
|
||||
<li id="wfm-help-gallery-13"><b>Load GenUI</b> button (blue, in the detail panel tab bar): loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab and switches to it; shows a warning toast if no workflow is embedded or the format is not supported</li>
|
||||
<li id="wfm-help-gallery-14"><b>Tagger</b> button (purple, in the detail panel action row): opens the selected image directly in the Tagger tab's Single mode for instant tagging</li>
|
||||
<li id="wfm-help-gallery-16"><b>Image Edit</b> button (toolbar, next to Refresh): sends the currently selected image to the Image Edit tab; if layers already exist the image is added as a new layer scaled to fit the canvas, otherwise it becomes Layer 1</li>
|
||||
<li id="wfm-help-gallery-16"><b>Image Edit</b> button (toolbar, next to Refresh): sends the currently selected image to the Image Edit tab; if layers already exist the image is added as a new layer scaled to fit the canvas, otherwise it becomes Layer 1; PSD files are fully decomposed into their original layers instead of being flattened into one image</li>
|
||||
<li id="wfm-help-gallery-17"><b>Send GenUI Image</b> button (toolbar, next to Image Edit): uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded first</li>
|
||||
<li id="wfm-help-gallery-27"><b>Send to LI node</b> button (toolbar, next to Send GenUI Image): uploads the selected image and writes its filename into the image widget of the currently selected node on the ComfyUI canvas (falling back to the first LoadImage-type node found) — same destination as Image Edit tab's Send to LI node; requires Workflow Studio to have been opened from ComfyUI's top menu button</li>
|
||||
<li id="wfm-help-gallery-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-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>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-gallery-card-formats-title">Formats & Auto-save</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gallery-10">Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the JSON tab</li>
|
||||
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF, MP4; workflow extraction reads ComfyUI prompt/workflow keys embedded in PNG metadata or in MP4 container tags (as written by SaveVideo, e.g. MiniMax H3 output)</li>
|
||||
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF, MP4, 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-30">PSD files: thumbnails and full-size previews show all layers merged together (hidden layers excluded); the underlying PSD file itself is left untouched — only the preview shown in Gallery is a flattened rendering</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1922,7 +1928,7 @@
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-settings-title">Settings pane</h3>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model; Unsloth always requires an API key even for local access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI, the key is read server-side and never sent to the browser; Unsloth also requires "Auto model switching (OpenAI API)" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a `think` flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</p>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model, and use the Unload Model button (next to Test connection) to free the loaded model from VRAM/memory — Ollama 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1934,7 +1940,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-tagger-card-overview-title">Overview & Setup</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-tagger-1">Automatic image tagging using WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and Ollama VLM — 3 sub-tabs: Single / Batch / DB</li>
|
||||
<li id="wfm-help-tagger-1">Automatic image tagging using WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and VLM (Ollama / LM Studio / Lemonade / Unsloth) — 3 sub-tabs: Single / Batch / DB</li>
|
||||
<li id="wfm-help-tagger-2">Model placement: put each model in its own subfolder under ComfyUI/models/tagger/<model-name>/; the folder must contain a .onnx file + selected_tags.csv (or .h5 + label file for DeepDanbooru)</li>
|
||||
<li id="wfm-help-tagger-9">Install dependencies into ComfyUI's embedded Python: python_embeded\python.exe -m pip install -r requirements.txt; for GPU ONNX inference install onnxruntime-gpu instead of onnxruntime; TensorFlow (for DeepDanbooru) is commented out by default due to size</li>
|
||||
</ul>
|
||||
@@ -1944,7 +1950,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-tagger-3">Single tab: drag & drop an image onto the preview area, or click Upload to pick a file; the Tagger button in the Gallery detail panel opens the selected image directly here</li>
|
||||
<li id="wfm-help-tagger-4">Threshold slider: minimum confidence score for general tags (default 0.35); Character Threshold: separate threshold for character: prefix tags (default 0.85)</li>
|
||||
<li id="wfm-help-tagger-5">Ollama VLM: enable the checkbox to run a vision model alongside WD Tagger; set API URL, select model (click ↻ to refresh), and customize the prompt; results from both models are merged</li>
|
||||
<li id="wfm-help-tagger-5">VLM Tagging: enable the checkbox to run a vision model alongside WD Tagger; click the ⚙ Settings button to choose backend (Ollama / LM Studio / Lemonade / Unsloth), URL, model, Thinking mode, Max tokens, and unload the model — independent from the Prompt tab's own AI settings; customize the prompt and Max Tags inline; results from both models are merged</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1956,7 +1962,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-tagger-card-batch-title">Batch & DB Tabs</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-tagger-7">Batch tab: enter a folder path, configure WD Tagger and Ollama settings; output options: Save to DB (default on), Write to File (EXIF/PNG metadata), Write .txt (creates <filename>.txt alongside each image); click Start Batch to process all images in the folder; progress bar and log are updated in real time; Stop cancels after the current image</li>
|
||||
<li id="wfm-help-tagger-7">Batch tab: enter a folder path, configure WD Tagger and enable VLM tagging (uses the same ⚙ Settings as the Single tab); output options: Save to DB (default on), Write to File (EXIF/PNG metadata), Write .txt (creates <filename>.txt alongside each image); click Start Batch to process all images in the folder; progress bar and log are updated in real time; Stop cancels after the current image</li>
|
||||
<li id="wfm-help-tagger-8">DB tab: searchable database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records as a CSV file</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1971,8 +1977,10 @@
|
||||
<h3 id="wfm-help-imageedit-card-overview-title">Overview</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-1">Layer-based image editor — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite</li>
|
||||
<li id="wfm-help-imageedit-2">Loading images: drag & drop onto the canvas, Upload button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas</li>
|
||||
<li id="wfm-help-imageedit-2">Loading images: drag & drop onto the canvas, <b>File Open</b> button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas</li>
|
||||
<li id="wfm-help-imageedit-3">New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers and automatically adds an empty Layer 1</li>
|
||||
<li id="wfm-help-imageedit-31"><b>Open PSD</b> button (next to File Open): loads a layered PSD file from disk and decomposes it into individual layers — position, size, visibility, opacity, and blend mode are restored for each layer; groups and non-raster layers (adjustment layers, etc.) are flattened into their rendered appearance</li>
|
||||
<li id="wfm-help-imageedit-32"><b>Close</b> button (next to Open PSD): closes the current document after a confirmation prompt, discarding all layers and unsaved edits and returning to the empty placeholder view</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1982,9 +1990,10 @@
|
||||
<li id="wfm-help-imageedit-5">Draw (B): freehand brush drawing onto the active draw layer; Tool Options: color, brush size, blend mode; Properties pane includes a MASK EDITOR ONE (COLOR) section for ABR image brushes — always visible, header text is green when brushes are available, gray when none are imported yet; Select is disabled until at least one brush exists</li>
|
||||
<li id="wfm-help-imageedit-6">Text (T): click on the canvas to place a text input overlay; configure font family, size, bold/italic, align, color in the Tool Options bar; click OK to place the text as an exact-size Text layer; double-click an existing text layer with the Select tool to re-edit</li>
|
||||
<li id="wfm-help-imageedit-7">Shape (S): draw geometric shapes onto the canvas; each committed shape becomes an independent draw layer; Tool Options: Shape type (Rect / Ellipse / Line / FreeLine), Rounded checkbox (Rect / Ellipse only), Fill color + None toggle (hidden for Line / FreeLine), Stroke color + width + None toggle (None hidden for Line / FreeLine where stroke is always active), Opacity slider, ↩ Undo button; drag to draw — a blue dashed outline previews the shape; release to commit</li>
|
||||
<li id="wfm-help-imageedit-7a2">Fill (G): bucket fill — click a contiguous same-color region on the active layer to flood-fill it with a solid color or a linear/radial gradient; Tool Options: Mode (Solid / Gradient), Color (solid) or Shape + Ramp (drag stops to reposition, click a stop then use the color swatch, +/- to add/remove) + Direction dial (drag to set angle/radius, gradient), Tolerance (color-match threshold), Opacity</li>
|
||||
<li id="wfm-help-imageedit-7b">Blur (≈): apply Gaussian blur or mosaic to the active layer — whole-layer or region-based (drag a rectangle); see the Blur Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7c">BG Remove (⬚): remove the background from the active layer using Lightweight (@imgly, runs in browser) or BiRefNet (requires Mask Editor One + birefnet.safetensors in ComfyUI/models/background_removal/); see the BG Remove Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7d">Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click "M" in the Layers header to add a mask layer; see the Mask Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7d">Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click "Mask" (second row of the Layers header) to add a mask layer; see the Mask Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7e">Inpaint (🩹): composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like VAEEncodeForInpaint); enter Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), and Denoise (applied to KSampler) in the properties panel, then click Run to upload and generate without leaving the Image Edit tab — the result is shown back in the properties panel when done; requires a Mask layer to be painted first and an inpaint-capable workflow loaded in GenerateUI; also works with workflows built around the Mask Editor One custom node (comfyui-mask-editor-one) instead of LoadImage — image/mask are pushed to it via its node_id-keyed server cache and layer_data widget; "Use dedicated workflow" checkbox: when off, the workflow currently loaded in GenerateUI is used (unchanged); when on, pick a saved workflow from the dropdown — it is loaded, edited, and generated entirely in the background without disturbing the workflow shown in GenerateUI</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1992,7 +2001,8 @@
|
||||
<h3 id="wfm-help-imageedit-layer-title">Layer Panel</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-8">Layers are listed from front (top) to back (bottom); click a layer row to make it active</li>
|
||||
<li id="wfm-help-imageedit-8b">"M" button in the Layers header: adds a new mask layer above the current layer and automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-8b">The Layers header has two rows: the top row has +, ⧉ (Duplicate), −, ↑, ↓; the "<b>Mask</b>" button sits alone on the second row — it adds a new mask layer above the current layer and automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-33">⧉ (Duplicate layer) button: copies the active layer — including its pixels, position, size, opacity, blend mode, and visibility — and inserts the copy directly above the original as the new active layer; works on any layer type (image, draw, text, mask)</li>
|
||||
<li id="wfm-help-imageedit-9">👁 / 🚫 button: toggle layer visibility</li>
|
||||
<li id="wfm-help-imageedit-10">🔒 / 🔓 button: toggle layer lock — a locked layer cannot be moved, resized, or rotated (orange bounding box is shown); Layer 1 is locked automatically when the first image is loaded to prevent accidental moves</li>
|
||||
<li id="wfm-help-imageedit-11">Opacity slider at the bottom adjusts the active layer's transparency</li>
|
||||
@@ -2024,7 +2034,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-imageedit-mask-title">Mask Tool (🎭)</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-mask-1">Add a mask layer: click the "M" button in the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically</li>
|
||||
<li id="wfm-help-imageedit-mask-1">Add a mask layer: click the "Mask" button on the second row of the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically</li>
|
||||
<li id="wfm-help-imageedit-mask-2">Clicking a mask layer in the layer list automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-mask-3">Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge; the overlay is only shown for the currently active mask layer — inactive mask layers stay hidden until selected</li>
|
||||
<li id="wfm-help-imageedit-mask-4">Properties pane — Paint mode: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); MASK EDITOR ONE section (always visible — header text is green when ABR brushes are available, gray when none are imported yet): Select button opens the ABR brush library (disabled until at least one brush exists) — choosing a brush replaces the default circle brush; ✕ clears the brush back to Circle; when an image brush is active, Hardness is disabled and additional controls appear: Spacing (stamp interval 5–100%), Angle (rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random rotation per stamp on/off)</li>
|
||||
@@ -2070,6 +2080,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-17">Save PNG: composites all visible layers and downloads the result as a PNG file</li>
|
||||
<li id="wfm-help-imageedit-18">Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root</li>
|
||||
<li id="wfm-help-imageedit-34">Save PSD: downloads a layered PSD file with every layer preserved separately — position, rotation, flip, and scale are baked into each layer's pixels (PSD layers cannot store a rotation angle), while opacity, blend mode, and visibility are kept as real, editable PSD layer attributes; clipping masks are merged into their target layer since PSD's native layer-mask mechanism is not used</li>
|
||||
<li id="wfm-help-imageedit-19b">Send to Workflow: uploads the composite PNG to ComfyUI's input folder via /upload/image and writes the resulting filename into the image widget of the currently selected node in the ComfyUI graph, falling back to the first LoadImage-type node found; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable</li>
|
||||
<li id="wfm-help-imageedit-20">Canvas navigation: scroll wheel to zoom, Space + drag to pan</li>
|
||||
<li id="wfm-help-imageedit-21">Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)</li>
|
||||
@@ -2082,6 +2093,7 @@
|
||||
<li id="wfm-help-imageedit-23">B: Draw tool</li>
|
||||
<li id="wfm-help-imageedit-24">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-25">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-25b">G: Fill tool</li>
|
||||
<li id="wfm-help-imageedit-26">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-27">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-28">Space + drag: Pan canvas</li>
|
||||
@@ -2093,16 +2105,21 @@
|
||||
|
||||
<div class="wfm-help-page" id="wfm-help-page-video">
|
||||
<h2 id="wfm-help-video-title">Video Tab</h2>
|
||||
<p id="wfm-help-video-desc">A dedicated video generation tab for the MiniMax H3 (Image to Video) workflow, independent of the GenerateUI tab.</p>
|
||||
<p id="wfm-help-video-desc">A dedicated video generation tab for MiniMax H3 (Image to Video), LTX-2.5 (Text / Image / First & Last Frame to Video), and Wan2.2 (Text to Video) workflows, independent of the GenerateUI tab. The center panel's Plan / Edit / Asset subtabs handle batch generation, clip editing, and browsing generated videos; the sidebar's Project subtab handles saved plans.</p>
|
||||
<div class="wfm-help-card">
|
||||
<ul>
|
||||
<li id="wfm-help-video-1">Set first_frame (optional) / last_frame (optional) images, prompt, aspect_ratio / megapixels / multiple (resolution), duration (seconds), and noise_seed, then click Generate. Leaving first_frame empty runs the workflow text-only (bypassed), and workflows with no LoadImage node at all are also supported.</li>
|
||||
<li id="wfm-help-video-2">Model selection (UNet / CLIP / VAE / Audio VAE) is fixed to the loaded workflow's defaults and cannot be changed from this tab.</li>
|
||||
<li id="wfm-help-video-3">Use the "Load in Video" button in the Workflow tab's detail modal to load a workflow containing a MiniMax H3 Image-to-Video node. Unsupported workflows show an error instead.</li>
|
||||
<li id="wfm-help-video-4">Generated videos (mp4) can also be browsed and played back in the Gallery tab.</li>
|
||||
<li id="wfm-help-video-5">The Video Source panel next to the preview lets you drop or pick any local video (not just a generated one) to use the Frame/GIF tools below without running MiniMax H3 first; Clear resets it back to empty.</li>
|
||||
<li id="wfm-help-video-6">Frame tab (right panel): play/seek the preview video to the position you want, click Capture Frame to grab that frame client-side, then Save to Output to write it as a PNG into ComfyUI's output folder.</li>
|
||||
<li id="wfm-help-video-1">Plan subtab: the topbar's settings row holds Aspect Ratio / Megapixels / Multiple / Duration (seconds) / Noise Seed as this plan's base settings — Duration always reflects the currently selected block (each block keeps its own value), the rest are plan-wide. A second row below adds Width / Height / Turbo Mode / Strength / Steps / Prompt Enhance, each enabled only for workflows that actually support it (see helpVideo2). Split the timeline into blocks with the + Split / + Block / Delete buttons — each block has its own Prompt, Duration, and First/Last Image — First Image can be None, an explicit image, or "use previous block's last frame" (an explicit Last Image on the previous block always wins over extracting one from its generated video). Click Run to generate every block in order; Pause/Cancel work mid-run.</li>
|
||||
<li id="wfm-help-video-2">A plan with a single block behaves exactly like the old one-shot form. Model selection (UNet / CLIP / VAE / Audio VAE) is fixed to the loaded workflow's defaults. For workflows with no ResolutionSelector node (e.g. Wan2.2, LTX-2.5 First & Last Frame to Video), the Aspect Ratio / Megapixels / Multiple fields are disabled and the workflow's own Width / Height become directly editable instead. Turbo Mode / Strength / Steps only apply to MiniMax H3's turbo LoRA, and Prompt Enhance only to LTX-2.5 — both stay disabled for other model families. Noise Seed is disabled for the one workflow that doesn't expose it: Wan2.2 Text-to-Video.</li>
|
||||
<li id="wfm-help-video-3">Use the "Load in Video" button in the Workflow tab's detail modal to load a workflow containing a MiniMax H3 Image-to-Video node, an LTX-2.5 Text/Image/First&Last-Frame-to-Video node, or a Wan2.2 Text-to-Video node — this starts a fresh single-block plan seeded from the workflow's own current values. Unsupported workflows show an error instead.</li>
|
||||
<li id="wfm-help-video-4">Generated videos (mp4) automatically appear in the Asset subtab's Video Temp group, and can also be browsed and played back in the Gallery tab. Organizing keepers into Video Assets is entirely up to you, on your own schedule.</li>
|
||||
<li id="wfm-help-video-5">The center panel shows two previews: the left one shows either the video you select in the Asset subtab or whatever you drop/pick in the Video Source panel on its left (Clear resets it to empty), letting you use the Frame/GIF tools without running the loaded video workflow first; the right one always shows the most recently generated clip.</li>
|
||||
<li id="wfm-help-video-6">Frame tab (right panel): play/seek the preview video to the position you want, click Capture Frame to grab that frame client-side, then Save to Output to write it as a PNG into ComfyUI's output folder — check "Add to Video Assets group" first to also tag the saved frame into Gallery's Video Assets group.</li>
|
||||
<li id="wfm-help-video-7">GIF tab (right panel): set Start/End (seconds), FPS, and an optional Max Width, then Convert to GIF — the server decodes the video and encodes an animated GIF into ComfyUI's output folder.</li>
|
||||
<li id="wfm-help-video-8">Chaining: setting a block's First Image to "use previous block's last frame" continues smoothly from the previous clip — if that previous block has its own explicit Last Image, that image is used directly; otherwise the actual last frame of its generated video is extracted automatically.</li>
|
||||
<li id="wfm-help-video-9">Project tab (sidebar): 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-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>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="wfm-help-jump-btn wfm-btn wfm-btn-sm" data-jump-tab="video">Go to Video Tab</button>
|
||||
@@ -2128,6 +2145,7 @@
|
||||
<li id="wfm-help-imageedit-tabkbd-2">B: Draw tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-3">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-4">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-4b">G: Fill tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-5">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-6">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-7">Space + drag: Pan canvas</li>
|
||||
@@ -2267,6 +2285,8 @@
|
||||
<button id="wfm-gallery-bulk-compare" class="wfm-btn wfm-btn-sm" style="display:none;"></button>
|
||||
<button id="wfm-gallery-bulk-move" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-export" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-psd" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-edit-layers" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-gallery-bulk-delete" class="wfm-btn wfm-btn-sm wfm-btn-danger"></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2609,6 +2629,7 @@
|
||||
<button class="ie-tool-btn" data-tool="draw" title="Draw (B)">✏</button>
|
||||
<button class="ie-tool-btn" data-tool="text" title="Text (T)">T</button>
|
||||
<button class="ie-tool-btn" data-tool="shape" title="Shape (S)">□</button>
|
||||
<button class="ie-tool-btn" data-tool="fill" title="Fill (G)">🪣</button>
|
||||
<button class="ie-tool-btn" data-tool="mask" title="Mask">🎭</button>
|
||||
<button class="ie-tool-btn" data-tool="blur" title="Blur">≈</button>
|
||||
<button class="ie-tool-btn" data-tool="filter" title="Filter">★</button>
|
||||
@@ -2621,15 +2642,21 @@
|
||||
<!-- Action bar -->
|
||||
<div class="ie-action-bar">
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;">
|
||||
Upload
|
||||
File Open
|
||||
<input type="file" id="ie-upload-input" accept="image/*" style="display:none;">
|
||||
</label>
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;" title="Open a layered PSD file">
|
||||
Open PSD
|
||||
<input type="file" id="ie-open-psd-input" accept=".psd" style="display:none;">
|
||||
</label>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-new-btn">New</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-close-btn" title="Close the current document">Close</button>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-undo-btn" title="Undo (Ctrl+Z)">↩</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-redo-btn" title="Redo (Ctrl+Y)">↪</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-btn">Save PNG</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-gallery-btn" title="Save to Gallery root folder">Save to Gallery</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-psd-btn" title="Save all layers as a layered PSD file">Save PSD</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-send-workflow-btn" title="Upload and write this image's filename into the selected node's image widget (e.g. LoadImage)">Send to LI node</button>
|
||||
</div>
|
||||
|
||||
@@ -2670,12 +2697,17 @@
|
||||
<!-- Right Panel (Layers) -->
|
||||
<div class="ie-right-panel">
|
||||
<div class="ie-layers-header">
|
||||
<span>Layers</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-layer-btn" title="Add layer">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-mask-btn" title="Add mask layer" style="font-weight:700;color:var(--wfm-accent,#4682e6);">M</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-del-layer-btn" title="Delete layer">−</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-up-btn" title="Move up">↑</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-down-btn" title="Move down">↓</button>
|
||||
<div class="ie-layers-header-row">
|
||||
<span>Layers</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-layer-btn" title="Add layer">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-dup-layer-btn" title="Duplicate layer">⧉</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-del-layer-btn" title="Delete layer">−</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-up-btn" title="Move up">↑</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-down-btn" title="Move down">↓</button>
|
||||
</div>
|
||||
<div class="ie-layers-header-row">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-mask-btn" title="Add mask layer" style="font-weight:700;color:var(--wfm-accent,#4682e6);">Mask</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-layer-list" id="ie-layer-list"></div>
|
||||
<div class="ie-opacity-row">
|
||||
@@ -2687,119 +2719,257 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Video Tab (MiniMax H3専用・GenerateUIタブとは独立) -->
|
||||
<!-- Video Tab (Plan[バッチ動画生成]/Asset[素材管理]/Edit[将来機能]の3サブタブ。
|
||||
中央のプレビュー+右のFrame/GIFプロパティパネルはサブタブ切替と独立した常時表示) -->
|
||||
<section id="wfm-tab-video" class="wfm-tab-content">
|
||||
<div class="wfm-video-layout">
|
||||
<div class="wfm-video-form-panel">
|
||||
<div class="wfm-video-header">
|
||||
<h3 id="wfm-video-title-label">Video (MiniMax H3)</h3>
|
||||
<span id="wfm-video-wf-name" class="wfm-video-wf-name">No workflow loaded</span>
|
||||
<div class="wfm-video-subtab-nav">
|
||||
<button type="button" class="wfm-video-subtab-btn" data-video-subtab="project" id="wfm-video-subtab-btn-project">Project</button>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-frame-slots">
|
||||
<div class="wfm-video-frame-slot">
|
||||
<label id="wfm-video-first-frame-label">First Frame (optional)</label>
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-video-first-frame-wrap" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-video-first-frame-preview">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-first-frame-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<span id="wfm-video-first-frame-drop-label">Drop image or click to select</span>
|
||||
<input type="file" accept="image/*" id="wfm-video-first-frame-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<span class="wfm-i2i-status" id="wfm-video-first-frame-status" style="flex:1;"></span>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-first-frame-clear">Clear</button>
|
||||
</div>
|
||||
<!-- Project: 保存済みVideo Plan一覧(クリックでPlanエディタへ読み込み) -->
|
||||
<div id="wfm-video-subtab-project" class="wfm-video-subtab-panel" data-video-subtab-panel="project" style="display:none;">
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<p class="wfm-video-prop-hint" id="wfm-video-project-hint" style="flex:1;margin:0;">Saved plans</p>
|
||||
<button type="button" id="wfm-video-project-refresh" class="wfm-btn wfm-btn-sm" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="wfm-video-frame-slot">
|
||||
<label id="wfm-video-last-frame-label">Last Frame (optional)</label>
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-video-last-frame-wrap" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-video-last-frame-preview">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-last-frame-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<span id="wfm-video-last-frame-drop-label">Drop image or click to select</span>
|
||||
<input type="file" accept="image/*" id="wfm-video-last-frame-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<span class="wfm-i2i-status" id="wfm-video-last-frame-status" style="flex:1;"></span>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-last-frame-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="wfm-video-prompt" id="wfm-video-prompt-label">Prompt</label>
|
||||
<textarea id="wfm-video-prompt" class="wfm-textarea" rows="6"></textarea>
|
||||
|
||||
<div class="wfm-video-resolution-row">
|
||||
<div>
|
||||
<label for="wfm-video-aspect-ratio" id="wfm-video-aspect-ratio-label">Aspect Ratio</label>
|
||||
<select id="wfm-video-aspect-ratio" class="wfm-input"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-megapixels" id="wfm-video-megapixels-label">Megapixels</label>
|
||||
<input type="number" id="wfm-video-megapixels" class="wfm-input" step="0.01" min="0.01" value="0.2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-multiple" id="wfm-video-multiple-label">Multiple</label>
|
||||
<input type="number" id="wfm-video-multiple" class="wfm-input" step="8" min="8" value="32">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-resolution-row">
|
||||
<div>
|
||||
<label for="wfm-video-duration" id="wfm-video-duration-label">Duration (seconds)</label>
|
||||
<input type="number" id="wfm-video-duration" class="wfm-input" step="0.5" min="0.5" value="2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-noise-seed" id="wfm-video-noise-seed-label">Noise Seed</label>
|
||||
<input type="number" id="wfm-video-noise-seed" class="wfm-input" value="0">
|
||||
</div>
|
||||
<div class="wfm-video-seed-randomize-wrap">
|
||||
<label><input type="checkbox" id="wfm-video-seed-randomize" checked> <span id="wfm-video-randomize-label">Randomize</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;">
|
||||
<button id="wfm-video-generate-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Generate</button>
|
||||
</div>
|
||||
<div class="wfm-gen-progress-container">
|
||||
<div class="wfm-gen-progress-track">
|
||||
<div id="wfm-video-progress-bar" class="wfm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span id="wfm-video-progress-text" class="wfm-gen-progress-text">Ready</span>
|
||||
<div class="wfm-video-project-list" id="wfm-video-project-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中央ペイン: プレビュー(生成結果 or ドロップされた任意の動画) + 編集(将来機能用プレースホルダー) -->
|
||||
<!-- 中央ペイン: プレビュー(常時表示) + Plan/Editタブ(バッチ生成編集・将来の動画編集機能)。
|
||||
左のAssetサブタブ切替とは独立して常に表示される -->
|
||||
<div class="wfm-video-center-panel">
|
||||
<div class="wfm-video-preview-row">
|
||||
<div class="wfm-video-preview-frame" id="wfm-video-preview-frame">
|
||||
<span class="wfm-placeholder" id="wfm-video-preview-placeholder">No video</span>
|
||||
<video id="wfm-video-preview-video" class="wfm-video-preview-el" controls style="display:none;"></video>
|
||||
</div>
|
||||
<div class="wfm-video-source-panel">
|
||||
<div class="wfm-video-pane-header" id="wfm-video-source-label">Video Source</div>
|
||||
<p class="wfm-video-prop-hint" id="wfm-video-source-hint">Load any video here to use Frame/GIF tools without generating one.</p>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-source-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<span id="wfm-video-source-drop-label">Drop video or click to select</span>
|
||||
<input type="file" accept="video/*" id="wfm-video-source-file" style="display:none;">
|
||||
<input type="file" accept="video/*,image/*" id="wfm-video-source-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-top:6px;">
|
||||
<span class="wfm-i2i-status" id="wfm-video-source-status" style="flex:1;"></span>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-source-add-to-edit">Add to Edit</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-source-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-preview-frame" id="wfm-video-source-preview-frame">
|
||||
<span class="wfm-placeholder" id="wfm-video-source-preview-placeholder">Asset / Source Preview</span>
|
||||
<video id="wfm-video-source-preview-video" class="wfm-video-preview-el" controls style="display:none;"></video>
|
||||
<img id="wfm-video-source-preview-img" class="wfm-video-preview-el" style="display:none;">
|
||||
</div>
|
||||
<div class="wfm-video-preview-frame" id="wfm-video-preview-frame">
|
||||
<span class="wfm-placeholder" id="wfm-video-preview-placeholder">Generated Video Preview</span>
|
||||
<video id="wfm-video-preview-video" class="wfm-video-preview-el" controls style="display:none;"></video>
|
||||
<img id="wfm-video-preview-img" class="wfm-video-preview-el" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-edit-panel">
|
||||
<span id="wfm-video-edit-placeholder-label" class="wfm-placeholder">Editing tools coming soon</span>
|
||||
|
||||
<div class="wfm-video-subtab-nav wfm-video-center-subtab-nav">
|
||||
<button type="button" class="wfm-video-subtab-btn active" data-video-subtab="plan">Plan</button>
|
||||
<button type="button" class="wfm-video-subtab-btn" data-video-subtab="edit">Edit</button>
|
||||
<button type="button" class="wfm-video-subtab-btn" data-video-subtab="asset" id="wfm-video-subtab-btn-asset">Asset</button>
|
||||
</div>
|
||||
|
||||
<!-- Asset: このPlanが生成した動画の素材管理(Galleryのグループ機能を再利用)。
|
||||
左=一覧(サムネイル/テーブル切替可) / 右=選択ファイルの詳細(タグ・メモ等) -->
|
||||
<div id="wfm-video-subtab-asset" class="wfm-video-subtab-panel wfm-video-asset-content" data-video-subtab-panel="asset" style="display:none;">
|
||||
<div class="wfm-video-asset-layout">
|
||||
<div class="wfm-video-asset-list-col">
|
||||
<div class="wfm-video-asset-toolbar">
|
||||
<select id="wfm-video-asset-group" class="wfm-input" style="flex:1;"></select>
|
||||
<select id="wfm-video-asset-kind" class="wfm-input" style="flex:0.5;">
|
||||
<option value="all">All Types</option>
|
||||
<option value="video">Video Only</option>
|
||||
<option value="image">Image Only</option>
|
||||
</select>
|
||||
<input type="text" id="wfm-video-asset-search" class="wfm-input" placeholder="Search filename..." style="flex:1;">
|
||||
<button type="button" id="wfm-video-asset-view-grid" class="wfm-btn wfm-btn-sm wfm-video-asset-view-btn active" title="Thumbnail view">▦</button>
|
||||
<button type="button" id="wfm-video-asset-view-table" class="wfm-btn wfm-btn-sm wfm-video-asset-view-btn" title="Table view">☰</button>
|
||||
<button type="button" id="wfm-video-asset-refresh" class="wfm-btn wfm-btn-sm" title="Refresh">↻</button>
|
||||
</div>
|
||||
<span id="wfm-video-asset-status" class="wfm-i2i-status"></span>
|
||||
<div class="wfm-video-asset-grid" id="wfm-video-asset-grid"></div>
|
||||
<div class="wfm-video-asset-table-wrap" id="wfm-video-asset-table-wrap" style="display:none;">
|
||||
<table class="wfm-video-asset-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Updated</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="wfm-video-asset-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-asset-detail-col">
|
||||
<div class="wfm-video-asset-detail" id="wfm-video-asset-detail">
|
||||
<span class="wfm-placeholder">Select a video</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan: バッチ動画生成のタイムライン編集・実行(中央下部、Planタブ選択時のみ表示) -->
|
||||
<div id="wfm-video-plan-content" class="wfm-video-subtab-panel wfm-video-plan-content" data-video-subtab-panel="plan">
|
||||
<div class="wfm-video-plan-topbar">
|
||||
<div class="wfm-video-wf-row">
|
||||
<span id="wfm-video-wf-name" class="wfm-video-wf-name">No workflow loaded</span>
|
||||
<div class="wfm-lab-plan-workflow-row" id="wfm-video-plan-workflow-row" style="display:none;">
|
||||
<span id="wfm-video-plan-workflow-name" class="wfm-lab-plan-workflow-name"></span>
|
||||
<button type="button" id="wfm-video-plan-workflow-load-btn" class="wfm-btn wfm-btn-xs" title="Load this plan's recorded workflow">Open Workflow</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-plan-basesettings">
|
||||
<div>
|
||||
<label for="wfm-video-plan-aspect-ratio" id="wfm-video-plan-aspect-ratio-label">Aspect Ratio</label>
|
||||
<select id="wfm-video-plan-aspect-ratio" class="wfm-input"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-megapixels" id="wfm-video-plan-megapixels-label">Megapixels</label>
|
||||
<input type="number" id="wfm-video-plan-megapixels" class="wfm-input" step="0.01" min="0.01" value="0.2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-multiple" id="wfm-video-plan-multiple-label">Multiple</label>
|
||||
<input type="number" id="wfm-video-plan-multiple" class="wfm-input" step="8" min="8" value="32">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-block-duration" id="wfm-video-block-duration-label">Duration (seconds)</label>
|
||||
<input type="number" id="wfm-video-block-duration" class="wfm-input" step="0.5" min="0.5" value="2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-seed-value" id="wfm-video-plan-seed-value-label">Noise Seed</label>
|
||||
<div class="wfm-video-seed-inline">
|
||||
<input type="number" id="wfm-video-plan-seed-value" class="wfm-input" value="0">
|
||||
<label class="wfm-video-seed-randomize-inline"><input type="checkbox" id="wfm-video-plan-seed-randomize" checked> Rand</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-video-plan-extrasettings">
|
||||
<div>
|
||||
<label for="wfm-video-plan-width" id="wfm-video-plan-width-label">Width</label>
|
||||
<input type="number" id="wfm-video-plan-width" class="wfm-input" step="16" min="16" value="1280">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-height" id="wfm-video-plan-height-label">Height</label>
|
||||
<input type="number" id="wfm-video-plan-height" class="wfm-input" step="16" min="16" value="720">
|
||||
</div>
|
||||
<div>
|
||||
<label id="wfm-video-plan-turbo-mode-label">Turbo Mode</label>
|
||||
<label class="wfm-video-checkbox-inline"><input type="checkbox" id="wfm-video-plan-turbo-mode"> Enabled</label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-turbo-strength" id="wfm-video-plan-turbo-strength-label">Strength</label>
|
||||
<input type="number" id="wfm-video-plan-turbo-strength" class="wfm-input" step="0.05" min="0" value="1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wfm-video-plan-turbo-steps" id="wfm-video-plan-turbo-steps-label">Steps</label>
|
||||
<input type="number" id="wfm-video-plan-turbo-steps" class="wfm-input" step="1" min="1" value="8">
|
||||
</div>
|
||||
<div>
|
||||
<label id="wfm-video-plan-prompt-enhance-label">Prompt Enhance</label>
|
||||
<label class="wfm-video-checkbox-inline"><input type="checkbox" id="wfm-video-plan-prompt-enhance"> Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-timeline-wrap">
|
||||
<div class="wfm-video-timeline-track" id="wfm-video-timeline-track"></div>
|
||||
<div class="wfm-video-timeline-toolbar">
|
||||
<button type="button" id="wfm-video-block-split-btn" class="wfm-btn wfm-btn-xs">+ Split</button>
|
||||
<button type="button" id="wfm-video-block-add-btn" class="wfm-btn wfm-btn-xs">+ Block</button>
|
||||
<button type="button" id="wfm-video-block-delete-btn" class="wfm-btn wfm-btn-xs">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-video-plan-main-row">
|
||||
<div class="wfm-video-block-editor" id="wfm-video-block-editor"></div>
|
||||
|
||||
<div class="wfm-video-runplan-panel">
|
||||
<div class="wfm-video-runplan-tabnav">
|
||||
<button type="button" class="wfm-video-runplan-tab active" data-runplan-tab="run">Run</button>
|
||||
<button type="button" class="wfm-video-runplan-tab" data-runplan-tab="plan">Plan</button>
|
||||
</div>
|
||||
|
||||
<div id="wfm-video-runplan-run" class="wfm-video-runplan-content active">
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button id="wfm-video-plan-run-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Run</button>
|
||||
<button id="wfm-video-plan-cancel-btn" class="wfm-btn wfm-btn-danger" style="display:none;">Cancel</button>
|
||||
</div>
|
||||
<button id="wfm-video-plan-pause-btn" class="wfm-btn" style="width:100%;margin-top:6px;" disabled>Pause</button>
|
||||
|
||||
<div class="wfm-gen-progress-container" style="margin-top:8px;">
|
||||
<div class="wfm-gen-progress-track">
|
||||
<div id="wfm-video-plan-progress-bar" class="wfm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span id="wfm-video-plan-progress-text" class="wfm-gen-progress-text">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wfm-video-runplan-plan" class="wfm-video-runplan-content" style="display:none;">
|
||||
<label>Note</label>
|
||||
<textarea id="wfm-video-plan-note" class="wfm-input wfm-lab-note" rows="3"></textarea>
|
||||
|
||||
<button id="wfm-video-plan-save-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:8px;">Plan Save</button>
|
||||
<button id="wfm-video-plan-saveas-btn" class="wfm-btn" style="width:100%;margin-top:6px;">Save As</button>
|
||||
<button id="wfm-video-plan-clear-btn" class="wfm-btn" style="width:100%;margin-top:6px;">Plan Clear</button>
|
||||
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-video-plan-drop-zone" style="margin-top:10px;">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<input type="file" id="wfm-video-plan-file-input" accept="application/json,.json,image/png,.png" style="display:none;">
|
||||
Drop plan (.json/.png) here or click to select
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit: 簡易動画編集(クリップのタイムライン配置・トリム・結合書き出し)。
|
||||
プレビューは上部のSource/Resultペインを再利用する(専用videoは持たない)。
|
||||
タイムライン+下の共有ツールバーはPlan subtabの.wfm-video-timeline-*と同じ構造
|
||||
(個別行ボタンではなく「選択中クリップに対して働く」共通ボタン方式)。 -->
|
||||
<div id="wfm-video-subtab-edit" class="wfm-video-subtab-panel wfm-video-edit-panel" data-video-subtab-panel="edit" style="display:none;">
|
||||
<div class="wfm-video-timeline-wrap">
|
||||
<div class="wfm-video-edit-timeline-track" id="wfm-video-edit-timeline-track"></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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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">
|
||||
<div id="wfm-video-edit-progress-bar" class="wfm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span id="wfm-video-edit-progress-text" class="wfm-gen-progress-text">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右ペイン: プロパティ(Frame抽出 / GIF変換の2タブ) -->
|
||||
<!-- 右ペイン: プロパティ(Frame抽出 / GIF変換の2タブ)。中央ペインと同様に常時表示 -->
|
||||
<div class="wfm-video-properties-panel">
|
||||
<div class="wfm-video-prop-tabnav">
|
||||
<button type="button" class="wfm-video-prop-tab active" data-prop-tab="frame" id="wfm-video-prop-tab-frame">Frame</button>
|
||||
@@ -2812,6 +2982,9 @@
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-video-frame-preview-wrap" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-video-frame-preview-img">
|
||||
</div>
|
||||
<label class="wfm-video-checkbox-inline" style="margin-top:6px;">
|
||||
<input type="checkbox" id="wfm-video-frame-add-to-assets"> Add to Video Assets group
|
||||
</label>
|
||||
<button type="button" id="wfm-video-frame-save-btn" class="wfm-btn wfm-btn-primary wfm-btn-sm" style="width:100%;margin-top:6px;" disabled>Save to Output</button>
|
||||
<span class="wfm-i2i-status" id="wfm-video-frame-status"></span>
|
||||
</div>
|
||||
@@ -2907,13 +3080,8 @@
|
||||
</div>
|
||||
<div id="wfm-tagger-ollama-settings" style="display:none">
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-ollama-url-label"></label>
|
||||
<input type="text" id="wfm-tagger-ollama-url" class="wfm-search-input" value="http://127.0.0.1:11434" style="flex:1;">
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-ollama-model-label"></label>
|
||||
<select id="wfm-tagger-ollama-model" class="wfm-select wfm-tagger-select"></select>
|
||||
<button id="wfm-tagger-ollama-refresh-btn" class="wfm-btn wfm-btn-sm" title="Refresh models">↻</button>
|
||||
<span id="wfm-tagger-ollama-backend-value" style="flex:1;color:var(--wfm-text-secondary);font-size:12px;"></span>
|
||||
<button id="wfm-tagger-ollama-settings-btn" class="wfm-btn wfm-btn-sm" title="Settings">⚙</button>
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row" style="align-items:flex-start">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-ollama-prompt-label" style="padding-top:4px;"></label>
|
||||
@@ -2975,18 +3143,13 @@
|
||||
<div class="wfm-tagger-section-header">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:600;">
|
||||
<input type="checkbox" id="wfm-tagger-batch-ollama-enable">
|
||||
Ollama VLM
|
||||
<span id="wfm-tagger-batch-ollama-header-label">VLM Tagging</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="wfm-tagger-batch-ollama-settings">
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-batch-url-label"></label>
|
||||
<input type="text" id="wfm-tagger-batch-ollama-url" class="wfm-search-input" value="http://127.0.0.1:11434" style="flex:1;">
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-batch-model-label"></label>
|
||||
<select id="wfm-tagger-batch-ollama-model" class="wfm-select wfm-tagger-select"></select>
|
||||
<button id="wfm-tagger-batch-ollama-refresh-btn" class="wfm-btn wfm-btn-sm">↻</button>
|
||||
<span id="wfm-tagger-batch-backend-value" style="flex:1;color:var(--wfm-text-secondary);font-size:12px;"></span>
|
||||
<button id="wfm-tagger-batch-ollama-settings-btn" class="wfm-btn wfm-btn-sm" title="Settings">⚙</button>
|
||||
</div>
|
||||
<div class="wfm-form-row wfm-tagger-form-row" style="align-items:flex-start">
|
||||
<label class="wfm-tagger-label" id="wfm-tagger-batch-prompt-label" style="padding-top:4px;"></label>
|
||||
@@ -3034,11 +3197,15 @@
|
||||
<button id="wfm-tagger-db-search-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"></button>
|
||||
<button id="wfm-tagger-db-show-all-btn" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-tagger-db-export-btn" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-tagger-db-delete-selected-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"></button>
|
||||
</div>
|
||||
<div id="wfm-tagger-db-table-wrap" class="wfm-tagger-db-table-wrap">
|
||||
<table id="wfm-tagger-db-table" class="wfm-table wfm-tagger-db-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="wfm-tagger-db-th-select">
|
||||
<input type="checkbox" id="wfm-tagger-db-select">
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th id="wfm-tagger-db-th-file"></th>
|
||||
<th id="wfm-tagger-db-th-interrogator"></th>
|
||||
@@ -3214,6 +3381,7 @@
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<button id="wfm-ai-test-btn" class="wfm-btn">Test connection</button>
|
||||
<button id="wfm-ai-unload-btn" class="wfm-btn">Unload Model</button>
|
||||
<span id="wfm-ai-test-result" class="wfm-ai-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||