Compare commits

...
115 Commits
Author SHA1 Message Date
ketle-manandClaude Opus 5.5 5fb1b1eb01 chore(release): v0.7.4 - Video EditにBGM合成・テキストオーバーレイ・クロップ・Undo/Redoを追加
Video Editサブタブの残りフェーズ(Phase 2〜5)を実装。
- BGM/音声トラック合成: Core音声ノード+ConcatenateVideoのcomplete_audioでグラフ内完結。複数クリップ結合時も各クリップの音声を保持。BGMはボタン/ドロップ/AssetのBGMに設定で指定
- テキストオーバーレイ: クリップ単位の字幕(位置・区間・色・縁取り・背景)、PyAVストリーム処理で焼き込み
- クロップ: Sourceプレビュー上の矩形エディタ(アスペクト固定・キャンセル)、VideoCropで書き出し
- Undo/Redo: 編集状態のスナップショット履歴(ボタン+Ctrl+Z/Y)
- クリアでオーディオ欄も初期化、BGM/Loadのファイル選択が開かない不具合を修正

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:43:32 +09:00
ketle-manandClaude Opus 5.5 4fa39daf0e docs: document Video Edit BGM, text overlays, crop and undo/redo
README (Video Tab section) and DEVLOG gain an "Unreleased" entry for the
feature/video-edit-bgm-text-overlay branch: audio/BGM track mixing,
per-clip text overlays, crop, undo/redo, BGM from Asset videos / drag &
drop, Clear resetting audio, crop Cancel/auto-exit, and the <label>-based
file picker fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:41:51 +09:00
ketle-manandClaude Opus 5.5 8beec6abdf feat(video-edit): crop edit Cancel button and auto-exit on tab switch
- "Cancel" appears next to "Done" while editing a crop and restores
  the crop the clip had when editing started (including none).
- Switching to the Plan/Asset subtab, another main tab, or another clip
  ends crop editing, keeping the adjusted rect (same as Done). The
  sidebar Project button doesn't, since the Edit subtab stays visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:57:13 +09:00
ketle-manandClaude Opus 5.5 99ffc1fedd feat(video-edit): Clear also resets the Audio section
Clear now empties the clips and restores the soundtrack settings (BGM,
volumes, keep-original, BGM start) to their defaults; it is enabled
whenever either clips exist or audio differs from the defaults, so a
BGM-only timeline can be cleared too. Undo restores both. The confirm
message and help text mention the audio reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:45:04 +09:00
ketle-manandClaude Opus 5.5 a63c6ec53b fix(video-edit): open BGM/Load file pickers via native <label>
The BGM picker (and the project Load button) opened a hidden file input
by calling input.click() from a button's click handler. On the user's
browser this silently showed no dialog, while the Video Source picker in
the same tab — a <label>-wrapped input — worked. Both are now
<label>-wrapped inputs styled as buttons, with Enter/Space wired for
keyboard activation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:28:42 +09:00
ketle-manandClaude Opus 5.5 4e39547a4f feat(video-edit): pick BGM from Asset videos and by drag & drop
- Asset subtab: "Set as BGM (audio only)" on video assets uses the
  video's audio track as the Edit BGM (LoadAudio decodes audio from video
  files); videos without an audio track are rejected with a message.
- Audio section accepts a dropped audio/video file, and the BGM file
  input lists common audio extensions and mp4 explicitly instead of only
  audio/*.
- A failed BGM pick no longer clears the previously set BGM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:20:46 +09:00
ketle-manandClaude Opus 5.5 c6487ad923 feat(video-edit): add undo/redo for timeline edits
Snapshot history of the editorial state (clip order, trim, crop, text
overlays, audio settings), recorded via a debounced hook on the existing
redraw paths and pushed only when the serialized state changes, so
selection/preview/resize never create entries. Deleted clips come back
from a clip registry without re-uploading. Toolbar buttons plus
Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z (ignored inside text fields); history is
reset on project load.

Also fixes a lost click: committing a trim/hold-length value on blur
rebuilt the text overlay section, replacing a "+ Add text" button that
was being clicked; it now only updates the rows' bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:04:55 +09:00
ketle-manandClaude Opus 5.5 0c6787f712 feat(video-edit): add per-clip crop with on-preview rectangle editor
Video clips get a normalized crop rect edited directly on the Source
preview (move / corner resize, optional 16:9, 9:16, 1:1, 4:3, 3:4 lock).
Export inserts core VideoCrop (double-nested {"crop": {...}} input, even
pixel values) after Video Slice. The export size is the first video
clip's cropped size; other clips of a different size are center-fit with
ImageScale. Text overlay previews are drawn relative to the cropped
frame, and crops are saved in Edit projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:01:32 +09:00
ketle-manandClaude Opus 5.5 59e96ce180 feat(video-edit): add BGM/audio track mixing and simple text overlays
Phase 4 (audio): the Edit tab export now builds a separate soundtrack in
the same ComfyUI graph and passes it to ConcatenateVideo's complete_audio.
Each clip contributes an exact-length segment (EmptyAudio + AudioMerge with
the clip's own GetVideoComponents audio), chained with AudioConcat, with an
optional BGM (LoadAudio -> TrimAudioDuration -> AudioAdjustVolume) mixed on
top. This also removes the old limitation that multi-clip exports lost
their audio. Export panel gets an Audio section (keep original audio +
volume, BGM file, BGM volume, BGM start offset).

Phase 3 (text): per-clip text overlays (text, start/end, size %, color,
9-grid anchor, outline, background) with a live CSS preview over the
Source/Result panes. Export burns them in via the new
/api/wfm/video/edit/overlay-text endpoint (PyAV streaming decode -> Pillow
composite -> libx264 encode, audio packets remuxed untouched, CJK-capable
system font lookup).

Text overlays and audio settings are persisted in Edit projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:49:55 +09:00
ketle-manandClaude Sonnet 5 6b262a9cf4 chore(release): v0.7.3 - GenerateUIタブにQwen Image 2.1対応を追加
TextEncodeQwenImage21ノード(Comfy-OrgのQwen-Image-2.1テンプレート、T2I/Edit兼用)のprompt/negative_prompt/resolutionをanalyzeWorkflow()で認識し、Input タブのPrompt欄・Settings欄から編集できるようにした。モデルロード・複数参照画像のLoadImageカードは既存の汎用ロジックで無改修対応。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:23:13 +09:00
ketle-manandClaude Sonnet 5 cdf18e89ce chore(release): v0.7.2 - Workflowタブソート機能、Video Editプロジェクト永続化、Assetタイプフィルタ
Workflowタブにグループフィルタ右隣のソートドロップダウン(Date/Name昇順降順)を
追加。VideoタブではEditサブタブのトリムパネルの不具合修正(「現在位置を使用」
でスクラバー表示が更新されないバグ、バッジ重なり)とUI調整(左ペイン幅・スクラバー
高さ)を行い、Video Edit Phase 5としてタイムラインのSave/Save As/Load永続化
(VideoEditProjectService新設)とサイドバーProjectタブのPlan/Editモード切替を
実装。Asset subtabには動画/画像タイプフィルタドロップダウンを追加した。

DEVLOG.md/README.mdを更新し、pyproject.tomlのバージョンを0.7.2に更新。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:48:36 +09:00
ketle-manandClaude Sonnet 5 7237bec315 feat: add sort dropdown to Workflow tab toolbar
Adds date/name asc/desc sorting next to the group filter, matching the
Gallery tab's existing sort UI and localStorage persistence pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:10:52 +09:00
ketle-man f687f8d8ea chore(release): v0.7.1 - Unsloth Tagger経路のSSRFキー漏洩を修正
backend=="unsloth"のTagger VLM呼び出し(vlm_models/vlm_predict)が
unsloth_routes.pyと同じlocalhost allowlistを持たず、クライアント指定の
api_urlへAPIキーを無検証で送信していた問題を修正。副次的にgmic_qt_path
の検証も絶対パス/.exe拡張子/実ファイルチェックへ強化。
2026-09-17 17:53:58 +09:00
ketle-manandClaude Sonnet 5 8a3de00440 chore: QWEN_CHAT_INTEGRATION_PLAN.mdもgit管理対象外に追加
VIDEO_EDIT_TAB_PLAN.mdと同様、作業用の計画メモのため.gitignoreに追加。
未追跡ファイルだったためrm --cachedは不要。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:40:13 +09:00
ketle-manandClaude Sonnet 5 cddfa4fdc1 chore: graphify-out全体とVIDEO_EDIT_TAB_PLAN.mdをgit管理対象外に変更
graphify-out/はこれまでgraph.json等の一部ファイルをバージョン管理下に
置いていたが、他クローンへの共有価値がないローカル専用出力として全体を
.gitignore対象にした。VIDEO_EDIT_TAB_PLAN.mdも作業用の計画メモとして
対象外にした。いずれもワーキングツリー上のファイルは削除せず、git管理
からのみ除外している。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:44:38 +09:00
ketle-manandClaude Sonnet 5 ac675ce74f docs: VIDEO_EDIT_TAB_PLAN.mdにv0.7.0でリリースしたUI改善一式を追記
Asset subtabの中央下部移設・トリムスクラバー・プレビュー枠固定・
Frame/Asset連携の4件を第11節として追記し、Phase 1 MVPからv0.7.0
リリースまでの状態を冒頭に明記。Critical Filesにも今回変更した
ファイルを追加した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:40:26 +09:00
ketle-manandClaude Sonnet 5 74eb8b779f Merge branch 'feature/video-edit-tab': Video Edit tab (v0.7.0)
Video タブに簡易動画編集機能(Editサブタブ)を新規実装。クリップの
タイムライン配置、トリム(Clipchamp風ビジュアルスクラバー)、複数
クリップの結合書き出し、静止画/アニメーション画像クリップ対応。
あわせてAssetサブタブをPlan/Editと並ぶ中央下部へ移設し検索・グリッド
/テーブル表示切替を追加、プレビュー枠の高さ固定、Frame保存のVideo
Assets登録とAsset→PlanのFirst/Last Image連携も実装した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:32:40 +09:00
ketle-manandClaude Sonnet 5 baed398fe8 chore(release): v0.7.0 - Video Edit tab初回リリース + Asset移設・トリムスクラバー等
pyproject.tomlのバージョンを0.7.0に更新し、DEVLOG.mdとREADME.mdを
今回のリリース内容(Edit subtab全体の初回公開、Assetサブタブの中央下部
移設、トリムパネルのClipchamp風スクラバー、Frame保存のVideo Assets
登録、Asset→PlanのFirst/Last Image連携)に合わせて更新した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:31:47 +09:00
ketle-manandClaude Sonnet 5 77cfedb243 docs(video-tab): Video Tabヘルプを現行UIとEdit/Asset機能に合わせて更新
Asset subtabが左サイドバーから中央下部(Plan/Editと並ぶ)に移設された
ことを反映し、Project subtabの説明からも「Assetの隣」という古い記述を
削除。Editサブタブ(クリップタイムライン・トリムスクラバー・書き出し)
とAssetサブタブ(検索・グリッド/テーブル切替・First/Last Image連携)の
説明を新規項目として追加し、Frameタブの説明にVideo Assets登録
チェックボックスを追記。index.html・i18n.js(en/ja/zh)・app.jsの
helpIdMapの3点セットで更新。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:28:28 +09:00
ketle-manandClaude Sonnet 5 ad70622942 feat(video-tab): FrameのVideo Assets登録チェックボックスとAsset→Plan画像連携を追加
Frameタブの「Outputに保存」にオプションのチェックボックスを追加し、
チェック時は保存したフレームをGalleryの__Video Assets__グループに
自動登録できるようにした。

またAssetタブの画像アセット詳細に「First Imageに設定」「Last Imageに
設定」ボタンを追加し、Planタブの選択中ブロックのFirst/Last Imageへ
直接送れるようにした(動画クリップでは非表示、画像のみ対象)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:19:51 +09:00
ketle-manandClaude Sonnet 5 f726418aeb feat(video-edit-tab): トリムパネルにClipchamp風のビジュアルスクラバーを追加
動画クリップのトリム編集を、テキスト入力のみだったUIから、時間目盛り・
再生位置(playhead)・ドラッグ可能なトリムハンドルを備えたビジュアルな
スクラバーに拡張。プレビュー動画のcurrentTimeとplayheadが同期し、
トラックのクリック/ドラッグでシークも可能。既存のStart/End数値入力・
「現在位置を使用」ボタンとは同じcommit経路で連動する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 12:55:30 +09:00
ketle-manandClaude Sonnet 5 a917e82ad7 feat(video-tab): AssetをPlan/Editと並ぶ中央下部の第3サブタブに移設
左サイドバーにあったAsset一覧・詳細パネルを中央ペイン下部に移設し、
左=素材一覧(サムネイル/テーブル切替可)・右=選択ファイルの詳細という
2カラムレイアウトに再構成。一覧上部にグループ切替・ファイル名検索・
表示切替ボタンを追加した。左サイドバーはProjectのみのシンプルな
パネルになった。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 12:32:54 +09:00
ketle-manandClaude Sonnet 5 2fc2365461 fix(video-tab): プレビュー枠の高さを固定してPlan/Editの位置ずれを解消
動画ソースのネイティブサイズによってプレビュー枠(min-height)が伸縮し、
中央ペイン下部のPlan/Editサブタブの表示位置が動画ごとに動いてしまう問題を修正。
枠の高さを固定し、動画/画像は縮小表示のみ(拡大はしない)に変更した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 12:18:58 +09:00
ketle-manandClaude Sonnet 5 337fd6ecb8 docs: log Video Edit tab work-in-progress in DEVLOG
Records this session's feature/video-edit-tab branch work (unreleased,
no version bump) — Edit subtab MVP, Phase 0 node-behavior findings,
image-clip support, and the audio-mismatch fix — under a new
Unreleased section, per existing DEVLOG convention where every
version heading corresponds to an actual tagged release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:13:07 +09:00
ketle-manandClaude Sonnet 5 831ba1bd43 Support still/animated image clips in the Edit timeline
Asset's Video Assets/Video Temp groups already list any image tagged
into them (no video-only filter), but selecting one always fed a
<video> element, which can't render image formats — the preview
stayed blank and "Send to Edit" produced a broken clip.

Adds an <img> companion to both preview panes (video-preview.js gains
a mediaType param on setSourcePreview/setResultPreview) so images
preview correctly, and gives Edit-tab clips a kind: "video" | "image".
Image clips get a single editable hold duration instead of in/out trim
points, and export turns them into a real video segment via
LoadImage -> (ImageScale center-crop, only if resolution needs to
match another clip) -> RepeatImageBatch -> CreateVideo — verified on a
live instance to produce an exact-duration, concatenation-compatible
clip. The resolution-mismatch guard now only compares video clips
against each other, since images are auto-fit instead of blocked.

Also fixes a bug this surfaced: ConcatenateVideo hard-errors mixing a
clip with real audio (e.g. stereo/32kHz) next to a silent
image-derived clip. Multi-clip exports now strip audio from every
video clip too (via GetVideoComponents -> CreateVideo) so the whole
timeline is uniformly silent when concatenating; a lone clip keeps its
original audio since no concatenation occurs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:10:07 +09:00
ketle-manandClaude Sonnet 5 899feb1bb5 Add sequential timeline preview sharing the Result pane with Export
Adds a Play/Stop toggle on the timeline toolbar that plays every ready
clip back-to-back (each trimmed to its in/out point) in the same
"Result" preview pane Export writes its finished output into — not a
new <video> element — so what's shown there is always exactly what
Export would currently produce. Clip-boundary advancement is driven by
timeupdate/ended listeners comparing against each clip's trimEnd.

video-preview.js gains getResultPreviewVideoElement() since driving
playback (seeking, listening for the trim-out point, swapping sources)
needs direct element access that setResultPreview()'s simple
src-and-visibility API doesn't provide.

Verified end-to-end on a live instance: 2-clip preview with automatic
mid-playback advancement at the first clip's trim-out, then export
producing the same combined duration the preview showed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:55:12 +09:00
ketle-manandClaude Sonnet 5 075e2a8c62 Fix trim panel overflow and add timeline total duration display
The Start/End (seconds) inputs and their "use current position"
buttons were laid out side by side, letting the button label overflow
past the container edge and making the button hard to distinguish
from a text field. Stacks the button below the input instead, shrinks
it, and narrows the input column so both fit cleanly.

Also adds a running total-duration readout next to the Clear button,
summing every clip's trimmed length — updated automatically whenever
the timeline re-renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:46:46 +09:00
ketle-manandClaude Sonnet 5 77163de202 Fix Edit tab layout collapsing to center; drop its own dropzone
Root cause: a leftover CSS rule from the pre-implementation
"Editing tools coming soon" placeholder era (display:flex;
align-items:center; justify-content:center; dashed border) still
targeted .wfm-video-edit-panel and, coming later in the cascade,
silently overrode the real layout — shrinking the timeline, trim
panel, and export panel to their content width and centering them
instead of stretching full-width like the Plan subtab. Removed the
stale rule and gave the timeline track explicit width:100%/flex:none
so it stays a fixed full-width bar (matching Plan's track) regardless
of clip count.

Also removes the Edit subtab's own drop zone now that clips can only
be added via Asset's "Editへ送る" and the Video Source panel's
"Add to Edit" button — one fewer entry point to keep in sync, and the
now-unused videoEditDropLabel i18n key goes with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:38:22 +09:00
ketle-manandClaude Sonnet 5 7f0ecc2ba3 Redesign Video Edit timeline to match the Plan subtab's UI pattern
Replaces the vertical clip list (per-row ▲▼⧉✕ buttons) with a
horizontal timeline track whose blocks are left-aligned at their real
duration (px/sec scale) rather than stretched to fill the track like
Plan's blocks. Reordering moves to a shared toolbar below the track
(◀/▶/Duplicate/Delete/Clear acting on the selected clip), matching
Plan's "+ Split/+ Block/Delete" pattern, plus native HTML5
drag-and-drop reordering directly on the blocks.

Adds a new "Add to Edit" button on the Video Source panel so whatever
is loaded there (a plain drop, an Asset selection, a Frame/GIF source)
can be added to the Edit timeline explicitly — deliberately not
automatic on every Source drop, since Frame/GIF is a single-video tool
used far more often than multi-clip editing and auto-adding would
clutter the timeline.

Verified end-to-end on a live ComfyUI 0.36.0 instance: left-aligned
block widths, Asset -> Add to Edit handoff, move/duplicate/delete/
clear (with i18n'd confirm dialog), and export after a clear.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:24:51 +09:00
ketle-manandClaude Sonnet 5 ac733db81e Add Video Edit tab MVP: clip timeline, trim, and concatenate export
Implements the Edit subtab (previously a placeholder) with a minimal
clip timeline: add clips from Asset/local files, reorder/duplicate/
delete, trim per clip, and export by dynamically assembling a ComfyUI
API-format prompt ("Video Slice" per clip -> ConcatenateVideo when
multiple clips -> SaveVideo) run through the existing execution
infrastructure. Exported videos are tagged into the __Video Temp__
gallery group like Plan-tab runs.

Verified end-to-end against a live ComfyUI 0.36.0 instance, including
two bugs only caught by inspecting actual output (not just
execution_success): a missing WebSocket connect before trackProgress,
and VideoTrim silently no-opping on a flat trim value (switched to the
simpler, non-experimental "Video Slice" node instead). See
VIDEO_EDIT_TAB_PLAN.md for the full design and findings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:05:31 +09:00
ketle-manandClaude Sonnet 5 7f2720ff8f release: v0.6.3 — Send CCボタンでMP4動画をComic Creatorの動画ツールへ送れるように対応
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 22:24:58 +09:00
ketle-manandClaude Sonnet 5 f9dbf3f399 release: v0.6.2 — Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
Draw/MaskツールをPointer Events化し、ペン筆圧でブラシサイズ/不透明度が
変化するように対応(マウス操作の挙動は変更なし)。Comic CreatorのImageタブ
から移植したFillツール(バケツ塗りつぶし、Solid/Gradient対応)を新規追加。
実機フィードバックを受け、Fillのグラデーションランプ/方向ダイヤルが
ペンタブレットで操作できない不具合も追加修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 09:25:36 +09:00
ketle-manandClaude Sonnet 5 a99bfc19a1 release: v0.6.1 — Galleryタブ検索の停止不具合・パフォーマンス問題を修正
検索欄のdebounce/AbortController追加、メタデータストアのロック保持時間短縮、
ponyxlWildcardsVaultフォールバック探索でのVaultフォルダ誤検出によるglob多発を解消。
53枚フォルダへの検索が3327ms→74msに改善。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:57:19 +09:00
ketle-manandClaude Sonnet 5 d75a9c812f release: v0.6.0 — Gallery/Image EditにPSD(Photoshop)対応を追加、Image Editのアクションバー/レイヤーパネル改善
- Gallery: 複数選択画像を1つのレイヤー付きPSDにまとめる「PSD作成」一括操作を追加
- Image Edit: Save PSD(レイヤー個別保持で書き出し)/Open PSD(レイヤー分解して読込)を追加
- Gallery→Image Edit: 複数選択画像を各レイヤーとして一括転送する「レイヤーとして編集」を追加
- Gallery: .psdファイルの一覧・サムネイル・プレビュー表示に対応(psd-tools合成→PNGキャッシュ)
- 実機発覚バグ2件を修正: サムネイル配信がイベントループをブロックする問題(run_in_executor化)、
  単一画像送信ボタンでPSDが1レイヤーに潰れる問題
- Image Edit: File Open改名、Open PSD配置、Close/複製レイヤーボタン追加、レイヤーヘッダー2段化
- ヘルプドキュメント更新+検証中に見つかった既存の翻訳漏れ/重複定義を修正
- 依存関係: psd-tools>=1.10を追加(pytoshopはWindowsでCコンパイラ必須のため不採用)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:22:41 +09:00
ketle-man 925a249cbe chore: v0.5.8リリース準備
Tagger DBタブの一括削除機能(PR #2)追加に伴うバージョン更新
2026-09-02 01:19:13 +09:00
ketle-man e067f34cd4 Merge pull request #2 from censor-ed/feature/tagger-bulk-delete
検証済み: バックエンドAPI・フロントエンドの動作を実機(ComfyUI + ブラウザ)で確認。一括削除時のcount不整合と全選択チェックボックスのトグル挙動の2点を修正コミットで追加。
2026-09-02 01:10:37 +09:00
ketle-man 995e98e1aa fix(tagger): 一括削除のcount不整合と全選択トグルの挙動を修正
- handle_db_delete_bulk: レスポンスのcountを渡されたID数ではなく
  実際の削除件数(delete_bulkの戻り値)にする
- _dbToggleSelection: 常時反転するトグル方式ではなく、クリック後の
  ヘッダーチェックボックス自身の状態に全行を揃える一般的な全選択挙動にする
2026-09-02 01:05:14 +09:00
censor-ed 398be43060 feat(tagger): Tagger DBの複数選択削除を追加 2026-09-01 10:53:27 +09:00
ketle-manandClaude Sonnet 5 1155cf1233 release: v0.5.7 — Video Planタブの設定項目レイアウト刷新(MiniMax H3 Turbo/LTX-2.5 Prompt Enhance/Wan2.2 Turbo対応) + フルバックアップのドキュメント修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:00:36 +09:00
ketle-manandClaude Sonnet 5 5aef634115 fix(tagger): WD Tagger系ONNXモデルの画像前処理をWD Tagger標準に修正
wd-swinv2-tagger-v3等で画像内容と一致しない不自然なタグが出力される問題を修正(Issue #1)。
単純リサイズ+RGBのまま0.0-1.0正規化だった前処理を、WD Tagger系標準の
アスペクト比維持パディング+RGB→BGR+0-255のfloat32のまま、に変更した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 16:54:27 +09:00
ketle-manandClaude Sonnet 5 b09cd2ef90 release: v0.5.5 — Videoタブ全面刷新(Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化) + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:30:28 +09:00
ketle-manandClaude Sonnet 5 426f27c05a Video: __VideoAssets__グループがGallery再起動後に消えるバグを修正
__VideoAssets__はバッチ実行側からensure_groupを呼ぶコードが無く、一度削除されると
add_to_groupが個別画像への書き込みのみ行いレジストリを再登録しないため二度と一覧・
詳細パネルに出てこなくなっていた。ensureFeederGroup()と同型のensureVideoGroup()を
新設し、Video AssetタブのInitで確実に再登録するよう修正。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:30:22 +09:00
ketle-manandClaude Sonnet 5 336e37724f Video: Plan/Asset/Editタブによるバッチ動画生成機能を追加
タイムラインブロック単位でFirst/Last画像・プロンプトを設定しバッチ生成するPlanタブ、
Galleryのグループ機能を再利用したAssetタブ(__VideoAssets__/__vtemp__)を新設。
調査過程でGalleryのジャンクションフォルダ環境でのパス不一致バグ、グループフィルタの
非再帰スキャンバグ、convertUiToApi()のCOMBO無警告フォールバックによるチェイン画像
すり替えバグの3件も修正。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:15:01 +09:00
ketle-manandClaude Sonnet 5 7274017347 release: v0.5.4 — GenerateUIタブのLTX-2.5/Wan2.2対応 + VideoタブのLTX-2.5/Wan2.2対応・ハイライト表示 + RAW JSON検索の意図しない編集を修正
- GenerateUIタブ: LTX-2.5/Wan2.2ワークフローのPositive/Negative Prompt検出(LTXVDualCFGGuider/
  LTXVConditioning対応)とSettingsタブLatent Image検出(EmptyHunyuanLatentVideo/EmptyLTXVLatentVideo
  + Lengthフィールド)を追加、isVideoWorkflow()の動画タイムアウト判定も拡張
- Videoタブ: 該当項目のラベルをGenerateUI Modelタブと同じ色でハイライト表示する機能を追加
  (Wan2.2など操作不可な項目は非ハイライトのまま)
- 生成UIタブRAW JSON検索: ヒット箇所へのフォーカス/selectionRange設定を廃止し、ハイライト表示のみ
  に変更(検索直後のキー入力で意図せずJSONが編集される問題を修正)
- 別セッションでのVideoタブLTX-2.5/Wan2.2対応(サブグラフ入力labelベースの汎用検出)・MultiType
  ウィジェット脱落バグ修正・動画再生ボリューム保存設定、およびAI Assistant設定のUnsloth対応・
  Prompt/Tagger独立モーダル化・モデルアンロード機能を本リリースに統合
- ヘルプ(index.html/i18n.js 3言語/app.js helpIdMap)とREADME.md/DEVLOG.mdを更新

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 16:50:30 +09:00
ketle-manandClaude Sonnet 5 b94ab48ca4 AI Assistant設定をUnsloth対応+Prompt/Tagger独立モーダル化、モデルアンロード機能を追加
- Prompt AI/Tagger VLMにUnslothバックエンドを追加(サーバー側プロキシでAPIキー付与)
- Taggerタブ: Ollama直呼び出しを廃止しOllama/LM Studio/Lemonade/Unsloth共通のvlm_models/vlm_predictに統一、API URL直接入力欄を廃止
- 中央Settingsタブの「AI Assistant Settings」を廃止し、Prompt/Tagger各タブに⚙設定ボタン+モーダル(ai-settings-modal.js)を新設、それぞれ独立したlocalStorageキーで管理
- Thinking mode / Max tokens をPrompt/Tagger双方の生成呼び出しに反映(Tagger側はPython側のvlm_predictにも反映)
- Ollama/LM Studio/Lemonadeのモデルアンロード機能を追加(AI TOOL設定にもUnloadボタンを追加、Unslothは非対応として明示)
- ヘルプタブ(Prompt/Settings/AI TOOL/Tagger)の説明文を新仕様に合わせて更新(EN/JA/ZH)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 13:17:03 +09:00
ketle-manandClaude Sonnet 5 ed3512d269 ci: publish-node-actionのバージョンをSHA固定(サプライチェーン対策)
@mainの可変ブランチ参照はComfy-Org/publish-node-actionが更新された際に
挙動が変わりうるため、リリースタグ1.0.1のコミットSHAに固定する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 10:24:53 +09:00
ketle-manandClaude Sonnet 5 9ed91e64f8 Video: LTX-2.5 / Wan2.2対応 + 動画音量の保存設定を追加
- VideoタブがLTX-2.5 (Text/Image/First&Last-Frame to Video) とWan2.2 (Text to
  Video) を検出・読み込み・生成できるよう汎用化。従来MiniMax H3専用だったノード
  検出/フォーム反映ロジックを、サブグラフが公開する入力のlabelベースで意味的に
  スロットを特定する方式に一般化した
- convertUiToApi(): object_infoの複合型宣言(例 "FLOAT,INT")がウィジェット名
  リストから丸ごと脱落し、後続ウィジェットの値がずれるバグを修正。
  LTXVEmptyLatentAudio.batch_sizeに誤った値が注入され動画生成が
  "Expected size 1 but got size 25" で失敗する原因だった
- Videoタブのminimax_test.json自動読み込みを廃止(同梱していないため)
- 動画再生ボリュームを設定タブに追加し、Video/Galleryの全プレーヤー間で
  保存・共有されるようにした(動画を切り替えるたびに最大音量へ戻る問題を解消)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 00:51:09 +09:00
ketle-manandClaude Sonnet 5 c0db7374b7 release: v0.5.3 — Labタブのワークフロー切替時の表示残留を修正 + Galleryタブ読み込み高速化・rootフォルダ自動展開 + READMEスクリーンショット刷新
- Labタブ: ワークフロー読み込み時、Labサブタブが既にアクティブでも
  Setting 1のライブ反映セルを再描画するよう修正(前ワークフローの表示残留を解消)
- Galleryタブ: フォルダスキャン時の不要なPath.resolve()呼び出しを除去し
  9,777枚のフォルダで7.5秒→0.14秒に高速化。os.walk()ベースの再帰スキャンも
  DirEntryベースに書き換え
- Galleryタブ: フォルダツリーのrootをデフォルト展開状態に変更
- README: スクリーンショット5件を削除、Video/Lab/AI TOOLタブの新規4枚を追加

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 16:08:24 +09:00
ketle-manandClaude Sonnet 5 cd4b721d51 release: v0.5.2 — GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
- GenerateUI/LabタブでMiniMax H3等のオールインワン動画ノードのプロンプト・画像を編集可能に(analyzeWorkflow拡張)、生成タイムアウト延長、動画結果プレビュー対応
- バグ修正: Lab index imageが真っ黒になる問題(動画は最終フレームを抽出して描画)
- バグ修正: Lab Ksampler列が動画ワークフローでも表示される問題(内部SamplerCustomAdvancedを編集対象から除外)
- Videoタブに Frame抽出(クライアント側canvas)/ アニメーションGIF変換(サーバー側PyAV+Pillow)を追加、Video Sourceパネルでプレビュー統合
- データ管理: civitai_cache.jsonのエクスポート漏れを修正、ZIP形式のフルバックアップ機能を新規実装(ai_skills/lab_plan/style/tagger.db対応、Zip Slip対策、デフォルトワークフロー/wildcardフォルダのオプトイン・復元除外)
- Image Edit/Galleryタブの画像送信ボタンを「Send to LI node」に統一し、Galleryタブにも追加
- Galleryタブの✕クリアボタンを表示形式切替ボタンの左隣に移動

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:27:12 +09:00
ketle-manandClaude Sonnet 5 46c789c492 release: v0.5.1 — mp4動画の埋め込みメタデータ対応 + Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
Gallery Prompt/JSONタブ・Metadataサブタブ・サイドパネルIタブの3箇所で、ComfyUIのSaveVideoがmp4のmoov/udta/metaに埋め込むworkflow/promptタグ(PNGのtEXtチャンクと同一キー名)を読み取れるようにした。バックエンドはPyAV、Metadataサブタブ/サイドパネルIタブはブラウザ側でMP4ボックス構造を直接パースする実装。副産物として、CLIPTextEncode系を介さず自ノードのprompt入力に直接テキストを持つ動画生成オールインワンノード(MiniMaxH3ImageToVideo等)のプロンプトが抽出できていなかったバグも修正。Gallery Infoタブには画像の幅×高さ・動画の再生時間の表示を追加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:04:48 +09:00
ketle-man ede39ec715 release: v0.5.0 — mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
- Galleryタブでmp4動画の閲覧・再生・サムネイル生成(PyAV)に対応
- 新規Videoタブ(MiniMax H3専用、GenerateUIとは独立)を3ペイン構成で追加
  - first_frame/last_frameは両方optional、LoadImageノードの無いワークフローにも対応
  - Workflowタブから「Load in Video」で読み込み可能
- バグ修正: cleanup_stale_images()が非再帰スキャン時にサブフォルダ内画像/動画のメタデータを誤って全削除していた問題を修正
- バグ修正: Gallery側のタグ/メモ保存がサーバーエラーをサイレントに無視していた問題を修正
2026-08-27 14:46:09 +09:00
ketle-manandClaude Sonnet 5 ad3b380929 Save to Input(旧Send to ComfyUI)ボタンを削除
Send to Workflowと機能が重複していたため、Image Editタブの
「Save to Input」ボタン・uploadToComfyUI()を削除。ヘルプ・README・
i18n(日英中)を更新。次回リリース時にバージョンを上げて反映する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:10:32 +09:00
ketle-manandClaude Sonnet 5 c589e562e4 release: v0.4.8 — LabにPromptグループ一括適用+キーフレーム並べ替え + デフォルトワークフロー上書き保存バグ修正 + StyleテーブルAdd to this file
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 12:16:06 +09:00
ketle-manandClaude Sonnet 5 cb61dc61a7 release: v0.4.7 — Prompt Tab内部整理 + Gallery Promptタブ新設 + Send to Canvas改善 + Presetsソート
- prompt-tab.js(1545行)をprompt-presets/ai-chat/wildcards/styles.jsの4ファイルに分割、
  prompt-tab.jsは薄いオーケストレーターに(prompt-table.js/app.jsは無変更でre-export経由)
- ワイルドカード入力支援ツールバーをPresetsのプロンプト編集モーダルへ追加(Wildcardモーダル
  ではなくプロンプトを書く場所に付けるべきという指摘を受けて再配置)
- Style適用とワイルドカード展開の実行順序を修正: Style内の__file__参照が展開されずに
  送信されていたバグを解消(Style適用→展開の順に変更)
- yamlワイルドカード(ImpactWildcardEncode)を使うサンプルワークフローを追加
- Galleryタブ詳細パネルに「Prompt」タブを新設(既定選択、Metadataタブと同じPOS/NEGリスト
  UIを再利用); Gallery側のプロンプト抽出はAPI形式優先のprompt_workflowを新設し、
  Metadataタブとの抽出結果の食い違いを解消
- Send to Canvasのフォールバックを、Workflow Studio LibraryのWタブを絞り込み表示する
  方式に統一(UI形式のタイトルドラッグ・API形式非対応だった従来方式を置き換え)
- PresetsテーブルツールバーにMarkdown ★/Bソートトグルボタンを追加(複合ソート対応)
- graphifyの運用を一時停止(CLAUDE.md、ユーザー指示による)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 12:19:09 +09:00
ketle-manandClaude Sonnet 5 ad9d06d3e1 graphify: v0.4.6の変更を反映しナレッジグラフを更新
v0.4.6(Metadataタブ移動・GenerateUI Save非表示・Prompt Table表示)で
変更した20ファイルをgraphify --updateで再抽出。
2320ノード・5560エッジ・157コミュニティ(旧グラフから+141ノード/+314エッジ、
重複ノード13件を整理)。DEVLOG.index.md/README.index.mdの2件はOllamaの
出力トークン上限で今回は未反映(次回--updateで再試行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:33:26 +09:00
ketle-manandClaude Sonnet 5 ea408690ff release: v0.4.6 — MetadataタブをGalleryへ統合 + PromptタブにTable表示を新設
- MetadataタブをGalleryタブのサブタブ(Style_Catalog右隣)へ移動
- GenerateUI: Batch/LabサブタブでSaveボタンを非表示化
- Promptタブ: Form/Table切替を追加。Tableには編集可能な
  Presets/Presets Group/Wildcards/Styleテーブルを新設
  - #列クリックで行選択→ツールバーDelete/Deselectで一括操作
  - 番号ダブルクリックでPositive/Negative編集モーダル
  - PresetsのB列/BCボタンでBatchグループ登録
  - PP/NP Copy + グループ選択でAdd to Group(Presets Groupの
    Add Preset選択式を置き換え)
- ヘルプ(index.html + i18n.js 3言語 + helpIdMap)とREADMEを更新

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 15:00:23 +09:00
ketle-man cbb096ec85 release: v0.4.5 — Lab Model列の無意味な空キーフレーム表示を解消(対象ノードなし時は非表示化)
対象ノード(Checkpoint/LoRA/VAE)がワークフローに存在しない列でも
Model列に常にSetting 1のキーフレーム行が表示されていた問題を修正。
Run時には何の効果も持たない死んだコントロールだったため、
_hasNodeForColumn()で判定し、対象ノードが無い列はプレースホルダー
表示にして+ボタンでの追加もブロックするようにした。
2026-08-21 20:27:05 +09:00
ketle-manandClaude Sonnet 5 99f342cc89 release: v0.4.4 — LoRAノードバイパス機能(Lab/GenerateUI Model) + Lab LoRA構文/トリガーワードのプロンプト適用 + AI TOOL Chat生成パラメータ拡張
- GenerateUI ModelタブのLoRA列にバイパストグルボタン(⛔)を追加(strength=0の簡易実装)
- Lab機能のLoRAキーフレームに「LoRAノードをバイパス」「LoRA構文/トリガーワードをポジティブプロンプトに適用」チェックボックスを追加
- AI TOOL Chatのgenerate_imageツールにsteps/cfg/sampler_name/scheduler/denoise/seed/width/height/batch_size/aspect_ratioを追加
- Resolution Selectorノード(ComfyUI組み込み)のaspect_ratio COMBO値対応
- ヘルプ(index.html/i18n.js 3言語/app.js helpIdMap)・README.md更新
- README/DEVLOG索引自動化(index.html PostToolUseフック + generate_doc_index.py)を導入

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:44:33 +09:00
ketle-manandClaude Sonnet 5 9681e76848 release: v0.4.3 — 共有モーダルの☆/リネーム残留バグ修正 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:44:26 +09:00
ketle-manandClaude Sonnet 5 e9fbb6dd39 release: v0.4.2 — Lab「Model」列統合(Checkpoint/LoRA Single/VAE)+ フィールド単位継承 + C/L/V表示切替
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:24:30 +09:00
ketle-man f858c5138a release: v0.4.1 — Mask Editor One連携ボタンを選択中ノード方式に再設計
Send to Workflowと同じ「選択中ノード優先・フォールバック」方式に
変更し、WFS生成UIタブへの事前読み込みを不要にした。
comfyUI.currentAnalysis依存を撤去し、ボタンは常時有効化。
2026-08-20 13:42:00 +09:00
ketle-man 7d9c498234 fix: Save to Inputボタンの背景色をデフォルトに戻す
改名時にwfm-btn-primaryクラスが残っており、他のアクションバー
ボタン(Save PNG/Save to Gallery/Send to Workflow)と見た目が
不揃いになっていた。
2026-08-20 12:17:49 +09:00
ketle-man d43b35dde3 chore: graphifyナレッジグラフをv0.4.0まで更新 + graphify CLAUDE.md統合
- graphify-out/: モジュール分割・Mask Editor One連携・Send to Workflow等、
  v0.4.0までの変更10ファイルを再抽出しグラフ再構築
- QWEN_CHAT_INTEGRATION_PLAN.md(未追跡の非公開プラン文書)由来の
  ノード/エッジ/ハイパーエッジを除去し、.graphifyignoreで今後も除外
- graphify claude install: CLAUDE.mdにgraphifyクエリ優先参照ルールを追記、
  .claude/settings.jsonにPreToolUseフック(graphify hook-guard)を登録
2026-08-20 12:14:48 +09:00
ketle-man b79f4e8fd0 release: v0.4.0 — モジュール分割リファクタ + Mask Editor One連携 + Send to Workflow
DEVLOGにv0.3.99以降の作業をまとめて記録:
- image-edit-tab.js / models-tab.js の低凝集度モジュール分割(前日実施分)
- Mask Editor One連携ボタン(DEVLOGフェーズ2計画の実装)
- Send to Workflowボタン追加 + Send to ComfyUI→Save to Input改名

pyproject.toml/READMEバージョンバッジをv0.4.0に更新。
push・GitHub Releaseはユーザー指示待ち。
2026-08-20 11:34:59 +09:00
ketle-man 87c53ef977 Rename Send to ComfyUI → Save to Input、Send to Workflowのヘルプ追加
- Image Edit タブの「Send to ComfyUI」ボタンを「Save to Input」に改名
  (README・ヘルプ英/日/中を追従)
- 前回追加した「Send to Workflow」ボタンのヘルプ項目が未記載だったため追加
2026-08-20 11:26:34 +09:00
ketle-man 8b75d2a8d0 Add Image Edit タブ「Send to Workflow」ボタン
Send to ComfyUI(アップロードのみ)とは別に、アップロード後の
ファイル名を ComfyUI 側で選択中のノードの image ウィジェットへ
直接書き込む機能を追加。chat_TE カスタムノードの
「Send to workflow」と同等(window.opener 経由のクロスウィンドウ
ブリッジ、Mask Editor One 連携と同じパターン)。
2026-08-20 11:15:38 +09:00
ketle-man 7aa2535964 Add Mask Editor One連携ボタン(DEVLOGフェーズ2) + ヘルプ更新
Image Edit タブの Mask ツールに「Edit in Mask Editor One →」ボタンを追加。
ワークフロー中の既存 MaskEditorOne ノードへキャンバス合成画像を送信し、
window.opener 経由で ComfyUI 側の実際のモーダルエディタを開いて編集、
Apply 後のマスクを新規マスクレイヤーとして WFS に取り込む。
2026-08-20 10:31:07 +09:00
ketle-manandClaude Sonnet 5 7769de6fbb chore: update graphify knowledge graph after models-tab.js refactor
Incremental update covering the 8 changed/new JS files from the
models-tab.js cohesion refactor (models-tab.js + 7 new models/*.js
modules). Code-only change, so semantic extraction was skipped.
2369->2378 nodes, 5401->5453 edges, 148->151 communities.

Unlike the image-edit-tab.js refactor, this one did NOT improve
cohesion: the 85 models-tab-related nodes remain a single community
(density 0.096), essentially unchanged from before the split (76 nodes,
same single community). Root cause: Phase 5 (grid-view/selection-bulk/
detail-panel) deliberately uses a circular import design rather than
one-directional callback injection, since rendering a card wires up
selection/side-panel/detail-modal handlers that in turn re-render the
grid. Files got smaller and easier to read, but the dependency graph
stayed tightly coupled - documented as a real finding, not a failure
to fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:21:29 +09:00
ketle-manandClaude Sonnet 5 7eafe9a684 refactor: models-tab.jsからグリッド描画/選択操作/詳細パネルをmodels/へ切り出し
低凝集度モジュール分割計画(models-tab.js側)Phase 5完了。
models/grid-view.js: グリッド描画(Thumbnail/Table)とBatch/Stack/
Favoriteトグル。
models/selection-bulk.js: 複数選択モードとバルクグループ/バッジ/
お気に入り/移動/削除操作。
models/detail-panel.js: 詳細モーダルとサイドパネルのInfo/Group/CivitAI
タブ。

この3モジュールはmodels-tab.js本体を含めた4者間で循環importする設計
にした(カード描画が選択/サイドパネル/詳細モーダルのクリックハンドラを
配線し、それらが変更後にグリッドを再描画するため、単純な一方向の依存
関係にならない)。全export/importがfunction宣言(hoisting対象)で、
相互呼び出しはすべてイベントハンドラ内(モジュール評価時ではない)に
限られるため、ES Modulesとして安全に解決される。実機でグリッド描画・
サイドパネル・複数選択・バルクバー・お気に入り/バッチトグル・詳細
モーダル開閉を確認し、循環import由来のエラーがないことを確認した。

models-tab.js: 2074→592行(約71%削減、Phase 3-5合計で2522→592行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:14:31 +09:00
ketle-manandClaude Sonnet 5 c9089c2892 refactor: models-tab.jsからフィルタ/ソートと共有ヘルパーをmodels/へ切り出し
低凝集度モジュール分割計画(models-tab.js側)Phase 4完了。
models/filters.js: フィルタリング(ステータス/お気に入り/バッチ/タグ/
バッジ/ディレクトリ/グループ/検索)・ソート・タグ/グループ/
ディレクトリフィルタドロップダウンの描画一式。
models/helpers.js: parseModelPath/getExtension/getStem/previewUrl/
loadPreviewImageという、以降のフェーズ(grid-view/detail-panel等)
でも共有される軽量パスヘルパーを、重複を避けるため先出しで切り出した。
(2522→2074行)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:01:45 +09:00
ketle-manandClaude Sonnet 5 9b3532704a refactor: models-tab.jsからバッジ機能をmodels/badges.jsへ切り出し
低凝集度モジュール分割計画(models-tab.js側)Phase 4の一部。バッジ
パレット管理・バッジHTML描画・バッジ編集モーダル・バッジフィルターバー
一式を切り出した。openBadgeEditModalはworkflow-tab.jsが直接importする
公開APIのため本体で再exportし、外部呼び出し側は無変更。renderModelGrid
(まだ本体に残るグリッド描画)への依存は循環importを避けるため
setGridChangeCallback()によるコールバック注入で解消した(2522→2300行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:55:08 +09:00
ketle-manandClaude Sonnet 5 f6f4b2c95c refactor: models-tab.jsから共有stateとルックアップ定数をmodels/state.jsへ切り出し
低凝集度モジュール分割計画(models-tab.js側)のPhase 3。共有state・
RESERVED_GROUPS/BATCH_MODEL_TYPES/STACK_MODEL_TYPES・FETCH_MAP/
TYPE_LABELS/GENUI_TYPE_MAPを切り出した。stateはconstのまま参照を
再代入せずプロパティ書き換えのみで運用し、ESM read-only制約を回避
している(2522→2446行)。以降のフェーズ(badges/filters/
selection-bulk/detail-panel/grid-view)はこのモジュールをimportして
共有する基盤。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:50:45 +09:00
ketle-manandClaude Sonnet 5 ac6a4f2dd6 chore: update graphify knowledge graph after image-edit-tab.js refactor
Incremental update covering the 6 new image-edit/*.js modules from the
cohesion refactor plus the 6 previously-hung docs (README.md, CLAUDE.md,
DEVLOG.md split into 4 parts, QWEN_CHAT_INTEGRATION_PLAN.md,
requirements.txt, publish.yml). 2094->2369 nodes, 5083->5401 edges,
112->148 communities.

Verifies the refactor's intent: each newly extracted module (GmicIntegration,
BlurTool, BgRemove, Sam3Segmentation, InpaintI2IActions, FileExport) now
forms its own high-cohesion community (density 0.18-0.48) versus the
original monolith's 0.07-0.08, while the intentionally-retained high-coupling
core (Mask Editor One dispatch, image loading) stays in one lower-cohesion
community as designed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 18:13:18 +09:00
ketle-manandClaude Sonnet 5 e6c503ef75 refactor: image-edit-tab.jsからファイル出力をFileExport.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。レイヤー合成(maskApply対応の
書き出し用コンポジット)・PNG保存・Gallery保存・ComfyUIアップロードの
3出力先を独立クラスに切り出した。レイヤー初期化・画像ロード(_loadFile/
_loadFromDataUrl/_newCanvas)はキャンバス/Undoスタックと深く結合した
高結合コアのため本体に残置(2668→2577行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:25:36 +09:00
ketle-manandClaude Sonnet 5 e21f5596d8 refactor: image-edit-tab.jsからInpaint/I2I実行をInpaintI2IActions.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。状態管理・ツールパネル描画・
Inpaint実行(ワークフロー合成/専用ワークフロー切替)・マスクなしI2I実行
一式を切り出した。gallery-tab.js(Comic Creator連携)から呼ばれる公開API
runInpaintExternal/runI2IExternalは本体に薄い委譲メソッドとして残し、
外部呼び出し側のインターフェースは変更していない(2932→2668行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:17:07 +09:00
ketle-manandClaude Sonnet 5 61bf2c83da refactor: image-edit-tab.jsからSAM3セグメンテーションをSam3Segmentation.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。状態管理・可用性チェック・
セグメント実行/マスク適用ロジック・結果サムネイルパネルを独立クラスに
切り出した。マスクツールバー全体(Paint/Color/Alpha/Text/Vector/Shape/
SAM3共通の統合UI)は高結合コアとして本体に残し、SAM3固有部分のみ
インスタンス参照に置き換えている(3130→2932行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:11:06 +09:00
ketle-manandClaude Sonnet 5 d000219b86 refactor: image-edit-tab.jsから背景除去をBgRemove.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。imgly(軽量ブラウザ内モデル)と
Mask Editor One連携のBiRefNetの2バックエンドを持つ背景除去一式(可用性
チェック・bgremoveツールパネルのrenderブロック・マスク合成処理)を独立
クラスとして切り出した(3304→3130行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:06:30 +09:00
ketle-manandClaude Sonnet 5 52ab637bd0 refactor: image-edit-tab.jsからBlur/MosaicツールをBlurTool.jsへ切り出し
低凝集度モジュール分割計画のPhase 2着手分。DrawTool等の既存ツールと同じ
onMouseDown/Move/Up/Leaveインターフェースを持つ独立クラスとして、全体/矩形
Blur・Mosaic機能一式を切り出した。ツール切替ディスパッチャーは委譲のみ残す
(3504→3304行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:01:01 +09:00
ketle-manandClaude Sonnet 5 5e5709594b refactor: image-edit-tab.jsからG'MIC統合をGmicIntegration.jsへ切り出し
低凝集度モジュール分割計画のPhase 1。G'MIC統合一式(状態管理・GUI起動/中断/
ジョブ待機/結果反映・filterツールのパネル描画)を独立クラスとして切り出し、
本体はコールバック注入するインスタンスを持つのみにした(3665→3504行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:54:53 +09:00
ketle-manandClaude Sonnet 5 b20519c092 chore: track graphify knowledge graph, ignore cache/cost/local-path files
- Add graphify-out/{graph.json,graph.html,GRAPH_REPORT.md,manifest.json,
  .graphify_labels.json} so the knowledge graph is available to other
  clones and future sessions without rebuilding.
- .gitignore: exclude graphify-out/cost.json (churns every run),
  graphify-out/cache/ (local extraction cache, no cross-machine value),
  and graphify-out/.graphify_python|.graphify_root (machine-local paths).
- Add .claudeignore for graph.json/graphify-out/ so Claude Code's
  automatic context loading doesn't invalidate the prompt cache on
  every graphify rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:18:01 +09:00
ketle-manandClaude Sonnet 5 9d952c4b6e Add Unsloth backend to AI TOOL tab / sidebar A tab (v0.3.99)
Unsloth's OpenAI-compatible API always requires an API key, unlike the
existing Ollama/LM Studio/Lemonade backends, so it's relayed through a
new server-side proxy (py/routes/unsloth_routes.py) that attaches the
key from a local .env file (UNSLOTH_API_KEY, loaded via python-dotenv
in prestartup_script.py) instead of ever sending it to the browser.

Also fixes a client-controlled baseUrl SSRF that could have exfiltrated
the API key, and folds Unsloth's separate reasoning_content field back
into <think> tags so low Max tokens no longer produces silently empty
replies from reasoning models.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 17:51:13 +09:00
ketle-manandClaude Sonnet 5 8248ff8ac7 release: v0.3.98 — AI TOOL翻訳の信頼性向上 + GenerateUI Modelタブ再構成 + Thinking mode/Max tokens設定
- AI TOOL翻訳: system role対応chat APIへ切替、出力クリーニング、未翻訳検知を追加(SPA/サイドパネル両対応)
- GenerateUI Modelタブ: ワークフローに含まれる項目のハイライト色設定を追加、ControlNet/Hypernetworkを折りたたみ化、Text Encoderの表示順をControlNetより上に変更、LoRA列見出しもハイライト対応
- AI TOOL Settings: Thinking mode ON/OFF・Max tokens設定を追加(Ollama think/num_predict、LM Studio/Lemonade max_tokens、OFF時は<think>タグをクライアント側で除去)
- ヘルプタブ・README・DEVLOGを更新

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 12:27:06 +09:00
ketle-manandClaude Sonnet 5 7548c49910 release: v0.3.97 — KREA-2ワークフロー対応 + Labタブ拡張 + Input Imageプレースホルダ機能
- KREA-2ワークフロー対応で発覚した2件の既存バグ修正
  - PreviewAny/StringConcatenate中継ノードの役割伝播バグ修正
    (GenerateUI/Metadata/IタブでPositive Promptが空になる問題)
  - DynamicComboウィジェットのスロット数カウント修正
    (Prompt outputs failed validationエラーの原因)
- Labタブ: プロンプトモーダルにClear/Get from Previousボタン追加
- Labタブ: Setting 1(キーフレーム#1)の各列にGenerateUIの現在値をライブ反映
- Labタブ: 各キーフレームにBypassチェックボックス追加(Lab内部のみのスキップ機構)
- GenerateUI Input Imageサブパネル: Clear/Placeholderボタン追加
  (Color/Imageモード、設定は記憶、低頻度機能のため折りたたみ表示)
- ヘルプタブ更新(3点セット: index.html/app.js/i18n.js)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 12:39:42 +09:00
ketle-manandClaude Sonnet 5 e9f2c6171a release: v0.3.96 — カタログ作成バッチ無言停止バグ修正 + Style Catalog/Promptタブ機能拡張
- trackProgress()にタイムアウト・execution_interrupted処理・WebSocket切断時のPromise解放を追加し、
  バッチ処理が特定件数で無言のまま停止するバグを修正
- Style Catalogタブに「Load in GenerateUI」「Open in Metadata Tab」ボタンとダブルクリック拡大表示を追加
- Promptタブ右ペインにStyle管理サブタブ(Wildcardと切替式)を新設。作成・編集・削除、
  ファイル名バッジ表示、既存ファイルへの追加(+ Add to this file)に対応
- カタログ作成モーダルに「Outputフォルダにコピーを残さない」オプションを追加

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 13:47:03 +09:00
ketle-manandClaude Sonnet 5 90a7d824fb Merge feature/style-catalog-imageprompt: v0.3.95 ImagePrompt改名+Style_Catalogギャラリー
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 01:26:46 +09:00
ketle-manandClaude Sonnet 5 05e061d656 release: v0.3.95 — Style/Prompt→ImagePrompt改名 + Style_Catalogギャラリー + カタログ作成機能
Galleryタブが Output / ImagePrompt / Style_Catalog の3サブタブ構成に。既存の
Style/PromptサブタブをImagePromptへ全面改名(フォルダ ws_style_prompt→ws_image_prompt
含む)した上で、GenerateUIの登録済みStyleをプレビュー画像から視覚的に選べる
Style_Catalogサブタブを新設。

- Style_Catalogは ws_style_catalog フォルダ配下の画像を閲覧し、埋め込み
  Positive/Negativeプロンプトを表示・コピー、「Select as Style」で該当スタイルへ
  GenerateUIのStyleドロップダウンを切り替え可能
- GenerateUIに「カタログ作成」ボタンを追加。Batchタブ Styleサブタブで選択済みの
  スタイル(複数可)を対象に、現在のワークフローで順次生成しスタイル名で
  ws_style_catalog配下へ保存(既存のバッチ実行機構を再利用)。隣に「カタログ」
  ボタンでStyle_Catalogサブタブへワンクリック遷移
- Output Galleryの詳細パネルにプロンプト表示が出ていなかった不具合も併せて修正

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 01:26:41 +09:00
ketle-manandClaude Sonnet 5 5afdec395c Merge feature/style-prompt-gallery: v0.3.94 Style/Promptギャラリー追加
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:16:35 +09:00
ketle-manandClaude Sonnet 5 3ab4f3e36b release: v0.3.94 — Galleryタブをサブタブ化しStyle/Promptギャラリーを新設
Output(既存)/ Style-Prompt(新規)の2サブタブ構成に変更。Style/Promptギャラリーは
output/ws_style_prompt フォルダを対象に、フォルダツリー・サムネイル一覧・プロンプト
ビルダー(選択→追加→チップ→最終プロンプト→コピー)を提供する視覚的プロンプト作成UI。

- プロンプトは画像と同名の.txtサイドカーで保存(gallery_metadata.jsonには非依存)
- .txtサイドカーが無い画像は ponyxlWildcardsVault形式(*.yaml + thumbnails/)を
  その場で解決するフォールバックに対応(インポート不要でパックを直接閲覧可能)
- tools/import_style_prompt_seed.py でwildcardデータから画像+プロンプトの対を
  ポータブルな.txtサイドカー形式へ変換投入可能(画像データ自体はリポジトリに含めない)
- 親フォルダ選択時のサブフォルダ画像再帰表示、右ペイン2カラムレイアウトにも対応

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:16:22 +09:00
ketle-manandClaude Sonnet 5 4a23564c66 release: v0.3.93 — LabタブVAEキーフレーム不発バグ修正・Style適用UI刷新・Plan JSONスクロール同期修正・キーフレーム個別削除・Batch数超過警告・ワイルドカード対応・Save index imageデフォルトON
- VAE: 独立VAELoaderノードが無いワークフローでキーフレームが無警告で無視されるバグを修正(動的ノード注入+リンク張替え)
- Style: 上部Style有効化チェックボックスに依存しないApplyボタン+ON/OFFチェックボックス+別表示名に刷新
- Plan JSON: テキストエリアとハイライトオーバーレイのスクロール同期漏れを修正
- キーフレーム: 最後以外もモーダルから個別削除可能に
- Batch数: 既存キーフレームの回数目を下回った場合に警告トーストを追加
- ワイルドカード: Promptキーフレームでも__name__記法によるSPA側展開に対応
- Save index image to Output on Run のデフォルトをONに変更(+ Plan Clear時のチェックボックス表示ズレを修正)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 19:28:31 +09:00
ketle-manandClaude Sonnet 5 d8ffcc182e release: v0.3.92 — CLIPTextEncodeEditPlus実行結果不一致の根本修正(convertUiToApiのforceInput未考慮バグ)+LoRA Stack機能改善
- convertUiToApiの_getWidgetInputNames/_getWidgetInputTypesがforceInput:trueを考慮しておらず、未接続の任意入力(例: CLIPTextEncodeEditPlusのtext2)が誤ってwidgets_valuesの余分な値を吸収し送信データに紛れ込んでいたバグを修正。ComfyUI直接実行との生成結果不一致(text_edit欠落)の根本原因だった。
- Metadata/Infoタブ(metadata-tab.js, node_sets_menu.js)でCLIPTextEncodeEditPlusのtext1+text_edit+mode結合ロジック(RAW/EDIT/front/back)を追加し、ノード本体のPython実装と同じルールで正しくプロンプトを抽出するよう修正。
- GenerateUI ModelタブにLoRA Stack機能を3点改善: Use Stack Groupチェックボックス(ワークフロー実データ/Modelsタブ登録グループの切替)、複数LoRAの正しい表示、タブ切替でのON/OFF状態リセットバグ修正。
- ヘルプ(GenerateUIタブページ)にModel Tab — LoRA Detailsカードを追加(EN/JA/ZH)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:54:47 +09:00
ketle-man 3d5bc47e20 release: v0.3.91 — Labインデックス画像にPlanメタデータをPNG埋め込み、READMEのChangelogセクションを廃止
- Lab: インデックス画像PNG(Plan Save/Save As、Run時Output保存の両方)にPlan全体をiTXtチャンク(wfm_lab_plan)として埋め込み
- Lab: Planドロップエリアへの.pngドロップ時、埋め込みメタデータを直接読み込むように変更(対応する.jsonが無いOutputフォルダ側の画像でも読込可能に)。古い画像は従来通りサーバーへフォールバック
- README: 肥大化した Changelog セクションを削除。以後のバージョン履歴はGitHub Releaseのリリースノートで管理
- CLAUDE.md: リリース手順からREADME Changelog更新の手順を削除
2026-08-10 11:14:11 +09:00
ketle-man 35899bf53e release: v0.3.90 — Labタブにワークフロー呼び出し・T2I対応・Run時インデックス画像Output保存+Eagle連携・プラン接頭辞を追加、Galleryルート表示を簡略化
- Lab: Plan JSONに使用ワークフローファイル名を記録し、Open Workflowボタンで呼び戻し可能に
- Lab: T2I workflow (no source image) チェックボックスでソース画像なしのT2Iワークフローにも対応
- Lab: Save index image to Output on Run チェックボックスで、Run完了ごとにインデックス画像をComfyUIのOutputフォルダへ保存し、Eagle自動保存にも連携
- Lab: Plan Save/Save Asで保存されるファイル名にws_labplan_接頭辞を自動付与
- Gallery: フォルダツリーのルート表示を「[root] <フォルダ名>」から「[root]」に簡略化
- README: FeaturesセクションをタブごとにHTML detailsで折りたたみ化
2026-08-10 10:35:20 +09:00
ketle-manandClaude Sonnet 5 f356826e9f release: v0.3.89 — Lab機能への実動作確認フィードバック3件を反映(Get from Image・スタイル追記・Modelタブフィルター✕)
Get from ImageをLabタブに現在読み込まれている画像から取得する方式に変更。
スタイル適用を{prompt}置換ではなく常時カンマ区切り追記(既存プロンプトを消さない)に変更。
GenerateUI ModelタブのCheckpoint/VAE/Diffusion Model/ControlNet/Hypernetwork/Lora各フィルターに
オーバーレイ✕クリアボタンを追加。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:43:33 +09:00
ketle-manandClaude Sonnet 5 003f3408fc release: v0.3.88 — Labタブ Promptセルにスタイル適用・画像/GenerateUIからのプロンプト取得、Checkpoint/VAEフィルター、Plan JSONサブタブを追加
Promptセル編集モーダルにGet from GenerateUI/Get from Image/Styleボタンを追加(Styleは
GenerateUIツールバー上部のチェックボックス・ドロップダウンをそのまま参照)。
Checkpoint/VAEセルにModelタブ同様のフィルター入力+オーバーレイ✕クリアボタンを追加。
Labに3つ目のサブタブ「Plan JSON」を新設し、現在のプランをJSONとして確認・編集・
Settingへ適用できるようにした。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:16:19 +09:00
ketle-manandClaude Sonnet 5 54198f196a release: v0.3.87 — GenerateUIタブに実験的なLabサブタブ(I2Iバッチ生成・キーフレーム方式)を追加
Checkpoint/VAE/Prompt/KSamplerを列ごとに独立したキーフレームで管理し、任意の回数目から
値を変更できるI2Iバッチ生成機能。ワークフローを直接書き換えず、実行時にクローンして適用。
Plan Save/Save As/Load(json/pngドロップ対応)、画像連鎖生成、Eagle自動保存連携、
インデックス画像(最大9枚コンタクトシート)に対応。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 12:15:07 +09:00
ketle-manandClaude Sonnet 5 3ff76aacc6 release: v0.3.86 — GalleryMetadataStoreの競合状態を修正
画像を一括生成(I2I)等でメタデータ保存リクエストが短時間に連発すると、バックグラウンドのフォルダインデックススレッドと競合し「dictionary changed size during iteration」が発生していた不具合を修正。GalleryMetadataStoreの共有辞書アクセスをthreading.RLockで保護した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 15:50:46 +09:00
ketle-manandClaude Sonnet 5 ec8be7ee6f release: v0.3.85 — Generate UI連携ブリッジ(T2I)にデフォルトワークフロー・Negative対応を追加
_wfmReceiveGenerateRequestをI2I/Inpaint連携と同じパターンに拡張し、
negative/workflowData/workflowFilename引数を追加。Comic Creator側の
「画像を一括生成」(T2I)からデフォルトワークフローを指定できるようになった。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:53:00 +09:00
ketle-manandClaude Sonnet 5 117fcad22a fix: release v0.3.84 — ernie_t2i.json実行時のPrompt outputs failed validationエラーを修正
サブグラフ境界のwidget値(width/height等)が複数の内部ノードへfan-outする場合
(EmptyFlux2LatentImage.widthへの実ウィジェット注入 + PreviewAny.sourceという
ウィジェット表現を持たないワイルドカード必須入力への中継)、_flattenSubgraphs
のinputPortTargetsが最後の1ターゲットで上書きされ、片方が未接続のまま
Required input is missing: sourceで検証エラーになっていた。全fan-outターゲット
を追跡し、非ウィジェット入力にはリテラル値を直接注入するよう修正。
実サーバーへの/promptバリデーションおよび実ブラウザでのGenerate実行で確認。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 21:45:00 +09:00
ketle-manandClaude Sonnet 5 feed327c23 release: v0.3.83 — Flux.2 Klein / Ernie Imageのプロンプト表示不具合を修正
CLIPTextEncode.textがPrimitiveStringMultilineへのリンク参照になっているパターン
(Metadata/IタブのresolveLinkedTextがvalueキー未対応)と、ComfySwitchNode経由で
生プロンプト/LLM強化版のどちらかに切り替わるErnie Imageのプロンプト強化トグル
(LLM出力は静的解析不能なため両分岐を辿りリテラルへ解決できる方を採用)に対応。
GenerateUIタブ・Metadataタブ・サイドパネルIタブの3箇所を修正し、実ブラウザで
3ワークフローすべての表示を確認。ヘルプにErnie Imageの表記を追加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 19:31:37 +09:00
ketle-manandClaude Sonnet 5 82ddbb340c release: v0.3.82 — Metadata/Iタブのプロンプト抽出修正・widgets_values/Rerouteバグ修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:17:13 +09:00
ketle-manandClaude Sonnet 5 31f02962f5 ヘルプ・対応フォーマット表記にLongCat/Boogu/HiDream E1/FireRedを追加
GenerateUIタブ(helpGen2)・Metadataタブ(helpMetadata5)のヘルプ、および
Metadataタブ内の対応フォーマット補足(metaFormatTodo)を3言語(EN/JA/ZH)で更新。
併せてmetaFormatTodoにMage-Flowが未記載だった既存の抜けも修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:10:01 +09:00
ketle-manandClaude Sonnet 5 413f0d5068 MetadataタブとサイドパネルIタブのプロンプト抽出をImage Edit系ワークフローに対応
Flux.2 Klein/LongCat/Boogu/HiDream E1/FireRedをAI TOOL Chatで実際に生成し
Metadataタブで確認したところ、qwen_image_edit2509/2511・longcat・boogu・
Fireredの画像でプロンプトが表示されない問題が見つかった(同じ画像を
GenerateUIタブへ読み込むと正しく表示されるため、GenerateUIタブ側の解析
[comfyui-workflow.js]とは別に実装されているMetadataタブ独自のプロンプト
抽出ロジックが未対応と判明)。

原因は主に2点:
- PNGは`prompt`チャンク(既にAPI形式)が`workflow`チャンクより優先して読まれる
  ため、extractPromptsAPI()のtextMap構築が常に使われるが、そこは"text"/
  "text_g"キーのみを見ており、TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit
  が使う"prompt"キーを見ていなかった。
- SamplerCustomAdvancedはpositive/negativeを直接持たずGuiderノード
  (CFGGuider/DualCFGGuider/BasicGuider)経由のため、サンプラーのinputsを
  直接見るだけのrole判定では見つけられなかった(Flux.2 Klein/HiDream E1)。

extractPromptsAPI/extractPromptsFromNodeSet双方に、guider経由のinputsを
辿る処理、TextEncodeBooguEditの1ノード2ロール(prompt/negative_prompt)対応、
InstructPixToPixConditioning経由の追加ホップ解決を追加。node_sets_menu.js
(ComfyUIサイドパネルのIタブ、web/comfyui/はESモジュールをimportできない
制約からcomfyui-workflow.js/metadata-tab.js相当のロジックを`_wfm`接頭辞で
ローカル複製している)にも同じ修正を適用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:02:47 +09:00
ketle-manandClaude Sonnet 5 bec84ac30b release: v0.3.81 — Flux.2 Klein / LongCat / Boogu / HiDream E1 / FireRed Image Edit対応
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 17:40:33 +09:00
ketle-manandClaude Sonnet 5 61e16ddce0 Rerouteノードを透過解決し、hidream_comicの400エラーを修正
HiDream E1ワークフロー(hidream_comic.json)はUNETLoaderの出力をBasicScheduler/
DualCFGGuiderへ分岐させるのにRerouteノードを使っていた。RerouteはUI専用の
パススルーノードでComfyUIバックエンドにノードクラスが存在しないため、
そのままAPIペイロードに含めると「missing_node_type」の400エラーになっていた
(実機でGenerateして初めて発覚)。

_resolveBypassSource()をReroute対応に拡張し、Bypass(mode:4)ノードと同様に
配線を実際の入力元まで直結して解決するようにした。Rerouteの入力型は多くの場合
汎用型"*"のまま保存されており、Bypass用の型一致検索では見つからないため、
Reroute(常に1入力1出力)は型チェックを省略して唯一の入力を辿るようにしている。
convertUiToApi本体のノードループでもReroute自体をAPIペイロードから除外する。

実機でGenerate実行し、エラーなく生成できることを確認済み。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:39:48 +09:00
ketle-manandClaude Sonnet 5 174a67a569 LongCat/Boogu/HiDream E1/FireRedのImage Editワークフローに対応
GenerateUIタブでの検出範囲を拡張:
- TextEncodeQwenImageEdit(Plus無し版)とFluxGuidance/FluxKontextMultiReferenceLatentMethod
  のCOND_PASSTHROUGH対応(LongCat)
- SamplerCustomをSamplerCustomAdvancedと分離し、自身が持つpositive/negative/cfg/noise_seed
  を正しく読み取るよう修正、TextEncodeBooguEdit対応(Boogu)
- DualCFGGuiderをguiderとして認識(cfg_condsを代表cfg値に)、InstructPixToPixConditioning
  のrole伝播中継に対応(HiDream E1)
- KSamplerのseed/steps/cfg/denoiseがリンク(Primitive経由)で非数値の場合にundefined化

あわせて、サブグラフ展開処理(_flattenSubgraphs)のwidgets_valuesノーマライズを実機検証で
発見した既存バグごと全面的に書き直した。従来は「widgets_values長とwidget数が完全一致する
場合のみ」ノーマライズが働く設計で、seed直後のcontrol_after_generate拡張値('fixed'等)が
挟まるノード(KSampler等)では条件が成立せず、リンクされたwidget(steps/cfg等)のエントリが
除去されないまま後続処理に渡り、cfg/denoiseなどの値が一つずつズレて上書きされていた
(Flux.2 KleinのEmptyFlux2LatentImage、Firered_comicのKSamplerで実際に発生)。
_simulateWidgetValues/_stripLegacyLinkedWidgetValues/_findInjectedWidgetIndexとして、
legacy-full形式かどうかの自動判定・control_after_generate込みの正確な位置計算・注入を
共通ロジックに統一した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 14:52:04 +09:00
ketle-manandClaude Sonnet 5 d903f33a31 Flux.2 Klein(Advanced Sampling)ワークフローのGenerateUI対応
CFGGuider+RandomNoise+SamplerCustomAdvanced+KSamplerSelect+Flux2Schedulerという
KSamplerを使わない構成を、SamplerCustomAdvanced起点でたどってSettingsパネルの
Seed/Steps/CFG/Sampler欄に集約表示・編集できるようにした。EmptyFlux2LatentImageも
Latentパネルに対応させ、GetImageSizeへリンクされたwidth/heightは空欄+説明表示にして
誤上書きを防止。CFGGuider/BasicGuiderをpositive/negative判定の起点にも追加。

あわせて実機検証で見つかった既存バグを修正:
- サブグラフ内部完結リンク(GetImageSize等)の扱いが誤り、widgets_values変換で
  batch_sizeが1024になったりCLIPTextEncodeのtextが消えたりしていた不具合
- 同ロールのプロンプトノードが複数ある場合にドロップダウン選択とテキスト内容が
  食い違う不具合
- ミュート/バイパスしたサブグラフの内部ノードにmodeが伝播せず、無効なパイプライン
  のノードがModelタブ等のデフォルト表示に紛れ込む不具合

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 15:55:55 +09:00
ketle-manandClaude Sonnet 5 afed788537 release: v0.3.80 — Comic Creator Semi-Automatic Manga bridges (LLM prompt draft + batch generate)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:14:55 +09:00
ketle-manandClaude Sonnet 5 ee020cb0c0 Comic Creator「半自動マンガ作成」向けブリッジを追加(LLMプロンプト下書き・バッチ画像生成)
gallery-tab.jsに_wfmReceiveLLMPromptRequest/_wfmReceiveGenerateRequestを追加。既存のI2I/Inpaint連携と同じ配置パターンで、Comic Creator側からiframe越しに呼び出される。ai-tab.jsのcallLLM/loadAiSettings/isValidBackendUrlをexportし再利用。

Comic Creator側からKaptureで実機E2E確認済み(Ollama接続でのLLM呼び出し、t2iワークフローでの画像生成とも成功)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:22:18 +09:00
ketle-manandClaude Sonnet 5 f6942eb7fa release: v0.3.79 — AI TOOL Chat Skills (SVG quality + Skill Creator), Help tab card redesign
- AI TOOL Chat: new "Skills" system — selectable .md system-prompt files
  (frontmatter name/description) managed in-pane via a wildcard-manager-style
  list+editor, backed by a new /api/wfm/skills service (skill_service.py /
  skill_routes.py). Bundled with two starter skills: "SVG Icon Generator"
  (explicit part-decomposition technique that fixes unrecognizable SVG output
  from smaller local models) and "Skill Creator" (a meta-skill that
  interviews the user and drafts a new skill as a ```skill block, with a
  one-click "Save as new skill" button).
- Help tab: every page now opens with a title + "Open tab" jump button and
  splits its content into topic-focused cards instead of one long bullet
  list, matching a pattern independently adopted by a sibling project that
  had in turn been modeled on this app's own help UI. Content itself is
  unchanged — only regrouped — except for fixing a duplicated Settings-pane
  bullet and filling in Tagger/Troubleshooting text that only existed in the
  i18n dictionary and never reached the page markup.
- Version bump bundles the previously-committed sidebar Info tab Mage-Flow/
  subgraph fix into this release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 12:02:46 +09:00
ketle-manandClaude Sonnet 5 bdfd0edb7a Sidebar Info tab: match Metadata tab's Mage-Flow/subgraph workflow support
node_sets_menu.js duplicated a convertUiToApi()-equivalent (subgraph
flattening with outer widget value injection, DynamicCombo support) and
added TextEncodeMageFlowEdit-specific prompt handling, mirroring the
v0.3.78 Metadata tab changes — web/comfyui/ can't import static/js/
modules (different serving URL), so the same local-duplication pattern
used for AI backend URL resolution was followed here too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 18:45:10 +09:00
ketle-manandClaude Sonnet 5 e1ffadbdca release: v0.3.78 — Eagle SVG support, subgraph workflow support (Mage-Flow), structured AI summary
- Eagle integration: SVG auto-save for both standard SaveSVGNode and comfyui-tosvg's Save SVG String node
- GenerateUI/Metadata tabs: subgraph-containing workflows (Mage-Flow etc.) no longer blocked as unsupported App format; outer subgraph widget values now correctly injected into internal nodes; DynamicCombo widget type support
- Workflow tab: AI summary now uses structured analysis instead of raw JSON truncation; accurate P/I/-> node counts and format badge for newer node types
- Workflow/Gallery tabs: Send to Canvas toast now distinguishes direct-load from drag-required fallback

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 14:29:24 +09:00
ketle-manandClaude Sonnet 5 65ef71b4ad release: v0.3.77 — Gallery SVG support, Chat SVG generation without a workflow
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:19:19 +09:00
ketle-manandClaude Sonnet 5 49019dfb05 DEVLOGに含まれるユーザー固有のパスを汎用表現に置き換え
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:41:28 +09:00
ketle-manandClaude Sonnet 5 78d9bba904 release: v0.3.76 — Comic Creator I2I execution bridge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:29:37 +09:00
ketle-manandClaude Sonnet 5 f8435ed770 release: v0.3.75 — Lemonade Server backend support, Bypass/Mute node fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 10:04:09 +09:00
110 changed files with 32665 additions and 6572 deletions
+60
View File
@@ -0,0 +1,60 @@
"""PostToolUse hook: regenerate README.index.md / DEVLOG.index.md right after
Claude Code edits README.md or DEVLOG.md, so graphify's excluded-doc index
stand-ins (see .graphifyignore) never drift from the source files within a
session. A git pre-commit hook covers edits made outside Claude Code.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
TARGETS = {"readme.md": "readme", "devlog.md": "devlog"}
def main() -> None:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return
if payload.get("tool_name") not in ("Edit", "Write"):
return
file_path = payload.get("tool_input", {}).get("file_path", "")
if not file_path:
return
name = Path(file_path).name.lower()
target = TARGETS.get(name)
if target is None:
return
project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or str(Path(file_path).resolve().parent)
script = Path(project_dir) / "tools" / "generate_doc_index.py"
if not script.exists():
return
result = subprocess.run(
[sys.executable, str(script), target],
cwd=project_dir,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
print(json.dumps({"systemMessage": f"[doc-index] {result.stdout.strip()}"}))
else:
print(
json.dumps(
{"systemMessage": f"[doc-index] generate_doc_index.py {target} failed: {result.stderr.strip()}"}
),
file=sys.stderr,
)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Grep",
"hooks": [
{
"type": "command",
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard search"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard read"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/on_doc_edit.py\"",
"timeout": 30
}
]
}
]
}
}
+5
View File
@@ -0,0 +1,5 @@
# graphify: keep graph.json/graphify-out/ out of Claude Code's automatic
# context so re-running `graphify extract` doesn't churn the prompt cache
# on every rebuild. These are still tracked by git (see .gitignore).
graph.json
graphify-out/
+11
View File
@@ -0,0 +1,11 @@
# ComfyUI-Workflow-Studio - Unsloth backend (AI TOOL tab / sidebar A tab)
#
# Unsloth Desktop requires an API key even for local access
# (Settings -> API in the Unsloth app, shown only once when issued).
#
# 1. Copy this file to ".env" (same directory as this example file)
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
# line becomes a comment and the key will not be loaded)
# 3. Restart ComfyUI
UNSLOTH_API_KEY=
+1 -1
View File
@@ -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 }}
+7
View File
@@ -5,3 +5,10 @@ __pycache__/
.venv
*.egg-info/
data/
# 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
+10
View File
@@ -0,0 +1,10 @@
# Private/unpublished planning notes — never committed to git, must not leak
# into the published graphify-out/ knowledge graph either.
QWEN_CHAT_INTEGRATION_PLAN.md
# Too large for reliable local-Ollama semantic extraction (large chunks make the
# model summarize in prose instead of emitting JSON, and time out on slower
# models/hardware). README.index.md / DEVLOG.index.md are condensed stand-ins
# that graphify extracts instead — regenerate them after editing the originals.
README.md
DEVLOG.md
+29 -4
View File
@@ -194,9 +194,34 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
2. `README.md` のバージョンバッジを更新(`![Version](https://img.shields.io/badge/version-X.Y.Z-green)`)
3. `README.md` の Changelog に新バージョンのエントリを追加
4. `DEVLOG.md` の先頭に変更詳細エントリを追加
5. 上記すべてを同一コミットにまとめてプッシュ
6. `gh release create vX.Y.Z` でリリースを作成
3. `DEVLOG.md` の先頭に変更詳細エントリを追加
4. 上記すべてを同一コミットにまとめてプッシュ
5. `gh release create vX.Y.Z` でリリースを作成(リリースノート本文に変更点を記載する — v0.3.90でREADME.mdのChangelogセクションは廃止したため、以後のバージョン履歴はGitHub Releaseのリリースノートのみで管理する)
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
<!-- graphify運用は一時停止中(2026-08-23〜、ユーザー指示により凍結)。再開時はこのコメントブロックを解除すること。graphify-out/ 自体は削除しておらずそのまま残っている。
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
### README.md / DEVLOG.md の索引ファイル
`README.md`/`DEVLOG.md`はサイズが大きすぎてローカルOllamaでの意味抽出が不安定になる(大きな塊を渡すとJSONではなく散文の要約を返す、または処理がタイムアウトする)ため、`.graphifyignore`でgraphifyの抽出対象から除外している。代わりに縮小版の`README.index.md`/`DEVLOG.index.md`を抽出対象にしている。
- 生成スクリプト: `python tools/generate_doc_index.py [readme|devlog|all]`
- 自動化: `README.md`/`DEVLOG.md`を編集すると以下の2つのフックが自動でindexを再生成する
- Claude Code PostToolUseフック(`.claude/hooks/on_doc_edit.py`)— Edit/Write直後に即時実行
- git pre-commitフック(`.git/hooks/pre-commit`)— コミット時にstagedファイルを検知して再生成し、同じコミットに含める(他エディタでの編集にも対応)
- 索引ファイルを手動で編集しない(次の生成で上書きされる)
- 索引ファイル再生成後は`graphify update .`を実行してグラフに反映する
-->
+184
View File
@@ -0,0 +1,184 @@
# DEVLOG Index — ComfyUI-Workflow-Studio
Condensed version-by-version index of DEVLOG.md (each line: version + one-line summary of what changed and why). Full rationale, code details, and "How to apply" lessons live in the excluded DEVLOG.md itself; this index exists so graphify can extract a queryable semantic node per release without feeding the full log to the local Ollama model. Regenerate with `python tools/generate_doc_index.py devlog` whenever DEVLOG.md changes.
- **v0.7.4(2026-09-29)** — Video Edit: BGM/音声トラック合成(Phase 4)— 複数クリップ結合時の音声消失も解消
- **v0.7.3(2026-09-24)** — GenerateUIタブにQwen Image 2.1(TextEncodeQwenImage21)対応を追加
- **v0.7.2(2026-09-18)** — Workflowタブにソート機能追加(Date/Name 昇順降順)
- **v0.7.1(2026-09-17)** — セキュリティ修正: Tagger VLM の Unsloth 経路でAPIキーがSSRFにより外部送信され得る不備を修正
- **v0.7.0(2026-09-17)** — プレビュー枠の高さ固定・AssetサブタブをPlan/Editと並ぶ中央下部へ移設・トリムパネルにClipchamp風スクラバー追加・Frame保存のVideo Assets登録とAsset→Plan画像連携
- **v0.6.3** — Send CCボタンでMP4動画をComic Creatorの新設「動画ツール」へ送れるように対応
- **v0.6.2** — Image Editタブにペンタブレット筆圧対応とFillツール(バケツ塗りつぶし)を追加
- **v0.6.1** — Galleryタブの検索が「読み込み中」のまま止まりクリアでも中断できない不具合、および50枚程度のフォルダでも数秒〜十数秒かかる不具合を修正
- **v0.6.0** — Gallery/Image Edit両タブにPSD(Photoshop)対応を追加 — 複数画像の一括PSD書き出し、レイヤー保存/読み込み、Gallery表示、Image Editのレイヤーパネル改善
- **v0.5.8** — Tagger DBタブに一括削除機能を追加(外部コントリビューターPRのレビュー・検証・修正・マージ)
- **v0.5.7** — 設定エクスポート/フルバックアップのドキュメント修正: v0.5.5のVideo Plan機能追加分の言及漏れ
- **v0.5.6** — バグ修正: WD Taggerで不正確なタグが生成される問題を修正(GitHub Issue #1)
- **v0.5.5** — Videoタブ全面刷新: Plan/Asset/Editバッチ生成 + Projectサブタブ + プレビュー2ペイン化 + Video Assets/Video Temp運用整理 + Galleryメタデータ消失バグ根本修正
- **v0.5.4** — 機能追加: Video/GenerateUIタブのLTX-2.5・Wan2.2対応拡大 + AI Assistant設定のUnsloth対応・独立モーダル化・モデルアンロード機能
- **v0.5.3** — バグ修正: Labタブのワークフロー切替時に前の内容が残留 + Galleryタブの読み込み高速化・root自動展開 + READMEスクリーンショット刷新
- **v0.5.2** — 機能追加: GenerateUI/Labタブの動画ワークフロー対応 + VideoタブにFrame抽出/GIF変換機能 + データ管理監査とZIPフルバックアップ + Send to LI node統一
- **v0.5.1** — 機能追加: mp4動画の埋め込みメタデータ対応(Gallery Prompt/JSON・Metadataサブタブ・サイドパネルIタブ)+ Gallery Infoタブに寸法/再生時間表示 + MiniMax H3等オールインワン動画ノードのプロンプト抽出バグ修正
- **v0.5.0** — 機能追加: mp4動画Gallery対応 + Videoタブ新設(MiniMax H3専用) + バグ修正: Galleryのタグ/メモが再起動後に消える
- **v0.4.8** — 機能追加: LabタブにPromptグループ一括適用+キーフレーム並べ替え + バグ修正: デフォルトワークフローの上書き保存が反映されない + Styleテーブルの「このファイルへ追加」対応
- **v0.4.7** — 機能追加: Prompt Tab内部リファクタ + Gallery Promptタブ新設 + Send to Canvas改善 + Presetsソート
- **v0.4.6** — 機能追加: MetadataタブをGalleryタブへ統合 + Promptタブに一覧編集可能なTable表示を新設
- **v0.4.5** — 調査: トップバー保存ワークフローのRMBG不具合報告(原因なし) + バグ修正: LabタブModel列の無意味な空キーフレーム表示を解消
- **v0.4.4** — 機能追加: Lab/GenerateUI ModelタブへLoRAバイパス機能 + AI TOOL Chatの画像生成パラメータ拡張
- **v0.4.3** — バグ修正: 共有モーダルのお気に入り☆/リネームがLabモーダルへ残留 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
- **v0.4.2** — 機能追加: Lab「Model」列統合(Checkpoint / LoRA Single / VAE)+ フィールド単位継承 + C/L/V表示切替
- **v0.4.1** — UX改善: Mask Editor One連携ボタンを「選択中ノード優先」方式に再設計
- **v0.4.0** — リファクタリング: image-edit-tab.js / models-tab.js の低凝集度モジュール分割
- **v0.3.99** — 機能追加: AI TOOLタブ + サイドパネルAタブにUnslothバックエンド追加(APIキーは.env管理、SSRF脆弱性修正、reasoning_content対応)
- **v0.3.98** — 機能追加・バグ修正: AI TOOL翻訳の信頼性向上 + GenerateUI Modelタブ再構成(ハイライト色・折りたたみ・並び替え)+ Thinking mode/Max tokens設定
- **v0.3.97** — 機能追加・バグ修正: KREA-2ワークフロー対応 + Labタブ拡張(Clear/Get from Previous/Bypass/ライブ反映)+ Input Imageプレースホルダ機能
- **v0.3.96** — バグ修正: カタログ作成バッチが特定件数で無言停止する問題の修正 + Style Catalog/Promptタブ機能拡張
- **v0.3.95** — 機能追加: Style/Prompt→ImagePrompt改名 + Style_Catalogギャラリー新設 + GenerateUI「カタログ作成」機能
- **v0.3.94** — 機能追加: Gallery タブのサブタブ化 + Style/Prompt ギャラリー新設(視覚的プロンプトビルダー)
- **v0.3.93** — バグ修正・機能改善: Labタブ VAEキーフレーム不発バグの修正+Style適用UI刷新+Plan JSONスクロール同期修正+キーフレーム個別削除+Batch数超過警告+ワイルドカード対応+Save index image デフォルトON
- **v0.3.92** — バグ修正: CLIPTextEncodeEditPlus使用ワークフローがGenerateUI経由だと直接実行と異なる画像になる問題(根本原因はconvertUiToApiのForceInput 未考慮バグ)+LoRA Stack機能の改善一式
- **v0.3.91** — 機能拡張: Labインデックス画像にPlanメタデータをPNG埋め込み+README構成整理(Changelogセクション廃止)
- **v0.3.90** — 機能拡張: Labタブの進化(ワークフロー呼び出し・T2I対応・Run時インデックス画像Output保存+Eagle連携・プラン接頭辞)+Galleryタブのルート表示簡略化
- **v0.3.89** — バグ修正: v0.3.88で追加したLab機能への実動作確認フィードバック3件を反映
- **v0.3.88** — 機能拡張: Labタブ Promptセルにスタイル適用・画像/GenerateUIからのプロンプト取得、Checkpoint/VAEフィルター、Plan JSONサブタブを追加
- **v0.3.87** — 追加機能: GenerateUIタブに実験的な「Lab」サブタブを追加(I2Iバッチ生成・キーフレーム方式)
- **v0.3.86** — バグ修正: 画像を一括生成(I2I)等の連続実行で "GalleryMetadataStore: save error: dictionary changed size during iteration" が発生
- **v0.3.85** — 機能拡張: Generate UI連携ブリッジ(T2I)にデフォルトワークフロー・Negativeプロンプト対応を追加
- **v0.3.84** — バグ修正: ernie_t2i.jsonをGenerateUIタブで実行すると "Prompt outputs failed validation" エラー
- **v0.3.83** — 追加機能: Ernie Image(ernie-image / ernie-image-turbo)ワークフロー対応 + プロンプトがリンク経由のノードから供給されるパターンの修正
- **v0.3.82** — バグ修正: MetadataタブとサイドパネルIタブでLongCat/Boogu/HiDream E1/FireRed等のプロンプトが表示されない不具合
- **v0.3.81** — 追加機能: GenerateUIタブでFlux.2 Klein / LongCat / Boogu / HiDream E1 / FireRedのImage Editワークフローに対応
- **v0.3.80** — 追加機能: Comic Creator「半自動マンガ作成」向けブリッジ(LLMプロンプト下書き・バッチ画像生成)
- **v0.3.79** — 追加機能: サイドパネル(Workflow Studio Library)IタブのMage-Flow等サブグラフワークフロー対応
- **v0.3.78** — 追加機能: Eagle自動保存のSVG対応
- **v0.3.76** — 追加機能: Comic Creator向けI2I実行ブリッジ`_wfmReceiveI2IRunRequest`
- **v0.3.77** — 追加機能: GalleryタブでSVGファイルの表示・管理に対応
- **v0.3.75** — Lemonadeバックエンド追加(3つ目のAIバックエンド)
- **v0.3.74** — AI TOOL Chatペインの画像添付対応 + I2I連携
- **v0.3.73** — AI TOOL Chatペインからの対話式画像生成(Tool Calling)+ Checkpoint関連の安全策
- **v0.3.72** — Comic Creator連携用の外部Inpaintエントリポイントを追加
- **v0.3.71** — Inpaint機能の追加(GenerateUI Imageタブ + Image Edit「Inpaint」タブ)
- **v0.3.70** — Workflow AI要約: Ollama 404エラー修正
- **v0.3.69** — Gallery → ComfyUI Comic Creator 連携(Send CC ボタン)
- **v0.3.68** — MASK EDITOR ONE セクション常時表示化
- **v0.3.67** — Alt+Click Apply & Generate
- **計画: Image Edit 拡張 — ABRブラシ連携 + Mask Editor One 統合** — フェーズ1: Draw ツール — ABRブラシ対応
- **2026-06-29: v0.3.66 — Mask ツール 5サブツール追加(Color / Alpha / Text / Vector / Shape)・Support リンク追加** — 概要
- **2026-06-29: v0.3.65 — Mask Editor One 連携修正・トップバーアイコン修正・Mask ABRブラシ対応** — 概要
- **2026-06-28: v0.3.64 — Image Edit マスク Add/Subtract・BiRefNet BG Remove・SAM3 セグメンテーション・ブラシカーソルバグ修正** — 概要
- **2026-06-28: v0.3.63 — ノードタブ複数選択・全タブフィルタークリア・検索Xボタン** — 概要
- **2026-06-27: WFS_PromptText の GenerateUI 対応・ドロップ時 title 固定** — 概要
- **2026-06-27: v0.3.62 — Send GenUI Image ボタン追加・tagger_settings.json バグ修正・README 更新** — 概要
- **2026-06-27: データ管理バグ修正 — tagger_settings.json をエクスポート対象に追加** — 概要
- **2026-06-27: v0.3.61 — G'MIC バグ修正・セキュリティ・Draw/Mask ブラシ操作性改善** — 概要
- **2026-06-27: v0.3.60 — Image Edit Filter Tool (G'MIC-Qt 連携) 実装** — 概要
- **2026-06-27: v0.3.59 — Image Edit Mask Tool 実装・Draw/Mask 描画バグ修正・クリッピング保存対応** — 概要
- **2026-06-27: v0.3.58 — GenerateUI Model タブ GGUF対応拡張(Diffusion Model / Text Encoder)** — 概要
- **2026-06-26: v0.3.57 — Image Edit Blur Tool / BG Remove Tool 追加** — 概要
- **2026-06-26: v0.3.56 — Style機能追加(GenerateUI ツールバー & Batchタブ)** — 概要
- **2026-06-25: v0.3.55 — グループ機能バグ修正(全タブ)** — 根本原因(データ消失): パス区切り文字の不一致
- **2026-06-25: v0.3.54 — Image Edit Shape ツール追加・Models タブ overlay バグ修正** — Image Edit Tab — Shape ツール(□ / S キー)
- **2026-06-25: v0.3.53 — Image Edit Tab 実装・レイヤーロック・ギャラリー保存** — Image Edit Tab(新規タブ)
- **2026-06-25: GenUI設定タブ LATENT IMAGE にプリセット機能を追加** — 変更内容
- **2026-06-24: GenUI ModelでLoRA適用後、Modelサブタブ再表示時に値がリセットされるバグ修正** — 問題
- **2026-06-24: v0.3.51 — Embedding GenUI PP/NP対応 + GenerateUIプロンプトタブEmbeddingセレクタ追加** — 変更内容
- **2026-06-24: v0.3.51 — GenerateUIタブ Hypernetwork対応 + ModelsタブGenUI Modelボタン追加** — 変更内容
- **2026-06-24: v0.3.51 — Hypernetwork/Embeddingモデル未表示バグ修正** — 原因
- **2026-06-24: v0.3.51 — ModelsタブサイドパネルにGenUI Modelボタン追加** — 変更内容
- **2026-06-22: v0.3.50 — ギャラリータブ プロンプト検索負荷改善(バックグラウンドインデックス)** — 問題
- **2026-06-22: v0.3.49 — 生成UIタブ A1111風ワイルドカード展開・ギャラリータブ バグ修正2件** — 生成UIタブ — A1111風SPAサイドワイルドカード展開
- **2026-06-22: v0.3.48 — ギャラリータブ 画像ダウンロード・一括エクスポート機能追加** — 詳細パネルの画像プレビューにダウンロード機能を追加
- **2026-06-22: v0.3.47 — モデルタブ ファイル移動後グループ状態修正** — モデル移動後のグループ状態不整合を修正
- **2026-06-21: v0.3.46 — プロンプトタブ AI Assistant LM Studio対応・設定タブ AI Assistant設定に変更** — 設定タブ「AI Assistant設定(プロンプトタブ)」
- **2026-06-21: v0.3.45 — グループ孤立エントリ自動クリーンアップ・Galleryタブ Shift+クリック範囲選択** — グループ孤立エントリ自動クリーンアップ
- **2026-06-21: v0.3.44 — Galleryタブ高速化・比較モード・バルク強化・バルクバーUI整理** — フェーズ2-A: サーバーサイドサムネイル生成
- **2026-06-21: ヘルプタブ改善 — Gallery mode 多言語対応・フォント拡大・検索機能追加** — Gallery mode の多言語対応
- **2026-06-21: Send to Canvas — window.opener経由のキャンバス直接ロード対応**
- **2026-06-20: API形式ワークフローのキャンバス読み込み修正・画像メタデータ埋め込み修正** — API形式ワークフローをキャンバスへドラッグすると空になる問題を修正
- **2026-06-20: セキュリティ修正(XSS・パスバリデーション)** — 修正内容
- **2026-06-20: WFS_GalleryFeeder — ComfyUI キャンバス上コントロール追加** — 新規ファイル(`web/comfyui/gallery_feeder_extension.js`)
- **2026-06-20: __Feeder__ グループ保護 + サムネイル F ボタン + seed バグ修正** — `__Feeder__` グループの予約済み保護
- **2026-06-20: Gallery Feeder 機能追加(WFS_GalleryFeeder ノード)** — 概要
- **2026-06-19: Generate UI — ImpactWildcardEncode/Processor プロンプト検出バグ修正(v0.3.41)** — 修正内容(`static/js/comfyui-workflow.js`)
- **2026-06-19: AI TOOL タブ Chat ペイン・ワイルドカード生成追加(v0.3.40)** — Chat ペイン(`templates/index.html`, `static/css/main.css`, `static/js/ai-tab.js`, `web/comfyui/node_sets_menu.js`)
- **2026-06-18: Send to Canvas機能追加(ワークフロータブ・ギャラリータブ→LibraryタイトルDnD)** — ワークフロータブ — Send to Canvas(`static/js/workflow-tab.js`, `static/js/app.js`, `templates/index.html`)
- **2026-06-15: TaggerシングルGenUI:Pボタン追加+生成UIのZITワークフロープロンプト解析修正** — TaggerシングルGenUI:Pボタン(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`)
- **2026-06-15: Taggerバッチ.txt出力追加+Galleryタグ保存バグ修正** — Taggerバッチ.txt出力(`templates/index.html`, `static/js/tagger-tab.js`, `static/js/i18n.js`, `py/routes/tagger_routes.py`, `py/services/tagger_service.py`)
- **2026-06-15: Taggerタブ新規実装+ギャラリー詳細パネルUI改善+パストラバーサル修正** — Taggerタブ新規実装
- **2026-06-15: 全選択ボタン追加+ギャラリーバルクバーi18n対応** — 変更内容
- **2026-06-10: v0.3.36 — コードレビュー修正+リファクタリング+README刷新** — コードレビュー修正(v0.3.35の不具合10件)
- **2026-06-10: v0.3.35 — テーブルソート・Load GenUI・ワークフロー保存** — 変更内容
- **2026-06-09: v0.3.34 — Modelsタブ テーブルビュー強化・バルク操作改善** — 変更内容
- **2026-06-09: v0.3.33 — LoRAペイン・ワークフロー解析バグ修正** — 変更内容
- **2026-06-08: v0.3.32 — Lora Loader (LoraManager) LoRA検出対応・ライブラリ機能拡張** — 変更内容
- **2026-06-08: v0.3.31 — LoRAペイン Single/Stack タブ分割・GenUI Model LoRA対応強化** — 変更内容
- **2026-06-08: v0.3.30 — LoRAスタック トリガーワードバグ修正・ヘルプ更新** — 変更内容
- **2026-06-08: v0.3.29 — LoRAセクション統合・ヘルプタブサイドバー・UI細部改善** — 変更内容
- **2026-06-08: 生成UIタブ 複数改善 — LoRA Stackトリガーワード連携・Reset Workflowボタン・Inputタブタブ化** — 変更内容
- **2026-06-08: Feederタブ UI改善 — (root)自動選択・PREVIEWペイン右移動・RUN中プレビュー反映** — 変更内容
- **2026-06-07: v0.3.28 — Batchタブ Sampler/Scheduler対応 + Modelsサムネイルバグ修正** — 概要
- **2026-06-07: v0.3.27 — model-and-prompt-from-metadata カスタムノード対応 + README修正** — 概要
- **2026-06-07: v0.3.26 — Stack LORA SYNTAX 表示修正 + 全体強度調整 UI** — 概要
- **2026-06-07: v0.3.25 — Lora Loader (LoraManager) 対応 + Stack 有効無効切り替え** — 概要
- **2026-06-06: v0.3.24 — Lora Stack 登録機能 + 生成UI Model タブ 3ペイン化** — 概要
- **2026-06-05: v0.3.23 — バグ修正2件** — バグ修正
- **2026-06-05: v0.3.22 — カードビュー廃止 + Batch タイプ切り替え UI + 4タイプバッチ生成** — 概要
- **2026-06-05: (未リリース) — 生成UIタブ Batch 4タイプ対応(Lora/Prompt/Workflow グループ選択 + BATCH QUEUE 4列化)** — 概要
- **2026-06-05: (未リリース) — Prompt・Workflow Batchグループ登録UI** — 概要
- **2026-06-04: (未リリース) — モデルBatchグループ登録UI(サムネイル/カード/テーブル・フィルター・一括解除)** — 概要
- **2026-06-04: (未リリース) — Batch予約グループ・Presets Save後リセット・グループフィルター種別絞り込み** — 概要
- **2026-05-31: v0.3.21 — CivitAI ホスト設定・Sample サブタブ・URL 修正・ヘルプ i18n 整備** — 概要
- **2026-05-31: v0.3.20 — CivitAI 詳細パネル強化(Type / Hash 表示・URL 修正・画像別タブ)** — 概要
- **2026-05-31: v0.3.19 — CivitAI 連携強化(バッチ高速化・リトライ・APIキー・フィールド拡充)** — 概要
- **2026-05-31: v0.3.18 — Batch タブ追加・Models 複数選択強化** — 概要
- **2026-05-31: v0.3.17 patch — SSLコンテキスト セキュリティ強化** — 概要
- **2026-05-30: v0.3.17 — SSL証明書エラー修正・Lora Manager対応** — 概要
- **2026-05-20: v0.3.16 — CivitAIプレビューフォールバック表示** — 概要
- **2026-05-20: v0.3.15 — サンプルワークフロー同梱・CivitAI自動プレビュー・Create Tagsオプション** — 概要
- **2026-05-20: AI TOOL タブ 英語化(SPA・Libraryパネル)** — 概要
- **2026-05-19: v0.3.14 — AI TOOL タブ 3ペイン化・RAW JSON 文字色カスタマイズ** — 概要
- **2026-05-19: v0.3.13 — AI タブ(A)追加** — 概要
- **2026-05-18: v0.3.12 リリース — README スクリーンショット刷新 / workflow_analyzer 拡張** — 概要
- **2026-05-17: RAW JSON パネルに検索機能を追加** — 概要
- **2026-05-17: v0.3.11 Metadata タブ対応ノードタイプ拡張 / Settings テキストサイズ UI 追加** — 概要
- **2026-05-17: v0.3.10 Information タブ キャンバスドラッグ対応 / ノードタイプ拡張 / バグ修正** — 概要
- **2026-05-16: v0.3.9 サイドパネル I タブ追加 / トップバーアイコン修正** — 概要
- **2026-05-16: v0.3.8 Metadata タブ: data9 対応 / プロンプト判別不能テキスト** — 概要
- **2026-05-15: v0.3.7 Metadata タブ: Flux2 / Qwen / Z-Image 対応** — 概要
- **2026-05-15: v0.3.6 Metadata タブ追加** — 概要
- **2026-05-09: v0.3.5 GenerateUI — Feeder サブタブ・ワークフロー解析精度向上** — 概要
- **2026-04-29: v0.3.4 GenerateUI — Checkpoint Batch 刷新・Settings 横並びレイアウト・ヘルプ i18n 修正** — 概要
- **2026-04-28: v0.3.3 Promptタブ — ワイルドカード支援パネル・Impact Packシンボリックリンク連携・サブディレクトリ対応・GenerateUI修正** — 概要
- **2026-04-28: v0.3.2 生成UIタブ — Checkpoint Batch・Seed UIレイアウト修正** — 概要
- **2026-04-27: v0.3.1 ギャラリー拡張(フォルダ/ファイル操作・ワークフロー保存)** — 概要
- **2026-04-18: v0.3.0 モデルグループ表示改善(全タイプ横断・サイドパネル修正)** — 概要
- **2026-04-18: v0.2.9 モデル有効/無効・複数選択・一括削除** — 概要
- **2026-04-14: v0.2.8 データ保存先変更・エクスポート/インポート機能追加** — 概要
- **2026-04-06: v0.2.6 UI改善・バッジ保存修正・フォルダフィルター追加** — 概要
- **2026-04-04: v0.2.5 追加修正** — 概要
- **2026-04-03: v0.2.5 サイドパネル Category/Package サブタブ・テーマ設定・Workflow修正** — 概要
- **2026-03-29: v0.2.4 GenerateUIタブ レイアウト再設計** — 概要
- **2026-03-29: v0.2.3 バッジ統一・GenUI Model・サイドパネルModels拡充** — 概要
- **2026-03-28: v0.2.2 Modelsタブ・CivitAI連携** — 概要
- **2026-03-27: テーマカスタマイズ機能・設定タブ改善** — 概要
- **2026-03-26: トップバーボタンアイコン修正・Appモードバッジ調査** — 概要
- **2026-03-25: v0.2.0 プロンプトプリセット機能・Workflow Studio Library** — 概要
- **2026-03-24: v0.1.9 サイドパネルUI改善・保存ダイアログ・バグ修正** — 概要
- **2026-03-24: v0.1.8 WF & Node Library サイドパネル拡張** — 概要
- **2026-03-23: v0.1.7 Nodes タブ追加** — 概要
- **2026-03-23: v0.1.6 パストラバーサル脆弱性修正** — 概要
- **2026-03-22: v0.1.5 テーマシステム追加** — 概要
- **2026-03-22: v0.1.4 App形式対応・プリセットコピー・分析バグ修正** — 概要
- **2026-03-21: v0.1.3 ヘルプ&サポートタブ追加** — 概要
- **2026-03-19: v0.1.2 リリース** — 概要
- **2026-03-19: v0.1.1 リリース** — 概要
- **2026-03-18: プロジェクトリネーム & アイコン適用** — 概要
- **2026-03-18: デフォルトビュー設定追加** — 概要
- **2026-03-18: MarkdownNote表示専用ノード除外** — 概要
- **2026-03-18: サブグラフワークフロー対応 & 変換精度修正** — 概要
- **2026-03-18: Raw JSON同期修正** — 概要
- **プロジェクト構成 (現在)**
- **2026-03-18: JSONシンタックスハイライト** — 概要
- **2026-03-18: プロンプトタブ 中国語↔英語翻訳追加** — 概要
- **2026-03-18: v0.1.0 リリース** — 概要
- **2026-04-13: Gallery タブ追加 & 全タブ Group UI 統一** — 概要
+2040 -154
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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.
+262
View File
@@ -0,0 +1,262 @@
# README Index — ComfyUI-Workflow-Studio
Condensed index of README.md: intro summary, feature names per tab (full descriptions live in the excluded README.md), and the installation/usage/requirements/project-structure sections verbatim. Exists so graphify can extract this without feeding the full README (with its screenshot table) to the local Ollama model. Regenerate with `python tools/generate_doc_index.py readme` whenever README.md changes.
# ComfyUI-Workflow-Studio
A comprehensive workflow, asset management, and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
**📁 Management** — organize all your assets in one studio
- Workflow, model, image, and prompt management
- AI-powered prompt writing assistance, translation, and tag generation
- File drop & Gallery-linked metadata viewing — inspect and reuse generation settings instantly
**⚡ GenerateUI** — a productivity-focused generation tab connected to every other tab
- Batch generation across models, samplers, prompts, and workflows
- Image Feeder for continuous folder-based generation
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Model (Checkpoint/LoRA Single/VAE)/Prompt/KSampler
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
- Drag & drop models, nodes, prompts, and workflows straight onto the canvas
- **Send to Canvas**: click "Send to Canvas" (Workflow tab) or "Copy & Send Canvas" (Gallery tab) to send the workflow directly to the ComfyUI canvas — UI and API formats both supported
- View metadata from images / JSON, then drop the detected models and prompts onto the canvas
- Built-in AI tools (translation and more)
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.7.2-green)
## Features (tab -> feature names)
### Workflow Tab
Thumbnail / Table views, Thumbnail side panel, Badge filtering, Search, Side panel tabs, Badge management, AI summary, Import / Export, Side panel toolbar buttons, Default view setting, Search clear (✕), Clear all filters (✕ Clear), Sort
### Canvas Snapshot (v0.1.2)
One-click capture, Auto-save as thumbnail, Embedded workflow metadata, Auto-import
### GenerateUI Tab (v0.3.5)
6-tab layout, Save button, Input tab, Model tab, Settings tab, Always-visible Raw JSON, Bypass/Mute node handling, One-click generation, Seed control, Style selector, Create Catalog / Catalog buttons, Batch type selector, Batch tab, UI-to-API conversion, Video workflow support, Eagle integration, Qwen Image 2.1 support
### Feeder subtab (v0.3.5 / v0.3.42)
ImageFeeder node control, Image library, Selection management, Presets, Continuous Run loop, Index sync, Seed, WFS_GalleryFeeder node, Node & group selector, Image grid, After Gen modes, Run / Stop controls
### Lab subtab (v0.3.87 – v0.5.2)
Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column — Checkpoint / LoRA Single / VAE, Empty state when no matching node exists, Wildcards, Prompt cell extras, Keyframe #1 live-reflect, Bypass checkbox, LoRA node bypass & prompt injection, T2I workflow support, Use generated image for next, Workflow recall, Plan files, Plan Load, Plan JSON tab, Save index image to Output on Run, Index images are self-contained plan files, Eagle integration, Select vs. edit, and keyframe reordering, PG — apply a Prompt tab preset group, Video workflow support, Use generated image for next, now video-aware
### Prompt Tab
3-column layout, AI chat assistant, Image attachment, Translation, Prompt presets, Preset Manager, Group management, Clipboard copy, GenUI Set, Wildcard/Style tab bar, Wildcard input toolbar, Wildcard file manager, Style manager, Form / Table toggle, Table view — Presets / Presets Group / Wildcards / Style
### Settings Tab
2-column layout, Collapsible sections, Theme selection, Theme customization, Workflows directory, Gallery output directory, Eagle connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, Video Playback Volume, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
### Gallery Tab (v0.3.44)
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send CC button, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, PSD file support, Performance
### ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)
Visual prompt library, 3-column layout, Prompt builder (right pane), Plain-text prompt storage, ponyxlWildcardsVault format support, Seed data importer
### Style_Catalog Gallery subtab (v0.3.95)
Visual Style picker, 3-column layout, Positive/Negative panel, Select as Style button, Load in GenerateUI / Open in Metadata Tab buttons, Double-click to enlarge
### Metadata Gallery subtab (v0.3.8, moved from a top-level tab in v0.4.6)
Now inside Gallery, 3-column layout, File drop, MP4 embedded metadata, Model extraction, LoRA extraction, Prompt extraction, Prompt actions, Format support, Format note
### Nodes Tab (v0.1.7)
Node Browser, Search & Filter, Package badges, Node detail panel, Node Sets, Right-click context menu, Multi-select, Search clear (✕), Clear all filters (✕ Clear)
### Models Tab (v0.2.3)
Model Browser, Thumbnail / Table views, Table column sort, Search & Filter, User-defined badges, Side panel tabs, CivitAI integration, CivitAI panel — Info / Sample sub-tabs, CivitAI panel states, Batch CivitAI fetch, Detail modal, GenUI Model button, Group management, Table view memo, Preview images, Enable / Disable models, Multi-select & bulk operations, Search clear (✕), Clear all filters (✕ Clear)
### Tagger Tab (v0.3.38)
3 sub-tabs, Model support, Threshold sliders, VLM tagging, Single tab, Single output options, Batch tab, DB tab, Dependencies
### Image Edit Tab (v0.3.65)
Layer-based image editor, Loading images, Open PSD button, Close button, New button, Tools, Layer panel, Duplicate layer (⧉), Layer lock, Text quality, Export, Canvas navigation, Undo / Redo
### Video Tab (v0.5.0 – v0.7.4)
Dedicated video generation UI, independent of GenerateUI, Plan subtab, Base settings row, Edit subtab, Edit: Audio / BGM, Edit: Text overlays, Edit: Crop, Edit: Undo / Redo, Edit project persistence, Asset subtab, Project subtab, Two center previews, First Frame / Last Frame are both optional, Semantic node lookup, not hardcoded IDs, Field highlight color, Load in Video, Frame tab, GIF tab, Playback volume
### AI TOOL Tab (v0.3.14)
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Model unload, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
Tab layout (W / N / P / M / I / A), W — Workflows tab, N — Nodes tab, M — Models tab, P — Prompts tab, I — Information tab, A — AI TOOL tab, Drag & drop workflows, Drag & drop nodes, Drag & drop prompts, Send to Canvas, Copy prompts, Double-click, Search, ⚙ Theme settings
### Help &amp; Support Tab (v0.1.3)
Sidebar navigation, Support, Feature list, Keyboard Shortcuts
## Installation
### Via ComfyUI Manager (Recommended)
Search for **Workflow Studio** in ComfyUI Manager and install.
### Manual Installation
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/ketle-man/ComfyUI-Workflow-Studio.git
```
Restart ComfyUI after installation.
---
## Sample Workflows
Sample workflows are included in the `workflows/` folder. You can open them directly in ComfyUI via drag & drop, or load them from the Workflow tab.
> **Note:** Some sample workflows require additional custom nodes.
> If a node is shown as missing (red/unknown) after loading, install the required custom nodes via **ComfyUI Manager** or by cloning the repository from GitHub into your `ComfyUI/custom_nodes/` directory.
>
> ```bash
> cd ComfyUI/custom_nodes
> git clone <repository-url>
> ```
>
> After installation, restart ComfyUI to activate the new nodes.
---
## Usage
### Launch
Click the **W** button in the ComfyUI top menu bar, or navigate to:
```
http://127.0.0.1:8188/wfm
```
> **Tip:** Shift+Click the W button to open in a new window.
### Canvas Snapshot
Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture the current workflow canvas as a thumbnail. The image is automatically saved to the workflow data folder and appears in Workflow Studio's workflow list.
### Quick Start
1. **Workflow Tab** — Your workflows from `ComfyUI/user/default/workflows/` are automatically listed
2. **Click a workflow** — View thumbnail, JSON details, and metadata in the side panel
3. **Load in GenerateUI** — Click the button to load a workflow into the generation interface
4. **Adjust parameters** — Modify prompts, models, seeds, and settings via the auto-generated UI
5. **Generate** — Hit the Generate button to queue the prompt
---
## Requirements
- **ComfyUI** — any recent version (v1.33.9+ recommended for action bar integration)
- **Python 3.10+**
- **Jinja2** — `pip install jinja2` (usually included with ComfyUI)
### Optional
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
- **[psd-tools](https://pypi.org/project/psd-tools/)** (v0.6.0) — enables all PSD (Photoshop) features: Gallery bulk "Create PSD" export, Gallery browsing/thumbnails for `.psd` files, and Image Edit's Save PSD / Open PSD buttons; `pip install -r requirements.txt` (ships prebuilt wheels, no C compiler required); without it, PSD-related actions show an error toast with the install command instead of failing silently
---
## Supported Languages
| Language | Status |
| -------- | ------ |
| English | Full |
| Japanese | Full |
| Chinese | Full |
---
## Project Structure
```
ComfyUI-Workflow-Studio/
├── __init__.py # ComfyUI entry point
├── py/
│ ├── wfm.py # Main class & route registration
│ ├── config.py # Path configuration
│ ├── nodes/
│ │ ├── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
│ │ └── gallery_feeder_node.py # WFS_GalleryFeeder custom node (gallery group → IMAGE)
│ ├── routes/
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
│ │ ├── models_routes.py # Model metadata, preview, CivitAI API
│ │ ├── prompts_routes.py # Prompt presets CRUD API
│ │ ├── settings_routes.py # Settings API
│ │ ├── ollama_routes.py # Ollama proxy API
│ │ ├── eagle_routes.py # Eagle integration API
│ │ └── gallery_routes.py # Gallery tab API (Output + ImagePrompt + Style_Catalog sub-tabs)
│ └── services/
│ ├── workflow_service.py # Workflow file operations
│ ├── nodes_service.py # Node metadata & node sets
│ ├── models_service.py # Model metadata & preview images
│ ├── civitai_service.py # CivitAI API integration & cache
│ ├── prompts_service.py # Prompt presets persistence
│ ├── workflow_analyzer.py # Model/node detection
│ ├── settings_service.py # Settings persistence
│ ├── png_extractor.py # PNG metadata extraction
│ ├── gallery_service.py # Gallery image scanning, .txt sidecar & ponyxlWildcardsVault prompt resolution
│ └── gallery_metadata.py # Gallery per-image metadata store (favorites/tags/memo/groups)
├── templates/
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Metadata/Gallery/Nodes/Models/Settings/Help/AI)
├── static/
│ ├── favicon.svg # Browser tab icon (W+S Wave)
│ ├── css/main.css # Styles
│ └── js/
│ ├── app.js # App initialization & routing
│ ├── workflow-tab.js # Workflow browser
│ ├── generate-tab.js # Generation UI
│ ├── feeder-tab.js # Feeder subtab (Image Loop + Gallery modes)
│ ├── gallery-tab.js # Gallery tab: Output sub-tab (image browser, groups, metadata) + sub-tab switching
│ ├── image-prompt-tab.js # Gallery tab: ImagePrompt sub-tab (visual prompt builder)
│ ├── style-catalog-tab.js # Gallery tab: Style_Catalog sub-tab (visual Style picker)
│ ├── tagger-tab.js # Tagger tab (WD Tagger, DeepDanbooru, Ollama VLM)
│ ├── prompt-tab.js # AI assistant & presets
│ ├── metadata-tab.js # Metadata extraction & display (PNG/WebP/JSON)
│ ├── settings-tab.js # Settings panel
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
│ ├── nodes-tab.js # Node browser & node sets
│ ├── models-tab.js # Model browser & CivitAI integration
│ ├── ai-tab.js # AI TOOL tab (Translation | Chat | TOOLS/VLM | Settings)
│ ├── comfyui-workflow.js # UI-to-API format conversion
│ ├── comfyui-editor.js # Dynamic parameter editor
│ ├── json-highlight.js # JSON syntax highlighting
│ ├── util.js # Shared utilities (escapeHtml, readJsonStorage, getSettings)
│ └── i18n.js # Internationalization
├── web/comfyui/
│ ├── top_menu_extension.js # ComfyUI menu bar integration
│ ├── node_sets_menu.js # Workflow Studio Library side panel
│ └── gallery_feeder_extension.js # WFS_GalleryFeeder canvas widgets (After Gen / Run / Stop)
├── tools/
│ └── import_style_prompt_seed.py # Dev tool: pre-process a wildcards-vault source into ws_image_prompt (not run by the plugin itself)
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
```
---
## License
MIT License
---
## Acknowledgements
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) by comfyanonymous
- [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss — Canvas snapshot and PNG workflow embedding implementation reference
- [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) — Plugin architecture and UI pattern reference
- [Ollama](https://ollama.com/) for local LLM inference
- [Eagle](https://eagle.cool/) for image management
- [Pillow (PIL Fork)](https://python-pillow.org/) — server-side thumbnail generation (`data/thumb_cache/`)
- [ComfyUI-Gallery](https://github.com/PanicTitan/ComfyUI-Gallery) by PanicTitan — thumbnail grid UX reference
- [infinite-image-browsing](https://github.com/zanllp/sd-webui-infinite-image-browsing) by zanllp — thumbnail caching and index strategy reference
- [Fooocus](https://github.com/lllyasviel/Fooocus) by lllyasviel — style preset JSON format reference
+275 -692
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

+24
View File
@@ -0,0 +1,24 @@
"""Runs before ComfyUI imports any custom node — the only place early enough
to populate os.environ for code that reads env vars at import/request time.
Loads a .env file (if present) from this plugin's root directory so the
Unsloth backend's API key (UNSLOTH_API_KEY) can be set without editing
settings.json or exposing it to the frontend.
"""
from pathlib import Path
PLUGIN_DIR = Path(__file__).resolve().parent
ENV_FILE = PLUGIN_DIR / ".env"
if ENV_FILE.exists():
try:
from dotenv import load_dotenv
load_dotenv(ENV_FILE)
except ImportError:
print(
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
)
except Exception as e:
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
+4
View File
@@ -69,6 +69,10 @@ NODE_SETS_FILE = DATA_DIR / "node_sets.json"
PROMPTS_FILE = DATA_DIR / "prompts.json"
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
WILDCARD_DIR = DATA_DIR / "wildcard"
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
VIDEO_PLAN_DIR = DATA_DIR / "video_plan"
VIDEO_EDIT_PROJECT_DIR = DATA_DIR / "video_edit_project"
# Tagger
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
+103 -2
View File
@@ -3,6 +3,7 @@
import asyncio
import json
import logging
import os
import urllib.request
import urllib.error
@@ -17,6 +18,84 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/eagle/test", handle_test)
def _resolve_local_path(filename, subfolder, type_name):
"""filename/subfolder/type を ComfyUI 出力ディレクトリ上の実パスに解決する。
ComfyUI と Eagle が同一マシン上で動作している場合のみ有効なパスが返る
(別マシンの場合は None を返し、呼び出し側は addFromURL にフォールバックする)。"""
try:
import folder_paths # type: ignore
except Exception:
return None
base_dir = folder_paths.get_directory_by_type(type_name or "output")
if not base_dir:
return None
base_dir = os.path.abspath(base_dir)
filename = os.path.basename(filename or "")
if not filename:
return None
full_dir = os.path.abspath(os.path.join(base_dir, subfolder or ""))
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
if os.path.commonpath((os.path.normcase(full_dir), os.path.normcase(base_dir))) != os.path.normcase(base_dir):
# ディレクトリトラバーサル対策: base_dir 配下以外は拒否
return None
full_path = os.path.join(full_dir, filename)
if not os.path.isfile(full_path):
return None
return full_path
def _resolve_absolute_svg_path(abs_path):
"""comfyui-tosvg の Save SVG String ノードなどが返す絶対パスを検証する。
ComfyUI管理下(output/input/temp)ディレクトリ配下の実在する .svg ファイルのみ許可する
(任意の絶対パスをそのまま信頼すると、外部からこのAPIを叩かれた際に
ローカルの任意ファイルを送信させられてしまうため)。"""
if not abs_path or not abs_path.lower().endswith(".svg"):
return None
try:
import folder_paths # type: ignore
except Exception:
return None
abs_path = os.path.abspath(abs_path)
if not os.path.isfile(abs_path):
return None
for type_name in ("output", "temp", "input"):
base_dir = folder_paths.get_directory_by_type(type_name)
if not base_dir:
continue
base_dir = os.path.abspath(base_dir)
try:
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
if os.path.commonpath((os.path.normcase(abs_path), os.path.normcase(base_dir))) == os.path.normcase(base_dir):
return abs_path
except ValueError:
# Windowsで異なるドライブレターの場合 commonpath が例外を投げる
continue
return None
def _eagle_add_from_path(eagle_url, file_path, name, tags):
"""Proxy request to Eagle's addFromPath API (runs in thread).
SVG は ComfyUI の /view がセキュリティ上 application/octet-stream +
attachment で返すため、addFromURL 経由だと正しい種別で保存されないことがある。
ComfyUI と Eagle が同一マシン上にある場合はローカルパスを直接渡す方が確実。"""
eagle_url = eagle_url.rstrip("/")
payload = json.dumps({"path": file_path, "name": name, "tags": tags}).encode("utf-8")
req = urllib.request.Request(
f"{eagle_url}/api/item/addFromPath",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
def _eagle_add(eagle_url, image_url, name, tags):
"""Proxy request to Eagle API (runs in thread)."""
eagle_url = eagle_url.rstrip("/")
@@ -71,11 +150,33 @@ async def handle_add(request: web.Request) -> web.Response:
image_url = body.get("url", "")
name = body.get("name", "image.png")
tags = body.get("tags", [])
filename = body.get("filename", "")
subfolder = body.get("subfolder", "")
file_type = body.get("type", "output")
local_path_hint = body.get("localPath", "")
if not image_url:
if not image_url and not local_path_hint:
return web.json_response({"status": "error", "message": "No URL provided"}, status=400)
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
# SVG は /view が application/octet-stream + attachment で返すため、
# ローカルファイルが解決できる場合は addFromPath で直接渡す
local_path = None
if local_path_hint:
# comfyui-tosvg の Save SVG String ノードなど、絶対パスが既知の場合
local_path = _resolve_absolute_svg_path(local_path_hint)
elif (filename or name or "").lower().endswith(".svg"):
local_path = _resolve_local_path(filename or name, subfolder, file_type)
if not local_path and not image_url:
return web.json_response(
{"status": "error", "message": f"Could not resolve local SVG path: {local_path_hint}"},
status=400,
)
if local_path:
result = await asyncio.to_thread(_eagle_add_from_path, eagle_url, local_path, name, tags)
else:
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
return web.json_response(result)
except urllib.error.URLError as e:
logger.error("Eagle URL error: %s", e)
+217 -24
View File
@@ -2,11 +2,9 @@
Gallery Routes - ギャラリータブ用APIエンドポイント
"""
import asyncio
import base64
import json
import mimetypes
import logging
import re
from pathlib import Path
from aiohttp import web
@@ -43,6 +41,16 @@ def _init_allowed_root() -> None:
except Exception as e:
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
# ImagePrompt/Style Catalogギャラリー用に実outputフォルダを常に別途保持する
# (Settingsで_allowed_rootが別フォルダに変更されていても影響しないようにするため)
try:
import folder_paths # type: ignore
output_dir = folder_paths.get_output_directory()
if output_dir and Path(output_dir).is_dir():
_service.set_comfy_output_root(output_dir)
except Exception as e:
logger.warning("GalleryRoutes: failed to get ComfyUI output dir for image-prompt root: %s", e)
_init_allowed_root()
@@ -55,7 +63,12 @@ def setup_routes(app: web.Application):
app.router.add_get("/wfm/gallery/image/serve", serve_image)
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
app.router.add_get("/wfm/gallery/image-prompt/root", get_image_prompt_root)
app.router.add_post("/wfm/gallery/image/image-prompt", save_image_prompt)
app.router.add_get("/wfm/gallery/style-catalog/root", get_style_catalog_root)
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
app.router.add_post("/wfm/gallery/image/save-to-folder", save_image_to_folder_route)
app.router.add_post("/wfm/gallery/output-image/delete", delete_output_image)
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
app.router.add_get("/wfm/gallery/groups", list_groups)
app.router.add_post("/wfm/gallery/groups", create_group)
@@ -75,6 +88,10 @@ def setup_routes(app: web.Application):
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
app.router.add_post("/wfm/gallery/images/move", move_images_route)
app.router.add_post("/wfm/gallery/images/export-zip", export_images_zip)
app.router.add_post("/wfm/gallery/images/export-psd", export_images_psd)
app.router.add_post("/wfm/gallery/image/export-psd-layers", export_psd_from_layers)
app.router.add_post("/wfm/gallery/image/import-psd-layers", import_psd_layers)
app.router.add_get("/wfm/gallery/image/psd-layers", get_psd_layers)
# ──────────────────────────────────────────────────────────────
@@ -104,6 +121,7 @@ async def list_images(request: web.Request) -> web.Response:
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
tag_filter = request.rel_url.query.get("tag", "")
group_filter = request.rel_url.query.get("group", "")
recursive = request.rel_url.query.get("recursive", "false") == "true"
if not folder:
return web.json_response({"error": "folder parameter required"}, status=400)
@@ -117,6 +135,7 @@ async def list_images(request: web.Request) -> web.Response:
favorite_only=favorite_only,
tag_filter=tag_filter,
group_filter=group_filter,
recursive=recursive,
)
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
if not search:
@@ -148,9 +167,15 @@ async def get_image_workflow(request: web.Request) -> web.Response:
return web.json_response({"error": "path required"}, status=400)
try:
wf = _service.extract_workflow_from_metadata(path)
if wf is None:
return web.json_response({"workflow": None, "has_workflow": False})
return web.json_response({"workflow": wf, "has_workflow": True})
# prompt_workflow: プロンプト抽出専用(PNG[prompt]優先)。トップレベルとサブグラフに
# 独立した複数系統を持つワークフローではUI形式(workflow)からの抽出がトップレベル
# 系統しか拾えないことがあるため、Promptタブはこちらを使う(Metadataタブと揃える)。
prompt_wf = _service.extract_prompt_workflow_from_metadata(path)
return web.json_response({
"workflow": wf,
"has_workflow": wf is not None,
"prompt_workflow": prompt_wf,
})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -160,7 +185,10 @@ async def serve_image(request: web.Request) -> web.Response:
if not path:
return web.Response(status=400)
img_path = _service.serve_image(path)
# PSDの合成処理はCPU負荷が高く同期実行するとイベントループ全体をブロックする
# (他の並行リクエスト、例えば別画像のサムネイル配信も止まってしまう)ためexecutorで実行する
loop = asyncio.get_event_loop()
img_path = await loop.run_in_executor(None, _service.serve_image, path)
if img_path is None:
return web.Response(status=404)
@@ -182,7 +210,10 @@ async def serve_thumb(request: web.Request) -> web.Response:
except ValueError:
width = 256
thumb_path = _service.serve_thumbnail(path, width)
# PSD/MP4のサムネイル生成はCPU負荷が高く同期実行するとイベントループ全体をブロックするため
# executorで実行する(他画像のサムネイル配信が巻き添えで遅延・停止するのを防ぐ)
loop = asyncio.get_event_loop()
thumb_path = await loop.run_in_executor(None, _service.serve_thumbnail, path, width)
if thumb_path is None:
return web.Response(status=404)
@@ -248,6 +279,36 @@ async def save_image_meta(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
async def get_image_prompt_root(request: web.Request) -> web.Response:
"""GET /wfm/gallery/image-prompt/root — ImagePromptギャラリーのルートフォルダを返す"""
root = _service.get_image_prompt_root()
if root is None:
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
return web.json_response({"root": root})
async def save_image_prompt(request: web.Request) -> web.Response:
"""POST /wfm/gallery/image/image-prompt — 画像の.txtサイドカーにプロンプトを保存"""
try:
body = await request.json()
img_path = body.get("path", "")
prompt = body.get("prompt", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
ok = _service.save_image_prompt(img_path, prompt)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def get_style_catalog_root(request: web.Request) -> web.Response:
"""GET /wfm/gallery/style-catalog/root — Style Catalogギャラリーのルートフォルダを返す"""
root = _service.get_style_catalog_root()
if root is None:
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
return web.json_response({"root": root})
async def toggle_favorite(request: web.Request) -> web.Response:
try:
body = await request.json()
@@ -261,7 +322,8 @@ async def toggle_favorite(request: web.Request) -> web.Response:
async def save_image_to_gallery(request: web.Request) -> web.Response:
"""Image Edit Tab からの PNG をギャラリーのルートフォルダに保存する。"""
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
try:
body = await request.json()
filename = body.get("filename", "").strip()
@@ -273,27 +335,64 @@ async def save_image_to_gallery(request: web.Request) -> web.Response:
if _service._allowed_root is None:
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
# ファイル名サニタイズ(OSで使えない文字を除去)
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
if not safe.lower().endswith(".png"):
safe += ".png"
result = _service.save_image_to_folder(str(_service._allowed_root), filename, image_data)
if not result["ok"]:
return web.json_response({"error": result["error"]}, status=400)
# base64 data URL をデコード
raw = image_data
if "," in raw:
raw = raw.split(",", 1)[1]
image_bytes = base64.b64decode(raw)
save_path = _service._allowed_root / safe
save_path.write_bytes(image_bytes)
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
return web.json_response({"ok": True, "path": str(save_path)})
logger.info("save_image_to_gallery: saved %s", result["path"])
return web.json_response({"ok": True, "path": result["path"]})
except Exception as e:
logger.error("save_image_to_gallery error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def save_image_to_folder_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/image/save-to-folder — 任意フォルダへ画像を保存(同名なら上書き)。
Style Catalogのカタログ作成で、スタイル名をファイル名として使う際に利用する。"""
try:
body = await request.json()
folder = body.get("folder", "").strip()
filename = body.get("filename", "").strip()
image_data = body.get("imageData", "")
if not folder or not filename or not image_data:
return web.json_response({"error": "folder, filename and imageData required"}, status=400)
result = _service.save_image_to_folder(folder, filename, image_data)
if not result["ok"]:
return web.json_response({"error": result["error"]}, status=400)
return web.json_response({"ok": True, "path": result["path"]})
except Exception as e:
logger.error("save_image_to_folder_route error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def delete_output_image(request: web.Request) -> web.Response:
"""POST /wfm/gallery/output-image/delete — ComfyUI標準の画像参照(filename/subfolder/type)から
実ファイルを削除する。Style Catalogのカタログ作成で「Outputフォルダに画像を残さない」
オプションが有効な時、カタログへコピー保存した直後の元画像を消すために使う。
type="output" 以外は拒否する(temp/inputの誤削除を防止)。"""
try:
body = await request.json()
filename = (body.get("filename") or "").strip()
subfolder = body.get("subfolder") or ""
img_type = body.get("type") or ""
if not filename or img_type != "output":
return web.json_response({"error": "filename required and type must be 'output'"}, status=400)
import folder_paths # type: ignore
output_dir = Path(folder_paths.get_output_directory())
target = output_dir / subfolder / filename
result = await asyncio.to_thread(_service.delete_images, [str(target)])
if result["deleted"]:
return web.json_response({"ok": True})
return web.json_response({"error": "; ".join(result["errors"]) or "File not found"}, status=404)
except Exception as e:
logger.error("delete_output_image error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
@@ -316,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:
@@ -485,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)
+2 -2
View File
@@ -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"
+85
View File
@@ -0,0 +1,85 @@
"""Lab tab plan file management API routes."""
import asyncio
import logging
from aiohttp import web
from ..config import LAB_PLAN_DIR
from ..services.lab_plan_service import LabPlanService
logger = logging.getLogger(__name__)
_service = LabPlanService(LAB_PLAN_DIR)
def setup_routes(app: web.Application):
app.router.add_get("/api/wfm/lab/plans", handle_list)
app.router.add_get("/api/wfm/lab/plans/content", handle_get_content)
app.router.add_post("/api/wfm/lab/plans/save", handle_save)
app.router.add_post("/api/wfm/lab/plans/delete", handle_delete)
app.router.add_post("/api/wfm/lab/index-image/save-to-output", handle_save_index_to_output)
async def handle_list(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.list_plans)
return web.json_response(result)
except Exception as e:
logger.error("Error listing lab plans: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_content(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
try:
data = await asyncio.to_thread(_service.get_plan, filename)
return web.json_response({"data": data})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error getting lab plan content: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
data = body.get("data", {})
index_image_base64 = body.get("index_image_base64")
result = await asyncio.to_thread(_service.save_plan, filename, data, index_image_base64)
return web.json_response({"status": "ok", **result})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error saving lab plan: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
await asyncio.to_thread(_service.delete_plan, filename)
return web.json_response({"status": "ok"})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error deleting lab plan: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_index_to_output(request: web.Request) -> web.Response:
try:
body = await request.json()
image_base64 = body.get("image_base64", "")
if not image_base64:
return web.json_response({"error": "image_base64 required"}, status=400)
result = await asyncio.to_thread(_service.save_index_image_to_output, image_base64)
return web.json_response({"status": "ok", **result})
except Exception as e:
logger.error("Error saving lab index image to output: %s", e)
return web.json_response({"error": str(e)}, status=500)
+307 -6
View File
@@ -1,8 +1,11 @@
"""Settings API routes."""
import asyncio
import io
import json
import logging
import shutil
import zipfile
from pathlib import Path
from aiohttp import web
@@ -24,6 +27,7 @@ _DATA_FILES = [
"model_metadata.json",
"gallery_metadata.json",
"tagger_settings.json",
"civitai_cache.json",
]
@@ -37,7 +41,12 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
app.router.add_get("/api/wfm/settings/export", handle_export)
app.router.add_post("/api/wfm/settings/import", handle_import)
app.router.add_get("/api/wfm/settings/export-full", handle_export_full)
app.router.add_post("/api/wfm/settings/import-full", handle_import_full)
app.router.add_get("/api/wfm/styles", handle_get_styles)
app.router.add_post("/api/wfm/styles", handle_create_style)
app.router.add_put("/api/wfm/styles/{name}", handle_update_style)
app.router.add_delete("/api/wfm/styles/{name}", handle_delete_style)
async def handle_get(request: web.Request) -> web.Response:
@@ -245,18 +254,174 @@ async def handle_import(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
def _load_styles() -> list:
"""Load all style JSON files from DATA_DIR/style/ directory."""
# Full-backup (ZIP) export/import: unlike the flat _DATA_FILES JSON bundle above, this
# covers the whole DATA_DIR — directory-shaped data (ai_skills/, lab_plan/, video_plan/,
# style/) and the SQLite tagger.db that the JSON bundle can't represent. Excludes transient/cache
# dirs, and wildcard/ specifically because it's typically a symlink into a *different*
# custom_node's folder (comfyui-impact-pack) rather than this plugin's own data.
_FULL_BACKUP_EXCLUDE_NAMES = {"gmic_temp", "thumb_cache", "wildcard"}
def _add_file_to_zip(zf: zipfile.ZipFile, path: Path, base: Path):
rel = str(path.relative_to(base)).replace("\\", "/")
if path.name == "settings.json":
# Same API-key scrubbing as the JSON-bundle export — fall through to a raw
# copy if the file turns out to be unreadable/corrupt rather than skipping it.
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
data = {k: v for k, v in data.items() if k not in _SETTINGS_EXPORT_EXCLUDE}
zf.writestr(rel, json.dumps(data, ensure_ascii=False, indent=2))
return
except Exception:
pass
zf.write(path, rel)
# Two folders this plugin doesn't own but users often want backed up alongside their
# own data: ComfyUI's own default workflows dir, and the Impact Pack's wildcard
# library (a separate custom_node — the plugin's own wildcard/ is usually just a
# symlink into this same folder, see _FULL_BACKUP_EXCLUDE_NAMES above). Opt-in only
# (see handle_export_full's include_workflows/include_wildcard query params), and
# stored under an "_external/" prefix so _apply_full_backup_zip can recognize and
# skip them on restore — see that function's docstring for why.
def _default_workflows_dir() -> Path:
from ..config import COMFYUI_ROOT
return COMFYUI_ROOT / "user" / "default" / "workflows"
def _impact_pack_wildcard_dir() -> Path:
from ..config import COMFYUI_ROOT
return COMFYUI_ROOT / "custom_nodes" / "comfyui-impact-pack" / "wildcards"
def _add_external_dir_to_zip(zf: zipfile.ZipFile, src_dir: Path, zip_prefix: str):
if not src_dir.is_dir() or src_dir.is_symlink():
return
for path in src_dir.rglob("*"):
if path.is_dir() or path.is_symlink():
continue
rel = f"{zip_prefix}/{path.relative_to(src_dir)}".replace("\\", "/")
zf.write(path, rel)
def _build_full_backup_zip(include_workflows: bool = False, include_wildcard: bool = False) -> bytes:
"""Zips everything under DATA_DIR except the excluded names above. Walks
top-level entries first so an excluded symlinked directory (wildcard/) is never
even opened, rather than filtering after a recursive walk has already followed it.
Optionally also includes the two external folders above under "_external/"."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for top in sorted(DATA_DIR.iterdir()):
if top.name in _FULL_BACKUP_EXCLUDE_NAMES or top.is_symlink():
continue
if top.is_file():
_add_file_to_zip(zf, top, DATA_DIR)
elif top.is_dir():
for path in top.rglob("*"):
if path.is_dir() or path.is_symlink():
continue
_add_file_to_zip(zf, path, DATA_DIR)
if include_workflows:
_add_external_dir_to_zip(zf, _default_workflows_dir(), "_external/default_workflows")
if include_wildcard:
_add_external_dir_to_zip(zf, _impact_pack_wildcard_dir(), "_external/wildcard")
return buf.getvalue()
def _apply_full_backup_zip(zip_bytes: bytes) -> dict:
"""Extracts a full-backup ZIP into DATA_DIR, overwriting existing files. Guards
against Zip Slip (entries whose path resolves outside DATA_DIR via '..' or an
absolute path) by checking each entry before writing it.
Entries under "_external/" (default workflows dir, Impact Pack wildcards — see
_build_full_backup_zip) are always skipped here rather than restored: they live
outside DATA_DIR and are managed by ComfyUI core / a different custom_node, so
auto-restoring them could silently overwrite files this plugin doesn't own. They
stay in the ZIP for the user to copy back manually if they want them."""
summary = {"extracted": [], "skipped": []}
DATA_DIR.mkdir(parents=True, exist_ok=True)
base = DATA_DIR.resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
if info.filename.startswith("_external/"):
summary["skipped"].append(info.filename)
continue
target = (base / info.filename).resolve()
try:
target.relative_to(base)
except ValueError:
summary["skipped"].append(info.filename)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
summary["extracted"].append(info.filename)
return summary
async def handle_export_full(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/export-full - Download a ZIP of the entire data
directory, including data the flat JSON bundle (handle_export) can't represent.
Optional query params include_workflows=1 / include_wildcard=1 additionally bundle
ComfyUI's default workflows dir / the Impact Pack's wildcard dir (see
_build_full_backup_zip) — both restore-excluded, export-only, manual-copy-back."""
try:
include_workflows = request.rel_url.query.get("include_workflows") == "1"
include_wildcard = request.rel_url.query.get("include_wildcard") == "1"
zip_bytes = await asyncio.to_thread(_build_full_backup_zip, include_workflows, include_wildcard)
return web.Response(
body=zip_bytes,
content_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="wfm-full-backup.zip"'},
)
except Exception as e:
logger.error("Error building full backup: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_import_full(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/import-full - Upload and restore a full-backup ZIP."""
try:
reader = await request.multipart()
field = await reader.next()
if field is None or field.name != "file":
return web.json_response({"error": "file field required"}, status=400)
zip_bytes = await field.read(decode=False)
summary = await asyncio.to_thread(_apply_full_backup_zip, zip_bytes)
return web.json_response({"status": "ok", **summary})
except zipfile.BadZipFile:
return web.json_response({"error": "Not a valid ZIP file"}, status=400)
except Exception as e:
logger.error("Error importing full backup: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _style_files() -> list:
style_dir = DATA_DIR / "style"
styles = []
if not style_dir.is_dir():
return styles
for json_file in sorted(style_dir.glob("*.json")):
return []
return sorted(style_dir.glob("*.json"))
def _load_styles() -> list:
"""Load all style JSON files from DATA_DIR/style/ directory.
各エントリに定義元ファイル名を "file" として付与する(Promptタブの一覧表示・
「このファイルへ追加」機能で使用)。"""
styles = []
for json_file in _style_files():
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
styles.extend(data)
for entry in data:
if isinstance(entry, dict):
entry = dict(entry)
entry["file"] = json_file.name
styles.append(entry)
except Exception as e:
logger.warning("Failed to load style file %s: %s", json_file.name, e)
return styles
@@ -270,3 +435,139 @@ async def handle_get_styles(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error loading styles: %s", e)
return web.json_response({"error": str(e)}, status=500)
# Promptタブ「Style」サブタブでの新規作成分の保存先。既存スタイルの編集は
# 元々そのスタイルが記載されているファイルをそのまま書き換える(他ファイルへは移動しない)。
_CUSTOM_STYLE_FILENAME = "custom.json"
def _find_style(name: str):
"""名前が一致する最初のスタイルを探し、(ファイルパス, そのファイルの全データ, インデックス) を返す。
見つからなければ None。"""
for json_file in _style_files():
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.warning("Failed to load style file %s: %s", json_file.name, e)
continue
if not isinstance(data, list):
continue
for i, entry in enumerate(data):
if isinstance(entry, dict) and entry.get("name") == name:
return json_file, data, i
return None
def _write_style_file(path: Path, data: list) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
async def handle_create_style(request: web.Request) -> web.Response:
"""POST /api/wfm/styles - 新規スタイルを作成する。
body.file を指定すると既存のそのファイルへ追記する(Promptタブの「このファイルへ追加」用、
ファイル名はstyleディレクトリに実在するもののみ許可しパストラバーサルを防ぐ)。
未指定ならデフォルトのcustom.jsonへ追記する。"""
try:
body = await request.json()
name = (body.get("name") or "").strip()
prompt = body.get("prompt", "") or ""
negative_prompt = body.get("negative_prompt", "") or ""
target_file = (body.get("file") or "").strip()
if not name:
return web.json_response({"error": "name is required"}, status=400)
def _create():
if _find_style(name) is not None:
return "duplicate"
style_dir = DATA_DIR / "style"
style_dir.mkdir(parents=True, exist_ok=True)
if target_file:
path = next((f for f in _style_files() if f.name == target_file), None)
if path is None:
return "file_not_found"
else:
path = style_dir / _CUSTOM_STYLE_FILENAME
data = []
if path.is_file():
try:
with open(path, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, list):
data = loaded
except Exception as e:
logger.warning("Failed to load %s, recreating: %s", path.name, e)
data.append({"name": name, "prompt": prompt, "negative_prompt": negative_prompt})
_write_style_file(path, data)
return "ok"
result = await asyncio.to_thread(_create)
if result == "duplicate":
return web.json_response({"error": f'Style "{name}" already exists'}, status=400)
if result == "file_not_found":
return web.json_response({"error": f'File "{target_file}" not found'}, status=400)
return web.json_response({"ok": True})
except Exception as e:
logger.error("Error creating style: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_update_style(request: web.Request) -> web.Response:
"""PUT /api/wfm/styles/{name} - 既存スタイルを更新する(記載元のファイルをそのまま書き換え、リネームも可)。"""
try:
original_name = request.match_info.get("name", "")
body = await request.json()
new_name = (body.get("name") or "").strip()
prompt = body.get("prompt", "") or ""
negative_prompt = body.get("negative_prompt", "") or ""
if not new_name:
return web.json_response({"error": "name is required"}, status=400)
def _update():
found = _find_style(original_name)
if found is None:
return "not_found"
path, data, idx = found
if new_name != original_name and _find_style(new_name) is not None:
return "duplicate"
data[idx] = {"name": new_name, "prompt": prompt, "negative_prompt": negative_prompt}
_write_style_file(path, data)
return "ok"
result = await asyncio.to_thread(_update)
if result == "not_found":
return web.json_response({"error": "Style not found"}, status=404)
if result == "duplicate":
return web.json_response({"error": f'Style "{new_name}" already exists'}, status=400)
return web.json_response({"ok": True})
except Exception as e:
logger.error("Error updating style: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_style(request: web.Request) -> web.Response:
"""DELETE /api/wfm/styles/{name} - スタイルを削除する(記載元のファイルから該当エントリのみ除去)。"""
try:
name = request.match_info.get("name", "")
def _delete():
found = _find_style(name)
if found is None:
return False
path, data, idx = found
del data[idx]
_write_style_file(path, data)
return True
ok = await asyncio.to_thread(_delete)
if not ok:
return web.json_response({"error": "Style not found"}, status=404)
return web.json_response({"ok": True})
except Exception as e:
logger.error("Error deleting style: %s", e)
return web.json_response({"error": str(e)}, status=500)
+64
View File
@@ -0,0 +1,64 @@
"""AI skill (.md system prompt) file management API routes."""
import asyncio
import logging
from aiohttp import web
from ..config import AI_SKILLS_DIR
from ..services.skill_service import SkillService
logger = logging.getLogger(__name__)
_service = SkillService(AI_SKILLS_DIR)
def setup_routes(app: web.Application):
app.router.add_get("/api/wfm/skills", handle_list)
app.router.add_get("/api/wfm/skills/content", handle_get_content)
app.router.add_post("/api/wfm/skills/save", handle_save)
app.router.add_post("/api/wfm/skills/delete", handle_delete)
async def handle_list(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.list_skills)
return web.json_response(result)
except Exception as e:
logger.error("Error listing skills: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_content(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
try:
content = await asyncio.to_thread(_service.get_content, filename)
return web.json_response({"content": content})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except Exception as e:
logger.error("Error getting skill content: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
content = body.get("content", "")
result = await asyncio.to_thread(_service.save_file, filename, content)
return web.json_response({"status": "ok", "file": result})
except Exception as e:
logger.error("Error saving skill: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
await asyncio.to_thread(_service.delete_file, filename)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error("Error deleting skill: %s", e)
return web.json_response({"error": str(e)}, status=500)
+36 -7
View File
@@ -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:
+99
View File
@@ -0,0 +1,99 @@
"""Unsloth API proxy routes.
Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop requires an API key even
for local access (Authorization: Bearer sk-unsloth-...). The key lives in
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
never sent to the frontend — the frontend calls this proxy with the target
path/method/payload, and the server attaches the Authorization header before
relaying to Unsloth's OpenAI-compatible API.
"""
import json
import logging
import os
import urllib.request
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from aiohttp import web
logger = logging.getLogger(__name__)
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions"}
# baseUrl is client-supplied (so a custom Unsloth port works), but the
# Authorization header carries a real secret — restrict the host to loopback
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
# server (SSRF). Port is unrestricted.
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _get_api_key():
"""Return the Unsloth API key from the environment (.env), or None."""
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
def _is_allowed_base_url(base_url):
"""Only relay (and attach the API key) to a loopback host."""
try:
parsed = urlparse(base_url)
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
except Exception:
return False
def setup_routes(app: web.Application):
"""Register Unsloth API routes."""
app.router.add_post("/api/wfm/unsloth/proxy", handle_proxy)
async def handle_proxy(request: web.Request) -> web.Response:
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
"""
import asyncio
try:
body = await request.json()
base_url = (body.get("baseUrl") or UNSLOTH_DEFAULT_URL).rstrip("/")
path = body.get("path") or "/v1/models"
method = (body.get("method") or "GET").upper()
payload = body.get("payload")
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
return web.json_response({"message": "Unsupported proxy target"}, status=400)
if not _is_allowed_base_url(base_url):
return web.json_response({
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
}, status=400)
api_key = _get_api_key()
if not api_key:
return web.json_response({
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
"plugin folder, fill in the key, and restart ComfyUI.",
}, status=401)
def _fetch():
data = json.dumps(payload).encode("utf-8") if payload is not None else None
headers = {"Authorization": f"Bearer {api_key}"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read().decode("utf-8"))
data = await asyncio.to_thread(_fetch)
return web.json_response(data)
except HTTPError as e:
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
except URLError as e:
logger.warning("Unsloth proxy connection error: %s", e)
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
except Exception as e:
logger.error("Unsloth proxy error: %s", e)
return web.json_response({"message": str(e)}, status=500)
+238
View File
@@ -0,0 +1,238 @@
"""Video tab API routes: last-frame capture, animated GIF conversion, Edit
tab probe/text-overlay burn-in, and Plan/Edit project file management."""
import asyncio
import logging
from aiohttp import web
from ..config import VIDEO_PLAN_DIR, VIDEO_EDIT_PROJECT_DIR
from ..services.video_service import VideoService
from ..services.video_plan_service import VideoPlanService
from ..services.video_edit_project_service import VideoEditProjectService
logger = logging.getLogger(__name__)
_service = VideoService()
_plan_service = VideoPlanService(VIDEO_PLAN_DIR)
_edit_project_service = VideoEditProjectService(VIDEO_EDIT_PROJECT_DIR)
def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/video/frame/save-to-output", handle_save_frame)
app.router.add_post("/api/wfm/video/to-gif", handle_to_gif)
app.router.add_get("/api/wfm/video/edit/probe", handle_probe_video)
app.router.add_post("/api/wfm/video/edit/overlay-text", handle_overlay_text)
app.router.add_get("/api/wfm/video/plans", handle_list_plans)
app.router.add_get("/api/wfm/video/plans/content", handle_get_plan_content)
app.router.add_post("/api/wfm/video/plans/save", handle_save_plan)
app.router.add_post("/api/wfm/video/plans/delete", handle_delete_plan)
app.router.add_post("/api/wfm/video/index-image/save-to-output", handle_save_index_to_output)
app.router.add_get("/api/wfm/video/edit/projects", handle_list_edit_projects)
app.router.add_get("/api/wfm/video/edit/projects/content", handle_get_edit_project_content)
app.router.add_post("/api/wfm/video/edit/projects/save", handle_save_edit_project)
app.router.add_post("/api/wfm/video/edit/projects/delete", handle_delete_edit_project)
async def handle_save_frame(request: web.Request) -> web.Response:
try:
body = await request.json()
image_base64 = body.get("image_base64", "")
if not image_base64:
return web.json_response({"error": "image_base64 required"}, status=400)
result = await asyncio.to_thread(_service.save_frame_to_output, image_base64)
return web.json_response({"status": "ok", **result})
except Exception as e:
logger.error("Error saving video frame to output: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_to_gif(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
subfolder = body.get("subfolder", "")
type_ = body.get("type", "output")
start_time = float(body.get("start_time") or 0)
end_time_raw = body.get("end_time")
end_time = float(end_time_raw) if end_time_raw not in (None, "") else None
fps = float(body.get("fps") or 10)
max_width_raw = body.get("max_width")
max_width = int(max_width_raw) if max_width_raw not in (None, "") else None
if not filename:
return web.json_response({"error": "filename required"}, status=400)
result = await asyncio.to_thread(
_service.convert_to_gif, filename, subfolder, type_, start_time, end_time, fps, max_width
)
return web.json_response({"status": "ok", **result})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error converting video to GIF: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_probe_video(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
subfolder = request.rel_url.query.get("subfolder", "")
type_ = request.rel_url.query.get("type", "input")
if not filename:
return web.json_response({"error": "filename required"}, status=400)
try:
result = await asyncio.to_thread(_service.probe_video, filename, subfolder, type_)
return web.json_response(result)
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error probing video: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_overlay_text(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
subfolder = body.get("subfolder", "")
type_ = body.get("type", "output")
overlays = body.get("overlays") or []
delete_source = bool(body.get("delete_source"))
if not filename:
return web.json_response({"error": "filename required"}, status=400)
if not isinstance(overlays, list):
return web.json_response({"error": "overlays must be a list"}, status=400)
# Decode+re-encode of every frame — run off the event loop so other
# requests aren't stalled for the duration of the burn-in.
result = await asyncio.to_thread(
_service.overlay_text_on_video, filename, subfolder, type_, overlays, "video/wfm_edit", delete_source
)
return web.json_response({"status": "ok", **result})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error burning text overlay into video: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_list_plans(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_plan_service.list_plans)
return web.json_response(result)
except Exception as e:
logger.error("Error listing video plans: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_plan_content(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
try:
data = await asyncio.to_thread(_plan_service.get_plan, filename)
return web.json_response({"data": data})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error getting video plan content: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_plan(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
data = body.get("data", {})
index_image_base64 = body.get("index_image_base64")
result = await asyncio.to_thread(_plan_service.save_plan, filename, data, index_image_base64)
return web.json_response({"status": "ok", **result})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error saving video plan: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_plan(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
await asyncio.to_thread(_plan_service.delete_plan, filename)
return web.json_response({"status": "ok"})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error deleting video plan: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_list_edit_projects(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_edit_project_service.list_projects)
return web.json_response(result)
except Exception as e:
logger.error("Error listing video edit projects: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_edit_project_content(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
try:
data = await asyncio.to_thread(_edit_project_service.get_project, filename)
return web.json_response({"data": data})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error getting video edit project content: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_edit_project(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
data = body.get("data", {})
result = await asyncio.to_thread(_edit_project_service.save_project, filename, data)
return web.json_response({"status": "ok", **result})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error saving video edit project: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_edit_project(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
await asyncio.to_thread(_edit_project_service.delete_project, filename)
return web.json_response({"status": "ok"})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error deleting video edit project: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_index_to_output(request: web.Request) -> web.Response:
try:
body = await request.json()
image_base64 = body.get("image_base64", "")
if not image_base64:
return web.json_response({"error": "image_base64 required"}, status=400)
result = await asyncio.to_thread(_plan_service.save_index_image_to_output, image_base64)
return web.json_response({"status": "ok", **result})
except Exception as e:
logger.error("Error saving video index image to output: %s", e)
return web.json_response({"error": str(e)}, status=500)
+192 -89
View File
@@ -1,8 +1,10 @@
"""
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
"""
import copy
import json
import logging
import threading
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -24,14 +26,27 @@ class GalleryMetadataStore:
{"name": str}
]
}
self._data(特に "images" 辞書)は、バックグラウンドのフォルダインデックススレッド
(gallery_service.pyの_bg_index_folder)と、リクエストハンドラを処理するメインスレッドの
双方から並行に読み書きされる。ロックなしで辞書を反復(.items()/.values()/for key in ...や
json.dumpによるシリアライズ)している最中に別スレッドがキーを追加/削除すると
"dictionary changed size during iteration" が発生するため、_data へアクセスする
全メソッドを self._lock で保護する(re-entrant: ensure_group()がcreate_group()を
内部呼び出しするため、通常のLockではなくRLockを使う)。
"""
def __init__(self, file_path: Path):
self.file_path = file_path
self._lock = threading.RLock()
# ディスクへの実書き込み(JSONシリアライズ+ファイルI/O)を直列化するための
# 別ロック。_save_to_disk() を参照。
self._write_lock = threading.Lock()
self._data: dict = {"images": {}, "groups": []}
self._load()
def _load(self):
# コンストラクタからのみ呼ばれ、この時点ではまだ他スレッドと共有されていないためロック不要
if self.file_path.exists():
try:
with open(self.file_path, "r", encoding="utf-8") as f:
@@ -42,145 +57,233 @@ class GalleryMetadataStore:
}
except Exception as e:
logger.warning("GalleryMetadataStore: load error: %s", e)
# 破損ファイルをリネームしてバックアップしておく。ここでリネームせずに
# 空の self._data のまま次の save() が走ると、破損直前までの全メタデータ
# (タグ/メモ/グループ登録)が跡形もなく上書きされ、二度と復元できなくなる
# (実際に発生した不具合: _save_to_disk が非アトミック書き込みだったため、
# 大量画像フォルダのBGインデックス処理中の頻繁な保存とComfyUIの終了タイミング
# が重なるとファイルが不完全な状態のまま残ることがあった — _save_to_disk側は
# os.replace()によるアトミック書き込みに修正済みだが、過去に壊れたファイルや
# 他要因による破損に備えてここでも保護しておく)。
try:
backup_path = self.file_path.with_name(self.file_path.name + ".corrupt")
self.file_path.replace(backup_path)
logger.warning("GalleryMetadataStore: corrupt file backed up to %s", backup_path)
except Exception as backup_err:
logger.warning("GalleryMetadataStore: failed to back up corrupt file: %s", backup_err)
def _save_to_disk(self):
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logger.error("GalleryMetadataStore: save error: %s", e)
return False
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド。
# ここではself._lock配下でスナップショット(deepcopy)を取るだけに留め、実際のJSON
# シリアライズ+ファイルI/Oはロックの外、別途self._write_lockで直列化して行う。
# 理由: gallery_metadata.jsonは画像数が多い環境では1MBを超えることがあり、
# self._lockを保持したままjson.dump+ファイル書き込みまで行うと、その間
# 他スレッドのget()(検索リクエストなど)が丸ごとブロックされてしまう
# (実際に発生した不具合: バックグラウンドインデックス処理が10枚ごとに保存する際、
# 検索リクエストのget()呼び出しがロック待ちになり、50枚程度のフォルダに対する
# 検索でも数秒〜十数秒かかっていた)。deepcopy自体はメモリ内操作でファイルI/Oより
# 大幅に軽いため、ロック内に残しても実用上のブロック時間は無視できるレベルになる。
snapshot = copy.deepcopy(self._data)
return self._write_snapshot(snapshot)
def _write_snapshot(self, snapshot: dict) -> bool:
"""self._lockの外から呼ばれる。一時ファイルに書き出してから置き換える
(os.replace相当のPath.replace)ことで書き込みをアトミックにする。素朴な
open(path, "w") での直接上書きだと、書き込み完了前に他プロセスが読むと
不完全なJSONを掴む(torn read)うえ、書き込み中にComfyUIプロセスが終了すると
ファイルが壊れたまま残る — 大量画像フォルダのBGインデックス処理で書き込み
頻度が高い環境ほど発生しやすく、次回起動時のロード失敗→空データでの全上書き
という形でユーザーの登録済みグループ/タグ/メモが消失する原因になっていた。
self._write_lockで直列化し、複数スレッドの書き込みが競合して古いスナップショット
が新しいものを上書きしないようにする。"""
with self._write_lock:
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.file_path.with_name(self.file_path.name + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(snapshot, f, ensure_ascii=False, indent=2)
tmp_path.replace(self.file_path)
return True
except Exception as e:
logger.error("GalleryMetadataStore: save error: %s", e)
return False
def _normalize_path(self, image_path: str) -> str:
return str(Path(image_path)).replace("\\", "/")
def get(self, image_path: str) -> dict:
key = self._normalize_path(image_path)
return dict(self._data["images"].get(key, {}))
with self._lock:
return dict(self._data["images"].get(key, {}))
def save(self, image_path: str, data: dict) -> bool:
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
with self._lock:
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
def save_batch(self, entries: dict[str, dict]) -> bool:
"""複数画像のメタデータを1回のディスク書き込みでまとめて保存する。
バックグラウンドインデックス処理のように多数の画像を連続して保存する場合、
1件ずつsave()を呼ぶと画像の数だけJSONシリアライズ+ファイルI/Oが発生してしまう
(実際に発生した不具合: gallery_metadata.jsonが1MBを超える環境で10枚ごとに
個別save()していたため、検索リクエストが完了するまで数秒〜十数秒かかっていた)。"""
if not entries:
return True
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
with self._lock:
for image_path, data in entries.items():
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
# ── グループ管理 ──────────────────────────────────────────────
def list_groups(self) -> list[dict]:
return list(self._data.get("groups", []))
with self._lock:
return list(self._data.get("groups", []))
def create_group(self, name: str) -> bool:
groups = self._data.get("groups", [])
if any(g["name"] == name for g in groups):
return False
groups.append({"name": name})
self._data["groups"] = groups
return self._save_to_disk()
with self._lock:
groups = self._data.get("groups", [])
if any(g["name"] == name for g in groups):
return False
groups.append({"name": name})
self._data["groups"] = groups
return self._save_to_disk()
def rename_group(self, old_name: str, new_name: str) -> bool:
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
groups = self._data.get("groups", [])
if not any(g["name"] == old_name for g in groups):
return False
if any(g["name"] == new_name for g in groups):
return False # 新名前が既に存在
# グループリスト更新
for g in groups:
if g["name"] == old_name:
g["name"] = new_name
break
# 全画像のgroupsフィールドを更新
for meta in self._data["images"].values():
img_groups = meta.get("groups", [])
if old_name in img_groups:
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
return self._save_to_disk()
with self._lock:
groups = self._data.get("groups", [])
if not any(g["name"] == old_name for g in groups):
return False
if any(g["name"] == new_name for g in groups):
return False # 新名前が既に存在
# グループリスト更新
for g in groups:
if g["name"] == old_name:
g["name"] = new_name
break
# 全画像のgroupsフィールドを更新
for meta in self._data["images"].values():
img_groups = meta.get("groups", [])
if old_name in img_groups:
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
return self._save_to_disk()
def delete_group(self, name: str) -> bool:
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
self._data["groups"] = groups
# 画像のgroupsからも削除
for meta in self._data["images"].values():
if name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != name]
return self._save_to_disk()
with self._lock:
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
self._data["groups"] = groups
# 画像のgroupsからも削除
for meta in self._data["images"].values():
if name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != name]
return self._save_to_disk()
def list_images_in_group(self, group_name: str) -> list[str]:
result = []
for key, meta in self._data["images"].items():
if group_name in meta.get("groups", []):
result.append(key)
return result
with self._lock:
result = []
for key, meta in self._data["images"].items():
if group_name in meta.get("groups", []):
result.append(key)
return result
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
stale_set = {self._normalize_path(p) for p in stale_paths}
count = 0
for key, meta in self._data["images"].items():
if key in stale_set and group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
count += 1
if count:
self._save_to_disk()
return count
with self._lock:
stale_set = {self._normalize_path(p) for p in stale_paths}
count = 0
for key, meta in self._data["images"].items():
if key in stale_set and group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
count += 1
if count:
self._save_to_disk()
return count
def get_group_member_set(self, group_name: str) -> set:
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
return {
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
with self._lock:
return {
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
def clear_group(self, group_name: str) -> bool:
"""グループ内の全画像を除外する(グループ自体は残す)"""
for meta in self._data["images"].values():
if group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
return self._save_to_disk()
with self._lock:
for meta in self._data["images"].values():
if group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
return self._save_to_disk()
def ensure_group(self, name: str) -> bool:
"""グループが存在しない場合のみ作成する"""
if any(g["name"] == name for g in self._data.get("groups", [])):
return True
return self.create_group(name)
with self._lock:
if any(g["name"] == name for g in self._data.get("groups", [])):
return True
return self.create_group(name)
def delete(self, image_path: str) -> bool:
"""画像のメタデータエントリを削除する"""
key = self._normalize_path(image_path)
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
with self._lock:
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
def rename_path(self, old_path: str, new_path: str) -> bool:
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
old_key = self._normalize_path(old_path)
new_key = self._normalize_path(new_path)
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
with self._lock:
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
def cleanup_stale_images(self, folder_path: str, existing_paths: set, recursive: bool = True) -> int:
"""folder_path 配下の存在しないファイルのメタデータを削除する。
existing_paths: そのフォルダの現在のファイルパス集合(_normalize_path 済み)
recursive: False の場合、folder_path 直下のファイルのみを対象にする。
呼び出し元(list_images)が非recursiveスキャン(フォルダ直下のみ列挙)の
結果を existing_paths として渡してくる場合、サブフォルダ内のファイルは
existing_paths に含まれない。それにもかかわらず対象範囲を「folder_path配下
全階層」のままにすると、サブフォルダ内画像のメタデータ(tags/memo等)を
"存在しない"と誤判定して全削除してしまう(実際に発生した不具合: サブフォルダ
に動画を保存する運用で、ルートフォルダを非recursiveで開くたびに
サブフォルダ内の全メタデータが失われていた)。
"""
folder_key = self._normalize_path(folder_path)
if not folder_key.endswith("/"):
folder_key += "/"
stale = [
key for key in self._data["images"]
if key.startswith(folder_key) and key not in existing_paths
]
if not stale:
return 0
for key in stale:
del self._data["images"][key]
self._save_to_disk()
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
return len(stale)
def _in_scope(key: str) -> bool:
if not key.startswith(folder_key):
return False
if recursive:
return True
return "/" not in key[len(folder_key):]
with self._lock:
stale = [
key for key in self._data["images"]
if _in_scope(key) and key not in existing_paths
]
if not stale:
return 0
for key in stale:
del self._data["images"][key]
self._save_to_disk()
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
return len(stale)
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
"""Lab tab plan file management service (JSON plan + optional PNG index thumbnail)."""
import base64
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
class LabPlanService:
def __init__(self, plan_dir: Path):
self.plan_dir = plan_dir
self.plan_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# Path safety
# ------------------------------------------------------------------
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
if not filename or not filename.strip():
return None
name = filename.strip()
if not name.endswith(ext):
name = name + ext
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
return None
resolved = (self.plan_dir / name).resolve()
plan_dir_resolved = self.plan_dir.resolve()
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
return None
return resolved
def _now_iso(self) -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
def list_plans(self) -> list[dict]:
if not self.plan_dir.is_dir():
return []
result = []
for path in sorted(self.plan_dir.glob("*.json")):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.warning("Skipping unreadable lab plan %s: %s", path.name, e)
continue
stem = path.stem
thumbnail = None
thumb_path = self.plan_dir / f"{stem}.png"
if thumb_path.is_file():
thumbnail = f"/wfm_data/lab_plan/{stem}.png"
result.append({
"filename": path.name,
"name": data.get("name", stem),
"note": data.get("note", ""),
"batch_count": data.get("batch_count", 1),
"updated_at": data.get("updated_at", ""),
"thumbnail": thumbnail,
})
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
return result
def get_plan(self, filename: str) -> dict:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if not path.is_file():
raise FileNotFoundError(f"Plan not found: {filename}")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
data = dict(data)
data["updated_at"] = self._now_iso()
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
if index_image_base64:
self.save_index_image(filename, index_image_base64)
return {"filename": path.name, "updated_at": data["updated_at"]}
def save_index_image(self, filename: str, image_base64: str) -> None:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
stem = path.stem
thumb_path = self.plan_dir / f"{stem}.png"
# Accept both raw base64 and data: URL forms
b64 = image_base64
if "," in b64 and b64.strip().lower().startswith("data:"):
b64 = b64.split(",", 1)[1]
image_bytes = base64.b64decode(b64)
with open(thumb_path, "wb") as f:
f.write(image_bytes)
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Lab_index") -> dict:
"""Saves the contact-sheet index image into ComfyUI's own output folder,
alongside the images generated during the run (unlike save_index_image,
which saves it next to the plan file in plan_dir)."""
b64 = image_base64
if "," in b64 and b64.strip().lower().startswith("data:"):
b64 = b64.split(",", 1)[1]
image_bytes = base64.b64decode(b64)
import folder_paths # type: ignore
output_dir = folder_paths.get_output_directory()
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
save_name = f"{filename}_{counter:05}_.png"
save_path = Path(full_output_folder) / save_name
save_path.write_bytes(image_bytes)
return {"filename": save_name, "subfolder": subfolder}
def delete_plan(self, filename: str) -> None:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if path.is_file():
path.unlink()
thumb_path = self.plan_dir / f"{path.stem}.png"
if thumb_path.is_file():
thumb_path.unlink()
+228
View File
@@ -0,0 +1,228 @@
"""AI skill (.md system prompt) file management service."""
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
ALLOWED_EXTS = {"md"}
DEFAULT_SKILL_FILENAME = "svg-icon.md"
DEFAULT_SKILL_CONTENT = """---
name: SVG Icon Generator
description: Produces clean, minimal, valid SVG icons and simple illustrations.
---
You are an expert SVG icon and illustration designer. When the user asks for an icon, logo, pictogram, diagram, character, animal, or any vector graphic, respond with valid, clean SVG markup.
## Composition process (do this before writing markup - skipping this step is the main cause of unrecognizable output)
- Break the subject down into 3-8 distinct parts that make it recognizable (e.g. a bird: body, head, beak, eye, wing, tail; a house: wall, roof, door, window). Drop parts that are not essential.
- Decide each part's approximate position, size, and draw order within the viewBox (elements listed later in the markup are drawn on top).
- Pick the simplest shape that captures each part's silhouette: `<circle>`/`<ellipse>` for round parts, `<rect>` for rectangular parts, `<polygon>` for angular parts (beaks, ears, leaves, roofs). For a rounded organic outline that no primitive can approximate well (a body silhouette, a wing, a leaf edge), use a short `<path>` with 2-4 smooth curve commands (`Q` or `C`) instead of a plain circle - a few deliberate curve points beat a distorted primitive.
- Never fall back to drawing an unrelated blob plus a few disconnected decorative lines when a subject is hard to render - always decompose it into the named parts above instead, even if each part is crude.
## Output rules
- Always wrap the SVG markup in a fenced code block labeled `svg`, e.g. ```svg ... ```.
- Keep any explanation brief and place it OUTSIDE the code block, never mixed inside the SVG.
- Output exactly one `<svg>...</svg>` element per code block.
## Structure rules
- Always include a `viewBox` attribute (e.g. `viewBox="0 0 24 24"`). Do not hardcode large pixel `width`/`height`; if you set them, make them match the viewBox aspect ratio.
- Use a small, round coordinate system (0-24 or 0-100).
- Round coordinate values to at most 2 decimal places.
- Keep every `<path>` short (at most about 6 commands) - long, intricate paths are where quality breaks down most often.
- Group related elements with `<g>` when it clarifies structure, but do not over-nest.
## Color rules
- For single-color icons, use `fill="currentColor"` (or `stroke="currentColor"` for line icons) so the icon inherits the surrounding text color.
- For multi-color illustrations, use explicit hex colors (e.g. `#4A90D9`) - never named CSS colors like `"blue"`.
- Avoid gradients, filters, and blend modes unless the user explicitly asks for a glossy/gradient style - they rarely render correctly from generated coordinates.
## Accessibility
- Include a `<title>` element as the first child of `<svg>` describing what the icon depicts.
## Security (mandatory - do not violate)
- Never include `<script>`, `<foreignObject>`, event handler attributes (`onload`, `onclick`, etc.), or `<a>`/`href` links to external URLs.
- Never reference external resources (no `xlink:href` to remote URLs, no `<image>` with external `src`).
## Example 1 - simple geometric icon (single color, primitives only)
Request: "Sun icon"
Parts: disc (circle), 8 rays (lines).
```svg
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Sun</title>
<circle cx="12" cy="12" r="5" fill="currentColor"/>
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="12" y1="1" x2="12" y2="4"/>
<line x1="12" y1="20" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="4" y2="12"/>
<line x1="20" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
</g>
</svg>
```
## Example 2 - organic subject (multi-color, decomposed into parts)
Request: "Bird icon"
Parts: body (rounded outline via a short curved path), head (circle), beak (small triangle), eye (dot), wing (small curved path) - each drawn as its own element, layered head/beak/eye on top of the body.
```svg
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Bird</title>
<path d="M4 16 Q4 10 11 9 Q18 8 20 13 Q20 17 14 18 Q7 19 4 16 Z" fill="#4A90D9"/>
<path d="M8 14 Q5 12 3 14 Q6 16 9 16 Z" fill="#3576B8"/>
<circle cx="17" cy="10" r="3.2" fill="#4A90D9"/>
<polygon points="20,10 23,9.3 20,11.4" fill="#F2B807"/>
<circle cx="18" cy="9" r="0.6" fill="#1A1A1A"/>
</svg>
```
Follow this pattern for every request: list the parts first, represent each with the simplest adequate shape, then assemble - never skip straight to unstructured shapes.
"""
SKILL_CREATOR_FILENAME = "skill-creator.md"
SKILL_CREATOR_CONTENT = """---
name: Skill Creator
description: Interactively interviews you and drafts a new WFS chat skill (.md system prompt) ready to save.
---
You are a skill-design assistant embedded in ComfyUI Workflow Studio's AI TOOL Chat pane. Your job is to interactively help the user design a NEW "skill" - a markdown file with frontmatter (`name`, `description`) followed by system-prompt instructions. Once saved, a skill can be selected from a dropdown and is silently prepended to future chat messages as a system prompt.
## How to conduct the interview
- Ask ONE focused question at a time, never a bulleted list of questions. Wait for the user's answer before asking the next one.
- Cover, in this order, only what is not already clear from context:
1. What task or domain should the skill specialize in (what should the model do differently once this skill is active)?
2. What output format is expected - plain prose, a specific fenced code block language, a fixed structure, a length limit?
3. Any hard constraints or things to avoid (tone, forbidden content, security concerns, things past attempts got wrong)?
4. Would a short worked example help smaller/local models follow the pattern more reliably? If yes, ask what a good example input/output looks like, or offer to draft one.
- Keep it short - 3 to 5 questions is usually enough. If the user says "just generate it", "that's enough", or similar at any point, stop asking and produce the best skill you can from what you already have.
## Output format for the finished skill
- Once you have enough information, output the complete skill file in a single fenced code block labeled `skill` (```skill ... ```). Never mix prose inside that block - explanation goes outside it.
- The block must start with frontmatter exactly in this shape:
---
name: <short display name, shown in the skill dropdown>
description: <one sentence, shown as a tooltip in the dropdown>
---
- After the frontmatter, write clear, imperative system-prompt instructions for the target skill: what role the model should take, concrete output-format rules, hard constraints, and - if useful - one short worked example following a "request -> ideal output" shape.
- After the code block, tell the user in one line that they can click the "Save as new skill" button below the message to add it, or copy the block manually into a new skill.
"""
_FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
def _parse_frontmatter(content: str) -> dict:
"""Extract a shallow `key: value` frontmatter block, if present."""
m = _FRONTMATTER_RE.match(content)
if not m:
return {}
fields: dict = {}
for line in m.group(1).splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if key:
fields[key] = value
return fields
class SkillService:
def __init__(self, skills_dir: Path):
self.skills_dir = skills_dir
self.skills_dir.mkdir(parents=True, exist_ok=True)
self._seed_default_skill()
def _seed_default_skill(self) -> None:
"""Seed a starter skill file the first time the directory is empty."""
try:
has_any = any(
p.is_file() and p.suffix.lstrip(".").lower() in ALLOWED_EXTS
for p in self.skills_dir.iterdir()
)
except FileNotFoundError:
has_any = False
if not has_any:
(self.skills_dir / DEFAULT_SKILL_FILENAME).write_text(
DEFAULT_SKILL_CONTENT, encoding="utf-8"
)
(self.skills_dir / SKILL_CREATOR_FILENAME).write_text(
SKILL_CREATOR_CONTENT, encoding="utf-8"
)
# ------------------------------------------------------------------
# File CRUD
# ------------------------------------------------------------------
def list_skills(self) -> list[dict]:
"""Return sorted list of skill files with parsed frontmatter metadata."""
if not self.skills_dir.exists():
return []
files = []
for path in sorted(self.skills_dir.glob("*")):
if not (path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS):
continue
try:
content = path.read_text(encoding="utf-8")
except Exception:
content = ""
meta = _parse_frontmatter(content)
files.append({
"filename": path.name,
"name": meta.get("name") or path.stem,
"description": meta.get("description", ""),
"size": path.stat().st_size,
})
return files
def get_content(self, filename: str) -> str:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if not path.is_file():
raise FileNotFoundError(f"File not found: {filename}")
return path.read_text(encoding="utf-8")
def save_file(self, filename: str, content: str) -> dict:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
path.write_text(content, encoding="utf-8")
meta = _parse_frontmatter(content)
return {
"filename": path.name,
"name": meta.get("name") or path.stem,
"description": meta.get("description", ""),
"size": path.stat().st_size,
}
def delete_file(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()
def _safe_path(self, filename: str) -> Path | None:
if not filename or not filename.strip():
return None
# Skills are a flat directory - reject any path separator (no subfolders).
name = filename.strip()
if "/" in name or "\\" in name or name in (".", ".."):
return None
ext = Path(name).suffix.lstrip(".").lower()
if ext not in ALLOWED_EXTS:
return None
return self.skills_dir / name
+15
View File
@@ -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(
+137 -31
View File
@@ -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]))
+98
View File
@@ -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()
+143
View File
@@ -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()
+354
View File
@@ -0,0 +1,354 @@
"""Video tab utilities: last-frame capture and animated GIF conversion.
Runs entirely on dependencies ComfyUI itself already requires — PyAV for
decoding (ComfyUI core already needs av>=17.0.0) and Pillow for GIF encoding
— so no ffmpeg binary and no new pip installs, unlike a typical ffmpeg-based
implementation (e.g. Eagle's video2gif/video-to-frame plugins).
"""
import base64
import logging
import os
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
try:
from PIL import Image
_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
except Exception: # pragma: no cover - Pillow is a hard ComfyUI dependency
_RESAMPLE = None
# Text overlay anchors: 9-grid "<vertical>-<horizontal>" (e.g. "bottom-center").
_ANCHOR_V = ("top", "middle", "bottom")
_ANCHOR_H = ("left", "center", "right")
# Fonts tried in order for text overlays. Pillow's built-in default font has no
# CJK glyphs (Japanese/Chinese text would render as boxes), so a system font
# with CJK coverage is preferred; load_default() is only the last resort.
_FONT_CANDIDATES = {
"win32": ["meiryo.ttc", "YuGothM.ttc", "NotoSansJP-Regular.ttf", "msgothic.ttc", "arial.ttf"],
"darwin": [
"/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/Library/Fonts/Arial Unicode.ttf",
],
"linux": [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
],
}
def _load_overlay_font(size: int):
from PIL import ImageFont
platform = "win32" if sys.platform.startswith("win") else ("darwin" if sys.platform == "darwin" else "linux")
for name in _FONT_CANDIDATES[platform]:
path = name
if platform == "win32" and not os.path.isabs(name):
path = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", name)
if os.path.isfile(path):
try:
return ImageFont.truetype(path, size)
except Exception:
continue
return ImageFont.load_default(size=size)
def _render_text_layer(width: int, height: int, ov: dict):
"""Renders one overlay's text onto a transparent full-frame RGBA layer, so
it can be alpha-composited over every frame in its time range without
re-rendering the text per frame (same idea as ComfyUI core's TextOverlay)."""
from PIL import ImageColor, ImageDraw
text = str(ov.get("text") or "").replace("\\n", "\n")
layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
if not text.strip():
return layer
size = max(8, round(float(ov.get("font_size") or 6.0) / 100.0 * height))
font = _load_overlay_font(size)
stroke = max(1, round(size * 0.06)) if ov.get("outline", True) else 0
spacing = round(size * 0.2)
try:
color = ImageColor.getrgb(str(ov.get("color") or "#ffffff"))
except ValueError:
color = (255, 255, 255)
fill = (*color[:3], 255)
anchor = str(ov.get("anchor") or "bottom-center").split("-")
v = anchor[0] if anchor and anchor[0] in _ANCHOR_V else "bottom"
h = anchor[1] if len(anchor) > 1 and anchor[1] in _ANCHOR_H else "center"
draw = ImageDraw.Draw(layer)
box = draw.multiline_textbbox((0, 0), text, font=font, spacing=spacing, stroke_width=stroke, align=h)
tw, th = box[2] - box[0], box[3] - box[1]
margin = round(0.04 * min(width, height))
x = {"left": margin, "center": (width - tw) / 2, "right": width - margin - tw}[h] - box[0]
y = {"top": margin, "middle": (height - th) / 2, "bottom": height - margin - th}[v] - box[1]
if ov.get("background"):
pad = round(size * 0.3)
draw.rounded_rectangle(
(x + box[0] - pad, y + box[1] - pad, x + box[2] + pad, y + box[3] + pad),
radius=pad, fill=(0, 0, 0, 140),
)
draw.multiline_text((x, y), text, font=font, fill=fill, spacing=spacing, align=h,
stroke_width=stroke, stroke_fill=(0, 0, 0, 255))
return layer
class VideoService:
def _resolve_media_path(self, filename: str, subfolder: str, type_: str) -> Path:
"""Resolves a ComfyUI-style {filename, subfolder, type} reference to an
absolute path, guarding against path traversal outside the matching
input/output/temp directory."""
import folder_paths # type: ignore
base_dir = folder_paths.get_directory_by_type(type_)
if not base_dir:
raise ValueError(f"Invalid type: {type_}")
base = Path(base_dir).resolve()
target = (base / (subfolder or "") / filename).resolve()
try:
target.relative_to(base)
except ValueError:
raise ValueError("Access denied: path outside allowed directory")
if not target.is_file():
raise FileNotFoundError(f"File not found: {filename}")
return target
def save_frame_to_output(self, image_base64: str, filename_prefix: str = "video/Frame") -> dict:
"""Saves a client-captured video frame (PNG data URL) into ComfyUI's own
output folder — same auto-numbered pattern as Lab's index-image save
(see LabPlanService.save_index_image_to_output)."""
import folder_paths # type: ignore
b64 = image_base64
if "," in b64 and b64.strip().lower().startswith("data:"):
b64 = b64.split(",", 1)[1]
image_bytes = base64.b64decode(b64)
output_dir = folder_paths.get_output_directory()
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
save_name = f"{filename}_{counter:05}_.png"
save_path = Path(full_output_folder) / save_name
save_path.write_bytes(image_bytes)
return {"filename": save_name, "subfolder": subfolder}
def probe_video(self, filename: str, subfolder: str, type_: str) -> dict:
"""Reads duration/dimensions/fps/audio presence via PyAV only (no ffmpeg
subprocess) — used by the Video Edit tab right after a clip is added, so
the timeline UI can show its length and the export-time resolution-match
check has real numbers to compare against."""
import av # type: ignore
src_path = self._resolve_media_path(filename, subfolder, type_)
container = av.open(str(src_path))
try:
stream = container.streams.video[0]
fps = float(stream.average_rate) if stream.average_rate else 0.0
if stream.duration is not None and stream.time_base is not None:
duration = float(stream.duration * stream.time_base)
elif container.duration is not None:
duration = float(container.duration) / 1_000_000
else:
duration = 0.0
width = stream.codec_context.width
height = stream.codec_context.height
has_audio = len(container.streams.audio) > 0
finally:
container.close()
return {
"duration": duration,
"width": width,
"height": height,
"fps": fps,
"has_audio": has_audio,
}
def convert_to_gif(
self,
filename: str,
subfolder: str,
type_: str,
start_time: float = 0.0,
end_time=None,
fps: float = 10.0,
max_width=None,
filename_prefix: str = "video/GIF",
) -> dict:
"""Decodes the given video (PyAV), samples it down to `fps`, optionally
downscales to `max_width`, and encodes the result as an animated GIF
(Pillow) into ComfyUI's own output folder."""
import av # type: ignore
import folder_paths # type: ignore
if _RESAMPLE is None:
raise RuntimeError("Pillow is not available")
src_path = self._resolve_media_path(filename, subfolder, type_)
fps = max(0.1, float(fps))
container = av.open(str(src_path))
try:
stream = container.streams.video[0]
source_fps = float(stream.average_rate) if stream.average_rate else 24.0
step = max(1, round(source_fps / fps))
pil_frames = []
idx = 0
for frame in container.decode(stream):
t = float(frame.pts * stream.time_base) if frame.pts is not None else None
if t is not None:
if t < start_time:
idx += 1
continue
if end_time is not None and t > end_time:
break
if idx % step == 0:
im = frame.to_image()
if max_width and im.width > max_width:
ratio = max_width / im.width
im = im.resize((max_width, max(1, round(im.height * ratio))), _RESAMPLE)
pil_frames.append(im.convert("RGB"))
idx += 1
finally:
container.close()
if not pil_frames:
raise ValueError("No frames found in the specified range")
output_dir = folder_paths.get_output_directory()
full_output_folder, out_filename, counter, out_subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
save_name = f"{out_filename}_{counter:05}_.gif"
save_path = Path(full_output_folder) / save_name
duration_ms = max(20, round(1000 / fps))
pil_frames[0].save(
save_path,
format="GIF",
save_all=True,
append_images=pil_frames[1:],
duration=duration_ms,
loop=0,
optimize=True,
)
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": len(pil_frames)}
def overlay_text_on_video(
self,
filename: str,
subfolder: str,
type_: str,
overlays: list,
filename_prefix: str = "video/wfm_edit",
delete_source: bool = False,
) -> dict:
"""Burns timed text overlays into a video (Video Edit tab, Phase 3).
ComfyUI core has no time-ranged video text node (core TextOverlay draws
on every frame of an IMAGE batch, top/bottom only), and routing the
whole timeline through IMAGE tensors would hold every decoded frame in
memory at once — so this streams instead: decode one frame, composite
any overlay whose [start, end) covers it, encode, repeat. Audio packets
are remuxed untouched (no audio re-encode), so the soundtrack built by
the export graph (original audio / BGM) survives this pass as-is.
overlays: [{text, start, end, font_size (% of frame height), color,
anchor ("<top|middle|bottom>-<left|center|right>"), outline, background}]
with start/end in seconds from the start of the video.
"""
import av # type: ignore
import folder_paths # type: ignore
src_path = self._resolve_media_path(filename, subfolder, type_)
items = []
for ov in overlays or []:
try:
start = max(0.0, float(ov.get("start") or 0.0))
end = float(ov.get("end") or 0.0)
except (TypeError, ValueError):
continue
if end > start and str(ov.get("text") or "").strip():
items.append((start, end, ov))
if not items:
raise ValueError("No valid text overlays")
output_dir = folder_paths.get_output_directory()
full_output_folder, out_filename, counter, out_subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
save_name = f"{out_filename}_{counter:05}_.mp4"
save_path = Path(full_output_folder) / save_name
in_c = av.open(str(src_path))
out_c = None
try:
in_v = in_c.streams.video[0]
in_a = in_c.streams.audio[0] if in_c.streams.audio else None
width, height = in_v.codec_context.width, in_v.codec_context.height
rate = in_v.average_rate or in_v.guessed_rate or 24
layers = [(s, e, _render_text_layer(width, height, ov)) for s, e, ov in items]
out_c = av.open(str(save_path), mode="w")
out_v = out_c.add_stream("libx264", rate=rate)
out_v.width = width
out_v.height = height
out_v.pix_fmt = "yuv420p"
out_v.options = {"crf": "18", "preset": "medium"}
out_a = out_c.add_stream_from_template(in_a) if in_a is not None else None
streams = [in_v] + ([in_a] if in_a is not None else [])
first_pts = None
index = 0
for packet in in_c.demux(*streams):
if packet.stream.type == "audio":
if packet.dts is None:
continue
packet.stream = out_a
out_c.mux(packet)
continue
for frame in packet.decode():
if frame.pts is not None and first_pts is None:
first_pts = frame.pts
t = float((frame.pts - first_pts) * in_v.time_base) if frame.pts is not None else index / float(rate)
active = [layer for s, e, layer in layers if s <= t < e]
if active:
img = frame.to_image().convert("RGBA")
for layer in active:
img.alpha_composite(layer)
out_frame = av.VideoFrame.from_image(img.convert("RGB"))
else:
out_frame = frame
out_frame.pts = index
out_frame.time_base = out_v.codec_context.time_base
index += 1
for out_packet in out_v.encode(out_frame):
out_c.mux(out_packet)
for out_packet in out_v.encode(None):
out_c.mux(out_packet)
except BaseException:
if out_c is not None:
out_c.close()
out_c = None
save_path.unlink(missing_ok=True)
raise
finally:
if out_c is not None:
out_c.close()
in_c.close()
# The export graph's intermediate file is only deleted when the caller
# explicitly asks and it's one of this tab's own "_pre" intermediates in
# output — never an arbitrary user file.
if delete_source and type_ == "output" and Path(filename).name.startswith("wfm_edit_pre"):
try:
src_path.unlink()
except OSError as e:
logger.warning("Could not delete intermediate %s: %s", src_path, e)
return {"filename": save_name, "subfolder": out_subfolder, "frame_count": index}
+20 -7
View File
@@ -3,6 +3,17 @@
import os
import re
# プロンプトを直接持つノードタイプ(CLIPTextEncode以外の複合ノード含む)
_PROMPT_NODE_TYPES = {
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner",
"CLIPTextEncodeEditPlus", "TextEncodeQwenImageEditPlus", "TextEncodeMageFlowEdit",
"SDXLPromptStyler", "SDXLPromptStylerAdvanced", "ImageMetadataPromptLoader",
"WFS_PromptText", "ImpactWildcardEncode", "ImpactWildcardProcessor",
}
# 画像を最終出力として保存するノードタイプ
_OUTPUT_IMAGE_NODE_TYPES = {"SaveImage", "SaveImageAdvanced"}
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
_CLIP_TYPE_TO_MODEL = {
"flux": "Flux",
@@ -181,25 +192,27 @@ def analyze_workflow(workflow_data, filename=""):
if "hidream" in title:
model_types.add("HiDream")
if ntype == "CLIPTextEncode" or "prompt" in title:
if ntype in _PROMPT_NODE_TYPES or "prompt" in title:
inputs["prompts"] += 1
if ntype in ("LoadImage", "LoadImageMask"):
inputs["images"] += 1
if ntype == "LoadVideo" or "load video" in title:
inputs["videos"] += 1
if ntype == "SaveImage":
if ntype in _OUTPUT_IMAGE_NODE_TYPES:
outputs["images"] += 1
if ntype in ("SaveVideo", "VH_VideoCombine"):
outputs["videos"] += 1
# Detect workflow format
if raw_nodes is not None:
# App format: UI-based structure with definitions (subgraphs) or linearMode
# App format: simplified web-app-style UI (linearMode) or .app.json filename.
# A workflow that merely *contains* subgraphs (definitions.subgraphs) is still a
# normal node graph (comfyui-workflow.js's convertUiToApi flattens subgraphs before
# conversion), so it's treated as "ui" here too — must match detectFormat() in
# static/js/comfyui-workflow.js or the App/UI badge shown to the user disagrees
# with what GenerateUI actually does with the file.
extra = workflow_data.get("extra", {})
if (
"definitions" in workflow_data
or extra.get("linearMode") is True
):
if extra.get("linearMode") is True:
wf_format = "app"
elif filename and filename.lower().endswith(".app.json"):
wf_format = "app"
+44 -2
View File
@@ -4,7 +4,7 @@ import jinja2
from aiohttp import web
from pathlib import Path
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR, VIDEO_PLAN_DIR
logger = logging.getLogger(__name__)
@@ -41,6 +41,38 @@ async def serve_workflow_file(request: web.Request) -> web.Response:
})
async def serve_lab_plan_file(request: web.Request) -> web.Response:
"""Serve files (index thumbnails) from the Lab plan directory."""
filename = request.match_info.get("filename", "")
if not filename or ".." in filename or "/" in filename or "\\" in filename:
return web.Response(status=404)
file_path = LAB_PLAN_DIR / filename
if not file_path.is_file():
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(file_path))
return web.FileResponse(file_path, headers={
"Content-Type": content_type or "application/octet-stream",
})
async def serve_video_plan_file(request: web.Request) -> web.Response:
"""Serve files (index thumbnails) from the Video plan directory."""
filename = request.match_info.get("filename", "")
if not filename or ".." in filename or "/" in filename or "\\" in filename:
return web.Response(status=404)
file_path = VIDEO_PLAN_DIR / filename
if not file_path.is_file():
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(file_path))
return web.FileResponse(file_path, headers={
"Content-Type": content_type or "application/octet-stream",
})
class WorkflowStudio:
"""Main entry point for Workflow Studio plugin."""
@@ -57,15 +89,22 @@ class WorkflowStudio:
# Dynamic workflow file serving (thumbnails etc.)
app.router.add_get("/wfm_data/workflows/{filename}", serve_workflow_file)
# Dynamic Lab plan file serving (index thumbnails)
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
# Dynamic Video plan file serving (index thumbnails)
app.router.add_get("/wfm_data/video_plan/{filename}", serve_video_plan_file)
# Main page
app.router.add_get("/wfm", serve_index_page)
# API routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes, video_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
ollama_routes.setup_routes(app)
unsloth_routes.setup_routes(app)
eagle_routes.setup_routes(app)
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
@@ -74,5 +113,8 @@ class WorkflowStudio:
wildcard_routes.setup_routes(app)
tagger_routes.setup_routes(app)
gmic_routes.setup_routes(app)
skill_routes.setup_routes(app)
lab_routes.setup_routes(app)
video_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+2 -2
View File
@@ -1,8 +1,8 @@
[project]
name = "comfyui-workflow-studio"
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
version = "0.3.74"
license = "MIT"
version = "0.7.4"
license = { file = "LICENSE" }
requires-python = ">= 3.10"
dependencies = ["jinja2"]
+14
View File
@@ -19,3 +19,17 @@ piexif>=1.1.3
# Omit if you do not use DeepDanbooru models.
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
# tensorflow>=2.10
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
python-dotenv>=1.0,<2.0
# -- Multi-layer PSD export (Gallery tab bulk action) --
# Pure-Python, ships prebuilt wheels (no C compiler required on Windows).
psd-tools>=1.10
# -- MP4 thumbnail generation (Gallery tab) --
# Uses PyAV (av) to decode the first frame. ComfyUI core already requires av>=17.0.0,
# so it is normally pre-installed in python_embeded and no action is needed here.
# If missing, mp4 thumbnail generation simply fails and falls back to a placeholder
# (Gallery itself keeps working). Not pinned here on purpose — see torch note above.
# av>=17.0.0
+188 -38
View File
@@ -2,11 +2,45 @@
Gallery Tab Styles
============================================================ */
/* ── サブタブ (Output / Style-Prompt) ──────────────────────── */
.wfm-gallery-subtab-nav {
display: flex;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-gallery-subtab-btn {
padding: 6px 16px;
font-size: 12px;
background: transparent;
border: none;
color: var(--wfm-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s;
}
.wfm-gallery-subtab-btn:hover { color: var(--wfm-text-primary); }
.wfm-gallery-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
.wfm-gallery-panel {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.wfm-gallery-panel.active { display: flex; }
/* ── 3カラムレイアウト ──────────────────────────────────────── */
.wfm-gallery-layout {
display: flex;
height: calc(100vh - var(--wfm-header-height, 96px));
flex: 1;
min-height: 0;
overflow: hidden;
gap: 0;
}
@@ -256,6 +290,24 @@
color: #38bdf8;
}
/* 動画ファイル(mp4)の再生アイコンバッジ(サムネイル中央) */
.wfm-gallery-thumb-video-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0,0,0,0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
pointer-events: none;
}
/* テーブルビューのお気に入りボタン */
.wfm-gallery-table-fav-btn {
background: none;
@@ -431,43 +483,6 @@
cursor: pointer;
}
.wfm-gallery-preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.wfm-gallery-preview-wrapper:hover .wfm-gallery-preview-overlay {
opacity: 1;
}
.wfm-gallery-download-btn {
background: var(--wfm-accent, #4f8ef7);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background 0.2s;
}
.wfm-gallery-download-btn:hover {
background: var(--wfm-accent-hover, #3f7fe7);
}
/* タブコンテンツ */
.wfm-gallery-detail-tab-content {
flex: 1;
@@ -499,6 +514,20 @@
white-space: nowrap;
}
.wfm-gallery-image-prompt-text {
font-size: 12px;
color: var(--wfm-text-primary);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
padding: 6px 8px;
max-height: 160px;
overflow-y: auto;
}
.wfm-gallery-tags-display {
display: flex;
flex-wrap: wrap;
@@ -788,6 +817,16 @@
border-color: #6a4de0;
}
.wfm-gallery-action-btn-orange {
background: #f59e0b;
border-color: #f59e0b;
color: #1a1a2e;
}
.wfm-gallery-action-btn-orange:hover {
background: #d97f06;
border-color: #d97f06;
}
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
.wfm-gallery-file-actions {
@@ -833,3 +872,114 @@
gap: 8px;
justify-content: flex-end;
}
/* ============================================================
ImagePrompt / Style_Catalog ギャラリー (右ペイン)
============================================================ */
.wfm-imageprompt-detail-panel {
width: 520px;
min-width: 520px;
max-width: 520px;
flex-direction: row;
overflow: hidden;
}
.wfm-imageprompt-detail-col {
display: flex;
flex-direction: column;
min-width: 0;
}
.wfm-imageprompt-detail-col-left {
width: 280px;
min-width: 280px;
max-width: 280px;
overflow-y: auto;
border-right: 1px solid var(--wfm-border);
padding-bottom: 8px;
}
.wfm-imageprompt-detail-col-right {
flex: 1;
min-width: 0;
padding: 8px 10px;
}
.wfm-imageprompt-section {
padding: 8px 10px;
}
.wfm-imageprompt-prompt-box {
width: 100%;
resize: vertical;
font-size: 12px;
padding: 6px 8px;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
color: var(--wfm-text-primary);
box-sizing: border-box;
}
.wfm-imageprompt-btn-row {
display: flex;
gap: 6px;
margin-top: 6px;
}
.wfm-imageprompt-btn-row .wfm-btn {
flex: 1;
}
.wfm-imageprompt-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
min-height: 24px;
}
.wfm-imageprompt-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
background: rgba(79,142,247,0.15);
color: var(--wfm-accent, #4f8ef7);
border: 1px solid rgba(79,142,247,0.3);
border-radius: 10px;
padding: 2px 4px 2px 8px;
font-size: 11px;
}
.wfm-imageprompt-chip-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.wfm-imageprompt-chip-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0 2px;
font-size: 13px;
line-height: 1;
opacity: 0.7;
}
.wfm-imageprompt-chip-remove:hover { opacity: 1; }
.wfm-imageprompt-final-section {
height: 100%;
}
.wfm-imageprompt-final-textarea {
margin-top: 4px;
flex: 1;
min-height: 0;
resize: none;
}
+9 -3
View File
@@ -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;
}
+413
View File
@@ -0,0 +1,413 @@
/* Lab tab — experimental I2I batch generation with per-column keyframes */
/* .wfm-gen-subtab-content.active defaults to flex-direction:row (main.css);
Lab needs its Setting/Results nav stacked above the panel body. */
#wfm-gen-subtab-lab.active {
flex-direction: column;
}
.wfm-lab-subtab-nav {
display: flex;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-lab-subtab-btn {
padding: 6px 16px;
font-size: 12px;
background: transparent;
border: none;
color: var(--wfm-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s;
}
.wfm-lab-subtab-btn:hover { color: var(--wfm-text-primary); }
.wfm-lab-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
.wfm-lab-panel {
display: none;
flex-direction: column;
flex: 1;
overflow: hidden;
height: calc(100% - 33px);
}
.wfm-lab-panel.active { display: flex; }
.wfm-lab-planjson-toolbar {
display: flex;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-lab-pane-header {
font-size: 11px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 4px 0 6px;
flex-shrink: 0;
}
/* ---------------- Setting layout: left | center (4 columns) | right ---------------- */
.wfm-lab-setting-layout {
display: flex;
height: 100%;
overflow: hidden;
gap: 0;
}
.wfm-lab-pane {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
border-right: 1px solid var(--wfm-border);
padding: 10px;
}
.wfm-lab-pane:last-child { border-right: none; }
.wfm-lab-left { width: 220px; flex-shrink: 0; }
.wfm-lab-center { flex: 1; padding: 0; }
.wfm-lab-right { width: 260px; flex-shrink: 0; overflow-y: auto; }
.wfm-lab-side-btns {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.wfm-lab-plan-workflow-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 8px;
background: var(--wfm-bg-secondary);
border: 1px solid var(--wfm-border);
border-radius: 4px;
}
.wfm-lab-plan-workflow-name {
flex: 1;
min-width: 0;
font-size: 11px;
color: var(--wfm-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-lab-field-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.wfm-lab-field-row label {
font-size: 12px;
color: var(--wfm-text-secondary);
white-space: nowrap;
}
.wfm-lab-field-row input {
flex: 1;
min-width: 0;
}
.wfm-lab-note {
width: 100%;
resize: vertical;
font-size: 12px;
}
.wfm-lab-checkbox-label {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
color: var(--wfm-text-secondary);
cursor: pointer;
}
/* ---------------- Keyframe columns ---------------- */
.wfm-lab-columns {
display: flex;
height: 100%;
overflow: hidden;
}
.wfm-lab-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--wfm-border);
overflow: hidden;
}
.wfm-lab-column:last-child { border-right: none; }
.wfm-lab-col-header {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px;
row-gap: 4px;
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
color: var(--wfm-text-secondary);
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-lab-col-btns { display: flex; gap: 2px; }
.wfm-lab-col-header-left {
display: flex;
align-items: center;
gap: 6px;
}
/* Model column header: C/L/V toggle that switches which single field its cells
summarize (see _modelViewMode in lab-tab.js) — not part of the Plan data. */
.wfm-lab-model-view-toggle { display: flex; gap: 2px; }
.wfm-lab-model-view-btn {
width: 20px;
height: 20px;
padding: 0;
font-size: 10px;
font-weight: 700;
line-height: 1;
background: var(--wfm-bg-tertiary);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
color: var(--wfm-text-secondary);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.wfm-lab-model-view-btn:hover { color: var(--wfm-text-primary); border-color: var(--wfm-primary); }
.wfm-lab-model-view-btn.active {
color: #fff;
background: var(--wfm-primary);
border-color: var(--wfm-primary);
}
.wfm-lab-col-cells {
flex: 1;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.wfm-lab-cell {
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
padding: 6px 8px;
font-size: 11px;
cursor: pointer;
background: var(--wfm-bg-tertiary);
transition: border-color 0.15s;
}
.wfm-lab-cell:hover { border-color: var(--wfm-primary); }
.wfm-lab-cell-index {
font-weight: 600;
color: var(--wfm-primary);
margin-right: 4px;
}
.wfm-lab-cell-value {
color: var(--wfm-text-primary);
word-break: break-all;
}
.wfm-lab-cell.wfm-lab-cell-revert .wfm-lab-cell-value {
color: var(--wfm-text-secondary);
font-style: italic;
}
/* Setting 1, not yet explicitly overridden: showing the live GenerateUI value instead of
its own stored one (see _isLiveDisplay in lab-tab.js). Dashed border distinguishes it
from a saved override (solid border) at a glance. */
.wfm-lab-cell.wfm-lab-cell-live {
border-style: dashed;
}
.wfm-lab-cell.wfm-lab-cell-live .wfm-lab-cell-value {
color: var(--wfm-text-secondary);
}
/* This keyframe row is skipped entirely (see _resolveKeyframe's bypassed handling) —
amber to read as "temporarily disabled", distinct from the neutral gray used for
revert/live. Takes priority over both of those in _cellHtml. */
.wfm-lab-cell.wfm-lab-cell-bypassed {
border-color: var(--wfm-warning);
opacity: 0.6;
}
.wfm-lab-cell.wfm-lab-cell-bypassed .wfm-lab-cell-value {
color: var(--wfm-warning);
}
/* LoRA node-bypass (strength forced to 0 at apply-time) — red to distinguish from the
amber row-level bypassed above; the row itself still applies, only the LoRA doesn't. */
.wfm-lab-cell.wfm-lab-cell-node-bypassed {
border-color: var(--wfm-danger);
}
.wfm-lab-cell.wfm-lab-cell-node-bypassed .wfm-lab-cell-value {
color: var(--wfm-danger);
}
/* Single-click selection target for the column header's up/down reorder buttons —
layered as a box-shadow (not border-color) so it never conflicts with the
revert/live/bypassed/node-bypassed border-color signaling above. */
.wfm-lab-cell.wfm-lab-cell-selected {
box-shadow: 0 0 0 2px var(--wfm-primary);
}
.wfm-lab-cell-applied {
margin-top: 3px;
padding-top: 3px;
border-top: 1px dashed var(--wfm-border);
font-size: 10px;
color: var(--wfm-text-secondary);
}
/* Shown instead of a keyframe list when the loaded workflow has no node this Model
sub-column (checkpoint/lora/vae) could override — see _hasNodeForColumn in lab-tab.js. */
.wfm-lab-cell-empty {
padding: 6px 8px;
font-size: 11px;
font-style: italic;
color: var(--wfm-text-secondary);
cursor: default;
}
.wfm-lab-cell-prompt-line {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* ---------------- Modal form (checkpoint/vae/prompt/ksampler edit) ---------------- */
.wfm-lab-modal-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.wfm-lab-modal-row label {
font-size: 12px;
color: var(--wfm-text-secondary);
}
.wfm-lab-modal-inline {
display: flex;
gap: 8px;
}
.wfm-lab-modal-btnrow {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.wfm-lab-modal-hint {
font-size: 11px;
color: var(--wfm-text-secondary);
font-style: italic;
margin: 0 0 10px;
}
.wfm-lab-modal-inline > div {
flex: 1;
min-width: 0;
}
.wfm-lab-modal-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin-top: 4px;
}
/* ---------------- Results layout ---------------- */
.wfm-lab-results-layout {
display: flex;
height: 100%;
overflow: hidden;
}
.wfm-lab-results-left {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--wfm-border);
padding: 10px;
}
.wfm-lab-results-center {
flex: 1;
min-width: 0;
padding: 10px;
overflow-y: auto;
}
.wfm-lab-results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.wfm-lab-result-thumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--wfm-radius-sm);
border: 1px solid var(--wfm-border);
cursor: pointer;
transition: border-color 0.15s;
}
.wfm-lab-result-thumb:hover { border-color: var(--wfm-primary); }
.wfm-lab-result-item { position: relative; }
.wfm-lab-result-index {
position: absolute;
top: 2px;
left: 2px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 10px;
padding: 1px 5px;
border-radius: var(--wfm-radius-sm);
}
+401 -6
View File
@@ -653,8 +653,8 @@ body {
align-items: center;
}
.wfm-lora-bypass-btn,
.wfm-lora-refresh-btn {
margin-left: auto;
font-size: 13px;
line-height: 1;
padding: 2px 5px;
@@ -662,8 +662,21 @@ body {
text-transform: none;
letter-spacing: 0;
}
.wfm-lora-bypass-btn {
margin-left: auto;
}
.wfm-lora-bypass-btn:hover,
.wfm-lora-refresh-btn:hover { opacity: 1; }
/* Bypass is active for the current LoRA target node: strength forced to 0 (see
comfyui-editor.js renderLoraPane / _loraNodeBypass). */
.wfm-lora-bypass-btn.active {
opacity: 1;
background: var(--wfm-danger);
border-color: var(--wfm-danger);
color: #fff;
}
.wfm-gen-lora-col-body {
flex: 1;
overflow-y: auto;
@@ -1056,6 +1069,17 @@ body {
height: auto !important;
}
/* Bypass/Mute node indicator (shown above the Raw JSON search bar when present) */
.wfm-gen-rawjson-bypass-note {
padding: 6px 10px;
background: rgba(255, 165, 2, 0.12);
border-bottom: 1px solid var(--wfm-border);
color: var(--wfm-warning);
font-size: 11px;
line-height: 1.5;
flex-shrink: 0;
}
/* RAW JSON search bar */
.wfm-rawjson-search {
display: flex;
@@ -2331,6 +2355,280 @@ body {
flex-shrink: 0;
}
/* Col3 (Wildcard/Style) タブ切り替え */
.wfm-prompt-col3-tabs {
display: flex;
background: var(--wfm-surface);
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-prompt-col3-tab {
flex: 1;
padding: 8px 4px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--wfm-text-secondary);
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: all 0.15s;
}
.wfm-prompt-col3-tab:hover { color: var(--wfm-text); }
.wfm-prompt-col3-tab.active {
color: var(--wfm-text);
border-bottom-color: var(--wfm-primary);
}
.wfm-prompt-col3-tab-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Prompt タブ: Form / Table 切り替え */
.wfm-prompt-toplevel-nav {
display: flex;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-prompt-toplevel-tab {
padding: 6px 16px;
font-size: 12px;
background: transparent;
border: none;
color: var(--wfm-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s;
}
.wfm-prompt-toplevel-tab:hover { color: var(--wfm-text-primary); }
.wfm-prompt-toplevel-tab.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
.wfm-prompt-panel {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.wfm-prompt-panel.active { display: flex; }
/* Table パネル内サブタブ (Presets / Wildcards / Style) */
.wfm-prompt-table-subnav {
display: flex;
background: var(--wfm-surface);
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-prompt-table-subtab {
padding: 8px 16px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--wfm-text-secondary);
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: all 0.15s;
}
.wfm-prompt-table-subtab:hover { color: var(--wfm-text); }
.wfm-prompt-table-subtab.active { color: var(--wfm-text); border-bottom-color: var(--wfm-primary); }
.wfm-prompt-table-pane {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.wfm-prompt-table-pane.active { display: flex; }
.wfm-prompt-table-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--wfm-border);
}
.wfm-prompt-table-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
/* 編集可能テーブル (Presets / Wildcards / Style 共通) */
.wfm-prompt-edit-table {
width: 100%;
border-collapse: collapse;
}
.wfm-prompt-edit-table th,
.wfm-prompt-edit-table td {
border-bottom: 1px solid var(--wfm-border);
padding: 4px 6px;
vertical-align: top;
}
.wfm-prompt-edit-table th {
position: sticky;
top: 0;
background: var(--wfm-surface);
text-align: left;
font-size: 11px;
color: var(--wfm-text-secondary);
z-index: 1;
}
.wfm-prompt-edit-table tbody tr:hover td {
background: var(--wfm-surface);
}
.wfm-prompt-edit-input,
.wfm-prompt-edit-textarea {
width: 100%;
box-sizing: border-box;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: var(--wfm-text);
font-size: 12px;
padding: 3px 5px;
font-family: inherit;
}
.wfm-prompt-edit-textarea {
min-height: 40px;
resize: vertical;
}
.wfm-prompt-edit-input:hover,
.wfm-prompt-edit-textarea:hover {
border-color: var(--wfm-border);
}
.wfm-prompt-edit-input:focus,
.wfm-prompt-edit-textarea:focus {
outline: none;
border-color: var(--wfm-primary);
background: var(--wfm-bg);
}
@keyframes wfm-prompt-cell-flash {
0% { background: rgba(74, 158, 255, 0.3); }
100% { background: transparent; }
}
.wfm-prompt-edit-cell-saved {
animation: wfm-prompt-cell-flash 0.7s ease;
}
.wfm-prompt-edit-fav-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--wfm-text-secondary);
padding: 2px;
line-height: 1;
}
.wfm-prompt-edit-fav-btn.active { color: #ffd700; }
.wfm-prompt-edit-batch-btn.active { color: #f0c040; }
.wfm-prompt-edit-row-new td { background: rgba(74, 158, 255, 0.06); }
.wfm-prompt-edit-file-badge {
font-size: 10px;
color: var(--wfm-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.wfm-prompt-edit-empty {
padding: 24px;
text-align: center;
color: var(--wfm-text-secondary);
font-size: 12px;
}
/* ダブルクリックで開くプロンプト編集モーダル (Presets / Wildcards / Style 共通) */
.wfm-prompt-modal-toggle {
display: flex;
gap: 6px;
}
.wfm-prompt-modal-textarea {
width: 100%;
box-sizing: border-box;
min-height: 320px;
resize: vertical;
margin-top: 8px;
}
.wfm-prompt-modal-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
margin-top: 10px;
}
/* #(自動採番)列: クリックで行選択 → 上部の Delete ボタンで一括削除 */
.wfm-prompt-edit-num-cell {
text-align: center;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
color: var(--wfm-text-secondary);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.wfm-prompt-edit-num-cell:hover {
color: var(--wfm-text);
background: var(--wfm-surface-2, var(--wfm-surface));
}
.wfm-prompt-edit-table tbody tr.wfm-prompt-edit-row-selected td {
background: rgba(255, 69, 58, 0.14);
}
.wfm-prompt-edit-table tbody tr.wfm-prompt-edit-row-selected .wfm-prompt-edit-num-cell {
color: var(--wfm-danger);
font-weight: bold;
}
.wfm-prompt-edit-num-cell-new {
display: flex;
gap: 2px;
justify-content: center;
}
.wfm-prompt-edit-inline-add-btn,
.wfm-prompt-edit-inline-cancel-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 2px 3px;
line-height: 1;
border-radius: 3px;
}
.wfm-prompt-edit-inline-add-btn { color: var(--wfm-primary); }
.wfm-prompt-edit-inline-add-btn:hover { background: var(--wfm-primary); color: #fff; }
.wfm-prompt-edit-inline-cancel-btn { color: var(--wfm-text-secondary); }
.wfm-prompt-edit-inline-cancel-btn:hover { color: var(--wfm-danger); }
/* Presets Group テーブル: プリセットバッジ */
.wfm-prompt-edit-preset-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: 10px;
padding: 2px 4px 2px 8px;
font-size: 11px;
margin: 2px 3px 2px 0;
}
.wfm-prompt-edit-badge-remove {
background: none;
border: none;
cursor: pointer;
color: var(--wfm-text-secondary);
font-size: 10px;
padding: 1px 3px;
line-height: 1;
}
.wfm-prompt-edit-badge-remove:hover { color: var(--wfm-danger); }
/* Ollama Assistant */
.wfm-ollama-assistant {
display: flex;
@@ -2874,6 +3172,29 @@ body {
Image Input (I2I) Slots
============================================ */
/* Container/header styling comes from .wfm-settings-section / .wfm-settings-summary
(shared details/summary accordion used by the Settings tab) — collapsed by default
since this is a low-frequency feature. */
.wfm-i2i-placeholder-mode {
display: flex;
gap: 16px;
font-size: 12px;
margin-bottom: 8px;
}
.wfm-i2i-placeholder-mode label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.wfm-i2i-placeholder-fields input[type="color"] {
padding: 2px;
height: 30px;
}
.wfm-i2i-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -2937,6 +3258,12 @@ body {
background: rgba(74, 158, 255, 0.08);
}
.wfm-i2i-drop-zone.wfm-disabled {
opacity: 0.4;
pointer-events: none;
cursor: default;
}
.wfm-i2i-drop-label {
font-size: 12px;
color: var(--wfm-text-secondary);
@@ -3171,6 +3498,21 @@ body {
display: block;
}
.wfm-help-article-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.wfm-help-article-header h2 {
font-size: 24px;
font-weight: 700;
color: var(--wfm-text);
margin: 0;
}
.wfm-help-card {
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
@@ -4931,15 +5273,12 @@ body {
Metadata Tab
============================================ */
#wfm-tab-metadata {
overflow: hidden;
}
.wfm-metadata-layout {
display: grid;
grid-template-columns: 220px 1fr 1fr;
gap: 0;
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
}
@@ -5613,6 +5952,23 @@ body {
object-fit: cover;
}
.wfm-ai-chat-svg-wrapper {
margin-top: 6px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.wfm-ai-chat-svg-img {
max-width: 240px;
max-height: 240px;
border-radius: 6px;
border: 1px solid var(--wfm-border);
background: #fff;
padding: 6px;
}
.wfm-ai-chat-attachment {
display: flex;
align-items: center;
@@ -5686,3 +6042,42 @@ body {
align-items: center;
gap: 6px;
}
.wfm-ai-chat-skill-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px 0;
flex-shrink: 0;
}
.wfm-ai-chat-skill-select {
flex: 1;
min-width: 0;
}
.wfm-ai-skill-panel {
margin: 6px 8px 0;
padding: 8px;
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
background: var(--wfm-surface);
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.wfm-ai-skill-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wfm-ai-skill-panel-title {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
+8
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+205
View File
@@ -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();
});
}
+526 -45
View File
@@ -1,11 +1,11 @@
/**
* AI Tab - Translation, VLM, Settings
* Supports Ollama and LM Studio as backends
* Supports Ollama, LM Studio, and Lemonade as backends
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage } from "./util.js";
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml, unloadAiModel } from "./util.js";
import { comfyUI } from "./comfyui-client.js";
import { comfyEditor } from "./comfyui-editor.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
@@ -50,33 +50,102 @@ function fileToBase64(file) {
});
}
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
// ============================================
// Thinking mode / Max tokens (shared by callLLM / callChat / callVLM)
// ============================================
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
// <think>タグ除去でも担保する(stripThinkingTagsを参照)。
function _applyGenOptions(body, backend, settings) {
const maxTokens = parseInt(settings?.maxTokens, 10);
if (backend === "ollama") {
body.think = !!settings?.thinkingMode;
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
} else if (maxTokens > 0) {
body.max_tokens = maxTokens;
}
return body;
}
function stripThinkingTags(text) {
return (text || "")
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
.trim();
}
async function callVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
body: JSON.stringify(_applyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).response || "";
const text = (await res.json()).response || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _applyGenOptions({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}, backend, settings);
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
const text = _unslothContent(data.choices?.[0]?.message);
return settings.thinkingMode ? text : stripThinkingTags(text);
} else {
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: JSON.stringify(_applyGenOptions({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}),
}, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).choices?.[0]?.message?.content || "";
const text = (await res.json()).choices?.[0]?.message?.content || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
}
}
// ============================================
// Unsloth proxy (server relays the request, attaching the API key from .env
// so it never reaches the frontend — see py/routes/unsloth_routes.py)
// ============================================
async function unslothProxy(baseUrl, path, method, payload) {
const res = await fetch("/api/wfm/unsloth/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl, path, method, payload }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
return data;
}
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
// `reasoning_content` field. Fold it back into a <think> block so the
// existing Thinking-mode show/strip logic (_applyGenOptions/stripThinkingTags)
// still applies uniformly. Without this, a low max_tokens can make the model
// spend its whole budget reasoning and return an empty `content` with no
// visible explanation.
function _unslothContent(message) {
const reasoning = message?.reasoning_content;
const content = message?.content || "";
return reasoning ? `<think>${reasoning}</think>${content}` : content;
}
// ============================================
// URL validation
// ============================================
@@ -100,6 +169,9 @@ async function fetchModels(url, backend) {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return (data.models || []).map((m) => m.name);
} else if (backend === "unsloth") {
const data = await unslothProxy(url, "/v1/models", "GET");
return (data.data || []).map((m) => m.id);
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/models`);
@@ -114,30 +186,37 @@ async function testConnection(url, backend) {
return models.length;
}
async function callLLM(url, backend, model, prompt) {
async function callLLM(url, backend, model, prompt, settings = {}) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, stream: false }),
body: JSON.stringify(_applyGenOptions({ model, prompt, stream: false }, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.response || "";
const text = data.response || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _applyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
const text = _unslothContent(data.choices?.[0]?.message);
return settings.thinkingMode ? text : stripThinkingTags(text);
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: JSON.stringify(_applyGenOptions({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
}),
}, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content || "";
const text = data.choices?.[0]?.message?.content || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
}
}
@@ -145,11 +224,41 @@ async function callLLM(url, backend, model, prompt) {
// Translation prompt builder
// ============================================
function buildTranslationPrompt(text, srcLang, dstLang, settings) {
const srcName = srcLang === "free" ? (settings.freeSrcLang || "Auto") : LANG_NAMES[srcLang];
const dstName = dstLang === "free" ? (settings.freeDstLang || "English") : LANG_NAMES[dstLang];
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
return `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
// Uses a system role (via callChat) rather than a single raw prompt string —
// local models follow "translate only, no commentary" far more reliably
// when it's a system instruction instead of buried inside the user text.
function buildTranslationMessages(text, srcLang, dstLang, settings) {
const srcName = srcLang === "free" ? (settings.freeSrcLang || "").trim() : LANG_NAMES[srcLang];
const dstName = dstLang === "free" ? (settings.freeDstLang || "English").trim() || "English" : LANG_NAMES[dstLang];
const fromPart = srcName ? ` from ${srcName}` : "";
const system = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
+ `no surrounding quotes, and do not repeat or echo the original text.`;
return [
{ role: "system", content: system },
{ role: "user", content: text },
];
}
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
function cleanTranslationOutput(raw) {
let s = (raw || "").trim();
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
if (s.length >= 2) {
const first = s[0], last = s[s.length - 1];
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
if (pairs[first] === last) s = s.slice(1, -1).trim();
}
return s;
}
// Heuristic: if the cleaned output is (near-)identical to the input, the
// model most likely echoed the source instead of translating it.
function looksUntranslated(input, output) {
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
const a = normalize(input), b = normalize(output);
return a.length > 0 && a === b;
}
// ============================================
@@ -199,7 +308,7 @@ function initTranslateTab() {
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
@@ -217,11 +326,19 @@ function initTranslateTab() {
outputEl.value = "";
try {
const prompt = buildTranslationPrompt(text, srcLangSel.value, dstLangSel.value, settings);
const result = await callLLM(url, backend, model, prompt);
outputEl.value = result.trim();
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
const messages = buildTranslationMessages(text, srcLangSel.value, dstLangSel.value, settings);
const { content } = await callChat(url, backend, model, messages, undefined, settings);
const cleaned = cleanTranslationOutput(content);
outputEl.value = cleaned;
if (srcLangSel.value !== dstLangSel.value && looksUntranslated(text, cleaned)) {
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
showToast(t("aiToastMaybeNotTranslated"), "error");
} else {
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
}
} catch (err) {
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
@@ -288,12 +405,16 @@ function initSettingsTab() {
const urlInput = document.getElementById("wfm-ai-backend-url");
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
// Update URL placeholder when backend changes
const thinkingModeCheckbox = document.getElementById("wfm-ai-thinking-mode-checkbox");
if (thinkingModeCheckbox) thinkingModeCheckbox.checked = !!saved.thinkingMode;
const maxTokensInput = document.getElementById("wfm-ai-max-tokens");
if (maxTokensInput && saved.maxTokens) maxTokensInput.value = saved.maxTokens;
// Switch URL to the new backend's default when the user changes backend
backendRadios.forEach((r) => {
r.addEventListener("change", () => {
if (!urlInput) return;
const defaultUrl = r.value === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
if (!saved.backendUrl) urlInput.value = defaultUrl;
urlInput.value = getAiBackendDefaultUrl(r.value);
});
});
@@ -335,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());
@@ -396,6 +531,8 @@ function initSettingsTab() {
const chatGenDedicatedFilename = chatGenSelect?.value || "";
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
const thinkingMode = !!thinkingModeCheckbox?.checked;
const maxTokens = Math.max(0, parseInt(maxTokensInput?.value, 10) || 0);
if (url && !isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
@@ -404,6 +541,7 @@ function initSettingsTab() {
saveAiSettings({
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
thinkingMode, maxTokens,
chatGenDedicatedEnabled, chatGenDedicatedFilename,
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
});
@@ -424,12 +562,22 @@ const IMAGE_GEN_TOOLS = [{
type: "function",
function: {
name: "generate_image",
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image.",
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image. All parameters besides prompt are optional — omit any the user didn't specify and the workflow's own current setting is kept.",
parameters: {
type: "object",
properties: {
prompt: { type: "string", description: "Positive prompt describing the desired image." },
negative_prompt: { type: "string", description: "What to avoid in the image. Optional." },
steps: { type: "integer", description: "Sampling steps. Optional." },
cfg: { type: "number", description: "Classifier-free guidance scale. Optional." },
sampler_name: { type: "string", description: "ComfyUI sampler name (e.g. euler, dpmpp_2m, ddim). Optional." },
scheduler: { type: "string", description: "ComfyUI scheduler name (e.g. normal, karras, simple). Optional." },
denoise: { type: "number", description: "Denoise strength 0-1 (mainly relevant for image-to-image). Optional." },
seed: { type: "integer", description: "Fixed seed. Omit for a random seed each time. Optional." },
width: { type: "integer", description: "Output image width in pixels. Ignored if the workflow has a Resolution Selector node — use aspect_ratio instead for those. Optional." },
height: { type: "integer", description: "Output image height in pixels. Ignored if the workflow has a Resolution Selector node — use aspect_ratio instead for those. Optional." },
aspect_ratio: { type: "string", enum: ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"], description: "Aspect ratio preset. Only applied if the workflow has a Resolution Selector node (feeding width/height from a ratio + megapixel target); ignored otherwise. Optional." },
batch_size: { type: "integer", description: "Number of images to generate in this single call. Optional." },
},
required: ["prompt"],
},
@@ -454,10 +602,10 @@ function _formatMessagesForBackend(messages, backend) {
});
}
async function callChat(url, backend, model, messages, tools) {
async function callChat(url, backend, model, messages, tools, settings = {}) {
const formattedMessages = _formatMessagesForBackend(messages, backend);
if (backend === "ollama") {
const body = { model, messages: formattedMessages, stream: false };
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
if (tools) body.tools = tools;
const res = await fetch(`${url}/api/chat`, {
method: "POST",
@@ -466,9 +614,17 @@ async function callChat(url, backend, model, messages, tools) {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const message = (await res.json()).message || {};
return { content: message.content || "", toolCalls: message.tool_calls || null };
const content = message.content || "";
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
} else if (backend === "unsloth") {
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
const message = data.choices?.[0]?.message || {};
const content = _unslothContent(message);
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
} else {
const body = { model, messages: formattedMessages, stream: false };
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
@@ -477,7 +633,8 @@ async function callChat(url, backend, model, messages, tools) {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const message = (await res.json()).choices?.[0]?.message || {};
return { content: message.content || "", toolCalls: message.tool_calls || null };
const content = message.content || "";
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
}
}
@@ -502,7 +659,7 @@ async function _loadDedicatedChatGenWorkflow(filename) {
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
}
async function generateImageFromChat(prompt, negativePrompt) {
async function generateImageFromChat(prompt, negativePrompt, params = {}) {
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
const settings = loadAiSettings();
@@ -527,12 +684,312 @@ async function generateImageFromChat(prompt, negativePrompt) {
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
const result = await window._wfmGenerateTab.generate(workflow);
const { steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size, aspect_ratio } = params;
let resolvedAspectRatio = null;
const resSelector = analysis.resolution_selector_nodes?.[0];
if (aspect_ratio && resSelector) {
resolvedAspectRatio = await comfyEditor.resolveAspectRatioOption(resSelector.type, aspect_ratio);
}
comfyEditor.setGenerationParams({
steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size,
resolvedAspectRatio, workflow, analysis,
});
const result = await window._wfmGenerateTab.generate(workflow, seed != null ? { seedValue: seed } : {});
const images = result?.images || [];
if (images.length === 0) throw new Error(t("aiChatNoImageProduced"));
const img = images[0];
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
return images.map((img) =>
`/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`
);
}
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
function _extractSvgCode(text) {
if (!text) return null;
const fenced = text.match(/```(?:svg|xml)?\s*([\s\S]*?<svg[\s\S]*?<\/svg>)[\s\S]*?```/i);
if (fenced) return fenced[1].trim();
const bare = text.match(/<svg[\s\S]*?<\/svg>/i);
if (bare) return bare[0].trim();
return null;
}
// SVGコードをプレビュー表示し、Galleryへ保存するボタンを追加する。
// <img src="data:..."> でのレンダリングはブラウザがスクリプト実行を無効化するため安全。
function _appendSvgPreview(container, svgCode) {
const wrapper = document.createElement("div");
wrapper.className = "wfm-ai-chat-svg-wrapper";
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgCode)))}`;
const img = document.createElement("img");
img.src = dataUrl;
img.className = "wfm-ai-chat-svg-img";
img.alt = "Generated SVG";
wrapper.appendChild(img);
const saveBtn = document.createElement("button");
saveBtn.className = "wfm-btn wfm-btn-sm";
saveBtn.textContent = t("aiChatSaveSvgToGallery");
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
try {
const res = await fetch("/wfm/gallery/image/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: `chat_svg_${Date.now()}.svg`, imageData: dataUrl }),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
showToast(t("aiChatSvgSaved"), "success");
} catch (e) {
showToast(t("aiChatSvgSaveFailed") + e.message, "error");
} finally {
saveBtn.disabled = false;
}
});
wrapper.appendChild(saveBtn);
container.appendChild(wrapper);
}
// アシスタント応答から ```skill フェンスを抽出する(Skill Creator用スキルが出力する完成品ブロック)
function _extractSkillBlock(text) {
if (!text) return null;
const m = text.match(/```skill\s*([\s\S]*?)```/i);
return m ? m[1].trim() : null;
}
// frontmatterの name から保存用ファイル名を推測する(未設定時は my-skill.md)
function _slugifySkillName(content) {
const m = content.match(/^---\r?\n[\s\S]*?name:\s*(.+?)\r?\n/);
const name = (m ? m[1] : "").trim();
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return `${slug || "my-skill"}.md`;
}
// 応答内にスキル定義ブロックがあれば「スキルとして保存」ボタンを追加する。
// クリックすると管理パネルを開き、新規スキルとしてエディタに内容を流し込む(保存前にレビュー可能)。
function _appendSkillSaveButton(container, skillContent) {
const btn = document.createElement("button");
btn.className = "wfm-btn wfm-btn-sm";
btn.textContent = t("aiChatSaveAsSkill");
btn.addEventListener("click", () => {
const panel = document.getElementById("wfm-ai-skill-panel");
if (panel) panel.style.display = "";
skillOpenEditor(null, skillContent);
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
if (nameInput) nameInput.value = _slugifySkillName(skillContent);
});
container.appendChild(btn);
}
// ============================================
// AI skills (Chat system prompt library)
// ============================================
async function skillFetchFiles() {
try {
const res = await fetch("/api/wfm/skills");
return res.ok ? await res.json() : [];
} catch { return []; }
}
async function skillFetchContent(filename) {
try {
const res = await fetch(`/api/wfm/skills/content?filename=${encodeURIComponent(filename)}`);
const data = await res.json();
return data.content ?? null;
} catch { return null; }
}
async function skillSaveFile(filename, content) {
try {
const res = await fetch("/api/wfm/skills/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, content }),
});
const data = await res.json();
return data.status === "ok" ? data.file : null;
} catch { return null; }
}
async function skillDeleteFile(filename) {
try {
await fetch("/api/wfm/skills/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
} catch { /* ignore */ }
}
// system prompt として送る前に frontmatter (---...---) を取り除く
function _stripSkillFrontmatter(content) {
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
return (m ? content.slice(m[0].length) : content).trim();
}
let skillFiles = [];
let skillEditingFilename = null; // null = new file
let _activeSkillCache = null; // { filename, content } キャッシュ(送信毎の再フェッチを避ける)
function skillRenderList() {
const list = document.getElementById("wfm-ai-skill-list");
if (!list) return;
list.innerHTML = "";
if (skillFiles.length === 0) {
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">${t("aiSkillNoFiles")}</div>`;
return;
}
for (const f of skillFiles) {
const item = document.createElement("div");
item.className = "wfm-wc-file-item";
item.innerHTML = `
<div class="wfm-wc-file-item-info">
<span class="wfm-wc-file-name" title="${escapeHtml(f.description || "")}">${escapeHtml(f.name)}</span>
<span class="wfm-wc-file-ext">.md</span>
</div>
<div class="wfm-wc-file-item-actions">
<button class="wfm-pm-action-btn wfm-ai-skill-edit-btn" title="Edit">&#9998;</button>
</div>
`;
item.querySelector(".wfm-ai-skill-edit-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const content = await skillFetchContent(f.filename);
skillOpenEditor(f.filename, content ?? "");
});
list.appendChild(item);
}
}
function skillOpenEditor(filename, content) {
skillEditingFilename = filename || null;
const editor = document.getElementById("wfm-ai-skill-editor");
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
const contentTA = document.getElementById("wfm-ai-skill-editor-content");
const deleteBtn = document.getElementById("wfm-ai-skill-editor-delete-btn");
if (!editor) return;
nameInput.value = filename || "";
contentTA.value = content || "";
deleteBtn.style.display = filename ? "" : "none";
editor.style.display = "";
nameInput.focus();
}
function skillCloseEditor() {
skillEditingFilename = null;
const editor = document.getElementById("wfm-ai-skill-editor");
if (editor) editor.style.display = "none";
}
function skillPopulateSelect() {
const sel = document.getElementById("wfm-ai-chat-skill-select");
if (!sel) return;
const settings = loadAiSettings();
const wanted = sel.value || settings.activeSkillFilename || "";
sel.innerHTML = `<option value="">-- ${t("aiSkillNone")} --</option>` +
skillFiles.map((f) => `<option value="${escapeHtml(f.filename)}" title="${escapeHtml(f.description || "")}">${escapeHtml(f.name)}</option>`).join("");
sel.value = skillFiles.some((f) => f.filename === wanted) ? wanted : "";
_activeSkillCache = null;
}
async function skillRefreshFiles() {
skillFiles = await skillFetchFiles();
skillRenderList();
skillPopulateSelect();
}
// 選択中スキルの本文を取得(system prompt注入用、選択が変わるまでキャッシュ)
async function _getActiveSkillSystemPrompt() {
const sel = document.getElementById("wfm-ai-chat-skill-select");
const filename = sel?.value || "";
if (!filename) return null;
if (_activeSkillCache?.filename === filename) return _activeSkillCache.content;
const raw = await skillFetchContent(filename);
const content = _stripSkillFrontmatter(raw || "");
_activeSkillCache = { filename, content };
return content;
}
function initSkillManager() {
const selectEl = document.getElementById("wfm-ai-chat-skill-select");
const manageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
const panel = document.getElementById("wfm-ai-skill-panel");
const closeBtn = document.getElementById("wfm-ai-skill-close-btn");
const newBtn = document.getElementById("wfm-ai-skill-new-btn");
const saveBtn = document.getElementById("wfm-ai-skill-editor-save-btn");
const deleteBtn = document.getElementById("wfm-ai-skill-editor-delete-btn");
const cancelBtn = document.getElementById("wfm-ai-skill-editor-cancel-btn");
if (!selectEl) return;
skillRefreshFiles();
selectEl.addEventListener("change", () => {
saveAiSettings({ activeSkillFilename: selectEl.value });
_activeSkillCache = null;
});
manageBtn?.addEventListener("click", () => {
if (!panel) return;
const show = panel.style.display === "none";
panel.style.display = show ? "" : "none";
if (show) skillRefreshFiles();
});
closeBtn?.addEventListener("click", () => {
if (panel) panel.style.display = "none";
});
newBtn?.addEventListener("click", () => {
skillOpenEditor(null, "---\nname: \ndescription: \n---\n\n");
});
saveBtn?.addEventListener("click", async () => {
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
const contentTA = document.getElementById("wfm-ai-skill-editor-content");
let filename = (nameInput?.value || "").trim();
const content = contentTA?.value || "";
if (!filename) { showToast(t("pleaseEnterFilename"), "error"); return; }
if (!/\.md$/i.test(filename)) filename += ".md";
if (!/^[\w\-. ]+\.md$/i.test(filename)) { showToast(t("invalidPathFormat"), "error"); return; }
const saved = await skillSaveFile(filename, content);
if (saved) {
showToast(t("savedAs", filename), "success");
skillCloseEditor();
await skillRefreshFiles();
selectEl.value = filename;
saveAiSettings({ activeSkillFilename: filename });
_activeSkillCache = null;
} else {
showToast(t("saveFailed"), "error");
}
});
deleteBtn?.addEventListener("click", async () => {
if (!skillEditingFilename) return;
if (!confirm(`Delete "${skillEditingFilename}"?`)) return;
await skillDeleteFile(skillEditingFilename);
showToast(t("deletedName", skillEditingFilename), "success");
if (selectEl.value === skillEditingFilename) {
saveAiSettings({ activeSkillFilename: "" });
_activeSkillCache = null;
}
skillCloseEditor();
await skillRefreshFiles();
});
cancelBtn?.addEventListener("click", () => skillCloseEditor());
}
function appendChatBubble(messagesEl, role, content, opts = {}) {
@@ -545,12 +1002,26 @@ function appendChatBubble(messagesEl, role, content, opts = {}) {
img.className = "wfm-ai-chat-img";
div.appendChild(img);
}
if (opts.imageUrls?.length) {
opts.imageUrls.forEach((url) => {
const img = document.createElement("img");
img.src = url;
img.className = "wfm-ai-chat-img";
div.appendChild(img);
});
}
if (opts.attachThumb) {
const img = document.createElement("img");
img.src = opts.attachThumb;
img.className = "wfm-ai-chat-attach-thumb";
div.appendChild(img);
}
if (role === "assistant" && content) {
const svgCode = _extractSvgCode(content);
if (svgCode) _appendSvgPreview(div, svgCode);
const skillBlock = _extractSkillBlock(content);
if (skillBlock) _appendSkillSaveButton(div, skillBlock);
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
@@ -578,6 +1049,8 @@ function initChatTab() {
_clearToolsImage();
});
initSkillManager();
let chatHistory = [];
async function sendMessage() {
@@ -586,7 +1059,7 @@ function initChatTab() {
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
@@ -610,7 +1083,11 @@ function initChatTab() {
try {
const tools = imgGenToggle?.checked !== false ? IMAGE_GEN_TOOLS : undefined;
const reply = await callChat(url, backend, model, chatHistory, tools);
const skillPrompt = await _getActiveSkillSystemPrompt();
const messagesToSend = skillPrompt
? [{ role: "system", content: skillPrompt }, ...chatHistory]
: chatHistory;
const reply = await callChat(url, backend, model, messagesToSend, tools, settings);
const toolCall = reply.toolCalls?.find(
(tc) => (tc.function?.name || tc.name) === "generate_image"
);
@@ -622,8 +1099,8 @@ function initChatTab() {
statusEl.textContent = t("aiStatusGeneratingImage");
const args = _parseToolArgs(toolCall);
try {
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
const imageUrls = await generateImageFromChat(args.prompt, args.negative_prompt, args);
appendChatBubble(messagesEl, "assistant", "", { imageUrls });
} catch (genErr) {
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
@@ -752,7 +1229,7 @@ function initVlmTab() {
const task = taskSel?.value || "describe";
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
@@ -770,7 +1247,7 @@ function initVlmTab() {
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
try {
const result = await callLLM(url, backend, model, prompt);
const result = await callLLM(url, backend, model, prompt, settings);
resultEl.value = result.trim()
.split("\n")
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
@@ -796,7 +1273,7 @@ function initVlmTab() {
resultEl.value = "";
try {
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType, settings);
resultEl.value = result.trim();
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
@@ -826,3 +1303,7 @@ export function initAiTab() {
initVlmTab();
initSettingsTab();
}
// gallery-tab.js の外部(Comic Creator)向けブリッジ(半自動マンガ作成の画像プロンプト
// 下書き機能)が、設定タブと同じAI接続設定・LLM呼び出しロジックを再利用するためのexport。
export { callLLM, loadAiSettings, isValidBackendUrl };
+279 -11
View File
@@ -42,11 +42,32 @@ export function showToast(message, type = "info", duration = 3000) {
export function openModal(title, contentHtml) {
const overlay = document.getElementById("wfm-modal-overlay");
const titleEl = document.getElementById("wfm-modal-title");
let titleEl = document.getElementById("wfm-modal-title");
const bodyEl = document.getElementById("wfm-modal-body");
const titleInput = document.getElementById("wfm-modal-title-input");
if (!overlay || !titleEl || !bodyEl) return;
// This modal shell is shared by every caller (Workflow/Models/Nodes detail
// modals, Lab's keyframe editor, etc.). Some callers (e.g. workflow-tab.js's
// openDetailModal) inject extra header chrome after calling openModal() — a
// favorite star button, and a click-to-rename listener + tooltip/cursor on the
// title — but nothing ever tore that down again. The next caller to reuse this
// same DOM inherited it as stale leftovers: a Lab cell modal could show a
// favorite star wired to the PREVIOUS caller's workflow object, and clicking it
// silently toggled that unrelated workflow's favorite state. Clean slate every
// time, before any caller-specific chrome is added back on top.
titleEl.parentElement?.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
const freshTitleEl = titleEl.cloneNode(false); // drops any addEventListener from a prior caller
freshTitleEl.removeAttribute("title");
freshTitleEl.removeAttribute("style"); // also clears any leftover display:none from an interrupted rename
titleEl.replaceWith(freshTitleEl);
titleEl = freshTitleEl;
if (titleInput) {
titleInput.style.display = "none";
titleInput.value = "";
}
titleEl.textContent = title;
bodyEl.innerHTML = contentHtml;
overlay.style.display = "flex";
@@ -91,7 +112,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", "image-edit": "tabImageEdit", settings: "tabSettings", help: "tabHelp", ai: "tabAi", tagger: "tabTagger" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", "image-edit": "tabImageEdit", video: "tabVideo", settings: "tabSettings", help: "tabHelp", ai: "tabAi", tagger: "tabTagger" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -113,6 +134,12 @@ function applyI18nToHtml() {
if (listLoadBtn.disabled) listLoadBtn.title = t("selectCardFirst");
}
const listLoadVideoBtn = document.getElementById("wfm-list-load-video-btn");
if (listLoadVideoBtn) {
listLoadVideoBtn.textContent = t("loadInVideo");
if (listLoadVideoBtn.disabled) listLoadVideoBtn.title = t("selectCardFirst");
}
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) {
listOpenComfyBtn.textContent = t("sendToCanvas");
@@ -195,6 +222,7 @@ function applyI18nToHtml() {
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
if (btn.dataset.detailTab === "prompt") btn.textContent = t("gallerySidePrompt");
});
const galleryBulkDeselect = document.getElementById("wfm-gallery-bulk-deselect");
if (galleryBulkDeselect) galleryBulkDeselect.textContent = t("galleryBulkDeselectAll");
@@ -217,20 +245,86 @@ function applyI18nToHtml() {
if (galleryBulkMove) galleryBulkMove.textContent = t("galleryBulkMoveTo");
const galleryBulkExport = document.getElementById("wfm-gallery-bulk-export");
if (galleryBulkExport) galleryBulkExport.textContent = t("galleryBulkExport");
const galleryBulkPsd = document.getElementById("wfm-gallery-bulk-psd");
if (galleryBulkPsd) galleryBulkPsd.textContent = t("galleryBulkPsd");
const galleryBulkEditLayers = document.getElementById("wfm-gallery-bulk-edit-layers");
if (galleryBulkEditLayers) galleryBulkEditLayers.textContent = t("galleryBulkEditLayers");
const galleryBulkDelete = document.getElementById("wfm-gallery-bulk-delete");
if (galleryBulkDelete) galleryBulkDelete.textContent = t("galleryBulkDelete");
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
if (galleryOpenTaggerBtn) galleryOpenTaggerBtn.textContent = t("tabTagger");
// Gallery サブタブ (Output / ImagePrompt / Style_Catalog)
const galleryOutputSubtab = document.getElementById("wfm-gallery-subtab-output");
if (galleryOutputSubtab) galleryOutputSubtab.textContent = t("galleryTabOutput");
const galleryImagePromptSubtab = document.getElementById("wfm-gallery-subtab-imageprompt");
if (galleryImagePromptSubtab) galleryImagePromptSubtab.textContent = t("galleryTabImagePrompt");
const galleryStyleCatalogSubtab = document.getElementById("wfm-gallery-subtab-styleCatalog");
if (galleryStyleCatalogSubtab) galleryStyleCatalogSubtab.textContent = t("galleryTabStyleCatalog");
const galleryMetadataSubtab = document.getElementById("wfm-gallery-subtab-metadata");
if (galleryMetadataSubtab) galleryMetadataSubtab.textContent = t("galleryTabMetadata");
// ImagePrompt ギャラリーパネル
const imagePromptSearch = document.getElementById("wfm-imageprompt-search");
if (imagePromptSearch) imagePromptSearch.placeholder = t("gallerySearchPlaceholder");
const imagePromptRefreshBtn = document.getElementById("wfm-imageprompt-refresh-btn");
if (imagePromptRefreshBtn) imagePromptRefreshBtn.textContent = t("refresh");
const imagePromptTreeTitle = document.getElementById("wfm-imageprompt-tree-title");
if (imagePromptTreeTitle) imagePromptTreeTitle.textContent = t("galleryFolders");
const imagePromptListPlaceholder = document.getElementById("wfm-imageprompt-list-placeholder");
if (imagePromptListPlaceholder) imagePromptListPlaceholder.textContent = t("gallerySelectFolder");
const imagePromptNoSelection = document.getElementById("wfm-imageprompt-no-selection");
if (imagePromptNoSelection) imagePromptNoSelection.textContent = t("galleryNoSelection");
const imagePromptSelectedPromptTitle = document.getElementById("wfm-imageprompt-selected-prompt-title");
if (imagePromptSelectedPromptTitle) imagePromptSelectedPromptTitle.textContent = t("imagePromptSelectedPrompt");
const imagePromptSelectedPrompt = document.getElementById("wfm-imageprompt-selected-prompt");
if (imagePromptSelectedPrompt) imagePromptSelectedPrompt.placeholder = t("imagePromptNoPrompt");
const imagePromptSavePromptBtn = document.getElementById("wfm-imageprompt-save-prompt-btn");
if (imagePromptSavePromptBtn) imagePromptSavePromptBtn.textContent = t("save");
const imagePromptAddBtn = document.getElementById("wfm-imageprompt-add-btn");
if (imagePromptAddBtn) imagePromptAddBtn.textContent = t("imagePromptAddBtn");
const imagePromptChipsTitle = document.getElementById("wfm-imageprompt-chips-title");
if (imagePromptChipsTitle) imagePromptChipsTitle.textContent = t("imagePromptChipsTitle");
const imagePromptClearBtn = document.getElementById("wfm-imageprompt-clear-btn");
if (imagePromptClearBtn) imagePromptClearBtn.textContent = t("imagePromptClearBtn");
const imagePromptFinalPromptTitle = document.getElementById("wfm-imageprompt-final-prompt-title");
if (imagePromptFinalPromptTitle) imagePromptFinalPromptTitle.textContent = t("imagePromptFinalPromptTitle");
const imagePromptCopyBtn = document.getElementById("wfm-imageprompt-copy-btn");
if (imagePromptCopyBtn) imagePromptCopyBtn.textContent = t("copy");
// Style_Catalog ギャラリーパネル
const scSearch = document.getElementById("wfm-stylecatalog-search");
if (scSearch) scSearch.placeholder = t("stylecatalogSearchPlaceholder");
const scRefreshBtn = document.getElementById("wfm-stylecatalog-refresh-btn");
if (scRefreshBtn) scRefreshBtn.textContent = t("refresh");
const scTreeTitle = document.getElementById("wfm-stylecatalog-tree-title");
if (scTreeTitle) scTreeTitle.textContent = t("galleryFolders");
const scListPlaceholder = document.getElementById("wfm-stylecatalog-list-placeholder");
if (scListPlaceholder) scListPlaceholder.textContent = t("gallerySelectFolder");
const scNoSelection = document.getElementById("wfm-stylecatalog-no-selection");
if (scNoSelection) scNoSelection.textContent = t("galleryNoSelection");
const scPositiveTitle = document.getElementById("wfm-stylecatalog-positive-title");
if (scPositiveTitle) scPositiveTitle.textContent = t("stylecatalogPositive");
const scCopyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
if (scCopyPositiveBtn) scCopyPositiveBtn.textContent = t("copy");
const scNegativeTitle = document.getElementById("wfm-stylecatalog-negative-title");
if (scNegativeTitle) scNegativeTitle.textContent = t("stylecatalogNegative");
const scCopyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
if (scCopyNegativeBtn) scCopyNegativeBtn.textContent = t("copy");
const scSelectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
if (scSelectStyleBtn) scSelectStyleBtn.textContent = t("stylecatalogSelectStyleBtn");
const scLoadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
if (scLoadGenuiBtn) scLoadGenuiBtn.textContent = t("stylecatalogLoadGenuiBtn");
const scOpenMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
if (scOpenMetadataBtn) scOpenMetadataBtn.textContent = t("stylecatalogOpenMetadataBtn");
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
const presetsHeader = document.querySelector(".wfm-prompt-split-right .wfm-prompt-split-header");
if (presetsHeader) presetsHeader.textContent = t("presetsSubtab");
const ollamaRefreshBtn = document.getElementById("wfm-ollama-refresh-btn");
if (ollamaRefreshBtn) ollamaRefreshBtn.textContent = t("refresh");
const ollamaTestBtn = document.getElementById("wfm-ollama-test-btn");
if (ollamaTestBtn) ollamaTestBtn.textContent = t("test");
const ollamaSettingsBtn = document.getElementById("wfm-ollama-settings-btn");
if (ollamaSettingsBtn) ollamaSettingsBtn.title = t("settings");
const ollamaAttachBtn = document.getElementById("wfm-ollama-attach-btn");
if (ollamaAttachBtn) ollamaAttachBtn.textContent = t("attachImage");
const ollamaClearBtn = document.getElementById("wfm-ollama-clear-btn");
@@ -302,6 +396,14 @@ function applyI18nToHtml() {
}
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
const aiGenerationTitle = document.getElementById("wfm-ai-settings-generation-title");
if (aiGenerationTitle) aiGenerationTitle.textContent = t("aiSettingsGenerationTitle");
const aiThinkingModeLabel = document.getElementById("wfm-ai-thinking-mode-label");
if (aiThinkingModeLabel) aiThinkingModeLabel.textContent = t("aiSettingsThinkingMode");
const aiMaxTokensLabel = document.getElementById("wfm-ai-max-tokens-label");
if (aiMaxTokensLabel) aiMaxTokensLabel.textContent = t("aiSettingsMaxTokens");
const aiMaxTokensInput = document.getElementById("wfm-ai-max-tokens");
if (aiMaxTokensInput) aiMaxTokensInput.placeholder = t("aiSettingsMaxTokensPlaceholder");
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
@@ -320,6 +422,19 @@ function applyI18nToHtml() {
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
const aiSkillSel = document.getElementById("wfm-ai-chat-skill-select");
if (aiSkillSel) {
const firstOpt = aiSkillSel.querySelector("option[value='']");
if (firstOpt) firstOpt.textContent = `-- ${t("aiSkillNone")} --`;
}
const aiSkillManageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
if (aiSkillManageBtn) aiSkillManageBtn.title = t("aiSkillManageBtnTitle");
const aiSkillPanelTitle = document.querySelector(".wfm-ai-skill-panel-title");
if (aiSkillPanelTitle) aiSkillPanelTitle.textContent = t("aiSkillPanelTitle");
const aiSkillNewBtn = document.getElementById("wfm-ai-skill-new-btn");
if (aiSkillNewBtn) aiSkillNewBtn.textContent = t("aiSkillNewBtn");
const aiSkillContentTA = document.getElementById("wfm-ai-skill-editor-content");
if (aiSkillContentTA) aiSkillContentTA.placeholder = t("aiSkillContentPlaceholder");
// Group panel labels
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
@@ -367,6 +482,14 @@ function applyI18nToHtml() {
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
"wfm-help-gen-17": "helpGen17", "wfm-help-gen-18": "helpGen18",
"wfm-help-gen-19": "helpGen19",
"wfm-help-gen-20": "helpGen20", "wfm-help-gen-21": "helpGen21",
"wfm-help-gen-22": "helpGen22", "wfm-help-gen-23": "helpGen23",
"wfm-help-gen-24": "helpGen24", "wfm-help-gen-25": "helpGen25",
"wfm-help-gen-26": "helpGen26", "wfm-help-gen-27": "helpGen27",
"wfm-help-gen-28": "helpGen28",
"wfm-help-gen-29": "helpGen29",
"wfm-help-gen-30": "helpGen30",
"wfm-help-gen-31": "helpGen31",
"wfm-help-feeder-title": "helpFeederTitle",
"wfm-help-feeder-desc": "helpFeederDesc",
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
@@ -381,18 +504,55 @@ function applyI18nToHtml() {
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
"wfm-help-lab-title": "helpLabTitle",
"wfm-help-lab-desc": "helpLabDesc",
"wfm-help-lab-card-columns-title": "helpLabCardColumnsTitle",
"wfm-help-lab-1": "helpLab1", "wfm-help-lab-2": "helpLab2",
"wfm-help-lab-3": "helpLab3", "wfm-help-lab-4": "helpLab4",
"wfm-help-lab-5": "helpLab5", "wfm-help-lab-6": "helpLab6",
"wfm-help-lab-7": "helpLab7", "wfm-help-lab-8": "helpLab8",
"wfm-help-lab-9": "helpLab9", "wfm-help-lab-10": "helpLab10",
"wfm-help-lab-11": "helpLab11", "wfm-help-lab-12": "helpLab12",
"wfm-help-lab-13": "helpLab13", "wfm-help-lab-14": "helpLab14",
"wfm-help-lab-15": "helpLab15",
"wfm-help-lab-16": "helpLab16", "wfm-help-lab-17": "helpLab17",
"wfm-help-lab-18": "helpLab18", "wfm-help-lab-19": "helpLab19",
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
"wfm-help-lab-plan-7": "helpLabPlan7", "wfm-help-lab-plan-8": "helpLabPlan8",
"wfm-help-lab-plan-9": "helpLabPlan9", "wfm-help-lab-plan-10": "helpLabPlan10",
"wfm-help-lab-plan-11": "helpLabPlan11",
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
"wfm-help-lab-results-3": "helpLabResults3",
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
"wfm-help-prompt-9": "helpPrompt9",
"wfm-help-prompt-card-style-title": "helpPromptCardStyleTitle",
"wfm-help-prompt-10": "helpPrompt10", "wfm-help-prompt-11": "helpPrompt11",
"wfm-help-prompt-12": "helpPrompt12", "wfm-help-prompt-13": "helpPrompt13",
"wfm-help-prompt-14": "helpPrompt14",
"wfm-help-prompt-card-toplevel-title": "helpPromptCardToplevel",
"wfm-help-prompt-15": "helpPrompt15",
"wfm-help-prompt-card-table-title": "helpPromptCardTable",
"wfm-help-prompt-16": "helpPrompt16", "wfm-help-prompt-17": "helpPrompt17",
"wfm-help-prompt-18": "helpPrompt18", "wfm-help-prompt-19": "helpPrompt19",
"wfm-help-prompt-20": "helpPrompt20", "wfm-help-prompt-21": "helpPrompt21",
"wfm-help-prompt-22": "helpPrompt22", "wfm-help-prompt-23": "helpPrompt23",
"wfm-help-prompt-24": "helpPrompt24", "wfm-help-prompt-25": "helpPrompt25",
"wfm-help-prompt-26": "helpPrompt26",
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
"wfm-help-settings-13": "helpSettings13",
"wfm-help-settings-13": "helpSettings13", "wfm-help-settings-14": "helpSettings14",
"wfm-help-settings-15": "helpSettings15", "wfm-help-settings-16": "helpSettings16",
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
@@ -412,7 +572,16 @@ function applyI18nToHtml() {
"wfm-help-gallery-11": "helpGallery11", "wfm-help-gallery-12": "helpGallery12",
"wfm-help-gallery-13": "helpGallery13", "wfm-help-gallery-14": "helpGallery14",
"wfm-help-gallery-15": "helpGallery15", "wfm-help-gallery-16": "helpGallery16",
"wfm-help-gallery-17": "helpGallery17",
"wfm-help-gallery-17": "helpGallery17", "wfm-help-gallery-27": "helpGallery27",
"wfm-help-gallery-18": "helpGallery18", "wfm-help-gallery-19": "helpGallery19",
"wfm-help-gallery-20": "helpGallery20", "wfm-help-gallery-21": "helpGallery21",
"wfm-help-gallery-22": "helpGallery22", "wfm-help-gallery-23": "helpGallery23",
"wfm-help-gallery-24": "helpGallery24", "wfm-help-gallery-25": "helpGallery25",
"wfm-help-gallery-26": "helpGallery26",
"wfm-help-gallery-28": "helpGallery28", "wfm-help-gallery-29": "helpGallery29",
"wfm-help-gallery-30": "helpGallery30",
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
"wfm-help-tagger-title": "helpTaggerTitle",
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
@@ -432,7 +601,18 @@ function applyI18nToHtml() {
"wfm-help-sidepanel-17": "helpSidepanel17",
"wfm-help-ai-title": "helpAiTitle",
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-5": "helpAi5", "wfm-help-ai-6": "helpAi6",
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-6": "helpAi6",
"wfm-help-ai-7": "helpAi7",
"wfm-help-ai-8": "helpAi8",
"wfm-help-ai-9": "helpAi9",
"wfm-help-ai-card-layout-title": "helpAiCardLayout",
"wfm-help-ai-card-translate-title": "helpAiCardTranslate",
"wfm-help-ai-card-chat-title": "helpAiCardChat",
"wfm-help-ai-card-svg-title": "helpAiCardSvg",
"wfm-help-ai-card-skills-title": "helpAiCardSkills",
"wfm-help-ai-card-skilltips-title": "helpAiCardSkillTips",
"wfm-help-ai-card-tools-title": "helpAiCardTools",
"wfm-help-ai-card-settings-title": "helpAiCardSettings",
"wfm-help-imageedit-title": "helpImageEditTitle",
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
@@ -447,13 +627,16 @@ function applyI18nToHtml() {
"wfm-help-imageedit-12": "helpImageEdit12", "wfm-help-imageedit-13": "helpImageEdit13",
"wfm-help-imageedit-14": "helpImageEdit14", "wfm-help-imageedit-15": "helpImageEdit15",
"wfm-help-imageedit-16": "helpImageEdit16", "wfm-help-imageedit-17": "helpImageEdit17",
"wfm-help-imageedit-18": "helpImageEdit18", "wfm-help-imageedit-19": "helpImageEdit19",
"wfm-help-imageedit-18": "helpImageEdit18",
"wfm-help-imageedit-19b": "helpImageEdit19b",
"wfm-help-imageedit-20": "helpImageEdit20", "wfm-help-imageedit-21": "helpImageEdit21",
"wfm-help-imageedit-22": "helpImageEdit22", "wfm-help-imageedit-23": "helpImageEdit23",
"wfm-help-imageedit-24": "helpImageEdit24", "wfm-help-imageedit-25": "helpImageEdit25",
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
"wfm-help-imageedit-30": "helpImageEdit30",
"wfm-help-imageedit-31": "helpImageEdit31", "wfm-help-imageedit-32": "helpImageEdit32",
"wfm-help-imageedit-33": "helpImageEdit33", "wfm-help-imageedit-34": "helpImageEdit34",
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
"wfm-help-imageedit-8b": "helpImageEdit8b",
@@ -473,6 +656,7 @@ function applyI18nToHtml() {
"wfm-help-imageedit-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
"wfm-help-imageedit-mask-14": "helpImageEditMask14",
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
@@ -493,11 +677,75 @@ function applyI18nToHtml() {
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
"wfm-help-trouble-9": "helpTrouble9",
"wfm-help-support-title": "helpSupportTitle",
"wfm-help-support-desc": "helpSupportDesc",
"wfm-help-github-desc": "helpGithubDesc",
"wfm-help-kofi-desc": "helpKofiDesc",
"wfm-help-thanks": "helpThanks",
// -- Help card headings (card-based redesign) --
"wfm-help-about-card-getting-started-title": "helpAboutCardGettingStarted",
"wfm-help-wf-title": "helpWfTitle",
"wfm-help-wf-card-browse-title": "helpWfCardBrowse",
"wfm-help-wf-card-groups-title": "helpWfCardGroups",
"wfm-help-wf-card-panel-title": "helpWfCardPanel",
"wfm-help-gen-title": "helpGenTitle",
"wfm-help-gen-card-connect-title": "helpGenCardConnect",
"wfm-help-gen-card-tabs-title": "helpGenCardTabs",
"wfm-help-gen-card-seed-title": "helpGenCardSeed",
"wfm-help-gen-card-batchsetup-title": "helpGenCardBatchSetup",
"wfm-help-gen-card-batchtypes-title": "helpGenCardBatchTypes",
"wfm-help-gen-card-lora-title": "helpGenCardLora",
"wfm-help-prompt-title": "helpPromptTitle",
"wfm-help-prompt-card-ai-title": "helpPromptCardAi",
"wfm-help-prompt-card-presets-title": "helpPromptCardPresets",
"wfm-help-prompt-card-wildcards-title": "helpPromptCardWildcards",
"wfm-help-metadata-card-overview-title": "helpMetadataCardOverview",
"wfm-help-metadata-card-extracted-title": "helpMetadataCardExtracted",
"wfm-help-metadata-card-formats-title": "helpMetadataCardFormats",
"wfm-help-settings-title": "helpSettingsTitle",
"wfm-help-settings-card-general-title": "helpSettingsCardGeneral",
"wfm-help-settings-card-connections-title": "helpSettingsCardConnections",
"wfm-help-settings-card-workflow-title": "helpSettingsCardWorkflow",
"wfm-help-settings-card-editor-title": "helpSettingsCardEditor",
"wfm-help-settings-card-misc-title": "helpSettingsCardMisc",
"wfm-help-nodes-title": "helpNodesTitle",
"wfm-help-nodes-card-browse-title": "helpNodesCardBrowse",
"wfm-help-nodes-card-groups-title": "helpNodesCardGroups",
"wfm-help-nodes-card-panel-title": "helpNodesCardPanel",
"wfm-help-gallery-card-browse-title": "helpGalleryCardBrowse",
"wfm-help-gallery-card-folder-title": "helpGalleryCardFolder",
"wfm-help-gallery-card-detail-title": "helpGalleryCardDetail",
"wfm-help-gallery-card-actions-title": "helpGalleryCardActions",
"wfm-help-gallery-card-formats-title": "helpGalleryCardFormats",
"wfm-help-models-title": "helpModelsTitle",
"wfm-help-models-card-browse-title": "helpModelsCardBrowse",
"wfm-help-models-card-civitai-title": "helpModelsCardCivitai",
"wfm-help-models-card-apply-title": "helpModelsCardApply",
"wfm-help-models-card-groups-title": "helpModelsCardGroups",
"wfm-help-models-card-bulk-title": "helpModelsCardBulk",
"wfm-help-sidepanel-card-tabs-title": "helpSidepanelCardTabs",
"wfm-help-sidepanel-card-dnd-title": "helpSidepanelCardDnd",
"wfm-help-sidepanel-card-info-title": "helpSidepanelCardInfo",
"wfm-help-sidepanel-card-ai-title": "helpSidepanelCardAi",
"wfm-help-sidepanel-card-nav-title": "helpSidepanelCardNav",
"wfm-help-tagger-card-overview-title": "helpTaggerCardOverview",
"wfm-help-tagger-card-single-title": "helpTaggerCardSingle",
"wfm-help-tagger-card-sending-title": "helpTaggerCardSending",
"wfm-help-tagger-card-batch-title": "helpTaggerCardBatch",
"wfm-help-imageedit-card-overview-title": "helpImageEditCardOverview",
"wfm-help-shortcuts-card-general-title": "helpShortcutsCardGeneral",
"wfm-help-trouble-card-connection-title": "helpTroubleCardConnection",
"wfm-help-trouble-card-workflow-title": "helpTroubleCardWorkflow",
"wfm-help-trouble-card-tagger-title": "helpTroubleCardTagger",
"wfm-help-video-title": "helpVideoTitle",
"wfm-help-video-desc": "helpVideoDesc",
"wfm-help-video-1": "helpVideo1", "wfm-help-video-2": "helpVideo2",
"wfm-help-video-3": "helpVideo3", "wfm-help-video-4": "helpVideo4",
"wfm-help-video-5": "helpVideo5", "wfm-help-video-6": "helpVideo6",
"wfm-help-video-7": "helpVideo7", "wfm-help-video-8": "helpVideo8",
"wfm-help-video-9": "helpVideo9", "wfm-help-video-10": "helpVideo10",
"wfm-help-video-11": "helpVideo11", "wfm-help-video-12": "helpVideo12",
};
for (const [id, key] of Object.entries(helpIdMap)) {
const el = document.getElementById(id);
@@ -510,6 +758,11 @@ function applyI18nToHtml() {
helpSearch.placeholder = t("helpSearchPlaceholder");
helpSearch.addEventListener("input", _onHelpSearch);
}
// Jump-to-tab buttons
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
btn.textContent = t("helpJumpToTab");
});
}
function _onHelpSearch(e) {
@@ -575,13 +828,15 @@ import { initWorkflowTab } from "./workflow-tab.js";
import { initNodesTab } from "./nodes-tab.js";
import { initGenerateTab } from "./generate-tab.js";
import { initPromptTab } from "./prompt-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
import { initPromptTableTab } from "./prompt-table.js";
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors, applyModelTabActiveColor } from "./settings-tab.js";
import { initModelsTab } from "./models-tab.js";
import { initGalleryTab } from "./gallery-tab.js";
import { initMetadataTab } from "./metadata-tab.js";
import { initAiTab } from "./ai-tab.js";
import { initTaggerTab } from "./tagger-tab.js";
import { imageEditTab } from "./image-edit-tab.js";
import { initVideoTab } from "./video-tab.js";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
@@ -591,6 +846,7 @@ try {
const _s = getSettings();
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
applyJsonColors(_s.jsonColors);
applyModelTabActiveColor(_s.modelTabActiveColor);
} catch {}
function initHelpTab() {
@@ -603,6 +859,16 @@ function initHelpTab() {
document.getElementById(`wfm-help-page-${page}`)?.classList.add("active");
});
});
// Jump-to-tab buttons: switch to the real feature tab this help page documents
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelector(`.wfm-tab[data-tab="${btn.dataset.jumpTab}"]`)?.click();
if (btn.dataset.jumpSubtab) {
document.querySelector(`.wfm-gallery-subtab-btn[data-gallery-subtab="${btn.dataset.jumpSubtab}"]`)?.click();
}
});
});
}
document.addEventListener("DOMContentLoaded", () => {
@@ -616,11 +882,13 @@ document.addEventListener("DOMContentLoaded", () => {
initModelsTab();
initGenerateTab();
initPromptTab();
initPromptTableTab();
initMetadataTab();
initGalleryTab();
initAiTab();
initTaggerTab();
imageEditTab.init();
initVideoTab();
console.log("Workflow Studio: App initialized");
});
+66 -10
View File
@@ -18,8 +18,15 @@ export const comfyUI = {
connected: false,
generating: false,
currentPromptId: null,
// trackProgress() 実行中の Promise を promptId -> reject関数 で保持。
// WebSocket切断時に、待機中のPromiseを解決させずに放置してバッチループが
// 無言のまま停止する(例: カタログ作成バッチが特定件数で止まる)のを防ぐ。
_pendingTrackers: new Map(),
currentWorkflow: null,
currentAnalysis: null,
// ワークフロー読み込み時点のLora Loader (LoraManager)ノードの loras/text 状態。
// Stackグループチェックボックスのトグル時に、OFF側の「読み込み時の状態へ戻す」用に使う。
loraManagerSnapshots: {},
updateUrl(url) {
if (!url) {
@@ -66,10 +73,18 @@ export const comfyUI = {
this.socket = new WebSocket(url);
this.socket.onopen = () => resolve(true);
this.socket.onerror = () => resolve(false);
this.socket.onclose = () => { this.socket = null; };
this.socket.onclose = () => {
this.socket = null;
this._rejectPendingTrackers(new Error("WebSocket disconnected"));
};
});
},
_rejectPendingTrackers(err) {
for (const rejectFn of this._pendingTrackers.values()) rejectFn(err);
this._pendingTrackers.clear();
},
// Node info fetching
async fetchObjectInfo(nodeClass) {
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
@@ -196,12 +211,29 @@ export const comfyUI = {
return await res.json();
},
trackProgress(promptId, progressCallback) {
// timeoutMs: この時間内に完了/エラー/中断通知が来なければ強制的にreject。
// WebSocketの取りこぼしやexecution_interrupted未処理でPromiseが永久に
// ぶら下がり続け、バッチループが無言で停止するのを防ぐための安全弁。
trackProgress(promptId, progressCallback, timeoutMs = 10 * 60 * 1000) {
return new Promise((resolve, reject) => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
reject(new Error("WebSocket not connected"));
return;
}
let settled = false;
const finish = (isResolve, value) => {
if (settled) return;
settled = true;
this.socket?.removeEventListener("message", handler);
clearTimeout(timer);
this._pendingTrackers.delete(promptId);
if (isResolve) resolve(value); else reject(value);
};
const timer = setTimeout(() => {
finish(false, new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s (prompt_id=${promptId})`));
}, timeoutMs);
const handler = (event) => {
try {
const msg = JSON.parse(event.data);
@@ -211,17 +243,19 @@ export const comfyUI = {
}
if (msg.type === "executing" && msg.data?.prompt_id === promptId) {
if (msg.data.node === null) {
this.socket.removeEventListener("message", handler);
resolve();
finish(true);
}
}
if (msg.type === "execution_error" && msg.data?.prompt_id === promptId) {
this.socket.removeEventListener("message", handler);
reject(new Error(msg.data.exception_message || "Execution error"));
finish(false, new Error(msg.data.exception_message || "Execution error"));
}
if (msg.type === "execution_interrupted" && msg.data?.prompt_id === promptId) {
finish(false, new Error("Execution interrupted"));
}
} catch {}
};
this.socket.addEventListener("message", handler);
this._pendingTrackers.set(promptId, (err) => finish(false, err));
});
},
@@ -240,7 +274,13 @@ export const comfyUI = {
});
const res = await fetch(`${this.baseUrl}/view?${params}`);
if (!res.ok) throw new Error(`Failed to fetch image: HTTP ${res.status}`);
return await res.blob();
const blob = await res.blob();
// /view はSVGをセキュリティ上application/octet-streamで返すため、
// <img>で表示できるよう拡張子から正しいMIMEタイプへ付け替える
if (imageData.filename?.toLowerCase().endsWith(".svg") && blob.type !== "image/svg+xml") {
return blob.slice(0, blob.size, "image/svg+xml");
}
return blob;
},
async uploadImage(file, filename, overwrite = true) {
@@ -270,7 +310,7 @@ export const comfyUI = {
// High-level generate
async generate(workflow, options = {}) {
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError } = options;
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError, timeoutMs } = options;
this.generating = true;
try {
@@ -292,7 +332,7 @@ export const comfyUI = {
this.currentPromptId = result.prompt_id;
// Track progress
await this.trackProgress(result.prompt_id, onProgress);
await this.trackProgress(result.prompt_id, onProgress, timeoutMs);
// Get history/results
const history = await this.getHistory(result.prompt_id);
@@ -300,6 +340,9 @@ export const comfyUI = {
// Extract images from SaveImage outputs only
const images = [];
// comfyui-tosvg の "Save SVG String" ノードは images ではなく
// saved_svg(ファイル名) / path(絶対パス) という独自キーで返す
const svgOutputs = [];
const outputs = history.outputs || {};
for (const nodeOutput of Object.values(outputs)) {
if (nodeOutput.images) {
@@ -307,10 +350,23 @@ export const comfyUI = {
images.push(img);
}
}
if (nodeOutput.saved_svg) {
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
const joinIfCharArray = (v) =>
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
? v.join("")
: v;
svgOutputs.push({
filename: joinIfCharArray(nodeOutput.saved_svg),
path: joinIfCharArray(nodeOutput.path) || "",
});
}
}
onComplete?.(images, seed);
return { images, seed };
return { images, seed, svgOutputs };
} catch (err) {
onError?.(err);
throw err;
+617 -85
View File
@@ -5,7 +5,8 @@
import { comfyUI } from "./comfyui-client.js";
import { syncJsonHighlight } from "./json-highlight.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./util.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
import { showToast } from "./app.js";
// ── Latent Image preset state ─────────────────────────────
const _LATENT_PRESET_KEY = "wfm_latent_presets";
@@ -27,6 +28,114 @@ function _saveLatentPresets(list) {
localStorage.setItem(_LATENT_PRESET_KEY, JSON.stringify(list));
}
// ── I2I placeholder image (Input tab / Image sub-panel) ──────────────────
// One shared default (color-generated or a fixed uploaded image), remembered across
// sessions. Each LoadImage slot's own "Placeholder" button applies this same default
// to just that slot — see renderImageTab.
const _I2I_PLACEHOLDER_KEY = "wfm_i2i_placeholder";
const _I2I_PLACEHOLDER_DEFAULT = { mode: "color", color: "#808080", width: 512, height: 512, imageFilename: "" };
function _loadI2IPlaceholderConfig() {
try {
const raw = localStorage.getItem(_I2I_PLACEHOLDER_KEY);
return raw ? { ..._I2I_PLACEHOLDER_DEFAULT, ...JSON.parse(raw) } : { ..._I2I_PLACEHOLDER_DEFAULT };
} catch { return { ..._I2I_PLACEHOLDER_DEFAULT }; }
}
function _saveI2IPlaceholderConfig(cfg) {
localStorage.setItem(_I2I_PLACEHOLDER_KEY, JSON.stringify(cfg));
}
// Renders a solid-color PNG on an offscreen canvas and returns it as a File, ready to
// upload through comfyUI.uploadImage() the same way a picked/dropped file would be.
function _generateColorImageFile(width, height, color) {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, width || 512);
canvas.height = Math.max(1, height || 512);
const ctx = canvas.getContext("2d");
ctx.fillStyle = color || "#808080";
ctx.fillRect(0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) { reject(new Error("Failed to render placeholder image")); return; }
resolve(new File([blob], `placeholder_${canvas.width}x${canvas.height}.png`, { type: "image/png" }));
}, "image/png");
});
}
// Wires the placeholder config panel at the top of the Image sub-panel (mode switch,
// color/size fields, default-image drop zone). Every change is persisted immediately via
// _saveI2IPlaceholderConfig so it's remembered next time the panel is rendered — this
// panel is display-only config, it doesn't touch any workflow node itself; only each
// slot's own Placeholder button (see renderImageTab) does that.
function _wireI2IPlaceholderConfig(scopeEl) {
const colorModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-color");
const imageModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-image");
const colorFields = scopeEl.querySelector("#wfm-i2i-ph-color-fields");
const imageFields = scopeEl.querySelector("#wfm-i2i-ph-image-fields");
const widthInput = scopeEl.querySelector("#wfm-i2i-ph-width");
const heightInput = scopeEl.querySelector("#wfm-i2i-ph-height");
const colorInput = scopeEl.querySelector("#wfm-i2i-ph-color");
const dropZone = scopeEl.querySelector("#wfm-i2i-ph-drop");
const fileInput = scopeEl.querySelector("#wfm-i2i-ph-file");
const previewWrap = scopeEl.querySelector("#wfm-i2i-ph-preview-wrap");
const previewImg = scopeEl.querySelector("#wfm-i2i-ph-preview-img");
const statusEl = scopeEl.querySelector("#wfm-i2i-ph-status");
if (!colorModeRadio) return; // no LoadImage slots rendered — panel doesn't exist
const setMode = (mode) => {
if (colorFields) colorFields.style.display = mode === "color" ? "" : "none";
if (imageFields) imageFields.style.display = mode === "image" ? "" : "none";
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), mode });
};
colorModeRadio.addEventListener("change", () => { if (colorModeRadio.checked) setMode("color"); });
imageModeRadio?.addEventListener("change", () => { if (imageModeRadio.checked) setMode("image"); });
widthInput?.addEventListener("change", () => {
const width = Math.max(1, parseInt(widthInput.value, 10) || 512);
widthInput.value = width;
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), width });
});
heightInput?.addEventListener("change", () => {
const height = Math.max(1, parseInt(heightInput.value, 10) || 512);
heightInput.value = height;
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), height });
});
colorInput?.addEventListener("change", () => {
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), color: colorInput.value });
});
// Default image: uploaded to ComfyUI immediately (its filename is what actually gets
// reused later, not the raw file) so every slot's Placeholder button can apply it
// without re-uploading.
const applyDefaultImageFile = async (file) => {
if (!file || !file.type.startsWith("image/")) return;
if (statusEl) statusEl.textContent = t("i2iPlaceholderUploading");
try {
const result = await comfyUI.uploadImage(file, file.name);
if (!result.name) throw new Error("Upload returned no filename");
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), imageFilename: result.name });
if (previewImg) previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
if (previewWrap) previewWrap.style.display = "";
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
} catch (err) {
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
}
};
fileInput?.addEventListener("change", () => {
if (fileInput.files.length > 0) applyDefaultImageFile(fileInput.files[0]);
});
if (dropZone) {
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("drag-over");
if (e.dataTransfer.files.length > 0) applyDefaultImageFile(e.dataTransfer.files[0]);
});
}
}
function _buildPresetOptions(customPresets) {
const customs = customPresets.map((p) =>
`<option value="${p.w}x${p.h}">${p.w}x${p.h}</option>`
@@ -48,6 +157,16 @@ function _refreshPresetSelect(customPresets) {
let _stackStrengths = {};
// { modelFullPath: boolean } — true = active (default)
let _stackActive = {};
// ── LoRA node-bypass state (GenerateUI Model tab header toggle) ───────────
// Force the LoRA node's strength to 0 without touching the actual ComfyUI node's mode —
// currentWorkflow is already-converted API format (see comfyui-workflow.js
// convertUiToApi), where mode has no effect at execution time, so a real Bypass(mode:4)
// can't be reproduced here; zeroing strength is the behavioral equivalent.
// { nodeId: boolean } — true = currently bypassed
let _loraNodeBypass = {};
// { nodeId: { loraName, strModel, strClip } | { loras, text } } — pre-bypass values to
// restore when toggled back off. Shape depends on isLoraManager at bypass-time.
let _loraBypassSnapshot = {};
// { id: string|null, selStart: number, selEnd: number }
let _lastPromptFocus = { id: null, selStart: 0, selEnd: 0 };
@@ -344,11 +463,11 @@ export const comfyEditor = {
return found || name;
},
renderAll(analysis, workflow) {
renderAll(analysis, workflow, opts = {}) {
this.renderPromptTab(analysis, "wfm-gen-prompt-fields");
this.renderImageTab(analysis, "wfm-gen-image-fields");
this.renderModelTab(analysis, "wfm-gen-model-fields");
this.renderLoraPane(analysis, "wfm-gen-lora-fields"); // async, fires independently
this.renderLoraPane(analysis, "wfm-gen-lora-fields", opts); // async, fires independently
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
_syncRawJson();
},
@@ -367,7 +486,7 @@ export const comfyEditor = {
<label>Positive Prompt</label>
<div style="display:flex;gap:8px;margin-bottom:6px;">
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${positiveNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-revert" title="${t("revertPromptTitle")}">↺</button>
@@ -379,7 +498,7 @@ export const comfyEditor = {
<label>Negative Prompt</label>
<div style="display:flex;gap:8px;margin-bottom:6px;">
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${negativeNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-revert" title="${t("revertPromptTitle")}">↺</button>
@@ -508,10 +627,14 @@ export const comfyEditor = {
const el = document.getElementById(containerId);
if (!el) return;
const sections = [
// 使用頻度の高い順: Checkpoint/VAE/Diffusion Model/Text Encoder は常時表示。
// ControlNet/Hypernetwork は意図的に使う場面が限られるため折りたたみにする。
const mainSections = [
{ label: "Checkpoint", key: "checkpoints", nodes: analysis.checkpoint_nodes, inputKey: "ckpt_name" },
{ label: "VAE", key: "vaes", nodes: analysis.vae_nodes, inputKey: "vae_name" },
{ label: "Diffusion Model", key: "diffusionModels", nodes: analysis.diffusion_model_nodes, inputKey: "unet_name" },
];
const collapsibleSections = [
{ label: "ControlNet", key: "controlNets", nodes: analysis.controlnet_nodes, inputKey: "control_net_name" },
{
label: "Hypernetwork", key: "hypernetworks", nodes: analysis.hypernetwork_nodes, inputKey: "hypernetwork_name",
@@ -519,48 +642,70 @@ export const comfyEditor = {
},
];
el.innerHTML = sections
.map((s) => {
const models = this.models[s.key] || [];
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
const isMissing = !!currentVal && !models.includes(currentVal);
const targetOpts = s.nodes
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
.join("");
const extrasHtml = (s.extras || []).map((ex) => {
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
data-key="${s.key}" data-input-key="${ex.inputKey}"
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
style="width:80px;">
</div>`;
}).join("");
const renderSectionBody = (s) => {
const models = this.models[s.key] || [];
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
const isMissing = !!currentVal && !models.includes(currentVal);
const targetOpts = s.nodes
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
.join("");
const extrasHtml = (s.extras || []).map((ex) => {
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
data-key="${s.key}" data-input-key="${ex.inputKey}"
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
style="width:80px;">
</div>`;
}).join("");
return `
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
<label>${s.label}</label>
<input type="text" class="wfm-input wfm-model-filter" placeholder="Filter..." data-target="wfm-model-${s.key}" style="margin-bottom:4px;">
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
</select>
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
${extrasHtml}
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply &amp; Generate)">Apply</button>
</div>
return `
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
<input type="text" class="wfm-input wfm-search-input wfm-model-filter" id="wfm-model-${s.key}-filter" placeholder="Filter..." data-target="wfm-model-${s.key}">
<button type="button" class="wfm-search-clear-btn" id="wfm-model-${s.key}-filter-clear" title="Clear search">✕</button>
</div>
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
</select>
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
${extrasHtml}
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply &amp; Generate)">Apply</button>
</div>
`;
})
.join("") + '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
};
const mainHtml = mainSections.map((s) => {
const hasNodes = (s.nodes?.length || 0) > 0;
return `
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
<label class="${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</label>
${renderSectionBody(s)}
</div>`;
}).join("");
const teHtml = '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
const collapsibleHtml = collapsibleSections.map((s) => {
const hasNodes = (s.nodes?.length || 0) > 0;
return `
<details class="wfm-settings-section">
<summary class="wfm-settings-summary ${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</summary>
<div class="wfm-form-group" style="margin-bottom:0;">
${renderSectionBody(s)}
</div>
</details>`;
}).join("");
el.innerHTML = mainHtml + teHtml + collapsibleHtml;
// Filter inputs
el.querySelectorAll(".wfm-model-filter").forEach((input) => {
input.addEventListener("input", () => {
const targetId = input.dataset.target;
const targetId = input.dataset.target;
const rebuild = () => {
const select = document.getElementById(targetId);
if (!select) return;
const filter = input.value.toLowerCase();
@@ -570,7 +715,9 @@ export const comfyEditor = {
.filter((m) => m.toLowerCase().includes(filter))
.map((m) => `<option value="${m}">${m}</option>`)
.join("");
});
};
input.addEventListener("input", rebuild);
setupSearchClearBtn(input.id, `${input.id}-clear`, rebuild);
});
// Apply buttons
@@ -606,13 +753,104 @@ export const comfyEditor = {
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
},
async renderLoraPane(analysis, containerId) {
// Toggles the GenerateUI Model tab's LoRA column-header bypass button: forces the
// current target LoRA node's strength to 0 (both tabs' strength fields, whichever
// is currently in currentWorkflow), or restores whatever was there before. Targets
// the same node Single/Stack Apply default to (first LoraManager, else first LoRA
// node) so one button covers both tabs without needing its own node picker.
toggleLoraNodeBypass(analysis) {
const loraNodes = analysis?.lora_nodes || [];
let nodeId = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
let isLoraManager = !!loraNodes.find((n) => String(n.id) === String(nodeId))?.is_lora_manager;
if (nodeId == null) {
nodeId = Object.keys(comfyUI.currentWorkflow || {}).find((id) => comfyUI.currentWorkflow[id]?.inputs && "lora_name" in comfyUI.currentWorkflow[id].inputs);
isLoraManager = false;
}
const wfNode = nodeId != null ? comfyUI.currentWorkflow?.[nodeId] : null;
if (!wfNode) {
showToast(t("modelsGenUINoNode", "LoRA"), "warning");
return;
}
if (_loraNodeBypass[nodeId]) {
// Restore whatever was set before bypassing
const snap = _loraBypassSnapshot[nodeId];
if (snap) {
if (isLoraManager) {
wfNode.inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras || [])) };
wfNode.inputs.text = snap.text ?? "";
} else {
wfNode.inputs.lora_name = snap.lora_name;
wfNode.inputs.strength_model = snap.strength_model;
wfNode.inputs.strength_clip = snap.strength_clip;
}
}
delete _loraNodeBypass[nodeId];
delete _loraBypassSnapshot[nodeId];
} else {
// Snapshot current values, then zero the strength (LoraManager: every
// entry in its loras list, keeping the list itself intact so un-bypassing
// restores the exact same Stack — not just a single-LoRA fallback).
if (isLoraManager) {
const loras = Array.isArray(wfNode.inputs.loras?.__value__) ? wfNode.inputs.loras.__value__ : [];
_loraBypassSnapshot[nodeId] = { loras: JSON.parse(JSON.stringify(loras)), text: wfNode.inputs.text ?? "" };
wfNode.inputs.loras = { __value__: loras.map((l) => ({ ...l, strength: 0, clipStrength: 0 })) };
wfNode.inputs.text = "";
} else {
_loraBypassSnapshot[nodeId] = {
lora_name: wfNode.inputs.lora_name,
strength_model: wfNode.inputs.strength_model,
strength_clip: wfNode.inputs.strength_clip,
};
wfNode.inputs.strength_model = 0;
wfNode.inputs.strength_clip = 0;
}
_loraNodeBypass[nodeId] = true;
}
_syncRawJson();
},
async renderLoraPane(analysis, containerId, opts = {}) {
const el = document.getElementById(containerId);
if (!el) return;
const loraNodes = analysis.lora_nodes || [];
document.getElementById("wfm-gen-lora-col-title")?.classList.toggle("wfm-model-label-active", loraNodes.length > 0);
const loras = this.models.loras || [];
const defaultStackTarget = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
const hasLoraManager = loraNodes.some((n) => n.is_lora_manager);
// Sync the column-header bypass button (lives outside this container's
// innerHTML, in the static wfm-gen-lora-col-header) to whatever the target
// node's current bypass state is.
const bypassBtn = document.getElementById("wfm-lora-bypass-toggle");
if (bypassBtn) {
bypassBtn.classList.toggle("active", defaultStackTarget != null && !!_loraNodeBypass[defaultStackTarget]);
bypassBtn.disabled = loraNodes.length === 0;
}
// Use Stack Group チェックボックスの現在状態を先読み(Stack表示の取得元判定に使う)。
// resetStackMode(新規読み込み)時は常にOFF扱い。
const stackModeChecked = hasLoraManager && !opts.resetStackMode && (el.querySelector("#wfm-lora-stack-mode-toggle")?.checked ?? false);
// ワークフロー新規読み込み時のみ: LoraManagerノードのloras/text状態をスナップショットし、
// Stackグループ使用チェックボックスをOFFへリセットする(既存のGenUI状態への影響を避けるため
// タブ切替やStack再読込などの単なる再描画では行わない)。
if (opts.resetStackMode) {
// New workflow loaded — bypass state/snapshots belonged to the previous
// workflow's node IDs, so they can't be meaningfully carried forward.
_loraNodeBypass = {};
_loraBypassSnapshot = {};
comfyUI.loraManagerSnapshots = {};
for (const n of loraNodes) {
if (!n.is_lora_manager) continue;
const wfNode = comfyUI.currentWorkflow?.[n.id];
if (!wfNode?.inputs) continue;
comfyUI.loraManagerSnapshots[n.id] = {
loras: Array.isArray(wfNode.inputs.loras?.__value__) ? JSON.parse(JSON.stringify(wfNode.inputs.loras.__value__)) : [],
text: wfNode.inputs.text ?? "",
};
}
}
const nodeOpts = loraNodes
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
.join("");
@@ -637,26 +875,53 @@ export const comfyEditor = {
}
}
// Fetch Stack group, metadata, CivitAI cache in parallel
// Stack表示の取得元: ON(Use Stack Group)ならModelsタブの登録済みStackグループ、
// OFF(既定)ならLoraManagerノードに実際に設定されているLoRAをそのまま表示する。
let stackModels = [];
let metadata = {};
let civitaiCache = {};
const useWorkflowLoraState = hasLoraManager && !stackModeChecked;
try {
const [grpRes, metaRes, civRes] = await Promise.all([
fetch("/api/wfm/models/groups?type=lora"),
const [metaRes, civRes] = await Promise.all([
fetch("/api/wfm/models/metadata"),
fetch("/api/wfm/models/civitai/cache"),
]);
const groups = grpRes.ok ? await grpRes.json() : {};
stackModels = groups["Stack"] || [];
metadata = metaRes.ok ? await metaRes.json() : {};
civitaiCache = civRes.ok ? await civRes.json() : {};
} catch { /* ignore */ }
stackModels.forEach((m) => {
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
if (_stackActive[m] === undefined) _stackActive[m] = true;
});
if (stackModeChecked) {
try {
const grpRes = await fetch("/api/wfm/models/groups?type=lora");
const groups = grpRes.ok ? await grpRes.json() : {};
stackModels = groups["Stack"] || [];
} catch { /* ignore */ }
stackModels.forEach((m) => {
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
if (_stackActive[m] === undefined) _stackActive[m] = true;
});
} else if (useWorkflowLoraState) {
const wfNode = comfyUI.currentWorkflow?.[defaultStackTarget];
const wfLoras = wfNode?.inputs?.loras?.__value__;
// resetStackMode(新規読み込み)/syncFromWorkflow(チェックボックスOFF切替直後)の時だけ
// ワークフローの実データで強制上書きする。タブ切替等の単なる再描画では、
// 既にメモリ上に値があればそれを維持し、Stackパネルでの一時的なON/OFF・強度編集を
// 再描画のたびに消してしまわないようにする。
const shouldSync = opts.resetStackMode || opts.syncFromWorkflow;
if (Array.isArray(wfLoras)) {
stackModels = wfLoras.map((l) => {
const found = (this.models.loras || []).find((m) => _loraBasename(m) === l.name);
const full = found || l.name;
if (shouldSync || _stackStrengths[full] === undefined) {
_stackStrengths[full] = { m: parseFloat(l.strength) || 1.0, c: parseFloat(l.clipStrength ?? l.strength) || 1.0 };
}
if (shouldSync || _stackActive[full] === undefined) {
_stackActive[full] = l.active !== false;
}
return full;
});
}
}
// Build Stack trigger words
const activeTriggerWords = [];
@@ -673,7 +938,9 @@ export const comfyEditor = {
? activeTriggerWords.map((w) => `<span class="wfm-lora-trigger-word">${w}</span>`).join(" ")
: `<span style="color:var(--wfm-text-secondary);font-size:12px;">—</span>`;
const stackLoraSyntax = _buildLoraSyntax(stackModels);
const allActive = stackModels.every((m) => _stackActive[m] !== false);
// stackModelsが空だと every() が空配列に対し true を返す(空虚な真)ため、
// 何も無いのに「トグルオール」チェックボックスがON表示になるのを防ぐ
const allActive = stackModels.length > 0 && stackModels.every((m) => _stackActive[m] !== false);
const anyActive = stackModels.some((m) => _stackActive[m] !== false);
const stackModelRows = stackModels.map((m) => {
@@ -691,7 +958,10 @@ export const comfyEditor = {
</div>`;
}).join("");
const _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
let _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
// 新規読み込み時、LoraManagerノードに実際に複数LoRAが設定されている場合は
// Single(1件しか表示できない)ではなくStackタブを初期表示にする
if (opts.resetStackMode && useWorkflowLoraState && stackModels.length > 1) _prevActiveTab = "stack";
// applyToGenUIで設定したSingle表示の状態を保存(再描画後に復元)
const _prevSingleSyntax = document.getElementById("wfm-lora-single-syntax")?.textContent || "";
const _prevSingleTriggers = document.getElementById("wfm-lora-single-triggers")?.innerHTML || "";
@@ -700,11 +970,19 @@ export const comfyEditor = {
<div class="wfm-lora-tab-header">
<button class="wfm-lora-tab-btn active" data-tab="single">Single</button>
<button class="wfm-lora-tab-btn" data-tab="stack">Stack</button>
${hasLoraManager ? `
<label class="wfm-lora-stack-mode-label" style="margin-left:auto;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;" title="ON: 登録済みStackグループを読み込む / OFF: ワークフローに実際に設定されているLoRAを表示・編集する">
<input type="checkbox" id="wfm-lora-stack-mode-toggle" ${stackModeChecked ? "checked" : ""}>
Use Stack Group
</label>` : ""}
</div>
<!-- Single tab -->
<div class="wfm-lora-tab-content" id="wfm-lora-panel-single">
<input type="text" class="wfm-input" id="wfm-lora-filter" placeholder="Filter...">
<div class="wfm-search-wrap" style="width:100%;">
<input type="text" class="wfm-input wfm-search-input" id="wfm-lora-filter" placeholder="Filter...">
<button type="button" class="wfm-search-clear-btn" id="wfm-lora-filter-clear" title="Clear search">✕</button>
</div>
<select class="wfm-select" id="wfm-lora-select">
${loras.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
</select>
@@ -731,6 +1009,7 @@ export const comfyEditor = {
<!-- Stack tab -->
<div class="wfm-lora-tab-content" id="wfm-lora-panel-stack" style="display:none;">
${hasLoraManager ? `<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:6px;">${useWorkflowLoraState ? "Showing: LoRAs currently in workflow" : "Showing: registered Stack group"}</div>` : ""}
<div class="wfm-lora-stack-header">
<select class="wfm-select" id="wfm-lora-stack-target" style="flex:1;min-width:0;">${stackTargetOpts}</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-lora-stack-apply" title="Apply Stack to node and sync to Positive prompt">Apply</button>
@@ -765,7 +1044,7 @@ export const comfyEditor = {
<div id="wfm-lora-stack-triggers" class="wfm-lora-stack-triggers">${stackTriggerHtml}</div>
</div>
<div class="wfm-lora-stack-models">
${stackModelRows || `<p class="wfm-placeholder">No models in Stack group</p>`}
${stackModelRows || `<p class="wfm-placeholder">${useWorkflowLoraState ? "No LoRAs set on this node" : "No models in Stack group"}</p>`}
</div>
</div>
`;
@@ -803,9 +1082,63 @@ export const comfyEditor = {
document.getElementById("wfm-lora-panel-stack").style.display = "";
}
// ── Stackグループ使用チェックボックス ─────────────────
// ON: LoraManagerノードへStackグループを(クリアしてから)読み込む(既存のStack Applyを流用)
// OFF: ワークフロー読み込み時点のloras/text状態へ(クリアしてから)戻す
document.getElementById("wfm-lora-stack-mode-toggle")?.addEventListener("change", async (e) => {
const nodeId = document.getElementById("wfm-lora-stack-target")?.value || defaultStackTarget;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
if (e.target.checked) {
// stackModelsはこの時点では「ワークフロー state」を指したままのため、
// 先に再描画してStackグループを取得させてからApplyする
await this.renderLoraPane(analysis, containerId);
document.getElementById("wfm-lora-stack-apply")?.click();
return;
}
const snap = comfyUI.loraManagerSnapshots?.[nodeId] || { loras: [], text: "" };
comfyUI.currentWorkflow[nodeId].inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras)) };
comfyUI.currentWorkflow[nodeId].inputs.text = snap.text;
_syncRawJson();
// ON時にPositiveプロンプトへ挿入されたStack由来の<lora:...>構文とトリガーワードを除去する
// (新規に追加はしない。ワークフロー本来のプロンプトへ戻すだけ)
const posTextarea = document.getElementById("wfm-prompt-pos-text");
if (posTextarea && stackModels.length > 0) {
let cleaned = posTextarea.value;
for (const m of stackModels) {
const stem = _loraBasename(m).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
cleaned = cleaned.replace(new RegExp(`,?\\s*<lora:${stem}:[^>]*>`, "gi"), "");
}
const stackWords = new Set();
stackModels.forEach((m) => {
const sha = (metadata[m] || {}).sha256;
const civInfo = sha && civitaiCache[sha];
civInfo?.trainedWords?.forEach((w) => stackWords.add(w.trim().toLowerCase()));
});
if (stackWords.size > 0) {
cleaned = cleaned.split(",").map((p) => p.trim()).filter((p) => p && !stackWords.has(p.toLowerCase())).join(", ");
}
posTextarea.value = cleaned.replace(/,\s*$/, "").trim();
const posNodeId = document.getElementById("wfm-prompt-pos-target")?.value;
if (posNodeId && comfyUI.currentWorkflow?.[posNodeId]) {
const promptNode = comfyUI.currentAnalysis?.prompt_nodes?.find((n) => String(n.id) === String(posNodeId));
comfyUI.currentWorkflow[posNodeId].inputs[promptNode?.textKey || "text"] = posTextarea.value;
_syncRawJson();
}
}
// syncFromWorkflow: OFFへ戻した直後は、たった今書き戻したloras/text状態と
// Stackパネルの強度/有効状態表示を一致させるため強制同期する
this.renderLoraPane(analysis, containerId, { syncFromWorkflow: true });
});
// ── Single: filter ───────────────────────────────────
document.getElementById("wfm-lora-filter")?.addEventListener("input", (e) => {
const filter = e.target.value.toLowerCase();
const loraFilterInput = document.getElementById("wfm-lora-filter");
const rebuildLoraSingleList = () => {
const filter = loraFilterInput?.value.toLowerCase() || "";
const select = document.getElementById("wfm-lora-select");
if (!select) return;
select.innerHTML = loras
@@ -813,7 +1146,9 @@ export const comfyEditor = {
.map((m) => `<option value="${m}">${m}</option>`)
.join("");
_refreshLoraSingleDynamic(metadata, civitaiCache);
});
};
loraFilterInput?.addEventListener("input", rebuildLoraSingleList);
setupSearchClearBtn("wfm-lora-filter", "wfm-lora-filter-clear", rebuildLoraSingleList);
// ── Single: update SYNTAX/TRIGGERS on model or strength change ──
document.getElementById("wfm-lora-select")?.addEventListener("change", () => _refreshLoraSingleDynamic(metadata, civitaiCache));
@@ -830,6 +1165,11 @@ export const comfyEditor = {
const strModel = parseFloat(document.getElementById("wfm-lora-str-model")?.value) || 1.0;
const strClip = parseFloat(document.getElementById("wfm-lora-str-clip")?.value) || 1.0;
_applyLoraToNode(nodeId, select.value, strModel, strClip, node?.is_lora_manager);
// An explicit Apply overwrites whatever bypass had zeroed — clear it so the
// header button (and a later un-bypass) don't stomp this new value.
delete _loraNodeBypass[nodeId];
delete _loraBypassSnapshot[nodeId];
document.getElementById("wfm-lora-bypass-toggle")?.classList.remove("active");
_syncRawJson();
// Sync to Positive prompt
@@ -962,6 +1302,11 @@ export const comfyEditor = {
comfyUI.currentWorkflow[nodeId].inputs.strength_clip = _stackStrengths[first]?.c ?? 1.0;
}
}
// An explicit Apply overwrites whatever bypass had zeroed — clear it so the
// header button (and a later un-bypass) don't stomp this new value.
delete _loraNodeBypass[nodeId];
delete _loraBypassSnapshot[nodeId];
document.getElementById("wfm-lora-bypass-toggle")?.classList.remove("active");
_syncRawJson();
// Sync Stack LORA SYNTAX + TRIGGER WORDS to Positive prompt
@@ -1030,23 +1375,39 @@ export const comfyEditor = {
const sampler = analysis.sampler_nodes?.[0];
const latent = analysis.latent_nodes?.[0];
// "Advanced Sampling" workflows (Flux.1/Flux.2, SD3.5, Chroma...) spread seed/steps/cfg/
// sampler/scheduler/denoise across RandomNoise + CFGGuider + KSamplerSelect + a *Scheduler
// node instead of one KSampler. sampler.*NodeId (set in analyzeWorkflow) says which real
// node each field writes back to; null/"" means that workflow has no such control at all
// (e.g. Flux2Scheduler has no named "scheduler" combo) so the field is disabled here.
const schedulerAvailable = !!sampler && sampler.schedulerNodeId != null;
const denoiseAvailable = !!sampler && sampler.denoiseNodeId != null;
el.innerHTML = `
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
<div style="flex:1;min-width:0;padding-right:14px;border-right:1px solid var(--wfm-border);">
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">${sampler?.advanced ? "Sampler (Advanced)" : "KSampler"}</h3>
${sampler ? `
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}" data-seed-key="${sampler.seedKey || "seed"}">
<input type="hidden" id="wfm-settings-sampler-id"
data-seed-key="${sampler.seedKey || "seed"}"
data-seed-node-id="${sampler.seedNodeId ?? sampler.id}"
data-steps-node-id="${sampler.stepsNodeId ?? sampler.id}"
data-cfg-key="${sampler.cfgKey || "cfg"}"
data-cfg-node-id="${sampler.cfgNodeId ?? sampler.id}"
data-sampler-node-id="${sampler.samplerNodeId ?? sampler.id}"
data-scheduler-node-id="${sampler.schedulerNodeId ?? ""}"
data-denoise-node-id="${sampler.denoiseNodeId ?? ""}">
<div class="wfm-form-group">
<label>Seed</label>
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? -1}">
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? ""}" placeholder="${sampler.seed === undefined ? "linked" : ""}">
</div>
<div class="wfm-form-group">
<label>Steps</label>
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? 20}" min="1" max="200">
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? ""}" placeholder="${sampler.steps === undefined ? "linked" : ""}" min="1" max="200">
</div>
<div class="wfm-form-group">
<label>CFG</label>
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? 7}" step="0.5" min="0">
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? ""}" placeholder="${sampler.cfg === undefined ? "linked" : ""}" step="0.5" min="0">
</div>
<div class="wfm-form-group">
<label>Sampler</label>
@@ -1056,13 +1417,15 @@ export const comfyEditor = {
</div>
<div class="wfm-form-group">
<label>Scheduler</label>
<select class="wfm-select" id="wfm-settings-scheduler">
${this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")}
<select class="wfm-select" id="wfm-settings-scheduler" ${schedulerAvailable ? "" : "disabled"}>
${schedulerAvailable
? this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")
: `<option value="">N/A</option>`}
</select>
</div>
<div class="wfm-form-group">
<label>Denoise</label>
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? 1.0}" step="0.05" min="0" max="1">
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? ""}" placeholder="${denoiseAvailable && sampler.denoise === undefined ? "linked" : ""}" step="0.05" min="0" max="1" ${denoiseAvailable ? "" : "disabled"}>
</div>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply" title="Apply (Alt+Click: Apply &amp; Generate)">Apply</button>
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
@@ -1073,17 +1436,24 @@ export const comfyEditor = {
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
<div class="wfm-form-group">
<label>Width</label>
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? 512}" step="8" min="64">
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? ""}" placeholder="${latent.width === undefined ? "linked" : ""}" step="8" min="64">
</div>
<div class="wfm-form-group">
<label>Height</label>
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? 512}" step="8" min="64">
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? ""}" placeholder="${latent.height === undefined ? "linked" : ""}" step="8" min="64">
</div>
<div class="wfm-form-group">
<label>Batch Size</label>
<input type="number" class="wfm-input" id="wfm-settings-batch" value="${latent.batch_size ?? 1}" min="1" max="64">
</div>
${latent.isVideoLatent ? `
<div class="wfm-form-group">
<label>Length (frames)</label>
<input type="number" class="wfm-input" id="wfm-settings-latent-length" value="${latent.length ?? ""}" placeholder="${latent.length === undefined ? "linked" : ""}" step="1" min="1">
</div>
` : ""}
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-latent-apply">Apply</button>
${latent.width === undefined || latent.height === undefined ? `<p class="wfm-placeholder" style="margin-top:6px;">Width/Height is linked to another node (e.g. auto-follows the input image) — leave blank to keep it linked.</p>` : ""}
<div style="margin-top:10px;border-top:1px solid var(--wfm-border);padding-top:10px;">
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
${[512, 768, 1024, 2048].map((s) => `<button class="wfm-btn wfm-btn-sm wfm-latent-sq-btn" data-size="${s}">${s}</button>`).join("")}
@@ -1109,17 +1479,21 @@ export const comfyEditor = {
`;
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", (e) => {
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
// KSamplerAdvanced uses noise_seed; fall back to seed
const seedKey = document.getElementById("wfm-settings-sampler-id")?.dataset?.seedKey || "seed";
inputs[seedKey] = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
inputs.steps = parseInt(document.getElementById("wfm-settings-steps")?.value) || 20;
inputs.cfg = parseFloat(document.getElementById("wfm-settings-cfg")?.value) || 7;
inputs.sampler_name = document.getElementById("wfm-settings-sampler-name")?.value;
inputs.scheduler = document.getElementById("wfm-settings-scheduler")?.value;
inputs.denoise = parseFloat(document.getElementById("wfm-settings-denoise")?.value) || 1.0;
const ds = document.getElementById("wfm-settings-sampler-id")?.dataset || {};
const write = (nodeId, key, value) => {
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
comfyUI.currentWorkflow[nodeId].inputs[key] = value;
};
const seedEl = document.getElementById("wfm-settings-seed");
if (seedEl?.value !== "") write(ds.seedNodeId, ds.seedKey || "seed", parseInt(seedEl.value) || -1);
const stepsEl = document.getElementById("wfm-settings-steps");
if (stepsEl?.value !== "") write(ds.stepsNodeId, "steps", parseInt(stepsEl.value) || 20);
const cfgEl = document.getElementById("wfm-settings-cfg");
if (cfgEl?.value !== "") write(ds.cfgNodeId, ds.cfgKey || "cfg", parseFloat(cfgEl.value) || 7);
write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
if (ds.schedulerNodeId) write(ds.schedulerNodeId, "scheduler", document.getElementById("wfm-settings-scheduler")?.value);
const denoiseEl = document.getElementById("wfm-settings-denoise");
if (ds.denoiseNodeId && denoiseEl?.value !== "") write(ds.denoiseNodeId, "denoise", parseFloat(denoiseEl.value) || 1.0);
_syncRawJson();
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
});
@@ -1128,9 +1502,14 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-settings-latent-id")?.value;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
inputs.width = parseInt(document.getElementById("wfm-settings-width")?.value) || 512;
inputs.height = parseInt(document.getElementById("wfm-settings-height")?.value) || 512;
const wEl = document.getElementById("wfm-settings-width");
const hEl = document.getElementById("wfm-settings-height");
// Blank = leave the (linked) width/height alone instead of clobbering it with a default
if (wEl?.value !== "") inputs.width = parseInt(wEl.value) || 512;
if (hEl?.value !== "") inputs.height = parseInt(hEl.value) || 512;
inputs.batch_size = parseInt(document.getElementById("wfm-settings-batch")?.value) || 1;
const lenEl = document.getElementById("wfm-settings-latent-length");
if (lenEl && lenEl.value !== "") inputs.length = parseInt(lenEl.value) || 1;
_syncRawJson();
});
@@ -1212,8 +1591,34 @@ export const comfyEditor = {
const slots = loadNodes.slice(0, 4);
// Mask Editor One ノード(画像+マスクを内包、常に両方必須)— 別枠で最大2件表示
const meoSlots = meoNodes.slice(0, 2);
const phCfg = _loadI2IPlaceholderConfig();
const isColorMode = phCfg.mode !== "image";
el.innerHTML = `
${slots.length === 0 ? "" : `
<details class="wfm-settings-section wfm-i2i-placeholder-config">
<summary class="wfm-settings-summary">${t("i2iPlaceholderTitle")}</summary>
<div class="wfm-i2i-placeholder-mode">
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-color" value="color" ${isColorMode ? "checked" : ""}> ${t("i2iPlaceholderColor")}</label>
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-image" value="image" ${isColorMode ? "" : "checked"}> ${t("i2iPlaceholderImage")}</label>
</div>
<div id="wfm-i2i-ph-color-fields" class="wfm-i2i-placeholder-fields wfm-lab-modal-inline" style="${isColorMode ? "" : "display:none;"}">
<div><label>${t("i2iPlaceholderWidth")}</label><input type="number" min="1" id="wfm-i2i-ph-width" class="wfm-input" value="${Number(phCfg.width) || 512}"></div>
<div><label>${t("i2iPlaceholderHeight")}</label><input type="number" min="1" id="wfm-i2i-ph-height" class="wfm-input" value="${Number(phCfg.height) || 512}"></div>
<div><label>${t("i2iPlaceholderColorLabel")}</label><input type="color" id="wfm-i2i-ph-color" class="wfm-input" value="${escapeHtml(/^#[0-9a-fA-F]{6}$/.test(phCfg.color) ? phCfg.color : "#808080")}"></div>
</div>
<div id="wfm-i2i-ph-image-fields" class="wfm-i2i-placeholder-fields" style="${isColorMode ? "display:none;" : ""}">
<div class="wfm-i2i-preview-wrap" id="wfm-i2i-ph-preview-wrap" style="${phCfg.imageFilename ? "" : "display:none;"}">
<img class="wfm-i2i-preview-img" id="wfm-i2i-ph-preview-img" src="${phCfg.imageFilename ? `/view?filename=${encodeURIComponent(phCfg.imageFilename)}&type=input` : ""}">
</div>
<div class="wfm-i2i-drop-zone" id="wfm-i2i-ph-drop">
<label class="wfm-i2i-drop-label">
${t("i2iPlaceholderDropImage")}
<input type="file" accept="image/*" id="wfm-i2i-ph-file" style="display:none;">
</label>
</div>
<span class="wfm-i2i-status" id="wfm-i2i-ph-status"></span>
</div>
</details>
<div class="wfm-i2i-grid">
${slots.map((node, i) => `
<div class="wfm-i2i-slot" data-slot="${i}" data-node-id="${node.id}">
@@ -1247,8 +1652,10 @@ export const comfyEditor = {
</div>
</div>
` : ""}
<div style="display:flex;gap:6px;margin-top:6px;">
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-i2i-apply" data-slot="${i}" disabled>Apply</button>
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-placeholder-btn" data-slot="${i}">${t("i2iPlaceholderApply")}</button>
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-clear" data-slot="${i}">${t("clear")}</button>
<span class="wfm-i2i-status" id="wfm-i2i-status-${i}"></span>
</div>
</div>
@@ -1409,8 +1816,64 @@ export const comfyEditor = {
applyBtn.disabled = false;
}
});
// Clear: reset this slot's staged/preview state back to empty. Doesn't touch
// the workflow node itself — LoadImage always needs *some* valid filename, so
// there's nothing meaningful to "unset" there; this just lets the user cancel
// a pending pick and start over.
const clearBtn = el.querySelector(`.wfm-i2i-clear[data-slot="${i}"]`);
clearBtn?.addEventListener("click", () => {
pendingFile = null;
previewImg.src = "";
previewWrap.style.display = "none";
filenameEl.textContent = "";
if (fileInput) fileInput.value = "";
applyBtn.disabled = !pendingMaskFile;
statusEl.textContent = "";
});
// Placeholder: generate (color mode) or reuse (image mode) the shared default
// configured at the top of the panel, and replace this slot's image
// immediately — no separate Apply click needed, matching the "one button,
// one slot" behavior described for this feature.
const placeholderBtn = el.querySelector(`.wfm-i2i-placeholder-btn[data-slot="${i}"]`);
placeholderBtn?.addEventListener("click", async () => {
const cfg = _loadI2IPlaceholderConfig();
placeholderBtn.disabled = true;
statusEl.textContent = t("i2iPlaceholderGenerating");
try {
let filename;
if (cfg.mode === "image") {
if (!cfg.imageFilename) throw new Error(t("i2iPlaceholderNoDefaultImage"));
filename = cfg.imageFilename;
} else {
const file = await _generateColorImageFile(cfg.width, cfg.height, cfg.color);
const result = await comfyUI.uploadImage(file, file.name);
if (!result.name) throw new Error("Upload returned no filename");
filename = result.name;
}
if (comfyUI.currentWorkflow?.[node.id]) {
comfyUI.currentWorkflow[node.id].inputs.image = filename;
}
pendingFile = null;
previewImg.src = `/view?filename=${encodeURIComponent(filename)}&type=input`;
previewWrap.style.display = "";
filenameEl.textContent = filename;
applyBtn.disabled = !pendingMaskFile;
statusEl.textContent = `✓ ${filename}`;
statusEl.style.color = "var(--wfm-success)";
_syncRawJson();
} catch (err) {
statusEl.textContent = `✗ ${err.message}`;
statusEl.style.color = "var(--wfm-danger)";
} finally {
placeholderBtn.disabled = false;
}
});
});
_wireI2IPlaceholderConfig(el);
// Initialize each Mask Editor One slot(画像+マスク両方が揃って初めてApply可能)
meoSlots.forEach((node, i) => {
const fileInput = document.getElementById(`wfm-meo-file-${i}`);
@@ -1626,6 +2089,75 @@ export const comfyEditor = {
if (!workflow) _syncRawJson();
},
// Resolves a user/LLM-supplied aspect ratio (e.g. "3:2" or the full COMBO label
// "3:2 (Photo)") against a ResolutionSelector-type node's actual options via
// /object_info — the workflow analysis only knows the node's CURRENT value, not its
// full option list, so a bare "3:2" needs matching against whatever labeled string
// that node's aspect_ratio COMBO actually accepts. Falls back to the raw input if
// the node type can't be queried or nothing matches, so ComfyUI's own validation
// surfaces a clear error instead of this silently doing nothing.
async resolveAspectRatioOption(nodeType, requested) {
if (!requested) return null;
const ratioMatch = String(requested).match(/(\d+)\s*:\s*(\d+)/);
const wantedRatio = ratioMatch ? `${ratioMatch[1]}:${ratioMatch[2]}` : String(requested).trim();
try {
const info = await comfyUI.fetchObjectInfo(nodeType);
const options = info?.[nodeType]?.input?.required?.aspect_ratio?.[1]?.options || [];
if (options.includes(requested)) return requested;
return options.find((o) => o.startsWith(wantedRatio)) || requested;
} catch {
return requested;
}
},
// AI TOOL Chat's generate_image tool call — writes whichever of KSampler
// (steps/cfg/sampler_name/scheduler/denoise/seed), Latent Image (width/height/
// batch_size), and Resolution Selector (resolvedAspectRatio) fields the LLM
// supplied onto the given workflow/analysis (or comfyUI.currentWorkflow/
// currentAnalysis by default). Mirrors lab-tab.js's _buildWorkflowForIteration
// KSampler branch — same per-field node-id indirection (stepsNodeId/cfgNodeId/...
// falling back to sampler.id) for Advanced-Sampling workflows (Flux.1/2, SD3.5,
// Chroma...) that spread these across several nodes.
// seed is returned rather than written directly — the caller passes it through to
// comfyUI.generate()'s seedMode/seedValue instead, same as every other seed source.
// resolvedAspectRatio must already be one of the node's actual option strings (see
// resolveAspectRatioOption above) — passed in rather than resolved here because
// that lookup is async and this stays a plain synchronous writer.
setGenerationParams({ steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size, resolvedAspectRatio, workflow, analysis } = {}) {
const wf = workflow || comfyUI.currentWorkflow;
const an = analysis || comfyUI.currentAnalysis;
if (!wf || !an) return { seed: null };
const sampler = an.sampler_nodes?.[0];
if (sampler) {
if (steps != null) { const id = sampler.stepsNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.steps = steps; }
if (cfg != null) { const id = sampler.cfgNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.cfg = cfg; }
if (sampler_name) { const id = sampler.samplerNodeId ?? sampler.id; if (wf[id]) wf[id].inputs.sampler_name = sampler_name; }
if (scheduler && sampler.schedulerNodeId != null && wf[sampler.schedulerNodeId]) {
wf[sampler.schedulerNodeId].inputs.scheduler = scheduler;
}
if (denoise != null && sampler.denoiseNodeId != null && wf[sampler.denoiseNodeId]) {
wf[sampler.denoiseNodeId].inputs.denoise = denoise;
}
}
// Resolution Selector takes priority over direct width/height — writing width/
// height straight onto the EmptyLatentImage would just clobber whatever the
// selector computed, silently ignoring the requested aspect ratio.
const resSelector = an.resolution_selector_nodes?.[0];
const latent = an.latent_nodes?.[0];
if (resolvedAspectRatio && resSelector && wf[resSelector.id]) {
wf[resSelector.id].inputs.aspect_ratio = resolvedAspectRatio;
} else if (latent && wf[latent.id]) {
if (width != null) wf[latent.id].inputs.width = width;
if (height != null) wf[latent.id].inputs.height = height;
}
if (latent && wf[latent.id] && batch_size != null) wf[latent.id].inputs.batch_size = batch_size;
if (!workflow) _syncRawJson();
return { seed: seed ?? null };
},
syncToWorkflow() {
// Sync prompt texts before generation
const posSelect = document.getElementById("wfm-prompt-pos-target");
@@ -1752,7 +2284,7 @@ async function _initTextEncoderSection(analysis, textEncoders, containerEl) {
const deviceOpts = ["default", "cpu"].map(d => `<option value="${d}" ${d === currentDevice ? "selected" : ""}>${d}</option>`).join("");
section.innerHTML = `
<label>Text Encoder</label>
<label class="wfm-model-label-active">Text Encoder</label>
<input type="text" class="wfm-input" id="wfm-te-filter" placeholder="Filter..." style="margin-bottom:4px;">
<select class="wfm-select" id="wfm-te-clip1" style="margin-bottom:4px;">${clip1Opts}</select>
${isDual ? `<select class="wfm-select" id="wfm-te-clip2" style="margin-bottom:4px;">${clip2Opts}</select>` : ""}
File diff suppressed because it is too large Load Diff
+507 -35
View File
@@ -5,12 +5,16 @@
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { loadFileIntoMetadataTab } from "./metadata-tab.js";
import { loadFileIntoMetadataTab, extractPrompts, buildPromptItem } from "./metadata-tab.js";
import { loadWorkflowIntoEditor } from "./generate-tab.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
import { escapeHtml, setupSearchClearBtn, getAiBackendDefaultUrl } from "./util.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
import { comfyUI } from "./comfyui-client.js";
import { comfyEditor } from "./comfyui-editor.js";
import { callLLM, loadAiSettings, isValidBackendUrl } from "./ai-tab.js";
import { initImagePromptTab, activateImagePromptTab } from "./image-prompt-tab.js";
import { initStyleCatalogTab, activateStyleCatalogTab } from "./style-catalog-tab.js";
import { applyStoredVideoVolume } from "./settings-tab.js";
// ── 定数 ─────────────────────────────────────────────────────
@@ -39,9 +43,27 @@ const API = {
imagesDelete: `/wfm/gallery/images/delete`,
imagesMove: `/wfm/gallery/images/move`,
imagesExportZip: `/wfm/gallery/images/export-zip`,
imagesExportPsd: `/wfm/gallery/images/export-psd`,
};
export const FEEDER_GROUP = "__Feeder__";
export const VIDEO_GROUP = "__Video Assets__";
// Video Planを実行するたびに生成された動画が自動的に追加される作業用グループ
// (Plan保存の有無に関わらず常にここへ入る)。__Video Assets__(ユーザーが手動で
// キュレーションする素材グループ)への整理はユーザー任意のタイミングで行う。
export const VTEMP_GROUP = "__Video Temp__";
const _RESERVED_GROUPS = [FEEDER_GROUP, VIDEO_GROUP, VTEMP_GROUP];
function _isReservedGroup(name) {
return _RESERVED_GROUPS.includes(name);
}
// 拡張子から動画ファイル(mp4)かどうかを判定する。
// 一覧アイテムは path/filename、詳細メタ(get_image_metadata)は ext フィールドを持つため両対応。
export function isVideoFile(img) {
const source = img?.ext || img?.filename || img?.path || "";
return source.toLowerCase().endsWith(".mp4");
}
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Generate UIのImage入力スロットへ直接セットする(I2I連携)。
// Comic Creater側の「I2Iへ送る」ボタンから
@@ -103,10 +125,117 @@ window._wfmReceiveInpaintRequest = async (imageBlob, maskBlob, params, workflowD
}
};
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Image Edit タブの
// Select I2I機能をUIを開かずに直接実行して結果URLを返す(I2I連携、マスク不要)。
// Comic Creater側の Image タブ「Select→I2I」から
// iframe.contentWindow._wfmReceiveI2IRunRequest(imageBlob, params, workflowData?, workflowFilename?) として呼ばれる。
// workflowData が渡された場合(Comic Creater側のI2I設定でデフォルトワークフローが有効な時)は、
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
window._wfmReceiveI2IRunRequest = async (imageBlob, params, workflowData, workflowFilename) => {
try {
if (workflowData) {
if (!comfyUI.connected) {
await comfyUI.checkConnection();
}
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
await comfyEditor.loadModelLists();
}
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
}
return await window._wfmImageEditTab.runI2IExternal(imageBlob, params);
} catch (e) {
return { ok: false, message: e.message };
}
};
// ComfyUI Comic Creater からiframe越しにコマの設定(シーン・要素・セリフ等)を受け取り、
// AIタブの設定タブと同じLLM接続設定(Ollama/LM Studio)で画像生成プロンプトの下書きを
// 生成して返す(半自動マンガ作成の「L」ボタン連携用)。
// Comic Creater側のスクリプトタブ「L」ボタンから
// iframe.contentWindow._wfmReceiveLLMPromptRequest(context) として呼ばれる。
// context: { scene, elements, dialogues, existingPrompt }(いずれも文字列、空文字可)
window._wfmReceiveLLMPromptRequest = async (context) => {
try {
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) return { ok: false, message: "AI settings: invalid backend URL (configure it in the Settings tab)" };
if (!model) return { ok: false, message: "AI settings: no model selected (configure it in the Settings tab)" };
const { scene = "", elements = "", dialogues = "", existingPrompt = "" } = context || {};
const prompt = [
"Create a concise Stable Diffusion image generation prompt (English, comma-separated tags/phrases describing scene, composition, character appearance and action) for one manga panel, based on the following context.",
"Output only the prompt text, nothing else.",
scene ? `Scene: ${scene}` : null,
elements ? `Characters/elements: ${elements}` : null,
dialogues ? `Dialogue: ${dialogues}` : null,
existingPrompt ? `Current draft prompt (revise/improve it): ${existingPrompt}` : null,
].filter(Boolean).join("\n");
const result = await callLLM(url, backend, model, prompt);
const text = (result || "").trim();
if (!text) return { ok: false, message: "LLM returned an empty response" };
return { ok: true, text };
} catch (e) {
return { ok: false, message: e.message };
}
};
// ComfyUI Comic Creater からiframe越しに画像プロンプト・サイズを受け取り、Generate UIで
// テキストから画像を生成して結果URLを返す(半自動マンガ作成のコマ単位バッチ画像生成用)。
// Comic Creater側のスクリプトタブ「画像を一括生成」から
// iframe.contentWindow._wfmReceiveGenerateRequest(prompt, width, height, negative, workflowData?, workflowFilename?)
// として呼ばれる。I2I/Inpaintと異なり画像アップロードは行わない(txt2img)。
// workflowData が渡された場合(Comic Creater側のT2I設定でデフォルトワークフローが有効な時)は、
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, workflowData, workflowFilename) => {
try {
if (!window._wfmGenerateTab?.generate) return { ok: false, message: "Generate UI is not ready yet" };
if (workflowData) {
try {
if (!comfyUI.connected) {
await comfyUI.checkConnection();
}
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
await comfyEditor.loadModelLists();
}
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
} catch (e) {
console.warn("[T2I] failed to load default workflow:", e);
}
}
const analysis = comfyUI.currentAnalysis;
if (!comfyUI.currentWorkflow || !analysis) return { ok: false, message: "No workflow loaded in Generate UI" };
const workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
const latent = analysis.latent_nodes?.[0];
if (latent && width && height && workflow[latent.id]) {
workflow[latent.id].inputs.width = width;
workflow[latent.id].inputs.height = height;
}
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
if (negative) comfyEditor.setPromptText("negative", negative, { workflow, analysis });
await window._wfmGenerateTab.generate(workflow);
const resultUrl = document.getElementById("wfm-gen-result-img")?.src;
if (!resultUrl) return { ok: false, message: "No result image produced" };
return { ok: true, url: resultUrl };
} catch (e) {
return { ok: false, message: e.message };
}
};
// ── ページング ────────────────────────────────────────────────
const PAGE_SIZE = 50;
let _renderedCount = 0;
let _scrollObserver = null;
let _loadImagesAbortController = null;
let _searchDebounceTimer = null;
const SEARCH_DEBOUNCE_MS = 300;
// ── 状態 ─────────────────────────────────────────────────────
@@ -146,6 +275,13 @@ function formatDate(mtime) {
return new Date(mtime * 1000).toLocaleString();
}
function formatDuration(seconds) {
const total = Math.round(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
async function openImageInMetadataTab(img) {
try {
const res = await fetch(API.serveImage(img.path));
@@ -201,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() {
@@ -296,7 +456,7 @@ function renderTreeNode(node, container, depth, isRoot) {
// フォルダ名
const label = document.createElement("span");
label.className = "wfm-gallery-tree-label";
label.textContent = isRoot ? `[root] ${node.name}` : node.name;
label.textContent = isRoot ? "[root]" : node.name;
if (node.image_count > 0) {
const badge = document.createElement("span");
badge.className = "wfm-gallery-tree-count";
@@ -346,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);
}
@@ -356,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>`;
@@ -367,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;
}
}
}
@@ -471,6 +662,14 @@ function createThumbCard(img) {
};
card.appendChild(imgEl);
// 動画ファイルは再生アイコンバッジを重ねて表示(サムネイル自体は先頭フレームのJPEG)
if (isVideoFile(img)) {
const playBadge = document.createElement("div");
playBadge.className = "wfm-gallery-thumb-video-badge";
playBadge.textContent = "▶";
card.appendChild(playBadge);
}
// お気に入りトグルボタン(カード右上)
const favBtn = document.createElement("button");
favBtn.className = `wfm-gallery-thumb-fav-btn${img.favorite ? " active" : ""}`;
@@ -770,18 +969,20 @@ async function loadImageDetail(img) {
if (moveBtn) moveBtn.disabled = false;
if (delBtn) delBtn.disabled = false;
// プレビュー
// プレビュー(mp4は<video controls>、それ以外は<img>)
const preview = document.getElementById("wfm-gallery-detail-preview");
const isVideo = isVideoFile(img);
const mediaHtml = isVideo
? `<video src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" controls title="Double-click to enlarge"></video>`
: `<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${escapeHtml(img.filename)}" title="Double-click to enlarge">`;
preview.innerHTML = `
<div class="wfm-gallery-preview-wrapper">
<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${escapeHtml(img.filename)}" title="Double-click to enlarge">
<div class="wfm-gallery-preview-overlay">
<button class="wfm-gallery-download-btn" title="Download image">⬇</button>
</div>
${mediaHtml}
</div>
`;
preview.querySelector("img").addEventListener("dblclick", () => openLightbox(img));
preview.querySelector(".wfm-gallery-download-btn").addEventListener("click", () => downloadImage(img));
const previewMediaEl = preview.querySelector(isVideo ? "video" : "img");
previewMediaEl.addEventListener("dblclick", () => openLightbox(img));
if (isVideo) applyStoredVideoVolume(previewMediaEl);
// ファイル名
document.getElementById("wfm-gallery-detail-filename").textContent = img.filename;
@@ -806,15 +1007,45 @@ async function loadImageDetail(img) {
state.embeddedWorkflow = wfRes.has_workflow ? wfRes.workflow : null;
renderWorkflowJson(state.embeddedWorkflow);
_updateCopyCanvasBtn();
renderImagePromptSection(metaRes.image_prompt);
renderDimensionInfo(metaRes);
// Promptタブは prompt_workflow (API形式優先) を使う。トップレベルとサブグラフに
// 独立した複数系統を持つワークフローでは workflow (UI形式) からの抽出だと
// トップレベル系統しか拾えないことがあるため(Metadataタブと同じ優先順位に揃える)。
renderPromptTab(wfRes.prompt_workflow ?? state.embeddedWorkflow);
} catch (e) {
renderWorkflowJson(null);
_updateCopyCanvasBtn();
renderImagePromptSection(null);
renderDimensionInfo(null);
renderPromptTab(null);
}
// グループタブ更新
renderDetailGroup(img);
}
function renderDimensionInfo(metaRes) {
const el = document.getElementById("wfm-gallery-info-dim");
if (!el) return;
const parts = [];
if (metaRes?.width && metaRes?.height) parts.push(`${metaRes.width}×${metaRes.height}`);
if (metaRes?.duration) parts.push(formatDuration(metaRes.duration));
el.textContent = parts.length ? parts.join(" · ") : "";
}
function renderImagePromptSection(imagePrompt) {
const section = document.getElementById("wfm-gallery-image-prompt-section");
const text = document.getElementById("wfm-gallery-image-prompt-text");
if (!section || !text) return;
if (imagePrompt) {
text.textContent = imagePrompt;
section.style.display = "";
} else {
section.style.display = "none";
}
}
function renderTagsDisplay(tags) {
const container = document.getElementById("wfm-gallery-tags-display");
container.innerHTML = "";
@@ -843,6 +1074,44 @@ function renderWorkflowJson(workflow) {
}
}
// 埋め込みワークフローからpositive/negativeプロンプトを抽出してPromptタブに表示する。
// 複数候補があるケース(PromptStyler等で正負が複数生成される場合)に対応するため、
// Metadataタブと同じ POS/NEGリスト → クリックで全文表示のUI(buildPromptItem)をそのまま
// 再利用する。選択画像を切り替えるたびにマウス操作無しで読めるよう、先頭項目を自動選択する。
function renderPromptTab(workflow) {
const listEl = document.getElementById("wfm-gallery-prompt-list");
const fullArea = document.getElementById("wfm-gallery-prompt-full");
const fullLabel = document.getElementById("wfm-gallery-prompt-full-label");
if (!listEl || !fullArea || !fullLabel) return;
listEl.innerHTML = "";
fullArea.value = "";
fullLabel.textContent = "";
if (!workflow) {
listEl.innerHTML = `<div class="wfm-meta-item" style="opacity:0.6;">No workflow embedded in this image.</div>`;
return;
}
const { positives, negatives, texts } = extractPrompts(workflow);
const allPrompts = [
...positives.map(p => ({ type: "positive", text: p })),
...negatives.map(p => ({ type: "negative", text: p })),
...(texts ?? []).map(p => ({ type: "text", text: p })),
];
if (allPrompts.length === 0) {
listEl.innerHTML = `<div class="wfm-meta-item" style="opacity:0.6;">No prompt found.</div>`;
return;
}
for (const { type, text } of allPrompts) {
listEl.appendChild(buildPromptItem(text, type, text, fullArea, fullLabel, listEl));
}
// Auto-select first item (positive prompts come first in allPrompts)
listEl.querySelector(".wfm-meta-item-clickable")?.click();
}
// ── タグ操作 ─────────────────────────────────────────────────
async function addTag(tag) {
@@ -850,7 +1119,8 @@ async function addTag(tag) {
const tags = [...(state.selectedImage.tags || [])];
if (tags.includes(tag.trim())) return;
tags.push(tag.trim());
await saveMetaField({ tags });
const ok = await saveMetaField({ tags });
if (!ok) return;
state.selectedImage.tags = tags;
renderTagsDisplay(tags);
updateTagFilter(state.images);
@@ -859,24 +1129,38 @@ async function addTag(tag) {
async function removeTag(tag) {
if (!state.selectedImage) return;
const tags = (state.selectedImage.tags || []).filter(t => t !== tag);
await saveMetaField({ tags });
const ok = await saveMetaField({ tags });
if (!ok) return;
state.selectedImage.tags = tags;
renderTagsDisplay(tags);
updateTagFilter(state.images);
}
// 戻り値: 保存が実際にサーバーへ永続化されたか(true/false)。呼び出し元はこれを見て
// ローカル状態(state.selectedImage等)の更新可否を判断すること — でないと、サーバーが
// 保存を拒否した場合にもUI上は成功したように見えてしまう(リロードすると消える不具合の原因)。
async function saveMetaField(fields) {
if (!state.selectedImage) return;
if (!state.selectedImage) return false;
try {
await fetch(API.saveImageMeta, {
const res = await fetch(API.saveImageMeta, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.selectedImage.path, ...fields }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
// サーバー側は常にHTTP 200を返し、成否は body.ok (bool) で表す設計
// (gallery_routes.py save_image_meta)。ここをチェックしないと、
// 許可パス外などでサーバーが保存を拒否してもUI上は成功したように見えてしまう。
if (json.error || json.ok === false) {
throw new Error(json.error || "Save rejected by server");
}
const idx = state.images.findIndex(i => i.path === state.selectedImage.path);
if (idx >= 0) Object.assign(state.images[idx], fields);
return true;
} catch (e) {
showToast(t("saveFailed", e.message), "error");
return false;
}
}
@@ -910,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 {
@@ -924,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() {
// ツールバー: グループフィルタ
@@ -1003,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")}">&#9998;</button>
${allGroups.length === 0 || _isReservedGroup(allGroups[0]) ? "disabled" : ""} title="${t("modelsRename")}">&#9998;</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")}">&times;</button>
${allGroups.length === 0 || _isReservedGroup(allGroups[0]) ? "disabled" : ""} title="${t("modelsDelete")}">&times;</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>
`;
@@ -1090,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");
@@ -1419,9 +1747,12 @@ async function bulkSetFavorite(favoriteValue) {
function openLightbox(img) {
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox";
const mediaHtml = isVideoFile(img)
? `<video src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" controls autoplay></video>`
: `<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">`;
overlay.innerHTML = `
<div class="wfm-gallery-lightbox-inner">
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
${mediaHtml}
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
<button class="wfm-gallery-lightbox-close">&times;</button>
</div>
@@ -1432,6 +1763,7 @@ function openLightbox(img) {
}
});
document.body.appendChild(overlay);
if (isVideoFile(img)) applyStoredVideoVolume(overlay.querySelector("video"));
}
// ── 画像比較ライトボックス ─────────────────────────────────────
@@ -1443,12 +1775,17 @@ function openCompare(paths) {
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox wfm-lightbox-compare";
const itemsHtml = imgs.map(img => `
const itemsHtml = imgs.map(img => {
const mediaHtml = isVideoFile(img)
? `<video src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" controls></video>`
: `<img src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" alt="${escapeHtml(img.filename)}" loading="lazy">`;
return `
<div class="wfm-lightbox-compare-item">
<img src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" alt="${escapeHtml(img.filename)}" loading="lazy">
${mediaHtml}
<div class="wfm-lightbox-compare-label">${escapeHtml(img.filename)}</div>
</div>
`).join("");
`;
}).join("");
overlay.innerHTML = `
<div class="wfm-lightbox-compare-inner">
@@ -1462,6 +1799,7 @@ function openCompare(paths) {
}
});
document.body.appendChild(overlay);
overlay.querySelectorAll("video").forEach(applyStoredVideoVolume);
}
// ── outputパス取得 ────────────────────────────────────────────
@@ -1501,6 +1839,9 @@ export function initGalleryTab() {
}
bindEvents();
_initGallerySubtabToggle();
initImagePromptTab();
initStyleCatalogTab();
}
let _initialized = false;
@@ -1512,6 +1853,24 @@ function onGalleryTabActivated() {
loadGroups();
}
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog / Metadata) ────────────
function _initGallerySubtabToggle() {
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const target = btn.dataset.gallerySubtab;
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
document.getElementById("wfm-gallery-panel-output")?.classList.toggle("active", target === "output");
document.getElementById("wfm-gallery-panel-imageprompt")?.classList.toggle("active", target === "imageprompt");
document.getElementById("wfm-gallery-panel-styleCatalog")?.classList.toggle("active", target === "styleCatalog");
document.getElementById("wfm-gallery-panel-metadata")?.classList.toggle("active", target === "metadata");
if (target === "imageprompt") activateImagePromptTab();
if (target === "styleCatalog") activateStyleCatalogTab();
});
});
}
function bindEvents() {
// リフレッシュ
document.getElementById("wfm-gallery-refresh-btn")?.addEventListener("click", () => {
@@ -1520,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 タブへ送信
@@ -1566,6 +1940,34 @@ function bindEvents() {
}
});
// 選択画像をComfyUIキャンバス上で選択中のノード(LoadImage系)のimageウィジェットへ送信。
// Image Editタブの「Send to LI node」と同じ橋渡し(window.opener.wfmSendImageToSelectedNode、
// node_sets_menu.js側で定義)を使う。Galleryの画像はGalleryルート配下の任意フォルダにあり得るため、
// GenUI送信と違いLoadImageノードの有無に依存しない — /upload/imageでinputフォルダへ
// アップロードしてから、そのファイル名を書き込む。
document.getElementById("wfm-gallery-send-li-node-btn")?.addEventListener("click", async () => {
if (!state.selectedImage) {
showToast(t("gallerySelectImageFirst"), "info");
return;
}
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
showToast(t("sendToLiNodeNoOpener"), "error");
return;
}
try {
const res = await fetch(API.serveImage(state.selectedImage.path));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const file = new File([blob], state.selectedImage.filename || "gallery-image.png", { type: blob.type || "image/png" });
const result = await comfyUI.uploadImage(file, file.name);
const filename = result.subfolder ? `${result.subfolder}/${result.name}` : result.name;
window.opener.wfmSendImageToSelectedNode(filename);
showToast(t("sendToLiNodeSuccess", filename), "success");
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
});
// 選択画像を ComfyUI Comic Creator の選択コマ/オーバーレイへ送信
// (ComfyUI Comic CreaterからこのGalleryタブがiframe埋め込みされている場合のみ表示・動作する)
const sendCcBtn = document.getElementById("wfm-gallery-send-cc-btn");
@@ -1584,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'なら従来通りコマ/オーバーレイへ挿入)
@@ -1594,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") {
@@ -1611,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") {
@@ -1628,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();
});
@@ -1699,7 +2130,7 @@ function bindEvents() {
// メモ保存
document.getElementById("wfm-gallery-memo-save-btn")?.addEventListener("click", () => {
const memo = document.getElementById("wfm-gallery-memo").value;
saveMetaField({ memo }).then(() => showToast(t("memoSaved"), "success"));
saveMetaField({ memo }).then((ok) => { if (ok) showToast(t("memoSaved"), "success"); });
});
// ワークフローコピー&キャンバスへ送る
@@ -1713,7 +2144,7 @@ function bindEvents() {
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
window.opener.wfmReceiveWorkflow(state.embeddedWorkflow);
await navigator.clipboard.writeText(JSON.stringify(state.embeddedWorkflow, null, 2)).catch(() => {});
showToast(t("workflowSentToCanvas"), "success");
showToast(t("workflowSentToCanvasDirect"), "success");
return;
}
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
@@ -1787,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;
@@ -1832,6 +2293,16 @@ function bindEvents() {
openImageInTaggerTab(state.selectedImage);
});
// Downloadボタン: プレビューのオーバーレイだと動画の再生コントロール操作を妨げるため、
// アクションボタン行に常時表示のボタンとして配置している
document.getElementById("wfm-gallery-download-btn")?.addEventListener("click", () => {
if (!state.selectedImage) {
showToast(t("gallerySelectImageFirst"), "error");
return;
}
downloadImage(state.selectedImage);
});
// Load in GenerateUI ボタン: 埋め込みワークフローをGenerateUIタブに読み込む
document.getElementById("wfm-gallery-load-genui-btn")?.addEventListener("click", async () => {
if (!state.selectedImage) {
@@ -1871,6 +2342,7 @@ function bindEvents() {
if (galleryClearBtn) {
galleryClearBtn.textContent = t("clearFilters");
galleryClearBtn.addEventListener("click", () => {
clearTimeout(_searchDebounceTimer);
state.search = "";
state.favoriteOnly = false;
state.tagFilter = "";
+361 -69
View File
@@ -9,44 +9,9 @@ import { comfyEditor } from "./comfyui-editor.js";
import { t } from "./i18n.js";
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
import { getSettings, readJsonStorage } from "./util.js";
// ============================================
// Eagle Auto-Save
// ============================================
function getEagleSettings() {
const s = getSettings();
return {
url: s.eagleUrl || "http://localhost:41595",
autoSave: !!s.eagleAutoSave,
};
}
async function saveToEagle(imageUrl, name, tags = []) {
const eagle = getEagleSettings();
if (!eagle.autoSave) return;
try {
const res = await fetch("/api/wfm/eagle/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eagleUrl: eagle.url,
url: imageUrl,
name,
tags: ["wfm-comfyui", ...tags],
}),
});
const data = await res.json();
if (data.status === "success") {
console.log("[Eagle] Saved:", name);
} else {
console.warn("[Eagle] Save failed:", data.message);
}
} catch (err) {
console.warn("[Eagle] Save error:", err.message);
}
}
import { initLabTab, refreshLabLiveDefaults } from "./lab-tab.js";
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
import { isVideoFilename } from "./video-utils.js";
// ============================================
// Gallery Metadata - ワークフロー保存
@@ -200,6 +165,17 @@ async function saveCurrentWorkflow() {
nameEl.dataset.filename = filename;
}
}
// このファイル名が「デフォルトワークフロー」(workflow-tab.jsの「デフォルトに設定」で
// localStorageにスナップショットされたAPI形式JSON、ページ起動時に自動読込される)に
// 一致する場合、保存内容と食い違ったまま残らないようスナップショットも更新する。
// 更新しないと、ここで上書き保存しても次回リロード時に古いモデル等が復活する。
const settings = getSettings();
if (settings.defaultWorkflow === filename) {
settings.defaultWorkflowData = workflow;
localStorage.setItem("wfm_settings", JSON.stringify(settings));
}
showToast(t("savedAs", filename), "success");
} catch (err) {
showToast(t("saveFailed", err.message), "error");
@@ -267,7 +243,9 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
comfyUI.currentAnalysis = comfyWorkflow.analyzeWorkflow(apiWorkflow);
// Render editor tabs
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow);
// resetStackMode: 新規ワークフロー読み込み時のみ、LoraManagerノードのloras/text状態を
// スナップショットし、Stackグループ使用チェックボックスをOFFへリセットする
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow, { resetStackMode: true });
// Update raw JSON with highlight
const rawTextarea = document.getElementById("wfm-gen-raw-json");
@@ -278,6 +256,30 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
syncJsonHighlight(rawHighlight, jsonStr);
}
// Update Bypass/Mute node indicator (shared Raw JSON widget header, visible on
// Input/Model/Settings tabs). API-format JSON has no mode field, so this info is
// only available right after a UI→API conversion — captured via comfyWorkflow's
// getLastBypassedNodes()/getLastMutedNodes() before it's discarded.
const bypassNote = document.getElementById("wfm-gen-rawjson-bypass-note");
if (bypassNote) {
const bypassed = format === "ui" ? comfyWorkflow.getLastBypassedNodes() : [];
const muted = format === "ui" ? comfyWorkflow.getLastMutedNodes() : [];
const lines = [];
if (bypassed.length > 0) {
lines.push(t("rawJsonBypassNote", bypassed.map((n) => `${n.title} (${n.nodeId})`).join(", ")));
}
if (muted.length > 0) {
lines.push(t("rawJsonMutedNote", muted.map((n) => `${n.title} (${n.nodeId})`).join(", ")));
}
if (lines.length > 0) {
bypassNote.innerHTML = lines.map((l) => escapeHtml(l)).join("<br>");
bypassNote.style.display = "";
} else {
bypassNote.textContent = "";
bypassNote.style.display = "none";
}
}
// Update workflow name display
const nameEl = document.getElementById("wfm-gen-wf-name");
if (nameEl) {
@@ -291,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 {
@@ -1275,6 +1284,12 @@ async function _loadStyles() {
_rebuildStyleList();
}
// Promptタブ「Style」サブタブでスタイルを作成・編集・削除した後に呼ばれる。
// GenerateUIのStyleドロップダウン・Batchタブのチェックリストを最新の状態へ再同期する。
export async function refreshStylesList() {
await _loadStyles();
}
function _renderStyleDropdown() {
const sel = document.getElementById("wfm-style-select");
if (!sel) return;
@@ -1303,6 +1318,21 @@ function _rebuildStyleList() {
);
}
// Style Catalogギャラリーの「Select as Style」から呼ばれる。名前が一致する登録済み
// Styleを探し、GenerateUIのStyleドロップダウンをそれに切り替えてタブへ移動する。
// 画像に埋め込まれたプロンプトを直接適用するのではなく、既存Styleシステムへの
// 視覚的なショートカットとして機能する(元のStyle JSONが変更・削除されても影響を受けない)。
export function selectStyleByName(name) {
const style = _stylesData.find((s) => s.name === name);
if (!style) return false;
const sel = document.getElementById("wfm-style-select");
if (sel) sel.value = name;
const enabled = document.getElementById("wfm-style-enabled");
if (enabled) enabled.checked = true;
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
return true;
}
function _applyNamedStyle(workflow, style) {
if (!style) return workflow;
const analysis = comfyUI.currentAnalysis;
@@ -1403,7 +1433,7 @@ async function _expandWildcardText(text) {
const _IMPACT_WC_TYPES = new Set(["ImpactWildcardEncode", "ImpactWildcardProcessor"]);
async function _expandWildcardsInWorkflow(workflow) {
export async function _expandWildcardsInWorkflow(workflow) {
let hasWildcard = false;
outer: for (const node of Object.values(workflow)) {
if (_IMPACT_WC_TYPES.has(node.class_type)) continue;
@@ -1432,26 +1462,38 @@ async function _expandWildcardsInWorkflow(workflow) {
// Generation (core — throws on error)
// ============================================
async function _coreGenerate(silent = false, workflowOverride = null) {
async function _coreGenerate(silent = false, workflowOverride = null, genOptions = {}) {
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultVideo = document.getElementById("wfm-gen-result-video");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
// genOptions.seedValue lets a caller (e.g. AI TOOL Chat's generate_image tool)
// force a specific seed on an override workflow without touching the GenerateUI
// seed fields — those still drive normal (non-override) generation as before.
const seedMode = genOptions.seedValue != null ? "fixed" : (document.getElementById("wfm-gen-seed-mode")?.value || "random");
const seedValue = genOptions.seedValue ?? (parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1);
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
// Style must be applied *before* wildcard expansion so a __name__ reference written
// inside a Style's prompt/negative_prompt also gets expanded, not just wildcards
// already present in the base workflow's prompt nodes.
const baseWorkflow = workflowOverride || comfyUI.currentWorkflow;
const workflowExpanded = await _expandWildcardsInWorkflow({ ...baseWorkflow });
const workflowForGenerate = _applyStyleToWorkflow(workflowExpanded);
const { images, seed } = await comfyUI.generate(
const workflowStyled = _applyStyleToWorkflow({ ...baseWorkflow });
const workflowForGenerate = await _expandWildcardsInWorkflow(workflowStyled);
// Video workflows (e.g. MiniMax H3) run far longer than a still image — use a longer
// timeout so a multi-minute generation doesn't get force-rejected mid-run (see
// comfyWorkflow.isVideoWorkflow and video-tab.js's own 30-minute timeout).
const isVideo = comfyWorkflow.isVideoWorkflow(comfyUI.currentAnalysis);
const { images, seed, svgOutputs } = await comfyUI.generate(
workflowForGenerate,
{
seedMode,
seedValue,
timeoutMs: isVideo ? 30 * 60 * 1000 : undefined,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
@@ -1465,24 +1507,68 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
// type=temp はプレビュー用の一時画像 (PreviewImage, SVG String Preview など) のため
// 生成結果表示からは除外し、実際に保存された成果物のみを表示する
const outputImages = images.filter((img) => img.type !== "temp");
if (svgOutputs?.length > 0) {
// comfyui-tosvg の Save SVG String は images ではなく saved_svg/path で返すため、
// images配列には(あれば)プレビュー用の一時PNGしか入らず実際の保存物と食い違う。
// 実際に保存されたSVGファイルをそのままプレビューに表示する。
// /view はSVGをセキュリティ上application/octet-streamで返すため使えず、
// 代わりにGalleryタブの配信エンドポイント(絶対パス指定・正しいContent-Type)を使う。
const svgUrl = (svg) => `/wfm/gallery/image/serve?path=${encodeURIComponent(svg.path)}`;
if (resultImg && svgOutputs[0].path) {
resultImg.src = svgUrl(svgOutputs[0]);
resultImg.style.display = "block";
}
if (resultThumbs && images.length > 1) {
if (resultThumbs && svgOutputs.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
svgOutputs.forEach((svg, i) => {
if (!svg.path) return;
const thumb = document.createElement("img");
thumb.src = u;
thumb.src = svgUrl(svg);
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultImg.src = thumb.src;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
});
}
} else if (outputImages.length > 0) {
// Video outputs (SaveVideo/PreviewVideo, e.g. MiniMax H3) come back through the
// same {"images":[...]} shape as still images — dispatch to <video> vs <img>
// per-item by extension rather than assuming the whole batch is one media type.
const showMedia = (blob, filename) => {
const url = URL.createObjectURL(blob);
if (isVideoFilename(filename)) {
if (resultVideo) { resultVideo.src = url; resultVideo.style.display = "block"; }
if (resultImg) resultImg.style.display = "none";
} else {
if (resultImg) { resultImg.src = url; resultImg.style.display = "block"; }
if (resultVideo) { resultVideo.pause(); resultVideo.style.display = "none"; }
}
};
const blob = await comfyUI.getImageBlob(outputImages[0]);
showMedia(blob, outputImages[0].filename);
if (resultThumbs && outputImages.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < outputImages.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(outputImages[i]);
const filename = outputImages[i].filename;
const u = URL.createObjectURL(b);
const isVid = isVideoFilename(filename);
const thumb = document.createElement(isVid ? "video" : "img");
thumb.src = u;
if (isVid) thumb.muted = true;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
showMedia(b, filename);
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
@@ -1491,10 +1577,18 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
}
}
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
if (getEagleSettings().autoSave && outputImages.length > 0) {
for (const img of outputImages) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
saveToEagle(viewUrl, img.filename, [], img);
}
}
if (getEagleSettings().autoSave && svgOutputs?.length > 0) {
// comfyui-tosvg の "Save SVG String" は絶対パスを直接返すため、/view を介さず
// サーバー側でそのパスを検証した上で addFromPath させる
for (const svg of svgOutputs) {
saveToEagle("", svg.filename, [], { filename: svg.filename, localPath: svg.path });
}
}
@@ -1512,7 +1606,9 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
// ============================================
// 汎用バッチループ: items を順に applyFn(item) → _coreGenerate() で処理する
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
// onResultFn(item, {images, seed}) が渡された場合、各アイテムの生成結果ごとに呼び出す
// (Style Catalogのカタログ作成が、生成画像をスタイル名で保存するために使用)
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x), onResultFn = null) {
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
const batchCount = document.getElementById("wfm-ckpt-batch-count");
@@ -1547,7 +1643,8 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
try {
await applyFn(item);
await _coreGenerate(true);
const result = await _coreGenerate(true);
if (onResultFn) await onResultFn(item, result);
completed++;
} catch (err) {
if (_ckptBatch.aborted) break;
@@ -1576,6 +1673,157 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
}
}
// ============================================
// Style Catalog 作成
// ============================================
// Batchタブ「Style」サブタブで選択済みのスタイル(_batchStyleSelected)を対象に、
// 現在ロード中のワークフローでそれぞれ生成し、結果をスタイル名でws_style_catalog
// 配下の指定フォルダへ保存する(Style Catalogギャラリーからの視覚的スタイル選択に使う)。
function _blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function _flattenFolderTree(node, acc = [], isRoot = true) {
acc.push({ path: isRoot ? "[root]" : node.path, abs_path: node.abs_path });
(node.children || []).forEach((c) => _flattenFolderTree(c, acc, false));
return acc;
}
async function openCatalogCreateModal() {
const list = _stylesData.filter((s) => _batchStyleSelected.has(s.name));
if (list.length === 0) {
showToast(t("catalogCreateNoStyles"), "error");
return;
}
let catalogRoot = null;
let folderTree = null;
try {
const rootRes = await fetch("/wfm/gallery/style-catalog/root");
const rootData = await rootRes.json();
catalogRoot = rootData.root;
if (!catalogRoot) throw new Error(rootData.error || "Style Catalog root not resolved");
const treeRes = await fetch(`/wfm/gallery/folders?root=${encodeURIComponent(catalogRoot)}`);
folderTree = await treeRes.json();
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
return;
}
const folderOptions = folderTree && !folderTree.error ? _flattenFolderTree(folderTree) : [];
const html = `
<p style="margin:0 0 10px;font-size:13px;color:var(--wfm-text-secondary);">${t("catalogCreateSelectedCount", list.length)}</p>
<div style="display:flex;flex-direction:column;gap:8px;">
<label style="display:flex;align-items:center;gap:6px;">
<input type="radio" name="wfm-catalog-dest" value="new" checked>
${t("catalogCreateNewFolder")}
</label>
<input type="text" id="wfm-catalog-new-folder-name" class="wfm-input" placeholder="folder name" style="margin-left:22px;">
<label style="display:flex;align-items:center;gap:6px;">
<input type="radio" name="wfm-catalog-dest" value="existing" ${folderOptions.length === 0 ? "disabled" : ""}>
${t("catalogCreateExistingFolder")}
</label>
<select id="wfm-catalog-existing-folder" class="wfm-input" style="margin-left:22px;" ${folderOptions.length === 0 ? "disabled" : ""}>
${folderOptions.map((f) => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.path)}</option>`).join("")}
</select>
<hr class="wfm-group-divider" style="margin:4px 0;">
<label style="display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="wfm-catalog-skip-output">
${t("catalogCreateSkipOutput")}
</label>
</div>
<button id="wfm-catalog-run-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:14px;">${t("catalogCreateRun")}</button>
`;
openModal(t("catalogCreateTitle"), html);
document.getElementById("wfm-catalog-run-btn")?.addEventListener("click", async () => {
const destType = document.querySelector('input[name="wfm-catalog-dest"]:checked')?.value;
let destFolder = null;
if (destType === "new") {
const name = document.getElementById("wfm-catalog-new-folder-name")?.value.trim();
if (!name) {
showToast(t("catalogCreateFolderNameRequired"), "error");
return;
}
try {
const res = await fetch("/wfm/gallery/folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent: catalogRoot, name }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || "Failed to create folder");
destFolder = data.path;
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
return;
}
} else {
destFolder = document.getElementById("wfm-catalog-existing-folder")?.value || null;
if (!destFolder) {
showToast(t("catalogCreateFolderRequired"), "error");
return;
}
}
const skipOutput = document.getElementById("wfm-catalog-skip-output")?.checked || false;
closeModal();
await _runCatalogCreate(list, destFolder, skipOutput);
});
}
async function _runCatalogCreate(list, destFolder, skipOutput) {
try {
await _runBatchLoop(
list,
(style) => { _batchStyleOverride = style; },
(s) => s.name,
async (style, result) => {
const outputImages = (result?.images || []).filter((img) => img.type !== "temp");
if (outputImages.length === 0) return;
const srcImage = outputImages[0];
const blob = await comfyUI.getImageBlob(srcImage);
const dataUrl = await _blobToDataUrl(blob);
const res = await fetch("/wfm/gallery/image/save-to-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder: destFolder, filename: style.name, imageData: dataUrl }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || "Failed to save catalog image");
// Outputフォルダに元画像を残さないオプション: カタログへのコピー保存が
// 成功した後にのみ、ComfyUIのOutputフォルダ側の元ファイルを削除する
// (コピー失敗時は削除せず、元画像を保持したままにする)
if (skipOutput) {
try {
await fetch("/wfm/gallery/output-image/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: srcImage.filename,
subfolder: srcImage.subfolder || "",
type: srcImage.type || "output",
}),
});
} catch { /* 削除失敗はカタログ作成自体を止めない */ }
}
}
);
} finally {
_batchStyleOverride = null;
}
}
// アクティブなバッチタイプに応じてバッチを実行するディスパッチャ
async function _runBatchGenerate() {
switch (_activeBatchType) {
@@ -1768,9 +2016,11 @@ async function _runBatchGenerate() {
// ============================================
// workflowOverride: 明示的に渡された場合、comfyUI.currentWorkflow の代わりにこのワークフローで
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行から利用)。
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行や
// AI TOOL Chatのgenerate_imageツールから利用)。
// genOptions: 現状 seedValue のみ(指定時は_coreGenerateがseedMode "fixed"扱いにする)。
// ボタンのclickハンドラから呼ばれる場合は第一引数がMouseEventになるため、その場合は無視する。
async function handleGenerate(workflowOverride = null) {
async function handleGenerate(workflowOverride = null, genOptions = {}) {
if (workflowOverride instanceof Event) workflowOverride = null;
if (!workflowOverride && !comfyUI.currentWorkflow) {
@@ -1799,7 +2049,7 @@ async function handleGenerate(workflowOverride = null) {
await _runBatchGenerate();
} else {
try {
return await _coreGenerate(false, workflowOverride);
return await _coreGenerate(false, workflowOverride, genOptions);
} catch (err) {
const progressText = document.getElementById("wfm-gen-progress-text");
if (progressText) progressText.textContent = "Error";
@@ -1864,6 +2114,25 @@ export async function initGenerateTab() {
}
});
// Style Catalog 作成ボタン
const catalogCreateBtn = document.getElementById("wfm-gen-catalog-create-btn");
if (catalogCreateBtn) {
catalogCreateBtn.textContent = t("catalogCreateBtn");
catalogCreateBtn.addEventListener("click", () => {
openCatalogCreateModal().catch((e) => showToast(t("errorWithMsg", e.message), "error"));
});
}
// Style Catalog を開くボタン(Gallery タブ → Style_Catalog サブタブへ遷移)
const catalogOpenBtn = document.getElementById("wfm-gen-catalog-open-btn");
if (catalogOpenBtn) {
catalogOpenBtn.textContent = t("catalogOpenBtn");
catalogOpenBtn.addEventListener("click", () => {
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="styleCatalog"]')?.click();
});
}
// Generate button
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", () => {
handleGenerate().catch(() => {});
@@ -1892,7 +2161,7 @@ export async function initGenerateTab() {
function moveRawJsonToTab(tabKey) {
const widget = document.getElementById("wfm-gen-rawjson-widget");
if (!widget) return;
if (tabKey === "feeder" || tabKey === "batch") {
if (tabKey === "feeder" || tabKey === "batch" || tabKey === "lab") {
widget.style.display = "none";
return;
}
@@ -1916,6 +2185,17 @@ export async function initGenerateTab() {
if (target === "model" && comfyUI.currentAnalysis) {
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
}
// Re-render Lab's column grid so Setting 1's live-reflected cells (checkpoint/
// VAE/prompt/KSampler) pick up whatever is currently loaded in GenerateUI, not
// whatever was loaded the last time Lab was rendered.
if (target === "lab") refreshLabLiveDefaults();
// Lab has its own Run/Results panel — hide the shared Generate/Seed/Batch column
const rightCol = document.querySelector(".wfm-gen-right");
if (rightCol) rightCol.style.display = (target === "lab") ? "none" : "";
// Save button writes the currently loaded workflow as-is — not meaningful while
// browsing Batch/Lab (which operate on workflow copies / their own Plan files)
const saveBtn = document.getElementById("wfm-gen-save-btn");
if (saveBtn) saveBtn.style.display = (target === "batch" || target === "lab") ? "none" : "";
});
});
@@ -1926,6 +2206,14 @@ export async function initGenerateTab() {
}
});
// Bypass button in LoRA column header — forces the target LoRA node's strength to
// 0 (or restores it), independent of the Single/Stack tabs' own Apply buttons.
document.getElementById("wfm-lora-bypass-toggle")?.addEventListener("click", () => {
if (!comfyUI.currentAnalysis) return;
comfyEditor.toggleLoraNodeBypass(comfyUI.currentAnalysis);
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
});
// Input inner tab (Prompt / Image)
document.querySelectorAll(".wfm-input-inner-tab").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -2003,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);
@@ -2092,6 +2381,9 @@ export async function initGenerateTab() {
// Feeder tab
await initFeederTab();
// Lab tab (experimental I2I batch generation)
initLabTab();
// Style dropdown
await _loadStyles();
+1521 -100
View File
File diff suppressed because it is too large Load Diff
+640 -1185
View File
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
/**
* Image Edit Tab - Background Removal
* Two backends: a lightweight in-browser model (@imgly/background-removal)
* and BiRefNet via the Mask Editor One server endpoints.
*/
import { showToast } from "../app.js";
export class BgRemove {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(newLayer: object) => void} callbacks.onLayerAdded
*/
constructor(callbacks) {
this._cb = callbacks;
this.birefnetAvailable = false;
}
async checkAvailability() {
try {
const resp = await fetch("/mask_editor/birefnet/status");
if (!resp.ok) return;
const json = await resp.json();
this.birefnetAvailable = json.loaded === true || json.model_found === true;
} catch {
this.birefnetAvailable = false;
}
}
renderPanel(el) {
const birefnetDisabled = this.birefnetAvailable ? "" : "disabled";
const birefnetLabel = this.birefnetAvailable
? "BiRefNet (Mask Editor One)"
: "BiRefNet (Mask Editor One required)";
el.innerHTML = `
<div class="ie-opt-group">
<label>Model</label>
<select id="ie-bgremove-model" class="ie-opt-select">
<option value="imgly">Lightweight (@imgly)</option>
<option value="birefnet" ${birefnetDisabled}>${birefnetLabel}</option>
</select>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;cursor:pointer;">
<input type="checkbox" id="ie-bgremove-new-layer" checked> New Layer
</label>
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm" id="ie-bgremove-btn">Remove BG</button>
</div>
<span id="ie-bgremove-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;"></span>
`;
document.getElementById("ie-bgremove-btn")?.addEventListener("click", () => this.apply());
}
async apply() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
const model = document.getElementById("ie-bgremove-model")?.value ?? "imgly";
const asNew = document.getElementById("ie-bgremove-new-layer")?.checked ?? true;
const statusEl = document.getElementById("ie-bgremove-status");
const btn = document.getElementById("ie-bgremove-btn");
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
if (btn) btn.disabled = true;
setStatus("Starting...");
try {
const dataUrl = layer.canvas.toDataURL("image/png");
let resultDataUrl;
if (model === "imgly") {
resultDataUrl = await this._removeImgly(dataUrl, setStatus);
} else {
resultDataUrl = await this._removeBiRefNet(dataUrl, setStatus);
}
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Result image load failed"));
i.src = resultDataUrl;
});
this._cb.saveUndo();
if (asNew) {
const newL = layerMgr.addLayer("image", layer.name + " (no bg)", {
contentW: img.width, contentH: img.height,
displayW: layer.displayW, displayH: layer.displayH,
x: layer.x, y: layer.y,
});
newL.ctx.drawImage(img, 0, 0);
layerMgr.setActive(newL.id);
this._cb.onLayerAdded(newL);
} else {
layer.canvas.width = img.width;
layer.canvas.height = img.height;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
}
this._cb.updateCompositeView();
this._cb.refreshLayerList();
setStatus("Done!");
setTimeout(() => setStatus(""), 3000);
showToast("Background removed", "success");
} catch (err) {
setStatus("Error: " + err.message);
showToast("BG remove failed: " + err.message, "error");
} finally {
if (btn) btn.disabled = false;
}
}
async _removeImgly(dataUrl, onStatus) {
if (!window._wfmImglyRemoveBg) {
onStatus("Loading model...");
const mod = await import("https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022");
window._wfmImglyRemoveBg = mod.removeBackground;
}
onStatus("Processing...");
const res = await fetch(dataUrl);
const blob = await res.blob();
const resultBlob = await window._wfmImglyRemoveBg(blob, {
publicPath: "https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/",
});
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(resultBlob);
});
}
async _removeBiRefNet(dataUrl, onStatus) {
const NODE_ID = "wfs_bgremove";
// 1. アクティブレイヤー画像を Mask Editor One のキャッシュに登録
onStatus("Sending image...");
const storeResp = await fetch("/mask_editor/store_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
});
if (!storeResp.ok) throw new Error("Failed to cache image for BiRefNet");
// 2. BiRefNet 推論実行
onStatus("Running BiRefNet...");
const resp = await fetch("/mask_editor/birefnet/remove_bg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID }),
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
// 3. グレースケールマスク (白=前景) → RGBA PNG に変換して返す
onStatus("Compositing...");
return await this._applyMaskToImage(dataUrl, json.mask_b64);
}
async _applyMaskToImage(imageB64, maskB64) {
const loadImage = src => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Image load failed: " + src.slice(0, 40)));
img.src = src;
});
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
const w = origImg.naturalWidth;
const h = origImg.naturalHeight;
// マスク: グレースケール輝度値 → アルファチャンネル (RGBA 白マスク)
const maskCanvas = document.createElement("canvas");
maskCanvas.width = w;
maskCanvas.height = h;
const mc = maskCanvas.getContext("2d");
mc.drawImage(maskImg, 0, 0, w, h);
const maskData = mc.getImageData(0, 0, w, h);
const md = maskData.data;
for (let i = 0; i < md.length; i += 4) {
md[i + 3] = md[i]; // 輝度 → アルファ
md[i] = md[i + 1] = md[i + 2] = 255;
}
mc.putImageData(maskData, 0, 0);
// 元画像にマスクを destination-in で適用
const out = document.createElement("canvas");
out.width = w;
out.height = h;
const ctx = out.getContext("2d");
ctx.drawImage(origImg, 0, 0, w, h);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(maskCanvas, 0, 0, w, h);
return out.toDataURL("image/png");
}
}
+259
View File
@@ -0,0 +1,259 @@
/**
* Image Edit Tab - Blur / Mosaic Tool
* Whole-layer blur/mosaic buttons plus a drag-to-select rectangle mode
* that applies blur or mosaic to just the dragged region.
*/
import { showToast } from "../app.js";
function _applyMosaicToRegion(ctx, x, y, w, h, size) {
if (w <= 0 || h <= 0 || size < 1) return;
const imgData = ctx.getImageData(x, y, w, h);
const d = imgData.data;
for (let py = 0; py < h; py += size) {
for (let px = 0; px < w; px += size) {
const i = (py * w + px) * 4;
const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3];
for (let by = py; by < Math.min(py + size, h); by++) {
for (let bx = px; bx < Math.min(px + size, w); bx++) {
const j = (by * w + bx) * 4;
d[j] = r; d[j + 1] = g; d[j + 2] = b; d[j + 3] = a;
}
}
}
}
ctx.putImageData(imgData, x, y);
}
export class BlurTool {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => number} callbacks.getZoom
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(toolId: string) => void} callbacks.renderToolOptions
*/
constructor(callbacks) {
this._cb = callbacks;
this.rectMode = null; // null | 'blur' | 'mosaic'
this._dragging = false;
this._dragStart = null;
this._dragCur = null;
}
activate() {
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.style.cursor = this.rectMode ? "crosshair" : "default";
}
deactivate() {
this.rectMode = null;
this._dragging = false;
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) {
overlay.style.cursor = "";
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
}
renderPanel(el) {
const blurOn = this.rectMode === "blur";
const mosaicOn = this.rectMode === "mosaic";
el.innerHTML = `
<div class="ie-opt-group">
<span style="font-size:11px;color:var(--wfm-text-secondary);">Whole:</span>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">Blur</label>
<input type="range" id="ie-whole-blur" min="1" max="50" value="10" style="width:70px;">
<span id="ie-whole-blur-val" style="font-size:11px;min-width:22px;">10</span>px
<button class="wfm-btn wfm-btn-sm" id="ie-whole-blur-apply">Apply</button>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">Mosaic</label>
<input type="range" id="ie-whole-mosaic" min="5" max="100" value="20" style="width:70px;">
<span id="ie-whole-mosaic-val" style="font-size:11px;min-width:22px;">20</span>px
<button class="wfm-btn wfm-btn-sm" id="ie-whole-mosaic-apply">Apply</button>
</div>
<div style="width:1px;height:22px;background:var(--wfm-border);margin:0 6px;flex-shrink:0;"></div>
<div class="ie-opt-group">
<span style="font-size:11px;color:var(--wfm-text-secondary);">Rect:</span>
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm${blurOn ? " ie-opt-active" : ""}" id="ie-rect-blur-toggle"
style="${blurOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
Rect Blur: ${blurOn ? "ON" : "OFF"}
</button>
<input type="range" id="ie-rect-blur" min="1" max="50" value="10" style="width:70px;">
<span id="ie-rect-blur-val" style="font-size:11px;min-width:22px;">10</span>px
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm${mosaicOn ? " ie-opt-active" : ""}" id="ie-rect-mosaic-toggle"
style="${mosaicOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
Rect Mosaic: ${mosaicOn ? "ON" : "OFF"}
</button>
<input type="range" id="ie-rect-mosaic" min="5" max="50" value="15" style="width:70px;">
<span id="ie-rect-mosaic-val" style="font-size:11px;min-width:22px;">15</span>px
</div>
`;
document.getElementById("ie-whole-blur")?.addEventListener("input", e => {
document.getElementById("ie-whole-blur-val").textContent = e.target.value;
});
document.getElementById("ie-whole-mosaic")?.addEventListener("input", e => {
document.getElementById("ie-whole-mosaic-val").textContent = e.target.value;
});
document.getElementById("ie-whole-blur-apply")?.addEventListener("click", () => {
this.applyWholeBlur(parseInt(document.getElementById("ie-whole-blur").value));
});
document.getElementById("ie-whole-mosaic-apply")?.addEventListener("click", () => {
this.applyWholeMosaic(parseInt(document.getElementById("ie-whole-mosaic").value));
});
document.getElementById("ie-rect-blur-toggle")?.addEventListener("click", () => {
this.rectMode = this.rectMode === "blur" ? null : "blur";
this._cb.renderToolOptions("blur");
const ov = document.getElementById("ie-canvas-overlay");
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
});
document.getElementById("ie-rect-mosaic-toggle")?.addEventListener("click", () => {
this.rectMode = this.rectMode === "mosaic" ? null : "mosaic";
this._cb.renderToolOptions("blur");
const ov = document.getElementById("ie-canvas-overlay");
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
});
document.getElementById("ie-rect-blur")?.addEventListener("input", e => {
document.getElementById("ie-rect-blur-val").textContent = e.target.value;
});
document.getElementById("ie-rect-mosaic")?.addEventListener("input", e => {
document.getElementById("ie-rect-mosaic-val").textContent = e.target.value;
});
}
onMouseDown(pos) {
if (!this.rectMode) return;
this._dragging = true;
this._dragStart = { x: pos.x, y: pos.y };
this._dragCur = { x: pos.x, y: pos.y };
}
onMouseMove(pos) {
if (!this._dragging) return;
this._dragCur = pos;
this._drawPreview();
}
onMouseUp() {
if (!this._dragging) return;
this._dragging = false;
this._applyRectEffect();
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
onMouseLeave() {
if (!this._dragging) return;
this._dragging = false;
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
applyWholeBlur(amount) {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) return;
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
this._cb.saveUndo();
const w = layer.canvas.width, h = layer.canvas.height;
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
const tc = tmp.getContext("2d");
tc.filter = `blur(${amount}px)`;
tc.drawImage(layer.canvas, 0, 0);
layer.ctx.clearRect(0, 0, w, h);
layer.ctx.drawImage(tmp, 0, 0);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
}
applyWholeMosaic(size) {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) return;
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
this._cb.saveUndo();
_applyMosaicToRegion(layer.ctx, 0, 0, layer.canvas.width, layer.canvas.height, size);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
}
// canvas座標 (cx,cy) → layer.canvas 座標への逆変換
_canvasToLayerCoords(layer, cx, cy) {
const centerX = layer.x + layer.displayW / 2;
const centerY = layer.y + layer.displayH / 2;
const dx = cx - centerX, dy = cy - centerY;
const angle = -(layer.rotation || 0) * Math.PI / 180;
const rdx = dx * Math.cos(angle) - dy * Math.sin(angle);
const rdy = dx * Math.sin(angle) + dy * Math.cos(angle);
const scaleX = layer.displayW / layer.canvas.width;
const scaleY = layer.displayH / layer.canvas.height;
let lx = rdx / scaleX + layer.canvas.width / 2;
let ly = rdy / scaleY + layer.canvas.height / 2;
if (layer.flipX) lx = layer.canvas.width - lx;
if (layer.flipY) ly = layer.canvas.height - ly;
return { x: lx, y: ly };
}
_drawPreview() {
const overlay = document.getElementById("ie-canvas-overlay");
if (!overlay) return;
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const s = this._dragStart, c = this._dragCur;
if (!s || !c) return;
const x = Math.min(s.x, c.x), y = Math.min(s.y, c.y);
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
const zoom = this._cb.getZoom();
ctx.strokeStyle = this.rectMode === "blur" ? "#4af" : "#fa4";
ctx.lineWidth = 1 / zoom;
ctx.setLineDash([4 / zoom, 2 / zoom]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
_applyRectEffect() {
const layer = this._cb.getLayerManager()?.activeLayer;
if (!layer || !this._dragStart || !this._dragCur) return;
const s = this._dragStart, c = this._dragCur;
if (Math.abs(c.x - s.x) < 3 || Math.abs(c.y - s.y) < 3) return;
const minX = Math.min(s.x, c.x), minY = Math.min(s.y, c.y);
const maxX = Math.max(s.x, c.x), maxY = Math.max(s.y, c.y);
const p1 = this._canvasToLayerCoords(layer, minX, minY);
const p2 = this._canvasToLayerCoords(layer, maxX, maxY);
const lx = Math.round(Math.max(0, Math.min(p1.x, p2.x)));
const ly = Math.round(Math.max(0, Math.min(p1.y, p2.y)));
const lw = Math.round(Math.min(layer.canvas.width - lx, Math.abs(p2.x - p1.x)));
const lh = Math.round(Math.min(layer.canvas.height - ly, Math.abs(p2.y - p1.y)));
if (lw <= 0 || lh <= 0) return;
this._cb.saveUndo();
if (this.rectMode === "blur") {
const amount = parseInt(document.getElementById("ie-rect-blur")?.value ?? "10");
const tmp = document.createElement("canvas");
tmp.width = layer.canvas.width; tmp.height = layer.canvas.height;
const tc = tmp.getContext("2d");
tc.filter = `blur(${amount}px)`;
tc.drawImage(layer.canvas, 0, 0);
layer.ctx.drawImage(tmp, lx, ly, lw, lh, lx, ly, lw, lh);
} else {
const size = parseInt(document.getElementById("ie-rect-mosaic")?.value ?? "15");
_applyMosaicToRegion(layer.ctx, lx, ly, lw, lh, size);
}
this._cb.updateCompositeView();
this._cb.refreshLayerList();
}
}
+57 -41
View File
@@ -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();
+222
View File
@@ -0,0 +1,222 @@
/**
* Image Edit Tab - File Export
* Composites the visible layers (respecting mask-apply clipping) into a
* flat canvas and offers three destinations: local PNG download, WFS
* Gallery, and the selected node's image widget on the ComfyUI canvas
* (Send to LI node).
*/
import { showToast } from "../app.js";
import { Layer } from "./LayerManager.js";
export class FileExport {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => {w: number, h: number}} callbacks.getCanvasSize
* @param {() => string} callbacks.getBaseName
* @param {(ctx: CanvasRenderingContext2D, drawCanvas: HTMLCanvasElement, maskLayer: object, targetLayer: object, showOverlay: boolean) => void} callbacks.renderMaskedLayer
*/
constructor(callbacks) {
this._cb = callbacks;
}
_buildCompositeCanvas() {
const { w, h } = this._cb.getCanvasSize();
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) return canvas;
this._compositeForExport(canvas, layerMgr);
return canvas;
}
// 保存用合成: maskApply=true のクリッピングを適用、マスクオーバーレイは除外
_compositeForExport(target, layerMgr) {
const ctx = target.getContext("2d");
ctx.clearRect(0, 0, target.width, target.height);
const layers = layerMgr.layers;
const maskedIndices = new Set();
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
maskedIndices.add(i + 1);
}
}
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i];
if (!layer.visible) continue;
if (maskedIndices.has(i)) continue;
if (layer.type === "mask") {
if (layer.maskApply && i + 1 < layers.length) {
this._cb.renderMaskedLayer(ctx, target, layer, layers[i + 1], false);
}
// maskApply=false のマスクはエクスポートに含めない
} else {
ctx.save();
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode;
Layer.applyTransform(ctx, layer);
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
ctx.restore();
}
}
}
// 保存用レイヤー配列を構築: 各レイヤーを個別のcanvas(ドキュメント全体サイズ)に
// 回転・反転・拡縮のみベイクして描画する。opacity/blendModeはピクセルに焼き込まず
// PSDレイヤー属性として渡すためメタデータのまま残す。
// maskApply=true のクリッピングマスクは対象レイヤーと合成して1枚にベイクする
// (PSDの本物のレイヤーマスクとしては書き出さない簡略化)。
_buildLayerExportList(layerMgr, w, h) {
const layers = layerMgr.layers;
const maskedIndices = new Set();
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
maskedIndices.add(i + 1);
}
}
const exportLayers = [];
// 背面→前面の順(PSD側で後から追加したレイヤーほど上に重なるため、この順で送る)
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i];
if (maskedIndices.has(i)) continue; // マスク側の処理でまとめて扱う
if (layer.type === "mask") {
if (!(layer.maskApply && layer.visible && i + 1 < layers.length)) continue;
const target = layers[i + 1];
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
this._cb.renderMaskedLayer(canvas.getContext("2d"), canvas, layer, target, false);
exportLayers.push({
name: target.name || "Layer",
visible: target.visible,
opacity: 1,
blendMode: "source-over",
imageData: canvas.toDataURL("image/png"),
});
continue;
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.save();
Layer.applyTransform(ctx, layer);
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
ctx.restore();
exportLayers.push({
name: layer.name || "Layer",
visible: layer.visible,
opacity: layer.opacity,
blendMode: layer.blendMode,
imageData: canvas.toDataURL("image/png"),
});
}
return exportLayers;
}
async saveAsPsd() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
const layerMgr = this._cb.getLayerManager();
const { w, h } = this._cb.getCanvasSize();
const exportLayers = this._buildLayerExportList(layerMgr, w, h);
if (exportLayers.length === 0) { showToast("No layers to export", "error"); return; }
try {
const r = await fetch("/wfm/gallery/image/export-psd-layers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ width: w, height: h, layers: exportLayers }),
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.error || r.statusText);
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (this._cb.getBaseName() || "wfs-edit") + ".psd";
a.click();
URL.revokeObjectURL(url);
showToast("PSD saved", "success");
} catch (err) {
showToast(`PSD save failed: ${err.message}`, "error");
}
}
savePng() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
const canvas = this._buildCompositeCanvas();
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = (this._cb.getBaseName() || "wfs-edit") + "-output.png";
a.click();
showToast("PNG saved", "success");
}
async saveToGallery() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
// デフォルトファイル名: wfs-image-YYYYMMDDHHmmss
const now = new Date();
const pad = n => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const defaultName = `wfs-image-${ts}`;
const filename = window.prompt("Save to Gallery — file name (without extension):", defaultName);
if (filename === null) return; // キャンセル
const safeName = filename.trim() || defaultName;
const canvas = this._buildCompositeCanvas();
const imageData = canvas.toDataURL("image/png");
try {
const r = await fetch("/wfm/gallery/image/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: safeName, imageData }),
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.error || r.statusText);
}
showToast(`Saved to Gallery: ${safeName}.png`, "success");
} catch (err) {
showToast(`Gallery save failed: ${err.message}`, "error");
}
}
// Uploads the composite via /upload/image, then (via window.opener,
// since WFS runs in its own window — same cross-window pattern as
// wfmReceiveWorkflow/wfmOpenMaskEditorForNode) writes the resulting
// filename into the "image" widget of the currently selected ComfyUI
// node (falling back to the first node in the graph with one), mirroring
// the chat_TE custom node's "Send to workflow" button.
async sendToWorkflow() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
return;
}
const canvas = this._buildCompositeCanvas();
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
const form = new FormData();
form.append("image", file);
form.append("overwrite", "true");
try {
const r = await fetch("/upload/image", { method: "POST", body: form });
const data = await r.json();
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
window.opener.wfmSendImageToSelectedNode(filename);
showToast(`Sent "${filename}" to LI node`, "success");
} catch (err) {
showToast(`Send to LI node failed: ${err.message}`, "error");
}
}
}
+228
View File
@@ -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);
}
}
}
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Image Edit Tab - G'MIC Integration
* Launches the G'MIC GUI on the server for the active layer, polls job
* status, and applies the resulting image back onto the layer.
*/
import { showToast } from "../app.js";
export class GmicIntegration {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.syncActiveLayerFromCanvas
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(toolId: string) => void} callbacks.renderToolOptions
*/
constructor(callbacks) {
this._cb = callbacks;
this.state = {
lastResultJobId: null,
processing: false,
aborted: false
};
}
renderPanel(el) {
const openBtnDisabled = this.state.processing ? "disabled" : "";
const applyBtnDisabled = (!this.state.lastResultJobId || this.state.processing) ? "disabled" : "";
const progressStyle = this.state.processing ? "display:flex" : "display:none";
el.innerHTML = `
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-gmic-open-btn" ${openBtnDisabled}>G'MIC GUIで編集</button>
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-apply-btn" ${applyBtnDisabled}>結果を反映</button>
</div>
<div class="ie-opt-group" id="ie-gmic-progress-area" style="${progressStyle}; align-items:center; gap:6px;">
<span id="ie-gmic-progress-lbl" style="font-size:11px; color:var(--wfm-text-secondary);">G'MIC GUIを起動中...</span>
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-abort-btn" style="background:#ea4335;color:#fff;">中断</button>
</div>
`;
document.getElementById("ie-gmic-open-btn")?.addEventListener("click", () => this.openGui());
document.getElementById("ie-gmic-apply-btn")?.addEventListener("click", () => this.applyResult());
document.getElementById("ie-gmic-abort-btn")?.addEventListener("click", () => this.abort());
}
async openGui() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
this._cb.syncActiveLayerFromCanvas();
if (this.state.processing) return;
const dataUrl = layer.canvas.toDataURL("image/png");
this.state.processing = true;
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
const applyBtn = document.getElementById("ie-gmic-apply-btn");
if (openBtn) openBtn.disabled = true;
if (applyBtn) applyBtn.disabled = true;
if (progressArea) progressArea.style.display = "flex";
if (progressLbl) progressLbl.textContent = "画像をサーバーへ送信中...";
try {
const res = await fetch("/api/wfm/gmic/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_b64: dataUrl })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const data = await res.json();
this.state.lastResultJobId = data.job_id;
if (progressLbl) progressLbl.textContent = "G'MIC GUIで編集中... フィルターを選択して「OK」を押すと適用可能になります";
await this._waitForJob(data.job_id);
} catch (e) {
if (e.message !== "__aborted__") {
showToast("G'MIC Error: " + e.message, "error");
}
this.state.processing = false;
this.state.aborted = false;
if (openBtn) openBtn.disabled = false;
if (progressArea) progressArea.style.display = "none";
}
}
abort() {
this.state.aborted = true;
this.state.processing = false;
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
if (progressArea) progressArea.style.display = "none";
if (openBtn) openBtn.disabled = false;
}
async _waitForJob(jobId) {
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
const applyBtn = document.getElementById("ie-gmic-apply-btn");
const maxWait = 600, interval = 2000;
const start = Date.now();
this.state.aborted = false;
while (true) {
if (this.state.aborted) throw new Error("__aborted__");
if ((Date.now() - start) / 1000 > maxWait) throw new Error("Timeout");
await new Promise(r => setTimeout(r, interval));
if (this.state.aborted) throw new Error("__aborted__");
try {
const res = await fetch(`/api/wfm/gmic/status/${jobId}`);
if (res.status === 404) throw new Error("__aborted__");
if (!res.ok) continue;
const status = await res.json();
if (status.status === "completed") {
this.state.lastResultJobId = jobId;
if (applyBtn) applyBtn.disabled = false;
if (progressLbl) progressLbl.textContent = "処理完了 → 「結果を反映」で画像に適用";
this.state.processing = false;
showToast("G'MIC filtering complete. Click Apply to insert result.", "success");
return;
}
if (status.status === "failed") {
if (progressLbl) progressLbl.textContent = status.error || "G'MIC GUIがキャンセルされました";
throw new Error("__aborted__");
}
if (progressLbl) progressLbl.textContent = status.message || "G'MIC GUIで編集中...";
} catch (e) {
if (e.message === "__aborted__" || e.message.includes("Timeout")) throw e;
}
}
}
async applyResult() {
if (!this.state.lastResultJobId) {
showToast("No G'MIC result to apply", "error");
return;
}
const applyBtn = document.getElementById("ie-gmic-apply-btn");
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
if (applyBtn) applyBtn.disabled = true;
try {
const statusRes = await fetch(`/api/wfm/gmic/status/${this.state.lastResultJobId}`);
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
const statusData = await statusRes.json();
if (!statusData.result_path) throw new Error("No result path found");
const b64res = await fetch("/api/wfm/gmic/result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result_path: statusData.result_path })
});
if (!b64res.ok) throw new Error(`HTTP ${b64res.status}`);
const { image_b64: dataUrl } = await b64res.json();
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Failed to load result image"));
i.src = dataUrl;
});
this._cb.saveUndo();
const layerMgr = this._cb.getLayerManager();
const layer = layerMgr?.activeLayer;
if (!layer) throw new Error("No active layer");
layer.canvas.width = img.width;
layer.canvas.height = img.height;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
showToast("G'MIC filter applied successfully", "success");
// Reset G'MIC status
this.state.lastResultJobId = null;
this.state.processing = false;
if (openBtn) openBtn.disabled = false;
if (progressArea) progressArea.style.display = "none";
this._cb.renderToolOptions("filter");
} catch (err) {
showToast("Failed to apply G'MIC result: " + err.message, "error");
if (applyBtn) applyBtn.disabled = false;
}
}
}
+303
View File
@@ -0,0 +1,303 @@
/**
* Image Edit Tab - Inpaint / I2I Actions
* Composites the active mask layer against the background and runs it
* through GenerateUI's currently loaded workflow (or a dedicated one) for
* inpainting, plus a mask-less I2I variant. Also exposes the external
* entry points used by other same-origin panels (e.g. Comic Creator).
*/
import { showToast } from "../app.js";
import { comfyUI } from "../comfyui-client.js";
import { comfyEditor } from "../comfyui-editor.js";
import { comfyWorkflow } from "../comfyui-workflow.js";
export class InpaintI2IActions {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
*/
constructor(callbacks) {
this._cb = callbacks;
this.positive = "";
this.negative = "";
this.growMaskBy = null; // null = 未初期化(ワークフローの現在値から初期化)
this.denoise = null; // null = 未初期化(ワークフローの現在値から初期化)
this.running = false;
this.resultUrl = null;
this.i2iExternalRunning = false;
// Inpaint 専用ワークフロー(OFF時は GenerateUI で読み込み中のワークフローを使用)
this.useDedicated = false;
this.dedicatedFilename = null;
this.workflowList = [];
}
async fetchWorkflowList(onLoaded) {
try {
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows`);
if (!resp.ok) return;
const list = await resp.json();
this.workflowList = (list || []).map((w) => w.filename).filter(Boolean);
onLoaded?.();
} catch {
this.workflowList = [];
}
}
renderPanel() {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body) return;
pane.style.display = "flex";
if (title) title.textContent = "Inpaint";
// 未初期化ならワークフローの現在値から初期化(VAEEncodeForInpaint.grow_mask_by / KSampler.denoise)
if (this.growMaskBy == null) {
this.growMaskBy = comfyUI.currentAnalysis?.inpaint_encode_nodes?.[0]?.grow_mask_by ?? 6;
}
if (this.denoise == null) {
const samplerNode = (comfyUI.currentAnalysis?.sampler_nodes || []).find((n) => n.denoise !== undefined);
this.denoise = samplerNode?.denoise ?? 1.0;
}
body.innerHTML = `
<div class="ie-props-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="ie-inpaint-use-dedicated" ${this.useDedicated ? "checked" : ""}>
Use dedicated workflow
</label>
</div>
<div class="ie-props-row" id="ie-inpaint-dedicated-row" style="${this.useDedicated ? "" : "display:none;"}">
<select id="ie-inpaint-dedicated-select" style="flex:1;">
<option value="">-- select workflow --</option>
${this.workflowList.map((f) => `<option value="${f}" ${f === this.dedicatedFilename ? "selected" : ""}>${f}</option>`).join("")}
</select>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Positive Prompt</label>
<textarea id="ie-inpaint-positive" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.positive}</textarea>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Negative Prompt</label>
<textarea id="ie-inpaint-negative" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.negative}</textarea>
</div>
<div class="ie-props-row">
<label>Grow Mask By</label>
<input type="number" id="ie-inpaint-grow-mask" min="0" max="200" step="1" value="${this.growMaskBy}" style="width:60px;">
</div>
<div class="ie-props-row">
<label>Denoise</label>
<input type="number" id="ie-inpaint-denoise" min="0" max="1" step="0.01" value="${this.denoise}" style="width:60px;">
</div>
<div class="ie-props-row">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-inpaint-run-btn" style="flex:1;" ${this.running ? "disabled" : ""}>
${this.running ? "Running..." : "Run"}
</button>
</div>
<span id="ie-inpaint-status" style="font-size:11px;color:var(--wfm-text-secondary);"></span>
${this.resultUrl ? `
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Result</label>
<img src="${this.resultUrl}" style="max-width:100%;border-radius:var(--wfm-radius-sm);border:1px solid var(--wfm-border);">
</div>
` : ""}
`;
document.getElementById("ie-inpaint-use-dedicated")?.addEventListener("change", e => {
this.useDedicated = e.target.checked;
this.renderPanel();
});
document.getElementById("ie-inpaint-dedicated-select")?.addEventListener("change", e => {
this.dedicatedFilename = e.target.value || null;
});
document.getElementById("ie-inpaint-positive")?.addEventListener("input", e => { this.positive = e.target.value; });
document.getElementById("ie-inpaint-negative")?.addEventListener("input", e => { this.negative = e.target.value; });
document.getElementById("ie-inpaint-grow-mask")?.addEventListener("input", e => {
this.growMaskBy = parseInt(e.target.value) || 0;
});
document.getElementById("ie-inpaint-denoise")?.addEventListener("input", e => {
this.denoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
if (Number.isNaN(this.denoise)) this.denoise = 1.0;
});
document.getElementById("ie-inpaint-run-btn")?.addEventListener("click", () => this.run());
}
_exportMaskCanvas(maskLayer) {
const w = maskLayer.canvas.width, h = maskLayer.canvas.height;
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(maskLayer.canvas, 0, 0);
return canvas;
}
async run() {
if (this.running) return;
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
// 非表示のマスクレイヤーはInpaint対象から除外する(目玉アイコンOFF=対象外)
const maskLayer = (layerMgr.activeLayer?.type === "mask" && layerMgr.activeLayer.visible)
? layerMgr.activeLayer
: layerMgr.layers.find(l => l.type === "mask" && l.visible);
if (!maskLayer) { showToast("Create or show a mask layer first (Mask tool)", "info"); return; }
if (this.useDedicated) {
if (!this.dedicatedFilename) { showToast("Select a dedicated workflow first", "info"); return; }
} else if (!comfyUI.currentAnalysis) {
showToast("Load a workflow in GenerateUI first", "info");
return;
}
const runBtn = document.getElementById("ie-inpaint-run-btn");
const statusEl = document.getElementById("ie-inpaint-status");
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
this.running = true;
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
setStatus(this.useDedicated ? "Loading dedicated workflow..." : "Compositing...");
try {
// 専用ワークフロー使用時は GenerateUI の comfyUI.currentWorkflow/currentAnalysis には
// 一切触れず、ここでロード・解析したローカルなワークフロー/解析結果だけを使う。
let overrideOpts = {};
if (this.useDedicated) {
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(this.dedicatedFilename)}`);
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
let dedicatedWorkflow = await resp.json();
const format = comfyWorkflow.detectFormat(dedicatedWorkflow, this.dedicatedFilename);
if (format === "ui") {
dedicatedWorkflow = await comfyWorkflow.convertUiToApi(dedicatedWorkflow);
} else if (format !== "api") {
throw new Error("Unsupported or unrecognized workflow format");
}
overrideOpts = { workflow: dedicatedWorkflow, analysis: comfyWorkflow.analyzeWorkflow(dedicatedWorkflow) };
}
setStatus("Compositing...");
const bgCanvas = this._cb.buildBgCanvas();
const imageBlob = await new Promise((resolve) => bgCanvas.toBlob(resolve, "image/png"));
const maskCanvas = this._exportMaskCanvas(maskLayer);
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
const resultUrl = await this._runWithImages(imageBlob, maskBlob, {
positive: this.positive,
negative: this.negative,
growMaskBy: this.growMaskBy,
denoise: this.denoise,
overrideOpts,
onStatus: setStatus,
});
if (resultUrl) this.resultUrl = resultUrl;
setStatus("Done");
showToast("Inpaint generation complete", "success");
} catch (err) {
setStatus("Error");
showToast(`Inpaint failed: ${err.message}`, "error");
} finally {
this.running = false;
this.renderPanel();
}
}
// 共通処理: 画像+マスクBlobを対象ノード(LoadImage/MaskEditorOne)へ反映し、
// プロンプト/grow_mask_by/denoiseを設定してGenerate UIで実行、結果URLを返す。
// UI駆動の run() と外部(Comic Creator等)向けの runExternal() の両方から使う。
async _runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise, overrideOpts = {}, onStatus } = {}) {
const setStatus = onStatus || (() => {});
// LoadImage(の MASK 出力が使われている)スロットを優先し、無ければ Mask Editor One
// ノード(LoadImage を介さず画像/マスクを内包する custom node)にフォールバックする
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
const slotIndex = (analysisForLookup.load_image_nodes || []).findIndex(n => n.mask_used);
const meoNode = (analysisForLookup.mask_editor_one_nodes || [])[0];
if (slotIndex === -1 && !meoNode) {
throw new Error("No inpaint-capable LoadImage or Mask Editor One node found in the workflow");
}
setStatus("Uploading...");
if (slotIndex !== -1) {
await comfyEditor.applyImageAndMaskToSlot(imageBlob, maskBlob, slotIndex, overrideOpts);
} else {
await comfyEditor.applyImageAndMaskToMaskEditorOneNode(imageBlob, maskBlob, meoNode.id, overrideOpts);
}
comfyEditor.setPromptText("positive", positive, overrideOpts);
comfyEditor.setPromptText("negative", negative, overrideOpts);
comfyEditor.setInpaintParams({ growMaskBy, denoise, ...overrideOpts });
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
setStatus("Generating...");
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
const resultImg = document.getElementById("wfm-gen-result-img");
return resultImg?.src || null;
}
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント。
// 専用ワークフロー選択(useDedicated)は扱わず、常にGenerate UIに現在
// ロード中のワークフロー(comfyUI.currentWorkflow/currentAnalysis)を対象にする。
async runExternal(imageBlob, maskBlob, { positive = "", negative = "", growMaskBy = 6, denoise = 1.0 } = {}) {
if (this.running) throw new Error("Inpaint is already running");
this.running = true;
try {
const resultUrl = await this._runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise });
if (!resultUrl) throw new Error("No result image produced");
return { ok: true, url: resultUrl };
} finally {
this.running = false;
}
}
// 共通処理: 画像Blob(マスクなし)を対象LoadImageノード(slot 0)へ反映し、
// プロンプト/denoiseを設定してGenerate UIで実行、結果URLを返す。
// _runWithImages のマスクなし版(Comic CreatorのSelect I2I連携専用)。
async _runI2IWithImage(imageBlob, { positive, negative, denoise, overrideOpts = {}, onStatus } = {}) {
const setStatus = onStatus || (() => {});
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
if (!(analysisForLookup.load_image_nodes?.length > 0)) {
throw new Error("No LoadImage node found in the workflow");
}
setStatus("Uploading...");
const file = new File([imageBlob], `i2i_${Math.random().toString(36).slice(2, 10)}.png`, { type: "image/png" });
await comfyEditor.applyImageToSlot(file, 0, overrideOpts);
comfyEditor.setPromptText("positive", positive, overrideOpts);
comfyEditor.setPromptText("negative", negative, overrideOpts);
comfyEditor.setInpaintParams({ denoise, ...overrideOpts });
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
setStatus("Generating...");
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
const resultImg = document.getElementById("wfm-gen-result-img");
return resultImg?.src || null;
}
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント(I2I版)。
// runExternal と対になる、マスク不要のシンプルなI2I実行。
async runI2IExternal(imageBlob, { positive = "", negative = "", denoise = 1.0 } = {}) {
if (this.i2iExternalRunning) throw new Error("I2I is already running");
this.i2iExternalRunning = true;
try {
const resultUrl = await this._runI2IWithImage(imageBlob, { positive, negative, denoise });
if (!resultUrl) throw new Error("No result image produced");
return { ok: true, url: resultUrl };
} finally {
this.i2iExternalRunning = false;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Image Edit Tab - Mask Editor One 連携ブリッジ
* WFS Image Edit タブの合成画像を、ComfyUIキャンバス上のMaskEditorOneノード(選択中のノードを
* 優先、無ければグラフ内で最初に見つかったもの)へ送り、window.opener(ComfyUIタブ)側で実際の
* モーダルを開いて編集させ、Apply後の結果マスクを新規マスクレイヤーとしてWFS側に取り込む。
* 「Send to Workflow」の image ウィジェット探索と同じ「選択中優先・フォールバック」方式のため、
* WFS側のワークフロー読み込み状況とは無関係に動作する。
* モーダル自体は ComfyUI ページの LiteGraph ノードに強く依存するため、WFS 側からは
* window.opener.wfmOpenMaskEditorForNode()(web/comfyui/node_sets_menu.js 側で定義)を介して操作する。
*/
import { showToast } from "../app.js";
export class MaskEditorOneBridge {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
*/
constructor(callbacks) {
this._cb = callbacks;
this.running = false;
}
get bridgeReady() {
return !!(window.opener && typeof window.opener.wfmOpenMaskEditorForNode === "function");
}
async openEditor() {
if (this.running) return;
if (!this.bridgeReady) {
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
return;
}
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
this.running = true;
try {
const dataUrl = this._cb.buildBgCanvas().toDataURL("image/png");
// アクティブレイヤーが既存のマスクレイヤーなら、その内容もそのまま Mask Editor One の
// 初期マスクとして渡す(画像だけでなく、今描いているマスクも引き継ぐ)
const activeLayer = layerMgr.activeLayer;
const existingMaskDataUrl = activeLayer?.type === "mask" ? activeLayer.canvas.toDataURL("image/png") : null;
window.opener.focus();
showToast("Switch to the ComfyUI tab, edit the mask, then click Apply", "info");
const maskDataUrl = await window.opener.wfmOpenMaskEditorForNode(dataUrl, existingMaskDataUrl);
if (!maskDataUrl) {
showToast("Mask Editor One: cancelled (no changes)", "info");
return;
}
this._cb.saveUndo();
const maskLayer = layerMgr.addLayer("mask", "Mask Editor One", {
contentW: layerMgr.width, contentH: layerMgr.height,
displayW: layerMgr.width, displayH: layerMgr.height,
x: 0, y: 0,
});
layerMgr.setActive(maskLayer.id);
await this._applyMask(maskLayer, maskDataUrl);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
showToast("Mask Editor One: mask imported", "success");
} catch (err) {
showToast("Mask Editor One error: " + err.message, "error");
} finally {
this.running = false;
}
}
// グレースケール輝度 → アルファ白マスクに変換して書き込む(Sam3Segmentation._applyMask と同じ変換)
_applyMask(maskLayer, maskDataUrl) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const W = maskLayer.canvas.width;
const H = maskLayer.canvas.height;
const off = document.createElement("canvas");
off.width = W; off.height = H;
const mc = off.getContext("2d");
mc.drawImage(img, 0, 0, W, H);
const imgData = mc.getImageData(0, 0, W, H);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const lum = d[i];
d[i] = d[i + 1] = d[i + 2] = 255;
d[i + 3] = lum;
}
mc.putImageData(imgData, 0, 0);
maskLayer.ctx.drawImage(off, 0, 0);
resolve();
};
img.onerror = resolve;
img.src = maskDataUrl;
});
}
}
+50 -40
View File
@@ -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();
+248
View File
@@ -0,0 +1,248 @@
/**
* Image Edit Tab - SAM3 Segmentation (Mask Editor One integration)
* Text-prompted segmentation via the Mask Editor One SAM3 server endpoints.
* Rendered as a subtool of the Mask tool; the toolbar shell itself stays in
* ImageEditTab (shared with the other mask subtools), but the prompt/run
* controls and the result thumbnail grid are owned here.
*/
import { showToast } from "../app.js";
export class Sam3Segmentation {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(toolId: string) => void} callbacks.renderToolOptions
* @param {(sub: string) => void} callbacks.renderMaskProps
*/
constructor(callbacks) {
this._cb = callbacks;
this.available = false;
this.results = []; // [{mask_b64, score, area}, ...]
this.prompt = "";
this.maxMasks = 9;
this.loading = false;
this.mode = "add"; // "add" | "erase"
this.selected = new Set(); // 選択中の結果インデックス
}
async checkAvailability() {
try {
const resp = await fetch("/mask_editor/sam3/status");
if (!resp.ok) return;
const json = await resp.json();
this.available = json.loaded === true || json.ckpt_found === true;
} catch {
this.available = false;
}
}
// マスクツールバー内、SAM3選択時にのみ表示するprompt/max/runコントロール
renderToolbarExtra(activeSub) {
if (!this.available || activeSub !== "sam3") return "";
return `
<div class="ie-opt-group">
<input type="text" id="ie-sam3-prompt" class="ie-opt-input"
placeholder="e.g. cat, person..."
value="${this.prompt}"
style="width:160px;font-size:11px;padding:2px 6px;border:1px solid var(--wfm-border);border-radius:3px;background:var(--wfm-surface);color:var(--wfm-text);">
</div>
<div class="ie-opt-group">
<label style="font-size:11px;color:var(--wfm-text-secondary);">Max</label>
<select id="ie-sam3-max" class="ie-opt-select" style="width:44px;">
${[3,6,9,12].map(n => `<option value="${n}"${n === this.maxMasks ? " selected" : ""}>${n}</option>`).join("")}
</select>
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-run-btn" ${this.loading ? "disabled" : ""}>
${this.loading ? "Running..." : "Segment"}
</button>
</div>
<span id="ie-sam3-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;">
${this.results.length > 0 ? `${this.results.length} masks found` : ""}
</span>
`;
}
bindToolbarEvents() {
document.getElementById("ie-sam3-prompt")?.addEventListener("input", e => {
this.prompt = e.target.value;
});
document.getElementById("ie-sam3-max")?.addEventListener("change", e => {
this.maxMasks = parseInt(e.target.value);
});
document.getElementById("ie-sam3-run-btn")?.addEventListener("click", () => this.runSegment());
}
// props ペイン("sam3" サブツール選択時): モード切替 + 結果サムネイルグリッド
renderResultsPanel(body) {
const modeAdd = this.mode === "add";
const selCount = this.selected.size;
const hasResult = this.results.length > 0;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="wfm-btn wfm-btn-sm${modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-add" style="flex:1;">Add</button>
<button class="wfm-btn wfm-btn-sm${!modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-erase" style="flex:1;">Erase</button>
</div>
</div>
${hasResult ? `
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div style="font-size:11px;color:var(--wfm-text-secondary);">Click to select / deselect:</div>
<div id="ie-sam3-results" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;">
${this.results.map((r, i) => {
const sel = this.selected.has(i);
const borderColor = sel ? "var(--wfm-primary,#4682e6)" : "var(--wfm-border)";
const bg = sel ? "color-mix(in srgb,var(--wfm-primary,#4682e6) 15%,transparent)" : "transparent";
return `<div class="ie-sam3-thumb" data-idx="${i}"
style="cursor:pointer;border:2px solid ${borderColor};border-radius:4px;overflow:hidden;text-align:center;background:${bg};position:relative;">
<img src="${r.mask_b64}" style="width:100%;display:block;background:#000;">
<div style="font-size:10px;padding:2px 0;color:var(--wfm-text-secondary);">
${Math.round((r.score ?? 0) * 100)}%
</div>
${sel ? '<div style="position:absolute;top:2px;right:3px;font-size:12px;line-height:1;color:var(--wfm-primary,#4682e6);">✓</div>' : ""}
</div>`;
}).join("")}
</div>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-apply-btn"
${selCount === 0 ? "disabled" : ""} style="margin-top:4px;">
Apply Selected${selCount > 0 ? ` (${selCount})` : ""}
</button>
</div>` : `
<div style="font-size:11px;color:var(--wfm-text-secondary);padding:4px 0;">
${this.loading ? "Segmenting..." : "Enter a prompt and press Segment"}
</div>`}
`;
document.getElementById("ie-sam3-mode-add")?.addEventListener("click", () => {
this.mode = "add";
this._cb.renderMaskProps("sam3");
});
document.getElementById("ie-sam3-mode-erase")?.addEventListener("click", () => {
this.mode = "erase";
this._cb.renderMaskProps("sam3");
});
body.querySelectorAll(".ie-sam3-thumb").forEach(el => {
el.addEventListener("click", () => {
const idx = parseInt(el.dataset.idx);
if (this.selected.has(idx)) this.selected.delete(idx);
else this.selected.add(idx);
this._cb.renderMaskProps("sam3");
});
});
document.getElementById("ie-sam3-apply-btn")?.addEventListener("click", () => {
this.applySelectedMasks();
});
}
async runSegment() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr || this.loading) return;
const prompt = this.prompt.trim();
if (!prompt) { showToast("Please enter a prompt", "warning"); return; }
// 推論対象: アクティブレイヤー(マスクなら下のイメージレイヤーを探す)
let imageLayer = layerMgr.activeLayer;
if (!imageLayer || imageLayer.type === "mask") {
imageLayer = layerMgr.layers.find(l => l.type !== "mask" && l.visible);
}
if (!imageLayer) { showToast("No image layer found", "error"); return; }
const NODE_ID = "wfs_sam3";
this.loading = true;
this.results = [];
this.selected = new Set();
this._cb.renderToolOptions("mask");
this._cb.renderMaskProps("sam3");
try {
const dataUrl = imageLayer.canvas.toDataURL("image/png");
await fetch("/mask_editor/store_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
});
const resp = await fetch("/mask_editor/sam3/segment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, prompt, max_masks: this.maxMasks }),
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
this.results = json.masks || [];
if (this.results.length === 0) showToast("No masks found", "warning");
else showToast(`${this.results.length} mask(s) found`, "success");
} catch (err) {
showToast("SAM3 error: " + err.message, "error");
} finally {
this.loading = false;
this._cb.renderToolOptions("mask");
this._cb.renderMaskProps("sam3");
}
}
async applySelectedMasks() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr || this.selected.size === 0) return;
const indices = [...this.selected].sort((a, b) => a - b);
const masks = indices.map(i => this.results[i]).filter(Boolean);
if (masks.length === 0) return;
let maskLayer = layerMgr.activeLayer;
if (!maskLayer || maskLayer.type !== "mask") {
const ref = layerMgr.activeLayer;
maskLayer = layerMgr.addLayer("mask", "SAM3 Mask", {
contentW: ref?.canvas.width ?? layerMgr.width,
contentH: ref?.canvas.height ?? layerMgr.height,
displayW: ref?.displayW ?? layerMgr.width,
displayH: ref?.displayH ?? layerMgr.height,
x: ref?.x ?? 0, y: ref?.y ?? 0,
});
layerMgr.setActive(maskLayer.id);
}
this._cb.saveUndo();
for (const r of masks) {
await this._applyMask(maskLayer, r.mask_b64, this.mode);
}
this._cb.updateCompositeView();
this._cb.refreshLayerList();
showToast(`SAM3: ${masks.length} mask(s) applied (${this.mode})`, "success");
}
_applyMask(maskLayer, maskB64, mode = "add") {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const W = maskLayer.canvas.width;
const H = maskLayer.canvas.height;
// グレースケール輝度 → アルファ白マスクに変換
const off = document.createElement("canvas");
off.width = W; off.height = H;
const mc = off.getContext("2d");
mc.drawImage(img, 0, 0, W, H);
const imgData = mc.getImageData(0, 0, W, H);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const lum = d[i];
d[i] = d[i+1] = d[i+2] = 255;
d[i+3] = lum;
}
mc.putImageData(imgData, 0, 0);
// モードに合わせてマスクキャンバスに合成
maskLayer.ctx.save();
maskLayer.ctx.globalCompositeOperation = mode === "erase" ? "destination-out" : "source-over";
maskLayer.ctx.drawImage(off, 0, 0);
maskLayer.ctx.restore();
resolve();
};
img.onerror = resolve;
img.src = maskB64;
});
}
}
+363
View File
@@ -0,0 +1,363 @@
/**
* ImagePrompt Gallery — Gallery タブのサブタブ。
* ws_image_prompt フォルダ配下の画像+.txtサイドカーを閲覧し、
* クリックで選択→「追加」でプロンプトを組み立てて最終的にコピーする。
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
const API = {
root: () => `/wfm/gallery/image-prompt/root`,
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
imageMeta: (path) => `/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`,
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
savePrompt: `/wfm/gallery/image/image-prompt`,
};
const state = {
root: null,
currentFolder: null,
search: "",
images: [],
selectedImage: null,
chips: [], // [{ path, filename, prompt }]
};
let _initialized = false;
async function apiFetch(url, opts = {}) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
// ── 初期化 ────────────────────────────────────────────────────
export function initImagePromptTab() {
document.getElementById("wfm-imageprompt-refresh-btn")?.addEventListener("click", () => {
loadFolderTree();
loadImages();
});
setupSearchClearBtn("wfm-imageprompt-search", "wfm-imageprompt-search-clear-btn", () => {
state.search = "";
loadImages();
});
const searchInput = document.getElementById("wfm-imageprompt-search");
searchInput?.addEventListener("input", debounce(() => {
state.search = searchInput.value.trim();
loadImages();
}, 300));
document.getElementById("wfm-imageprompt-add-btn")?.addEventListener("click", addSelectedToChips);
document.getElementById("wfm-imageprompt-save-prompt-btn")?.addEventListener("click", saveSelectedPrompt);
document.getElementById("wfm-imageprompt-clear-btn")?.addEventListener("click", clearAllChips);
document.getElementById("wfm-imageprompt-copy-btn")?.addEventListener("click", copyFinalPrompt);
}
export async function activateImagePromptTab() {
if (_initialized) return;
_initialized = true;
try {
const data = await apiFetch(API.root());
state.root = data.root;
await loadFolderTree();
} catch (e) {
const tree = document.getElementById("wfm-imageprompt-tree");
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
// ── フォルダツリー ────────────────────────────────────────────
async function loadFolderTree() {
if (!state.root) return;
const tree = document.getElementById("wfm-imageprompt-tree");
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
try {
const data = await apiFetch(API.folders(state.root));
if (data.error) {
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
return;
}
tree.innerHTML = "";
renderTreeNode(data, tree, 0, true);
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
if (firstLabel) firstLabel.click();
} catch (e) {
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderTreeNode(node, container, depth, isRoot) {
const item = document.createElement("div");
item.className = "wfm-gallery-tree-item";
item.style.paddingLeft = `${depth * 12}px`;
item.dataset.path = node.abs_path;
const hasChildren = node.children && node.children.length > 0;
const absPath = node.abs_path;
const arrow = document.createElement("span");
arrow.className = "wfm-gallery-tree-arrow";
if (hasChildren) {
arrow.textContent = "▶";
arrow.dataset.expanded = "false";
} else {
arrow.textContent = " ";
arrow.style.visibility = "hidden";
}
item.appendChild(arrow);
const label = document.createElement("span");
label.className = "wfm-gallery-tree-label";
label.textContent = isRoot ? "[root]" : node.name;
// サブフォルダ内も含めた合計件数(クリックで再帰的に全画像を表示するため)
const totalCount = node.image_count_total ?? node.image_count;
if (totalCount > 0) {
const badge = document.createElement("span");
badge.className = "wfm-gallery-tree-count";
badge.textContent = totalCount;
label.appendChild(badge);
}
item.appendChild(label);
label.addEventListener("click", () => {
document.querySelectorAll("#wfm-imageprompt-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
item.classList.add("selected");
state.currentFolder = absPath;
loadImages();
});
if (hasChildren) {
const childContainer = document.createElement("div");
childContainer.className = "wfm-gallery-tree-children";
childContainer.style.display = "none";
arrow.addEventListener("click", (e) => {
e.stopPropagation();
const expanded = arrow.dataset.expanded === "true";
if (!expanded) {
arrow.dataset.expanded = "true";
arrow.textContent = "▼";
childContainer.style.display = "";
if (childContainer.children.length === 0) {
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
}
} else {
arrow.dataset.expanded = "false";
arrow.textContent = "▶";
childContainer.style.display = "none";
}
});
container.appendChild(item);
container.appendChild(childContainer);
} else {
container.appendChild(item);
}
}
// ── 画像一覧 ─────────────────────────────────────────────────
async function loadImages() {
if (!state.currentFolder) return;
const grid = document.getElementById("wfm-imageprompt-grid");
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
if (state.search) params.search = state.search;
try {
const data = await apiFetch(API.images(params));
state.images = data.images || [];
document.getElementById("wfm-imageprompt-count").textContent = `${state.images.length} images`;
renderImages();
} catch (e) {
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderImages() {
const grid = document.getElementById("wfm-imageprompt-grid");
if (state.images.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
return;
}
grid.innerHTML = "";
const fragment = document.createDocumentFragment();
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
grid.appendChild(fragment);
}
function createThumbCard(img) {
const card = document.createElement("div");
card.className = "wfm-gallery-thumb-card";
card.title = img.filename;
card.dataset.path = img.path;
if (state.selectedImage && state.selectedImage.path === img.path) {
card.classList.add("selected");
}
const imgEl = document.createElement("img");
imgEl.className = "wfm-gallery-thumb-img";
imgEl.loading = "lazy";
imgEl.src = API.thumb(img.path);
imgEl.alt = img.filename;
imgEl.onerror = () => {
imgEl.style.display = "none";
const placeholder = document.createElement("div");
placeholder.className = "wfm-gallery-thumb-placeholder";
placeholder.textContent = "?";
card.insertBefore(placeholder, imgEl.nextSibling);
};
card.appendChild(imgEl);
card.addEventListener("click", () => selectImage(img));
return card;
}
// ── 選択画像 / プロンプト表示 ─────────────────────────────────
async function selectImage(img) {
state.selectedImage = img;
document.querySelectorAll("#wfm-imageprompt-grid .wfm-gallery-thumb-card").forEach(el => {
el.classList.toggle("selected", el.dataset.path === img.path);
});
document.getElementById("wfm-imageprompt-selected-name").textContent = img.filename;
const preview = document.getElementById("wfm-imageprompt-selected-preview");
preview.innerHTML = "";
const previewImg = document.createElement("img");
previewImg.src = API.serveImage(img.path);
previewImg.alt = img.filename;
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;";
preview.appendChild(previewImg);
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
promptBox.value = "";
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = true;
document.getElementById("wfm-imageprompt-add-btn").disabled = true;
try {
const meta = await apiFetch(API.imageMeta(img.path));
promptBox.value = meta.image_prompt || "";
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = false;
document.getElementById("wfm-imageprompt-add-btn").disabled = !meta.image_prompt;
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
async function saveSelectedPrompt() {
if (!state.selectedImage) return;
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
try {
const res = await apiFetch(API.savePrompt, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.selectedImage.path, prompt: promptBox.value }),
});
if (res.ok) {
showToast(t("imagePromptSaved"), "success");
document.getElementById("wfm-imageprompt-add-btn").disabled = !promptBox.value.trim();
} else {
showToast(t("saveFailed"), "error");
}
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
// ── プロンプトビルダー (チップ / 最終プロンプト) ─────────────────
function cleanPromptText(text) {
return String(text || "")
.replace(/^[,\s]+|[,\s]+$/g, "")
.replace(/\s*,\s*/g, ", ")
.trim();
}
function addSelectedToChips() {
const img = state.selectedImage;
if (!img) return;
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
const prompt = cleanPromptText(promptBox.value);
if (!prompt) return;
if (state.chips.some(c => c.path === img.path)) {
showToast(t("imagePromptAlreadyAdded"), "info");
return;
}
state.chips.push({ path: img.path, filename: img.filename, prompt });
renderChips();
rebuildFinalPrompt();
}
function removeChip(path) {
state.chips = state.chips.filter(c => c.path !== path);
renderChips();
rebuildFinalPrompt();
}
function clearAllChips() {
state.chips = [];
renderChips();
document.getElementById("wfm-imageprompt-final-prompt").value = "";
}
function renderChips() {
const container = document.getElementById("wfm-imageprompt-chips");
container.innerHTML = "";
state.chips.forEach(chip => {
const el = document.createElement("span");
el.className = "wfm-imageprompt-chip";
el.title = chip.prompt;
const text = document.createElement("span");
text.className = "wfm-imageprompt-chip-text";
text.textContent = chip.filename.replace(/\.[^.]+$/, "");
el.appendChild(text);
const removeBtn = document.createElement("button");
removeBtn.className = "wfm-imageprompt-chip-remove";
removeBtn.textContent = "×";
removeBtn.addEventListener("click", () => removeChip(chip.path));
el.appendChild(removeBtn);
container.appendChild(el);
});
}
function rebuildFinalPrompt() {
const combined = state.chips.map(c => c.prompt).join(", ");
document.getElementById("wfm-imageprompt-final-prompt").value = cleanPromptText(combined);
}
async function copyFinalPrompt() {
const text = document.getElementById("wfm-imageprompt-final-prompt").value;
if (!text.trim()) {
showToast(t("imagePromptNothingToCopy"), "info");
return;
}
try {
await navigator.clipboard.writeText(text);
showToast(t("imagePromptCopiedToast"), "success");
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
+1870
View File
File diff suppressed because it is too large Load Diff
+306 -41
View File
@@ -6,6 +6,7 @@
import { t } from "./i18n.js";
import { escapeHtml } from "./util.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
// ── File size limit ───────────────────────────────────────────
const MAX_FILE_SIZE = 50 * 1024 * 1024;
@@ -74,7 +75,7 @@ function parseITXtChunk(data, latin1, utf8) {
pos = findNull(data, pos); if (pos === -1) return null; pos++;
return { keyword, text: utf8.decode(data.slice(pos)) };
}
async function readAllPNGTextChunks(file) {
export async function readAllPNGTextChunks(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
@@ -96,6 +97,81 @@ async function readAllPNGTextChunks(file) {
return chunks;
}
// ── MP4 container metadata (moov/udta/meta の mdta keys/ilst) ──
// ComfyUIのSaveVideoはPNGのtEXtチャンクと同じキー名(workflow=UI形式, prompt=API形式)で
// JSON文字列をisobmff(mp4)のudta/meta(mdta)にコンテナレベルタグとして書き込む(FFmpegの
// movflags=+use_metadata_tags)。ここではPyAV等に頼らずブラウザ側でボックスを直接辿って読む。
function findMP4Box(view, bytes, start, end, targetType) {
let offset = start;
while (offset + 8 <= end) {
const size = view.getUint32(offset);
const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
let boxSize = size;
let bodyStart = offset + 8;
if (size === 1) {
if (offset + 16 > end) break;
const hi = view.getUint32(offset + 8);
const lo = view.getUint32(offset + 12);
boxSize = hi * 4294967296 + lo;
bodyStart = offset + 16;
} else if (size === 0) {
boxSize = end - offset;
}
if (boxSize < 8) break;
if (type === targetType) return { bodyStart, end: offset + boxSize };
offset += boxSize;
}
return null;
}
export async function readMP4MetaTags(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
if (bytes.length < 12) return null;
const ftyp = String.fromCharCode(bytes[4], bytes[5], bytes[6], bytes[7]);
if (ftyp !== "ftyp") return null;
const moov = findMP4Box(view, bytes, 0, bytes.length, "moov");
if (!moov) return null;
const udta = findMP4Box(view, bytes, moov.bodyStart, moov.end, "udta");
let meta = udta ? findMP4Box(view, bytes, udta.bodyStart, udta.end, "meta") : null;
if (!meta) meta = findMP4Box(view, bytes, moov.bodyStart, moov.end, "meta");
if (!meta) return null;
const metaBody = meta.bodyStart + 4; // meta は FullBox (version+flags 4バイト)
const keysBox = findMP4Box(view, bytes, metaBody, meta.end, "keys");
const ilstBox = findMP4Box(view, bytes, metaBody, meta.end, "ilst");
if (!keysBox || !ilstBox) return null;
const utf8 = new TextDecoder("utf-8", { fatal: false });
let off = keysBox.bodyStart + 4; // version+flags をスキップ
const count = view.getUint32(off); off += 4;
const keyNames = [];
for (let i = 0; i < count && off + 8 <= keysBox.end; i++) {
const ksize = view.getUint32(off);
if (ksize < 8) break;
keyNames.push(utf8.decode(bytes.slice(off + 8, off + ksize)));
off += ksize;
}
const result = {};
let ioff = ilstBox.bodyStart;
while (ioff + 8 <= ilstBox.end) {
const itemSize = view.getUint32(ioff);
const keyIndex = view.getUint32(ioff + 4);
if (itemSize < 8) break;
const itemEnd = Math.min(ioff + itemSize, ilstBox.end);
const data = findMP4Box(view, bytes, ioff + 8, itemEnd, "data");
if (data) {
const payload = bytes.slice(data.bodyStart + 8, data.end); // type(4)+locale(4)をスキップ
const name = keyNames[keyIndex - 1];
if (name) { try { result[name] = utf8.decode(payload); } catch { /* skip */ } }
}
ioff = itemEnd;
}
return result;
}
// ── Workflow extraction helpers ───────────────────────────────
function collectUnique(arr) {
const seen = new Set(), out = [];
@@ -187,11 +263,67 @@ function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
function extractPrompts(wf) {
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
function resolveEditPlusText(mode, textEdit, text1, text2) {
const t1 = typeof text1 === "string" ? text1 : "";
const edit = typeof textEdit === "string" ? textEdit : "";
const insert = typeof text2 === "string" && text2 !== "" ? text2 : edit;
switch (mode) {
case "RAW": return t1;
case "EDIT": return edit;
case "front": return t1 ? `${insert}, ${t1}` : insert;
case "back": return t1 ? `${t1}, ${insert}` : insert;
default: return t1 || edit;
}
}
export function extractPrompts(wf) {
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
}
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
function resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
if (depth > 6) return null;
const srcNode = nodeMap.get(srcId);
if (!srcNode) return null;
const srcType = srcNode.type ?? "";
if (isPromptStylerNode(srcType)) {
const v = srcNode.widgets_values?.[slot];
return (v && typeof v === "string") ? v : null;
}
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
for (const name of ["on_false", "on_true"]) {
const inp = srcNode.inputs.find(i => i.name === name);
if (inp?.link == null) continue;
const originId = linkOrigin.get(inp.link);
const originSlot = linkSlot.get(inp.link) ?? 0;
if (originId == null) continue;
const text = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
if (text) return text;
}
return null;
}
// WFS_PromptText, PrimitiveStringMultiline 等、任意の STRING 出力ノード
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
if (v && typeof v === "string") return v;
// さらにリンクされている場合(StringReplace等の中継ノード)は追跡する
// "source" = PreviewAny(値タップ中継), "string_a" = StringConcatenate(LoRAトリガーワード
// 連結等)— Krea-2のプロンプト強化配線で使われる中継ノード
if (Array.isArray(srcNode.inputs)) {
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string", "source", "string_a"].includes(i.name));
if (nextInput?.link != null) {
const originId = linkOrigin.get(nextInput.link);
const originSlot = linkSlot.get(nextInput.link) ?? 0;
if (originId != null) return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
}
}
return null;
}
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
function extractPromptsFromNodeSet(nodes, links) {
@@ -211,7 +343,22 @@ function extractPromptsFromNodeSet(nodes, links) {
}
const textMap = new Map();
for (const n of nodes) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const type = n.type ?? "";
if (type === "CLIPTextEncodeEditPlus") {
const resolveNamedInput = (name) => {
if (!Array.isArray(n.inputs)) return null;
const inp = n.inputs.find(i => i.name === name);
if (inp?.link == null) return null;
const originId = linkOrigin.get(inp.link);
const originSlot = linkSlot.get(inp.link) ?? 0;
if (originId == null) return null;
return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
};
const combined = resolveEditPlusText(n.widgets_values?.[1], n.widgets_values?.[0], resolveNamedInput("text1"), resolveNamedInput("text2"));
if (combined) textMap.set(n.id, combined);
continue;
}
if (!isTextEncoderNode(type)) continue;
const text = n.widgets_values?.[0];
if (text && typeof text === "string") {
textMap.set(n.id, text);
@@ -220,18 +367,8 @@ function extractPromptsFromNodeSet(nodes, links) {
if (textInput?.link != null) {
const originId = linkOrigin.get(textInput.link);
const originSlot = linkSlot.get(textInput.link) ?? 0;
const srcNode = originId != null ? nodeMap.get(originId) : null;
if (srcNode) {
const srcType = srcNode.type ?? "";
if (isPromptStylerNode(srcType)) {
const v = srcNode.widgets_values?.[originSlot];
if (v && typeof v === "string") textMap.set(n.id, v);
} else {
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
if (v && typeof v === "string") textMap.set(n.id, v);
}
}
const text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
if (text2) textMap.set(n.id, text2);
}
}
}
@@ -240,15 +377,48 @@ function extractPromptsFromNodeSet(nodes, links) {
for (const n of nodes) {
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
foundSampler = true;
for (const inp of n.inputs) {
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
// node's inputs instead so the loop below can find the positive/negative-role slots.
let inputsToScan = n.inputs;
const guiderInput = n.inputs.find(inp => inp.name === "guider");
if (guiderInput?.link != null) {
const guiderId = linkOrigin.get(guiderInput.link);
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
}
for (const inp of inputsToScan) {
if (!inp || inp.link == null) continue;
const originId = linkOrigin.get(inp.link);
if (originId == null) continue;
const text = textMap.get(originId);
if (!text) continue;
const name = inp.name ?? "";
if (name === "positive" || name.startsWith("positive")) pos.add(text);
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
const isNeg = name === "negative" || name.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
// negative_prompt directly, so textMap (single text per node) can't represent both
// roles.
const srcNode = nodeMap.get(originId);
if (srcNode?.type === "TextEncodeMageFlowEdit" || srcNode?.type === "TextEncodeBooguEdit") {
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
const text = srcNode.widgets_values?.[widgetIdx];
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
continue;
}
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
// conditioning through its own positive/negative inputs — follow one more hop.
let resolvedOriginId = originId;
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
if (innerInput?.link != null) {
const innerId = linkOrigin.get(innerInput.link);
if (innerId != null) resolvedOriginId = innerId;
}
}
const text = textMap.get(resolvedOriginId);
if (!text) continue;
if (isPos) pos.add(text); else neg.add(text);
}
}
if (!foundSampler) return null;
@@ -300,7 +470,8 @@ function extractMarkdownNoteModels(wf) {
}
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
function resolveLinkedText(wf, srcId, slot) {
function resolveLinkedText(wf, srcId, slot, depth = 0) {
if (depth > 6) return null;
const src = wf[String(srcId)];
if (!src || typeof src !== "object") return null;
const ct = src.class_type ?? "";
@@ -309,13 +480,34 @@ function resolveLinkedText(wf, srcId, slot) {
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
return (v && typeof v === "string") ? v : null;
}
// 汎用テキストキー
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
// リテラルへ解決できた方を採用する。
if (ct === "ComfySwitchNode") {
for (const key of ["on_false", "on_true"]) {
const v = src.inputs?.[key];
if (Array.isArray(v)) {
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
if (text) return text;
} else if (typeof v === "string" && v) {
return v;
}
}
return null;
}
// 汎用テキストキー("value" は PrimitiveString/PrimitiveStringMultiline、"source" は
// PreviewAny の値タップ中継、"string_a" は StringConcatenate の主オペランド — いずれも
// Krea-2のプロンプト強化配線で使われる中継ノード)
const keys = slot === 0
? ["text_positive", "text", "text_g", "prompt"]
: ["text_negative", "text_l"];
? ["text_positive", "text", "text_g", "prompt", "value", "source", "string_a"]
: ["text_negative", "text_l", "source", "string_a"];
for (const k of keys) {
const v = src.inputs?.[k];
if (v && typeof v === "string") return v;
if (typeof v === "string" && v) return v;
if (Array.isArray(v)) {
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
if (text) return text;
}
}
return null;
}
@@ -329,8 +521,30 @@ function extractPromptsAPI(wf) {
}
const textMap = new Map();
for (const [id, n] of Object.entries(wf)) {
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
if (!n) continue;
const ct = n.class_type ?? "";
if (ct === "CLIPTextEncodeEditPlus") {
const resolveField = (key) => {
const v = n.inputs?.[key];
if (typeof v === "string") return v;
if (Array.isArray(v)) return resolveLinkedText(wf, v[0], v[1] ?? 0);
return null;
};
const combined = resolveEditPlusText(n.inputs?.mode, n.inputs?.text_edit, resolveField("text1"), resolveField("text2"));
if (combined) textMap.set(id, combined);
continue;
}
// MiniMaxH3ImageToVideo等の動画生成オールインワンノード。CLIPTextEncode等を経由せず
// 自ノードの"prompt"入力に直接テキストを持つ(positive/negativeの区別も無い)ため、
// isTextEncoderNode判定を通さずtextMapへ直接投入する(fallbackのtextsに載る)。
if (typeof n.inputs?.prompt === "string" && /ImageToVideo|TextToVideo/i.test(ct)) {
textMap.set(id, n.inputs.prompt);
continue;
}
if (!isTextEncoderNode(ct)) continue;
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
// model text encoders use this key instead of "text"/"text_g".
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? null;
if (raw && typeof raw === "string") {
textMap.set(id, raw);
} else if (Array.isArray(raw)) {
@@ -344,12 +558,37 @@ function extractPromptsAPI(wf) {
for (const n of Object.values(wf)) {
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
foundSampler = true;
for (const [key, val] of Object.entries(n.inputs ?? {})) {
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
// node's inputs instead so the loop below can find the positive/negative-role keys.
let inputsToScan = n.inputs ?? {};
if (Array.isArray(n.inputs?.guider)) {
const guiderNode = wf[String(n.inputs.guider[0])];
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
}
for (const [key, val] of Object.entries(inputsToScan)) {
if (!Array.isArray(val)) continue;
const text = textMap.get(String(val[0]));
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
const isNeg = key === "negative" || key.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
// negative_prompt directly, so textMap (single text per node) can't represent both
// roles.
const srcNode = wf[String(val[0])];
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit") {
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
continue;
}
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
// conditioning through its own positive/negative inputs — follow one more hop.
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
: String(val[0]);
const text = textMap.get(resolvedId);
if (!text) continue;
if (key === "positive" || key.startsWith("positive")) pos.add(text);
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
if (isPos) pos.add(text); else neg.add(text);
}
}
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
@@ -446,15 +685,28 @@ function parseFooocusMetadata(raw) {
}
// ── Master extractAllMetadata ─────────────────────────────────
async function extractAllMetadata(file) {
export async function extractAllMetadata(file) {
const name = file.name.toLowerCase();
const isJSON = file.type === "application/json" || name.endsWith(".json");
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
const isMP4 = file.type === "video/mp4" || name.endsWith(".mp4");
function fromWorkflow(wf, source) {
async function fromWorkflow(originalWf, source) {
let wf = originalWf;
// サブグラフを持つワークフローは、外側のサブグラフノードに実際に設定された値
// (ユーザーが見ている値)が内部ノードのwidgets_valuesへ反映されていないことがある
// (テンプレートの古いデフォルト値のまま)。convertUiToApi() はこの注入も含めて
// 正しく解決するため、そちらを経由してから抽出する。通常のワークフローには影響しない。
if (Array.isArray(wf?.nodes) && wf.definitions?.subgraphs?.length > 0) {
try {
const apiWf = await comfyWorkflow.convertUiToApi(wf);
if (apiWf && Object.keys(apiWf).length > 0) wf = apiWf;
} catch { /* 変換失敗時は元のUI形式のまま抽出(既存ロジックにフォールバック) */ }
}
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)
const mdm = extractMarkdownNoteModels(wf);
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)。
// API変換後は nodes 情報が失われるため、常に元のUI形式ワークフローから抽出する。
const mdm = extractMarkdownNoteModels(originalWf);
if (mdm) {
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
if (!base.vaes.length) base.vaes = mdm.vaes;
@@ -467,20 +719,31 @@ async function extractAllMetadata(file) {
if (isJSON) {
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
return wf ? fromWorkflow(wf, "comfyui") : null;
return wf ? await fromWorkflow(wf, "comfyui") : null;
}
if (isWebP) {
const exif = await readWebPEXIFChunk(file);
if (!exif) return null;
const wf = extractWorkflowFromEXIF(exif);
return wf ? fromWorkflow(wf, "comfyui") : null;
return wf ? await fromWorkflow(wf, "comfyui") : null;
}
if (isMP4) {
let tags; try { tags = await readMP4MetaTags(file); } catch { return null; }
if (!tags) return null;
for (const key of ["prompt", "workflow"]) {
const raw = tags[key];
if (!raw) continue;
let wf; try { wf = JSON.parse(sanitizeJSON(raw)); } catch { continue; }
if (wf) return await fromWorkflow(wf, "comfyui");
}
return null;
}
// PNG
const chunks = await readAllPNGTextChunks(file);
if (!chunks) return null;
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
if (chunks.fooocus_scheme === "fooocus" && chunks.parameters) {
const f = parseFooocusMetadata(chunks.parameters);
@@ -524,7 +787,7 @@ function buildLoRAItem(lora) {
return el;
}
function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
export function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
const el = document.createElement("div");
el.className = "wfm-meta-item wfm-meta-item-clickable";
const snippet = label.length > 60 ? label.slice(0, 60) + "…" : label;
@@ -554,7 +817,8 @@ function renderSection(sectionEl, listEl, items, buildFn) {
let _externalHandleFile = null;
export async function loadFileIntoMetadataTab(file) {
document.querySelector('.wfm-tab[data-tab="metadata"]')?.click();
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="metadata"]')?.click();
await new Promise(r => setTimeout(r, 0));
if (_externalHandleFile) await _externalHandleFile(file);
}
@@ -621,6 +885,7 @@ export function initMetadataTab() {
"wfm-help-metadata-3": "helpMetadata3",
"wfm-help-metadata-4": "helpMetadata4",
"wfm-help-metadata-5": "helpMetadata5",
"wfm-help-metadata-6": "helpMetadata6",
};
for (const [id, key] of Object.entries(helpIds)) {
const el = document.getElementById(id);
+17 -1947
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
/**
* Models Tab - Badge palette, badge rendering, badge edit modal, badge filter bar
*/
import { openModal } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml, readJsonStorage } from "../util.js";
import { state } from "./state.js";
// Grid re-render is owned by models-tab.js (grid rendering hasn't been split
// out yet); registered once from initModelsTab() to avoid a circular import.
let _onGridChange = () => {};
export function setGridChangeCallback(fn) { _onGridChange = fn; }
export function getBadgePalette() {
return readJsonStorage("wfm_models_badge_palette");
}
export function saveBadgePalette(palette) {
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
}
export function badgeHtml(label) {
const palette = getBadgePalette();
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
return `<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>`;
}
export function modelBadgesHtml(modelName, clickable = false) {
const meta = state.modelMetadata[modelName] || {};
const badges = meta.badges || [];
if (badges.length === 0) return "";
return badges.map((label) => {
const palette = getBadgePalette();
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
const dataAttr = clickable ? ` data-badge-filter="${escapeHtml(label)}"` : "";
return `<span class="wfm-badge wfm-badge-model${clickable ? " wfm-badge-clickable" : ""}"${style}${dataAttr}>${escapeHtml(label)}</span>`;
}).join("");
}
export function openBadgeEditModal(onPaletteChange = null) {
const palette = getBadgePalette();
const labels = Object.keys(palette).sort();
const rowsHtml = labels.map((label) => {
const color = palette[label] || "#6366f1";
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
${badgeHtml(label)}
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">&times;</button>
</div>`;
}).join("");
const html = `
<div style="min-width:320px;">
<div id="wfm-badge-list">${rowsHtml || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`}</div>
<div style="border-top:1px solid var(--wfm-border);margin-top:12px;padding-top:12px;display:flex;gap:6px;align-items:center;">
<input type="text" id="wfm-badge-new-label" class="wfm-input" style="flex:1;" placeholder="${t("badgeNewLabel")}">
<input type="color" id="wfm-badge-new-color" value="#6366f1" style="width:36px;height:28px;padding:1px;border-radius:4px;cursor:pointer;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-badge-add-btn">${t("badgeAdd")}</button>
</div>
</div>`;
openModal(t("badgeManage"), html);
bindBadgeModalEvents(onPaletteChange);
}
function bindBadgeModalEvents(onPaletteChange = null) {
const afterChange = () => {
renderBadgeFilter();
_onGridChange();
if (onPaletteChange) onPaletteChange();
};
const refreshList = () => {
const palette = getBadgePalette();
const labels = Object.keys(palette).sort();
const listEl = document.getElementById("wfm-badge-list");
if (!listEl) return;
listEl.innerHTML = labels.map((label) => {
const color = palette[label] || "#6366f1";
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
${badgeHtml(label)}
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">&times;</button>
</div>`;
}).join("") || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`;
bindBadgeRowEvents(refreshList, afterChange);
afterChange();
};
bindBadgeRowEvents(refreshList, afterChange);
document.getElementById("wfm-badge-add-btn")?.addEventListener("click", () => {
const labelInput = document.getElementById("wfm-badge-new-label");
const colorInput = document.getElementById("wfm-badge-new-color");
const label = labelInput?.value.trim();
if (!label) return;
const palette = getBadgePalette();
palette[label] = colorInput?.value || "#6366f1";
saveBadgePalette(palette);
if (labelInput) labelInput.value = "";
refreshList();
});
}
function bindBadgeRowEvents(refreshList, afterChange = null) {
document.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
input.addEventListener("input", (e) => {
const label = e.target.dataset.badgeLabel;
const palette = getBadgePalette();
palette[label] = e.target.value;
saveBadgePalette(palette);
refreshList();
});
});
document.querySelectorAll(".wfm-badge-delete-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const label = btn.dataset.badgeLabel;
const palette = getBadgePalette();
delete palette[label];
saveBadgePalette(palette);
refreshList();
});
});
}
// ── Badge Filter Bar ──────────────────────────────────────
export function renderBadgeFilter() {
const container = document.getElementById("wfm-models-badge-filter-bar");
if (!container) return;
const palette = getBadgePalette();
const labels = Object.keys(palette).sort();
if (labels.length === 0) {
container.innerHTML = "";
return;
}
container.innerHTML = labels.map((label) => {
const color = palette[label] || "";
const isActive = state.badgeFilter === label;
const style = color ? `background:${color};color:#fff;` : "";
return `<button class="wfm-badge-filter-btn${isActive ? " active" : ""}" data-badge="${escapeHtml(label)}" style="${style}">${escapeHtml(label)}</button>`;
}).join("");
container.querySelectorAll(".wfm-badge-filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const label = btn.dataset.badge;
state.badgeFilter = state.badgeFilter === label ? "" : label;
state.currentPage = 0;
renderBadgeFilter();
_onGridChange();
});
});
}
+898
View File
@@ -0,0 +1,898 @@
/**
* Models Tab - Detail modal (double-click) and the side panel's
* Info / Group / CivitAI tabs.
*
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
* import triangle — see the note at the top of grid-view.js.
*/
import { showToast, openModal, closeModal } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state, RESERVED_GROUPS, GENUI_TYPE_MAP } from "./state.js";
import { getCurrentModels, renderTagFilter, renderDirFilter } from "./filters.js";
import { getBadgePalette, modelBadgesHtml, openBadgeEditModal } from "./badges.js";
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl } from "./helpers.js";
import { renderModelGrid } from "./grid-view.js";
import {
applyToGenUI, applyEmbeddingToPrompt, saveModelMetadata, toggleGroupEnable,
fetchModelMetadata, saveModelGroups,
} from "../models-tab.js";
// ── Detail Modal (double-click, with badges) ──────────────
export function openDetailModal(modelName) {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const isFav = !!meta.favorite;
const tagsStr = (meta.tags || []).join(", ");
const selectedBadges = meta.badges || [];
const palette = getBadgePalette();
const allBadgeLabels = Object.keys(palette).sort();
const badgeCheckboxes = allBadgeLabels.map((label) => {
const checked = selectedBadges.includes(label) ? " checked" : "";
const color = palette[label] || "";
const style = color ? `background:${color};color:#fff;` : "";
return `<label class="wfm-badge-check-label">
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
<span class="wfm-badge wfm-badge-model" style="${style}">${escapeHtml(label)}</span>
</label>`;
}).join("");
const html = `
<div class="wfm-modal-thumb-section">
<img class="wfm-modal-thumb-img" style="display:none" />
<div class="wfm-modal-thumb-placeholder">${t("modelsNoPreview")}</div>
</div>
<div style="text-align:center;margin-bottom:8px;">
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-change-thumb">${t("changeThumbnail")}</button>
<input type="file" id="wfm-modal-thumb-file" accept="image/*" style="display:none">
</div>
<div class="wfm-modal-two-col">
<div class="wfm-modal-left">
<section>
<h4>${t("modelsInfo")}</h4>
<div><span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>${dir ? ` <span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}</div>
</section>
<section>
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-modal-badge-manage" style="margin-left:6px;font-size:10px;">&#9881; ${t("badgeManage")}</button></h4>
<div id="wfm-modal-badge-checkboxes" class="wfm-badge-checkboxes">
${allBadgeLabels.length === 0
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
: badgeCheckboxes}
</div>
</section>
<section>
<h4>${t("modelsTags")} <span style="font-weight:normal;font-size:11px;">${t("modelsTagsHint")}</span></h4>
<input type="text" class="wfm-input" id="wfm-modal-model-tags" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
</section>
<section>
<h4>${t("modelsMemo")}</h4>
<textarea class="wfm-textarea" id="wfm-modal-model-memo" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
</section>
<div class="wfm-modal-actions">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-modal-model-save">${t("modelsSave")}</button>
${state.activeModelType === "embedding"
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-pp" title="Positive Promptにエンベッディング追加">GenUI PP</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-np" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
: (GENUI_TYPE_MAP[state.activeModelType]
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-model" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
: "")}
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-modal-model-delete" style="margin-left:auto;">${t("modelsDelete")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-model-close">${t("close")}</button>
</div>
</div>
</div>`;
openModal(getStem(name), html);
// Load preview image
const modalImg = document.querySelector(".wfm-modal-thumb-img");
const modalPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
if (modalImg) loadPreviewImage(modalImg, modalPlaceholder, modelName);
// Add favorite button in header
const titleEl = document.getElementById("wfm-modal-title");
if (titleEl) {
titleEl.parentNode.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
const favBtn = document.createElement("button");
favBtn.className = isFav ? "wfm-fav-btn active" : "wfm-fav-btn";
favBtn.style.cssText = "position:static;font-size:18px;margin-right:8px;";
favBtn.textContent = isFav ? "★" : "☆";
favBtn.addEventListener("click", async () => {
const newVal = !meta.favorite;
await saveModelMetadata(modelName, { favorite: newVal });
meta.favorite = newVal;
favBtn.textContent = newVal ? "★" : "☆";
favBtn.classList.toggle("active", newVal);
renderModelGrid();
});
titleEl.parentNode.insertBefore(favBtn, titleEl);
}
// Badge manage button → open badge edit modal
document.getElementById("wfm-modal-badge-manage")?.addEventListener("click", () => {
openBadgeEditModal();
});
// Save
document.getElementById("wfm-modal-model-save")?.addEventListener("click", async () => {
const tagsInput = document.getElementById("wfm-modal-model-tags");
const memoInput = document.getElementById("wfm-modal-model-memo");
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
const memo = memoInput ? memoInput.value : "";
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map((cb) => cb.value);
await saveModelMetadata(modelName, { tags, memo, badges });
showToast(t("modelsSaved"), "success");
renderTagFilter();
renderDirFilter();
renderModelGrid();
});
// GenUI Model / Embedding buttons
document.getElementById("wfm-modal-genui-model")?.addEventListener("click", () => {
applyToGenUI(modelName, state.activeModelType);
});
document.getElementById("wfm-modal-genui-pp")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "positive");
});
document.getElementById("wfm-modal-genui-np")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "negative");
});
// Close button (連続作業時にXボタンまでマウス移動しなくて済むように)
document.getElementById("wfm-modal-model-close")?.addEventListener("click", () => {
closeModal();
});
// Delete model button
document.getElementById("wfm-modal-model-delete")?.addEventListener("click", async () => {
const confirmMsg = t("modelBulkDeleteConfirm").replace("{count}", "1");
if (!confirm(confirmMsg)) return;
try {
const res = await fetch("/api/wfm/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names: [modelName] }),
});
const data = await res.json();
if (data.errors?.length > 0) {
showToast(`${t("modelBulkDeleteError")}: ${data.errors[0].error}`, "error");
return;
}
showToast(`${getStem(name)} ${t("modelBulkDeleteDone")}`, "success");
closeModal();
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(modelName);
if (idx !== -1) list.splice(idx, 1);
delete state.modelMetadata[modelName];
const ds = state.disabledModels[state.activeModelType];
if (ds) ds.delete(modelName);
state.selectedModels.delete(modelName);
if (state.selectedModel === modelName) {
state.selectedModel = null;
const titleEl2 = document.getElementById("wfm-models-panel-title");
if (titleEl2) titleEl2.textContent = "";
}
renderModelGrid();
} catch (err) {
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
}
});
// Change thumbnail
const changeBtn = document.getElementById("wfm-modal-change-thumb");
const thumbFile = document.getElementById("wfm-modal-thumb-file");
if (changeBtn && thumbFile) {
changeBtn.addEventListener("click", () => { thumbFile.value = ""; thumbFile.click(); });
thumbFile.addEventListener("change", async () => {
const file = thumbFile.files?.[0];
if (!file) return;
changeBtn.disabled = true;
changeBtn.textContent = t("uploading");
try {
const fd = new FormData();
fd.append("type", state.activeModelType);
fd.append("name", modelName);
fd.append("file", file);
const res = await fetch("/api/wfm/models/change-preview", { method: "POST", body: fd });
const data = await res.json();
if (data.error) throw new Error(data.error);
// Reload preview in modal
const newUrl = previewUrl(modelName) + "&t=" + Date.now();
const mImg = document.querySelector(".wfm-modal-thumb-img");
const mPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
if (mImg) { mImg.src = newUrl; mImg.style.display = ""; }
if (mPlaceholder) mPlaceholder.style.display = "none";
renderModelGrid();
if (state.selectedModel === modelName) renderSideInfo(modelName);
showToast(t("thumbnailChanged"), "success");
} catch (err) {
showToast(t("thumbnailError") + ": " + err.message, "error");
} finally {
changeBtn.disabled = false;
changeBtn.textContent = t("changeThumbnail");
thumbFile.value = "";
}
});
}
}
// ── Side Panel ────────────────────────────────────────────
export function showSidePanel(modelName) {
state.selectedModel = modelName;
// Highlight selected
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
el.classList.toggle("wfm-card-selected", el.dataset.modelName === modelName);
});
const { name } = parseModelPath(modelName);
document.getElementById("wfm-models-panel-title").textContent = name;
// Reset to CivitAI tab
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelector('.wfm-models-side-tab-btn[data-side-tab="civitai"]')?.classList.add("active");
document.querySelectorAll(".wfm-models-side-content").forEach((c) => (c.style.display = "none"));
const civitaiEl = document.getElementById("wfm-models-side-civitai");
if (civitaiEl) civitaiEl.style.display = "block";
const genuiNavBtn = document.getElementById("wfm-side-genui-nav-btn");
const genuiNpBtn = document.getElementById("wfm-side-genui-np-btn");
if (genuiNavBtn) {
if (state.activeModelType === "embedding") {
genuiNavBtn.textContent = "GenUI PP";
genuiNavBtn.title = "Positive Promptにエンベッディング追加";
genuiNavBtn.style.display = "";
if (genuiNpBtn) {
genuiNpBtn.textContent = "GenUI NP";
genuiNpBtn.title = "Negative Promptにエンベッディング追加";
genuiNpBtn.style.display = "";
}
} else if (GENUI_TYPE_MAP[state.activeModelType]) {
genuiNavBtn.textContent = t("modelsGenUIBtn");
genuiNavBtn.title = t("modelsGenUITitle");
genuiNavBtn.style.display = "";
if (genuiNpBtn) genuiNpBtn.style.display = "none";
} else {
genuiNavBtn.style.display = "none";
if (genuiNpBtn) genuiNpBtn.style.display = "none";
}
}
renderSideInfo(modelName);
renderSideGroup(modelName);
renderSideCivitai(modelName);
}
export function closeSidePanel() {
state.selectedModel = null;
const titleEl = document.getElementById("wfm-models-panel-title");
if (titleEl) titleEl.textContent = "";
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
el.classList.remove("wfm-card-selected");
});
const npBtn = document.getElementById("wfm-side-genui-np-btn");
if (npBtn) npBtn.style.display = "none";
const ppBtn = document.getElementById("wfm-side-genui-nav-btn");
if (ppBtn) ppBtn.style.display = "none";
}
// ── Side Panel: Info Tab ──────────────────────────────────
export function renderSideInfo(modelName) {
const el = document.getElementById("wfm-models-side-info");
if (!el) return;
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const tagsStr = (meta.tags || []).join(", ");
const userBadgesHtml = modelBadgesHtml(modelName, false);
el.innerHTML = `
<div class="wfm-side-thumb-container">
<div class="wfm-side-thumb-img-wrap">
<img style="display:none" />
<span class="wfm-side-thumb-placeholder">${t("modelsNoPreview")}</span>
</div>
<div class="wfm-side-thumb-info">
<div class="wfm-side-thumb-name wfm-model-name-copy" title="${t("modelsCopyName")}">${escapeHtml(name)}</div>
<div class="wfm-side-thumb-meta">
<span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>
${dir ? `<span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}
${userBadgesHtml}
</div>
</div>
</div>
<div class="wfm-node-detail-section">
<div class="wfm-node-detail-label">${t("modelsFilePath")}</div>
<div class="wfm-node-detail-value">
<span id="wfm-models-side-filepath" class="wfm-model-filepath" title="${t("modelsCopyPath")}" style="cursor:pointer;word-break:break-all;font-size:0.85em;color:#aaa;">${t("modelsLoading")}...</span>
</div>
</div>
<div class="wfm-node-detail-section">
<div class="wfm-node-detail-label">${t("modelsTags")}</div>
<div class="wfm-node-detail-value">
<input type="text" id="wfm-models-side-tags" class="wfm-search-input" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
</div>
</div>
<div class="wfm-node-detail-section">
<div class="wfm-node-detail-label">${t("modelsMemo")}</div>
<div class="wfm-node-detail-value">
<textarea id="wfm-models-side-memo" class="wfm-textarea" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
</div>
</div>
<div class="wfm-node-detail-section" style="display:flex;gap:6px;">
<button id="wfm-models-side-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsSave")}</button>
${state.activeModelType === "embedding"
? `<button id="wfm-models-side-genui-pp-btn" class="wfm-btn wfm-btn-sm" title="Positive Promptにエンベッディング追加">GenUI PP</button>
<button id="wfm-models-side-genui-np-btn" class="wfm-btn wfm-btn-sm" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
: (GENUI_TYPE_MAP[state.activeModelType]
? `<button id="wfm-models-side-genui-btn" class="wfm-btn wfm-btn-sm" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
: "")}
</div>`;
// Load preview image
const sideImg = el.querySelector(".wfm-side-thumb-img-wrap img");
const sidePlaceholder = el.querySelector(".wfm-side-thumb-placeholder");
if (sideImg) loadPreviewImage(sideImg, sidePlaceholder, modelName);
// Copy model name on click
el.querySelector(".wfm-model-name-copy")?.addEventListener("click", () => {
navigator.clipboard.writeText(modelName).then(() => {
showToast(t("modelsCopiedName"), "success");
});
});
// Fetch and display file path
fetch(`/api/wfm/models/filepath?type=${encodeURIComponent(state.activeModelType)}&name=${encodeURIComponent(modelName)}`)
.then((r) => r.json())
.then((data) => {
const fpEl = document.getElementById("wfm-models-side-filepath");
if (fpEl && data.path) {
fpEl.textContent = data.path;
fpEl.title = t("modelsCopyPath");
} else if (fpEl) {
fpEl.textContent = modelName;
}
})
.catch(() => {
const fpEl = document.getElementById("wfm-models-side-filepath");
if (fpEl) fpEl.textContent = modelName;
});
// Copy file path on click
el.querySelector("#wfm-models-side-filepath")?.addEventListener("click", () => {
const fpEl = document.getElementById("wfm-models-side-filepath");
if (fpEl) {
navigator.clipboard.writeText(fpEl.textContent).then(() => {
showToast(t("modelsCopiedPath"), "success");
});
}
});
// Save button
el.querySelector("#wfm-models-side-save-btn")?.addEventListener("click", () => {
const tagsInput = document.getElementById("wfm-models-side-tags");
const memoInput = document.getElementById("wfm-models-side-memo");
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
const memo = memoInput ? memoInput.value : "";
saveModelMetadata(modelName, { tags, memo }).then(() => {
showToast(t("modelsSaved"), "success");
renderTagFilter();
renderModelGrid();
});
});
// GenUI Model / Embedding buttons
el.querySelector("#wfm-models-side-genui-btn")?.addEventListener("click", () => {
applyToGenUI(modelName, state.activeModelType);
});
el.querySelector("#wfm-models-side-genui-pp-btn")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "positive");
});
el.querySelector("#wfm-models-side-genui-np-btn")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "negative");
});
}
// ── Side Panel: Group Tab ─────────────────────────────────
export function renderSideGroup(modelName) {
const el = document.getElementById("wfm-models-side-group");
if (!el) return;
// Find groups this model belongs to
const memberOf = [];
for (const [gName, members] of Object.entries(state.modelGroups)) {
if (members.includes(modelName)) memberOf.push(gName);
}
// All group names for the assign dropdown
const allGroups = Object.keys(state.modelGroups).sort();
const availableGroups = allGroups.filter((g) => !memberOf.includes(g));
el.innerHTML = `
<div style="padding:0 4px;">
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
${memberOf.length === 0
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
: memberOf.map((g) => `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
<span style="font-size:13px;">${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-group-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">&times;</button>
</div>`).join("")}
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-models-group-assign" class="wfm-select" style="flex:1;font-size:12px;">
${availableGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: availableGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-assign-btn" ${availableGroups.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
</div>
</div>
<div>
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
<div style="display:flex;gap:4px;">
<input type="text" id="wfm-models-group-new" class="wfm-search-input" style="flex:1;font-size:12px;" placeholder="${t("modelsGroupName")}">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-create-btn">${t("modelsCreate")}</button>
</div>
</div>
<div style="margin-top:16px;border-top:1px solid var(--wfm-border);padding-top:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
<div style="display:flex;gap:4px;margin-bottom:6px;">
<select id="wfm-models-group-manage-select" class="wfm-select" style="flex:1;font-size:12px;">
${allGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: allGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-rename-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsRename")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-models-group-delete-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsDelete")}</button>
</div>
<div style="display:flex;gap:4px;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-enable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupEnableAll")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-disable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupDisableAll")}</button>
</div>
</div>
</div>
`;
// Remove from group
el.querySelectorAll(".wfm-group-remove").forEach((btn) => {
btn.addEventListener("click", () => {
const g = btn.dataset.group;
const members = state.modelGroups[g] || [];
state.modelGroups[g] = members.filter((m) => m !== modelName);
if (state.modelGroups[g].length === 0) delete state.modelGroups[g];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
renderModelGrid();
});
});
});
// Assign to group
document.getElementById("wfm-models-group-assign-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-assign");
const g = sel?.value;
if (!g) return;
if (!state.modelGroups[g]) state.modelGroups[g] = [];
if (!state.modelGroups[g].includes(modelName)) state.modelGroups[g].push(modelName);
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
});
});
// Create new group
document.getElementById("wfm-models-group-create-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-models-group-new");
const name = input?.value.trim();
if (!name) return;
if (state.modelGroups[name]) {
showToast(t("modelsGroupExists"), "warning");
return;
}
state.modelGroups[name] = [modelName];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
});
});
// Rename group
document.getElementById("wfm-models-group-rename-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const oldName = sel?.value;
if (!oldName) return;
if (RESERVED_GROUPS.includes(oldName)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
const newName = prompt(t("modelsRenamePrompt"), oldName);
if (!newName || newName === oldName) return;
if (state.modelGroups[newName]) {
showToast(t("modelsGroupExists"), "warning");
return;
}
state.modelGroups[newName] = state.modelGroups[oldName];
delete state.modelGroups[oldName];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
renderModelGrid();
});
});
// Delete group
document.getElementById("wfm-models-group-delete-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
if (RESERVED_GROUPS.includes(g)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
if (!confirm(t("modelsDeleteGroupConfirm").replace("{name}", g))) return;
delete state.modelGroups[g];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
renderModelGrid();
});
});
// Enable all models in selected group
document.getElementById("wfm-models-group-enable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, true).then(() => renderSideGroup(modelName));
});
// Disable all models in selected group
document.getElementById("wfm-models-group-disable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, false).then(() => renderSideGroup(modelName));
});
}
// ── Side Panel: CivitAI Tab ───────────────────────────────
export function renderSideCivitai(modelName) {
const el = document.getElementById("wfm-models-side-civitai");
if (!el) return;
// Check if we have cached civitai data via sha256
const meta = state.modelMetadata[modelName] || {};
const sha256 = meta.sha256;
const cached = sha256 && state.civitaiCache[sha256];
if (cached) {
renderCivitaiInfo(el, cached, modelName);
} else if (sha256) {
// SHA256 is known but model was not found on CivitAI
el.innerHTML = `
<div style="padding:0 4px;text-align:center;">
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
${t("civitaiNotFoundDesc")}
</p>
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-fetch-btn">
${t("civitaiRefetchBtn")}
</button>
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
</div>`;
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
fetchCivitaiForModel(modelName, el);
});
} else {
el.innerHTML = `
<div style="padding:0 4px;text-align:center;">
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
${t("civitaiFetchDesc")}
</p>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-civitai-fetch-btn">
${t("civitaiFetch")}
</button>
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
</div>`;
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
fetchCivitaiForModel(modelName, el);
});
}
}
export async function fetchCivitaiForModel(modelName, el) {
const statusEl = document.getElementById("wfm-civitai-status");
const fetchBtn = document.getElementById("wfm-civitai-fetch-btn");
if (fetchBtn) fetchBtn.disabled = true;
if (statusEl) statusEl.textContent = t("civitaiHashing");
try {
const res = await fetch("/api/wfm/models/civitai/fetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: state.activeModelType, name: modelName }),
});
const data = await res.json();
if (data.status === "ok" && data.civitai) {
// Update caches
if (data.sha256) {
state.civitaiCache[data.sha256] = data.civitai;
// Update metadata with sha256
if (!state.modelMetadata[modelName]) state.modelMetadata[modelName] = {};
state.modelMetadata[modelName].sha256 = data.sha256;
}
renderCivitaiInfo(el, data.civitai, modelName);
showToast(t("civitaiFound"), "success");
// Always refresh preview: local file if saved, otherwise civitai image as fallback
const sidePanel = document.getElementById("wfm-models-side-panel");
if (sidePanel) {
const sideImg = sidePanel.querySelector(".wfm-side-thumb-img-wrap img");
const sidePh = sidePanel.querySelector(".wfm-side-thumb-placeholder");
if (sideImg) {
if (data.preview_saved) {
sideImg.src = previewUrl(modelName) + "&t=" + Date.now();
} else {
const civitaiImg = data.civitai.images && data.civitai.images[0];
if (civitaiImg) sideImg.src = civitaiImg;
}
sideImg.style.display = "";
if (sidePh) sidePh.style.display = "none";
}
}
renderModelGrid();
} else if (data.status === "not_found") {
if (statusEl) statusEl.textContent = t("civitaiNotFound");
if (fetchBtn) fetchBtn.disabled = false;
} else {
if (statusEl) statusEl.textContent = data.error || t("civitaiError");
if (fetchBtn) fetchBtn.disabled = false;
}
} catch (err) {
console.error("CivitAI fetch error:", err);
if (statusEl) statusEl.textContent = t("civitaiError");
if (fetchBtn) fetchBtn.disabled = false;
}
}
export function renderCivitaiInfo(el, info, modelName) {
// URL: ユーザー設定ホスト(localStorage)を使用、modelId なし時は /model-versions/ にフォールバック
const civitaiHost = localStorage.getItem("wfm_civitai_host") || "civitai.com";
const modelUrl = info.modelId && info.versionId
? `https://${civitaiHost}/models/${info.modelId}?modelVersionId=${info.versionId}`
: info.versionId
? `https://${civitaiHost}/model-versions/${info.versionId}`
: (info.modelUrl || "#");
// Hash: BLAKE3 優先、なければ SHA256
const fileHashes = info.fileHashes || {};
const blake3 = fileHashes.BLAKE3 || fileHashes.Blake3 || "";
const sha256 = fileHashes.SHA256 || (state.modelMetadata[modelName] || {}).sha256 || "";
const hashType = blake3 ? "BLAKE3" : (sha256 ? "SHA256" : "");
const hashFull = blake3 || sha256;
// Detail rows
const ROW = "display:flex;align-items:center;font-size:12px;margin-bottom:5px;";
const LABEL = "color:var(--wfm-text-secondary);min-width:80px;flex-shrink:0;";
const typeRow = info.type ? `
<div style="${ROW}">
<span style="${LABEL}">${t("civitaiType")}</span>
<span style="font-size:10px;font-weight:700;background:var(--wfm-bg-tertiary,rgba(255,255,255,0.1));padding:2px 7px;border-radius:3px;letter-spacing:0.6px;">${escapeHtml(info.type.toUpperCase())}</span>
</div>` : "";
const baseModelRow = info.baseModel ? `
<div style="${ROW}">
<span style="${LABEL}">${t("civitaiBaseModel")}</span>
<span>${escapeHtml(info.baseModel)}</span>
</div>` : "";
const hashRow = hashFull ? `
<div style="${ROW}">
<span style="${LABEL}">${t("civitaiHashLabel")}</span>
<code class="wfm-hash-value" data-hash="${escapeHtml(hashFull)}"
style="background:var(--wfm-bg-secondary);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 84px);"
title="${t("civitaiCopyHash")}">${escapeHtml(hashType)}: ${escapeHtml(hashFull.substring(0, 16).toUpperCase())}…</code>
</div>` : "";
const detailSection = (typeRow || baseModelRow || hashRow)
? `<div style="margin-bottom:10px;">${typeRow}${baseModelRow}${hashRow}</div>` : "";
const tagsHtml = (info.tags || []).map((tag) =>
`<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`
).join(" ");
const trainedWordsHtml = (info.trainedWords || []).map((w) =>
`<code style="font-size:11px;background:var(--wfm-bg-secondary);padding:1px 4px;border-radius:3px;cursor:pointer;" class="wfm-trained-word" title="${t("civitaiCopyWord")}">${escapeHtml(w)}</code>`
).join(" ");
// Sample images — all go to Sample pane
const images = info.images || [];
const sampleImagesHtml = images.map((url) =>
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${t("civitaiOpenImage")}"><img src="${escapeHtml(url)}" style="width:100%;border-radius:4px;margin-bottom:6px;cursor:pointer;display:block;" loading="lazy" /></a>`
).join("");
el.innerHTML = `
<div style="padding:0 4px;">
<div style="display:flex;border-bottom:1px solid var(--wfm-border);margin-bottom:10px;">
<button class="wfm-civitai-subtab-btn" data-pane="info"
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid var(--wfm-primary);color:var(--wfm-primary);font-weight:600;margin-bottom:-1px;">
${t("civitaiTabInfo")}
</button>
<button class="wfm-civitai-subtab-btn" data-pane="sample"
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;color:var(--wfm-text-secondary);margin-bottom:-1px;">
${t("civitaiTabSample")}${images.length ? ` (${images.length})` : ""}
</button>
</div>
<div id="wfm-civitai-pane-info">
<div style="margin-bottom:10px;">
<div style="font-weight:700;font-size:14px;margin-bottom:2px;">
<a href="${escapeHtml(modelUrl)}" target="_blank" style="color:var(--wfm-primary);text-decoration:none;">${escapeHtml(info.modelName)}</a>
</div>
<div style="font-size:12px;color:var(--wfm-text-secondary);">
${escapeHtml(info.versionName)}${info.creator ? ` · by ${escapeHtml(info.creator)}` : ""}
</div>
</div>
${detailSection}
${tagsHtml ? `<div style="margin-bottom:8px;">${tagsHtml}</div>` : ""}
${trainedWordsHtml ? `<div style="margin-bottom:10px;"><div style="font-weight:600;font-size:12px;margin-bottom:4px;">${t("civitaiTriggerWords")}</div>${trainedWordsHtml}</div>` : ""}
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${info.description}</div>` : ""}
<div style="margin-top:10px;">
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-refresh-btn">${t("civitaiRefresh")}</button>
</div>
</div>
<div id="wfm-civitai-pane-sample" style="display:none;">
${sampleImagesHtml || `<p style="color:var(--wfm-text-secondary);font-size:13px;text-align:center;margin-top:20px;">${t("civitaiNoImages")}</p>`}
</div>
</div>`;
// Sub-tab switching
const subtabBtns = el.querySelectorAll(".wfm-civitai-subtab-btn");
subtabBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const pane = btn.dataset.pane;
subtabBtns.forEach((b) => {
const isActive = b === btn;
b.style.borderBottom = isActive ? "2px solid var(--wfm-primary)" : "2px solid transparent";
b.style.color = isActive ? "var(--wfm-primary)" : "var(--wfm-text-secondary)";
b.style.fontWeight = isActive ? "600" : "400";
});
el.querySelector("#wfm-civitai-pane-info").style.display = pane === "info" ? "" : "none";
el.querySelector("#wfm-civitai-pane-sample").style.display = pane === "sample" ? "" : "none";
});
});
// Copy trigger word on click
el.querySelectorAll(".wfm-trained-word").forEach((wordEl) => {
wordEl.addEventListener("click", () => {
navigator.clipboard.writeText(wordEl.textContent).then(() => {
showToast(t("civitaiWordCopied"), "success");
});
});
});
// Copy hash on click
el.querySelectorAll(".wfm-hash-value").forEach((hashEl) => {
hashEl.addEventListener("click", () => {
navigator.clipboard.writeText(hashEl.dataset.hash).then(() => {
showToast(t("civitaiHashCopied"), "success");
});
});
});
// Refresh button
document.getElementById("wfm-civitai-refresh-btn")?.addEventListener("click", () => {
const meta = state.modelMetadata[modelName] || {};
if (meta.sha256) delete state.civitaiCache[meta.sha256];
delete meta.sha256;
fetchCivitaiForModel(modelName, el);
});
}
// ── CivitAI cache / batch fetch ───────────────────────────
export async function fetchCivitaiCache() {
try {
const res = await fetch("/api/wfm/models/civitai/cache");
return res.ok ? await res.json() : {};
} catch { return {}; }
}
export async function batchFetchCivitai() {
const models = getCurrentModels();
if (models.length === 0) {
showToast(t("modelsNoModels"), "warning");
return;
}
// Filter out models that already have CivitAI data
const meta = state.modelMetadata;
const uncached = models.filter((m) => {
const sha = meta[m]?.sha256;
return !sha || !state.civitaiCache[sha];
});
if (uncached.length === 0) {
showToast(t("civitaiBatchAllCached"), "info");
return;
}
const btn = document.getElementById("wfm-models-civitai-batch-btn");
const progressEl = document.getElementById("wfm-models-civitai-progress");
if (btn) btn.disabled = true;
try {
const res = await fetch("/api/wfm/models/civitai/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: state.activeModelType, models: uncached }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let eventType = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
eventType = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (eventType === "progress") {
const pct = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
const statusText = data.status === "hashing" ? t("civitaiHashing2")
: data.status === "fetching" ? t("civitaiFetching")
: data.status === "cached" ? "✓"
: data.status === "found" ? "✓"
: data.status === "not_found" ? "—"
: "";
if (progressEl) progressEl.textContent = `${pct}% (${data.current}/${data.total}) ${statusText}`;
} else if (eventType === "done") {
if (progressEl) progressEl.textContent = "";
const previewNote = data.preview_saved > 0 ? ` (+${data.preview_saved} preview)` : "";
showToast(t("civitaiBatchDone", data.found, data.not_found) + previewNote, "success");
// Reload caches
const [newMeta, newCache] = await Promise.all([fetchModelMetadata(), fetchCivitaiCache()]);
// Apply sha256 hashes from batch result directly in case fetchModelMetadata
// returns before the server has flushed updated metadata to disk
if (data.hashes) {
for (const [modelName, sha256] of Object.entries(data.hashes)) {
if (!newMeta[modelName]) newMeta[modelName] = {};
if (!newMeta[modelName].sha256) newMeta[modelName].sha256 = sha256;
}
}
state.modelMetadata = newMeta;
state.civitaiCache = newCache;
renderModelGrid();
// Refresh side panel if open
if (state.selectedModel) renderSideCivitai(state.selectedModel);
}
}
}
}
} catch (err) {
console.error("Batch CivitAI error:", err);
showToast(t("civitaiError"), "error");
} finally {
if (btn) btn.disabled = false;
if (progressEl) progressEl.textContent = "";
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Models Tab - Filtering, sorting, and the tag/group/directory filter dropdowns
*/
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state } from "./state.js";
import { parseModelPath, getExtension } from "./helpers.js";
export function getCurrentModels() {
return state.modelsByType[state.activeModelType] || [];
}
export function isModelDisabled(modelName) {
const s = state.disabledModels[state.activeModelType];
return s ? s.has(modelName) : false;
}
export function filterModels() {
let models = getCurrentModels();
if (state.statusFilter === "enabled") {
models = models.filter((m) => !isModelDisabled(m));
} else if (state.statusFilter === "disabled") {
models = models.filter((m) => isModelDisabled(m));
}
if (state.showFavoritesOnly) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
return meta && meta.favorite;
});
}
if (state.showBatchOnly) {
const batchMembers = state.modelGroups["Batch"] || [];
models = models.filter((m) => batchMembers.includes(m));
}
if (state.tagFilter) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
return meta && meta.tags && meta.tags.includes(state.tagFilter);
});
}
if (state.badgeFilter) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
return meta && meta.badges && meta.badges.includes(state.badgeFilter);
});
}
if (state.dirFilter) {
models = models.filter((m) => {
const { dir } = parseModelPath(m);
return dir === state.dirFilter;
});
}
if (state.groupFilter) {
const members = state.modelGroups[state.groupFilter] || [];
models = models.filter((m) => members.includes(m));
}
if (state.searchText) {
const q = state.searchText.toLowerCase();
models = models.filter((m) => {
const meta = state.modelMetadata[m];
const searchable = [m, ...(meta?.tags || []), meta?.memo || ""]
.join(" ")
.toLowerCase();
return searchable.includes(q);
});
}
return sortModels(models);
}
export function sortKeyOf(modelName) {
const meta = state.modelMetadata[modelName] || {};
switch (state.sortColumn) {
case "fav":
return meta.favorite ? 1 : 0;
case "filename":
return parseModelPath(modelName).name.toLowerCase();
case "subdir":
return parseModelPath(modelName).dir.toLowerCase();
case "civtype": {
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
return (civ?.type || "").toLowerCase();
}
case "basemodel": {
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
return (civ?.baseModel || "").toLowerCase();
}
case "ext":
return getExtension(parseModelPath(modelName).name).toLowerCase();
case "tags":
return (meta.tags || []).join(", ").toLowerCase();
case "memo":
return (meta.memo || "").toLowerCase();
case "enabled":
return isModelDisabled(modelName) ? 1 : 0;
default:
return 0;
}
}
export function sortModels(models) {
if (!state.sortColumn) return models;
// ソートキーを1モデル1回だけ計算(比較ごとのparseModelPath等の再計算を回避)
const dir = state.sortDir === "asc" ? 1 : -1;
return models
.map((m) => [sortKeyOf(m), m])
.sort((a, b) => (a[0] < b[0] ? -dir : a[0] > b[0] ? dir : 0))
.map((pair) => pair[1]);
}
export function getAllTags() {
const set = new Set();
const models = getCurrentModels();
models.forEach((m) => {
const meta = state.modelMetadata[m];
if (meta?.tags) meta.tags.forEach((tag) => set.add(tag));
});
return [...set].sort();
}
// ── Render: Tag Filter ────────────────────────────────────
export function renderTagFilter() {
const select = document.getElementById("wfm-models-tag-filter");
if (!select) return;
const tags = getAllTags();
select.innerHTML =
`<option value="">${t("modelsAllTags")}</option>` +
tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
select.value = state.tagFilter;
}
export function renderGroupFilter() {
const select = document.getElementById("wfm-models-group-filter");
if (!select) return;
const groups = state.allModelGroups[state.activeModelType] || {};
const names = Object.keys(groups).sort();
// 選択中のグループが削除された場合はフィルターをリセット
if (state.groupFilter && !groups[state.groupFilter]) {
state.groupFilter = "";
}
const currentValue = state.groupFilter
? `${state.activeModelType}::${state.groupFilter}`
: "";
select.innerHTML =
`<option value="">${t("modelsAllGroups")}</option>` +
names.map((name) => {
const value = `${state.activeModelType}::${name}`;
const isActive = value === currentValue;
return `<option value="${escapeHtml(value)}"${isActive ? " selected" : ""}>${escapeHtml(name)}</option>`;
}).join("");
}
export function renderDirFilter() {
const select = document.getElementById("wfm-models-dir-filter");
if (!select) return;
const dirs = [...new Set(
getCurrentModels()
.map((m) => parseModelPath(m).dir)
.filter((d) => d !== "")
)].sort();
select.innerHTML =
`<option value="">${t("modelsAllDirs")}</option>` +
dirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("");
select.value = state.dirFilter;
}
+329
View File
@@ -0,0 +1,329 @@
/**
* Models Tab - Grid rendering (Thumbnail / Table views) and per-card
* Batch/Stack/Favorite toggles.
*
* This module and selection-bulk.js / detail-panel.js form a circular
* import triangle with each other and with models-tab.js: rendering a card
* needs to wire up selection, side-panel, and detail-modal click handlers,
* and those in turn re-render the grid after they change state. All the
* cross-module calls happen inside event handlers (never at module
* evaluation time), and every export here is a `function` declaration
* (hoisted), so the cycle resolves safely under ES Modules.
*/
import { showToast } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state } from "./state.js";
import { filterModels, getCurrentModels, isModelDisabled } from "./filters.js";
import { modelBadgesHtml } from "./badges.js";
import { parseModelPath, getExtension, getStem, loadPreviewImage } from "./helpers.js";
import { toggleModelSelection } from "./selection-bulk.js";
import { showSidePanel, openDetailModal, renderSideInfo } from "./detail-panel.js";
import { saveModelGroups, saveModelMetadata, toggleModelEnable } from "../models-tab.js";
export function renderModelGrid() {
const grid = document.getElementById("wfm-models-grid");
if (!grid) return;
grid.className = `wfm-grid wfm-view-${state.viewMode}`;
const filtered = filterModels();
// Update count
const countEl = document.getElementById("wfm-models-count");
if (countEl) {
const total = getCurrentModels().length;
countEl.textContent = `${filtered.length} / ${total}`;
}
if (filtered.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("modelsNoModels")}</p>`;
return;
}
if (state.viewMode === "table") {
renderTableView(grid, filtered);
} else {
renderThumbView(grid, filtered);
}
}
// ── Thumbnail View (same as Workflow tab) ─────────────────
export function renderThumbView(grid, models) {
grid.innerHTML = "";
models.forEach((modelName) => {
const meta = state.modelMetadata[modelName] || {};
const { name } = parseModelPath(modelName);
const disabled = isModelDisabled(modelName);
const userBadges = modelBadgesHtml(modelName);
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
const favStar = meta.favorite ? "★" : "☆";
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
const showStackBtn = state.activeModelType === "lora";
const inBatch = showBatchBtn && isInBatch(modelName);
const inStack = showStackBtn && isInStack(modelName);
const batchClass = inBatch ? "wfm-batch-btn active" : "wfm-batch-btn";
const stackClass = inStack ? "wfm-stack-btn active" : "wfm-stack-btn";
const card = document.createElement("div");
card.className = "wfm-card";
if (disabled) card.classList.add("wfm-model-disabled");
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
card.dataset.modelName = modelName;
card.innerHTML = `
<div class="wfm-card-thumb">
<img style="display:none" />
<span class="wfm-card-thumb-placeholder">${t("modelsNoPreview")}</span>
${disabled ? `<span class="wfm-disabled-overlay">${t("modelDisabled")}</span>` : ""}
</div>
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(getStem(name))}</div>
<div class="wfm-card-meta">${userBadges} ${tagsHtml}</div>
</div>
${showBatchBtn ? `<button class="${batchClass}" title="${t("modelsBatch")}">B</button>` : ""}
${showStackBtn ? `<button class="${stackClass}" title="Stack (Lora multi-apply)">S</button>` : ""}
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
// Load preview without 404 console spam
const img = card.querySelector(".wfm-card-thumb img");
const placeholder = card.querySelector(".wfm-card-thumb-placeholder");
loadPreviewImage(img, placeholder, modelName);
card.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await toggleBatch(modelName);
if (state.showBatchOnly) {
renderModelGrid();
} else {
btn.classList.toggle("active", isInBatch(modelName));
}
});
card.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await toggleStack(modelName);
btn.classList.toggle("active", isInStack(modelName));
});
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(modelName);
});
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleModelEnable(modelName);
});
if (state.selectMode) {
const isChecked = state.selectedModels.has(modelName);
card.classList.toggle("wfm-card-checked", isChecked);
const checkEl = document.createElement("div");
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
card.appendChild(checkEl);
card.addEventListener("click", (e) => {
if (e.target.closest(".wfm-batch-btn, .wfm-stack-btn, .wfm-fav-btn, .wfm-toggle-btn")) return;
toggleModelSelection(modelName);
});
} else {
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
}
grid.appendChild(card);
});
}
// ── Table View ────────────────────────────────────────────
export function thSortHtml(label, col, extraClass = "", extraStyle = "") {
const isActive = state.sortColumn === col;
const arrow = isActive ? (state.sortDir === "asc" ? " ▲" : " ▼") : "";
const activeStyle = isActive ? "color:var(--wfm-accent,#6366f1);" : "";
const cls = ["wfm-table-th-sortable", extraClass].filter(Boolean).join(" ");
return `<th class="${cls}" data-sort-col="${col}" style="${activeStyle}${extraStyle}">${label}${arrow}</th>`;
}
export function renderTableView(grid, models) {
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
const showStackBtn = state.activeModelType === "lora";
const rows = models
.map((modelName) => {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const sha256 = meta.sha256;
const civitai = sha256 && state.civitaiCache[sha256];
const civitaiType = civitai ? (civitai.type || "") : "";
const civitaiBaseModel = civitai ? (civitai.baseModel || "") : "";
const disabled = isModelDisabled(modelName);
const isChecked = state.selectMode && state.selectedModels.has(modelName);
const favIcon = meta.favorite ? "&#9733;" : "&#9734;";
const batchIcon = !showBatchBtn ? "" : isInBatch(modelName) ? `<button class="wfm-batch-btn active" title="${t("modelsBatch")}">B</button>` : `<button class="wfm-batch-btn" title="${t("modelsBatch")}">B</button>`;
const stackIcon = isInStack(modelName) ? `<button class="wfm-stack-btn active" title="Stack (Lora multi-apply)">S</button>` : `<button class="wfm-stack-btn" title="Stack (Lora multi-apply)">S</button>`;
const tagsStr = (meta.tags || []).join(", ");
const memo = meta.memo || "";
const toggleLabel = disabled ? t("modelEnable") : t("modelDisable");
const toggleIcon = disabled ? "▶" : "⏸";
const checkCell = state.selectMode
? `<td class="wfm-table-td-check"><div class="wfm-select-check${isChecked ? " checked" : ""}"></div></td>`
: "";
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}${disabled ? " wfm-model-disabled" : ""}${isChecked ? " wfm-card-checked" : ""}" data-model-name="${escapeHtml(modelName)}">
${checkCell}
<td class="wfm-models-table-fav" title="Favorite">${favIcon}</td>
<td class="wfm-table-td-thumb"><img class="wfm-table-thumb" style="display:none" /></td>
<td class="wfm-table-td-filename" title="${escapeHtml(modelName)}">${escapeHtml(name)}</td>
<td class="wfm-table-td-subdir">${escapeHtml(dir)}</td>
<td class="wfm-table-td-civtype" title="${escapeHtml(civitaiType)}">${escapeHtml(civitaiType)}</td>
<td class="wfm-table-td-basemodel" title="${escapeHtml(civitaiBaseModel)}">${escapeHtml(civitaiBaseModel)}</td>
<td class="wfm-table-td-ext">${escapeHtml(ext)}</td>
<td>${escapeHtml(tagsStr)}</td>
<td class="wfm-table-td-memo" title="${escapeHtml(memo)}">${escapeHtml(memo)}</td>
<td class="wfm-table-td-toggle"><button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${toggleLabel}">${toggleIcon}</button></td>
${showBatchBtn ? `<td class="wfm-table-td-batch">${batchIcon}</td>` : ""}
${showStackBtn ? `<td class="wfm-table-td-stack">${stackIcon}</td>` : ""}
</tr>`;
})
.join("");
const checkTh = state.selectMode ? `<th style="width:24px;"></th>` : "";
grid.innerHTML = `<table class="wfm-models-table"><thead><tr>
${checkTh}
${thSortHtml("&#9733;", "fav", "", "width:30px;text-align:center;")}
<th style="width:40px;"></th>
${thSortHtml(t("modelsFileName"), "filename", "wfm-table-th-filename")}
${thSortHtml(t("modelsSubdir"), "subdir", "wfm-table-th-subdir")}
${thSortHtml(t("civitaiType"), "civtype", "wfm-table-th-civtype")}
${thSortHtml(t("civitaiBaseModel"), "basemodel", "wfm-table-th-basemodel")}
${thSortHtml(t("modelsExt"), "ext", "wfm-table-th-ext")}
${thSortHtml(t("modelsTags"), "tags")}
${thSortHtml(t("modelsMemo"), "memo")}
${thSortHtml("E/D", "enabled", "", "width:50px;text-align:center;")}
${showBatchBtn ? `<th style="width:30px;">B</th>` : ""}
${showStackBtn ? `<th style="width:30px;">S</th>` : ""}
</tr></thead><tbody>${rows}</tbody></table>`;
grid.querySelectorAll(".wfm-models-table-row").forEach((row) => {
const mn = row.dataset.modelName;
const img = row.querySelector(".wfm-table-thumb");
if (img) loadPreviewImage(img, null, mn);
if (state.selectMode) {
row.addEventListener("click", (e) => {
if (e.target.closest(".wfm-models-table-fav, .wfm-toggle-btn, .wfm-batch-btn, .wfm-stack-btn")) return;
toggleModelSelection(mn);
});
} else {
row.addEventListener("click", () => showSidePanel(mn));
row.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(mn); });
}
row.querySelector(".wfm-models-table-fav").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(mn);
});
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleModelEnable(mn);
});
row.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
await toggleBatch(mn);
if (state.showBatchOnly) {
renderModelGrid();
} else {
e.currentTarget.classList.toggle("active", isInBatch(mn));
}
});
row.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
await toggleStack(mn);
e.currentTarget.classList.toggle("active", isInStack(mn));
});
});
grid.querySelectorAll(".wfm-table-th-sortable").forEach((th) => {
th.addEventListener("click", () => {
const col = th.dataset.sortCol;
if (state.sortColumn === col) {
if (state.sortDir === "asc") {
state.sortDir = "desc";
} else {
state.sortColumn = null;
state.sortDir = "asc";
}
} else {
state.sortColumn = col;
state.sortDir = "asc";
}
renderModelGrid();
});
});
}
// ── Batch group helpers ───────────────────────────────────
export function isInBatch(modelName) {
return (state.modelGroups["Batch"] || []).includes(modelName);
}
export async function toggleBatch(modelName) {
const batch = state.modelGroups["Batch"] || [];
const idx = batch.indexOf(modelName);
if (idx >= 0) {
batch.splice(idx, 1);
} else {
batch.push(modelName);
}
state.modelGroups["Batch"] = batch;
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
}
export async function clearBatchGroup() {
state.modelGroups["Batch"] = [];
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
if (state.showBatchOnly) {
state.showBatchOnly = false;
document.getElementById("wfm-models-batch-filter-btn")?.classList.remove("active");
}
renderModelGrid();
showToast(t("modelsBatchClear"), "success");
}
export function isInStack(modelName) {
return (state.modelGroups["Stack"] || []).includes(modelName);
}
export async function toggleStack(modelName) {
const stack = state.modelGroups["Stack"] || [];
const idx = stack.indexOf(modelName);
if (idx >= 0) {
stack.splice(idx, 1);
} else {
stack.push(modelName);
}
state.modelGroups["Stack"] = stack;
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
}
export async function clearStackGroup() {
state.modelGroups["Stack"] = [];
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
renderModelGrid();
showToast(t("stackCleared"), "success");
}
// ── Favorite toggle ───────────────────────────────────────
export async function toggleFavorite(modelName) {
const meta = state.modelMetadata[modelName] || {};
const newFav = !meta.favorite;
await saveModelMetadata(modelName, { favorite: newFav });
renderModelGrid();
if (state.selectedModel === modelName) renderSideInfo(modelName);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Models Tab - Small path/preview helpers shared across the models/* modules
*/
import { state } from "./state.js";
export function parseModelPath(fullName) {
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
if (lastSlash === -1) return { dir: "", name: fullName };
return { dir: fullName.substring(0, lastSlash), name: fullName.substring(lastSlash + 1) };
}
export function getExtension(name) {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.substring(dot) : "";
}
export function getStem(name) {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.substring(0, dot) : name;
}
export function previewUrl(modelName, modelType) {
const type = modelType || state.activeModelType;
return `/api/wfm/models/preview?type=${encodeURIComponent(type)}&name=${encodeURIComponent(modelName)}`;
}
/**
* Load preview image. Uses img onload/onerror instead of HEAD request
* (aiohttp add_get does not auto-handle HEAD method).
* Falls back to CivitAI cached image if no local preview exists.
*/
export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
const url = previewUrl(modelName, modelType);
imgEl.onload = () => {
imgEl.style.display = "";
if (placeholderEl) placeholderEl.style.display = "none";
};
imgEl.onerror = () => {
// Fallback: use CivitAI cached image if available
const meta = state.modelMetadata[modelName] || {};
const sha256 = meta.sha256;
const civitai = sha256 && state.civitaiCache[sha256];
const civitaiImg = civitai && civitai.images && civitai.images[0];
if (civitaiImg) {
imgEl.onerror = () => {
imgEl.style.display = "none";
if (placeholderEl) placeholderEl.style.display = "";
};
imgEl.src = civitaiImg;
imgEl.style.display = "";
if (placeholderEl) placeholderEl.style.display = "none";
} else {
imgEl.style.display = "none";
if (placeholderEl) placeholderEl.style.display = "";
}
};
imgEl.src = url;
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Models Tab - Multi-select mode and bulk group/badge/favorite/move/delete
* operations on the current selection.
*
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
* import triangle — see the note at the top of grid-view.js.
*/
import { showToast } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state, RESERVED_GROUPS } from "./state.js";
import { filterModels, renderDirFilter } from "./filters.js";
import { getBadgePalette } from "./badges.js";
import { renderModelGrid } from "./grid-view.js";
import { saveModelGroups, saveModelMetadata } from "../models-tab.js";
export function toggleSelectMode() {
state.selectMode = !state.selectMode;
if (!state.selectMode) state.selectedModels.clear();
const btn = document.getElementById("wfm-models-select-btn");
if (btn) {
btn.classList.toggle("active", state.selectMode);
btn.textContent = state.selectMode ? t("modelSelectExit") : t("modelSelectMode");
}
renderModelGrid();
renderBulkActionBar();
}
export function toggleModelSelection(modelName) {
if (state.selectedModels.has(modelName)) {
state.selectedModels.delete(modelName);
} else {
state.selectedModels.add(modelName);
}
// Update DOM directly (avoid full re-render)
document.querySelectorAll("[data-model-name]").forEach((el) => {
if (el.dataset.modelName !== modelName) return;
const checked = state.selectedModels.has(modelName);
el.classList.toggle("wfm-card-checked", checked);
const c = el.querySelector(".wfm-select-check");
if (c) c.classList.toggle("checked", checked);
});
renderBulkActionBar();
}
export function clearSelection() {
state.selectedModels.clear();
renderModelGrid();
renderBulkActionBar();
}
export function selectAll() {
filterModels().forEach(m => state.selectedModels.add(m));
renderModelGrid();
renderBulkActionBar();
}
export function renderBulkActionBar() {
const bar = document.getElementById("wfm-models-bulk-bar");
if (!bar) return;
if (!state.selectMode || state.selectedModels.size === 0) {
bar.style.display = "none";
return;
}
const count = state.selectedModels.size;
const groupNames = Object.keys(state.modelGroups).sort();
const noGroups = groupNames.length === 0;
const groupOptions = noGroups
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: groupNames.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
const palette = getBadgePalette();
const badgeLabels = Object.keys(palette).sort();
const noBadges = badgeLabels.length === 0;
const badgeOptions = noBadges
? `<option value="">${t("modelBulkNoBadge")}</option>`
: badgeLabels.map((b) => `<option value="${escapeHtml(b)}">${escapeHtml(b)}</option>`).join("");
bar.style.display = "flex";
bar.innerHTML = `
<div class="wfm-bulk-header">
<span class="wfm-bulk-count">${count} ${t("modelSelected")}</span>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-deselect-all-btn">${t("modelBulkDeselectAll")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-select-all-btn">${t("modelBulkSelectAll")}</button>
<span class="wfm-bulk-sep"></span>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-add-btn">${t("modelBulkFavAdd")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-remove-btn">${t("modelBulkFavRemove")}</button>
</div>
<div class="wfm-bulk-rows">
<div class="wfm-bulk-row">
<span class="wfm-bulk-row-label">Group:</span>
<select id="wfm-bulk-group-select" class="wfm-select wfm-bulk-select">${groupOptions}</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-add-btn"${noGroups ? " disabled" : ""}>${t("modelBulkAddGroup")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-remove-btn"${noGroups ? " disabled" : ""}>${t("modelBulkRemoveGroup")}</button>
<span class="wfm-bulk-sep"></span>
<input type="text" id="wfm-bulk-new-group-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelsGroupName")}">
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-create-add-btn">${t("modelBulkCreateAdd")}</button>
</div>
<div class="wfm-bulk-row">
<span class="wfm-bulk-row-label">Badge:</span>
<select id="wfm-bulk-badge-select" class="wfm-select wfm-bulk-select">${badgeOptions}</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-badge-apply-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeApply")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-badge-remove-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeRemove")}</button>
</div>
<div class="wfm-bulk-row">
<span class="wfm-bulk-row-label">File:</span>
<select id="wfm-bulk-move-select" class="wfm-select wfm-bulk-select">
<option value="">${t("modelBulkMoveRoot")}</option>
${state.subdirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("")}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-move-btn">${t("modelBulkMoveBtn")}</button>
<span class="wfm-bulk-sep"></span>
<input type="text" id="wfm-bulk-new-dir-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelBulkMoveNewFolder")}">
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-mkdir-move-btn">${t("modelBulkMoveCreateMove")}</button>
<span style="flex:1"></span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-delete-btn">${t("modelBulkDelete")}</button>
</div>
</div>
`;
document.getElementById("wfm-bulk-deselect-all-btn")?.addEventListener("click", clearSelection);
document.getElementById("wfm-bulk-select-all-btn")?.addEventListener("click", selectAll);
document.getElementById("wfm-bulk-fav-add-btn")?.addEventListener("click", () => bulkSetFavorite(true));
document.getElementById("wfm-bulk-fav-remove-btn")?.addEventListener("click", () => bulkSetFavorite(false));
document.getElementById("wfm-bulk-add-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkAddToGroup(g);
});
document.getElementById("wfm-bulk-remove-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkRemoveFromGroup(g);
});
document.getElementById("wfm-bulk-create-add-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-bulk-new-group-input");
const name = input?.value.trim();
if (!name) return;
if (state.modelGroups[name]) { showToast(t("modelsGroupExists"), "warning"); return; }
bulkAddToGroup(name);
});
document.getElementById("wfm-bulk-badge-apply-btn")?.addEventListener("click", () => {
const label = document.getElementById("wfm-bulk-badge-select")?.value;
if (label) bulkApplyBadge(label, true);
});
document.getElementById("wfm-bulk-badge-remove-btn")?.addEventListener("click", () => {
const label = document.getElementById("wfm-bulk-badge-select")?.value;
if (label) bulkApplyBadge(label, false);
});
document.getElementById("wfm-bulk-move-btn")?.addEventListener("click", () => {
const dest = document.getElementById("wfm-bulk-move-select")?.value || "";
bulkMoveModels(dest);
});
document.getElementById("wfm-bulk-mkdir-move-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-bulk-new-dir-input");
const name = input?.value.trim();
if (!name) return;
bulkMoveModels(name);
});
document.getElementById("wfm-bulk-delete-btn")?.addEventListener("click", bulkDeleteModels);
}
export async function bulkAddToGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) groups[groupName] = [];
const toAdd = [...state.selectedModels].filter((m) => !groups[groupName].includes(m));
groups[groupName] = [...groups[groupName], ...toAdd];
await saveModelGroups(groups);
showToast(`${toAdd.length} ${t("modelBulkAddDone")}`, "success");
renderBulkActionBar();
}
export async function bulkRemoveFromGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) return;
const before = groups[groupName].length;
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
const removed = before - groups[groupName].length;
// RESERVED_GROUPS (Batch / Stack) は空になってもキーを保持する
if (groups[groupName].length === 0 && !RESERVED_GROUPS.includes(groupName)) delete groups[groupName];
await saveModelGroups(groups);
showToast(`${removed} ${t("modelBulkRemoveDone")}`, "success");
renderModelGrid();
renderBulkActionBar();
}
export async function bulkSetFavorite(isFav) {
const models = [...state.selectedModels];
let count = 0;
for (const mn of models) {
const meta = state.modelMetadata[mn] || {};
if (meta.favorite === isFav) continue;
await saveModelMetadata(mn, { favorite: isFav });
count++;
}
if (count > 0) {
showToast(`${count} ${isFav ? t("modelBulkFavDone") : t("modelBulkUnfavDone")}`, "success");
renderModelGrid();
}
}
export async function bulkApplyBadge(badgeLabel, add) {
const models = [...state.selectedModels];
let count = 0;
for (const mn of models) {
const meta = state.modelMetadata[mn] || {};
const badges = [...(meta.badges || [])];
if (add) {
if (badges.includes(badgeLabel)) continue;
badges.push(badgeLabel);
} else {
const idx = badges.indexOf(badgeLabel);
if (idx === -1) continue;
badges.splice(idx, 1);
}
await saveModelMetadata(mn, { badges });
count++;
}
if (count > 0) {
showToast(`${count} ${add ? t("modelBulkBadgeApplyDone") : t("modelBulkBadgeRemoveDone")}`, "success");
renderModelGrid();
}
}
export async function bulkDeleteModels() {
const count = state.selectedModels.size;
if (count === 0) return;
const msg = t("modelBulkDeleteConfirm").replace("{count}", count);
if (!window.confirm(msg)) return;
const model_names = [...state.selectedModels];
try {
const res = await fetch("/api/wfm/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names }),
});
const data = await res.json();
const okCount = (data.ok || []).length;
const errCount = (data.errors || []).length;
if (errCount > 0) {
showToast(`${t("modelBulkDeleteError")}: ${errCount} errors`, "error");
}
if (okCount > 0) {
showToast(`${okCount} ${t("modelBulkDeleteDone")}`, "success");
// Remove deleted models from local state
model_names.forEach((mn) => {
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(mn);
if (idx !== -1) list.splice(idx, 1);
delete state.modelMetadata[mn];
const ds = state.disabledModels[state.activeModelType];
if (ds) ds.delete(mn);
state.selectedModels.delete(mn);
});
state.loaded[state.activeModelType] = false;
renderModelGrid();
renderBulkActionBar();
}
} catch (err) {
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
}
}
export async function fetchSubdirs() {
try {
const res = await fetch(`/api/wfm/models/subdirs?type=${encodeURIComponent(state.activeModelType)}`);
state.subdirs = res.ok ? await res.json() : [];
} catch { state.subdirs = []; }
}
export async function bulkMoveModels(destSubdir) {
const model_names = [...state.selectedModels];
if (model_names.length === 0) return;
try {
const res = await fetch("/api/wfm/models/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names, dest: destSubdir }),
});
const data = await res.json();
const okCount = data.moved?.length || 0;
const errCount = data.errors?.length || 0;
if (okCount > 0) {
data.moved.forEach(({ from, to }) => {
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(from);
if (idx !== -1) list[idx] = to;
if (state.modelMetadata[from]) {
state.modelMetadata[to] = state.modelMetadata[from];
delete state.modelMetadata[from];
}
const ds = state.disabledModels[state.activeModelType];
if (ds && ds.has(from)) { ds.delete(from); ds.add(to); }
state.selectedModels.delete(from);
for (const members of Object.values(state.modelGroups)) {
const gi = members.indexOf(from);
if (gi !== -1) members[gi] = to;
}
});
if (state.selectedModel && data.moved.some((m) => m.from === state.selectedModel)) {
state.selectedModel = null;
const titleEl = document.getElementById("wfm-models-panel-title");
if (titleEl) titleEl.textContent = "";
}
showToast(`${okCount} ${t("modelBulkMoveDone")}`, "success");
await fetchSubdirs();
renderDirFilter();
renderModelGrid();
renderBulkActionBar();
}
if (errCount > 0) {
showToast(`${t("modelBulkMoveError")}: ${data.errors[0].error}`, "error");
}
} catch (err) {
showToast(`${t("modelBulkMoveError")}: ${err.message}`, "error");
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Models Tab - Shared state and static lookup tables
* `state` is a `const` object never reassigned — modules read/write its
* properties directly, which stays within ESM's read-only binding rule
* (only the binding itself, not the object it points to, is immutable).
*/
import { comfyUI } from "../comfyui-client.js";
export const RESERVED_GROUPS = ["Batch", "Stack"];
export const BATCH_MODEL_TYPES = ["checkpoint", "lora"];
export const STACK_MODEL_TYPES = ["lora"];
export const state = {
modelsByType: {
checkpoint: [],
lora: [],
vae: [],
controlnet: [],
unet: [],
textencoder: [],
hypernetwork: [],
embedding: [],
},
modelMetadata: {},
modelGroups: {},
allModelGroups: {}, // { type: { groupName: [models] } } — all types
civitaiCache: {},
disabledModels: {}, // { type: Set<modelName> }
subdirs: [],
selectMode: false,
selectedModels: new Set(),
searchText: "",
tagFilter: "",
badgeFilter: "",
dirFilter: "",
groupFilter: "",
statusFilter: "all", // "all" | "enabled" | "disabled"
showFavoritesOnly: false,
showBatchOnly: false,
viewMode: localStorage.getItem("wfm_models_view") === "table" ? "table" : "thumb",
activeModelType: "checkpoint",
selectedModel: null,
loaded: {},
currentPage: 0,
sortColumn: null, // "fav" | "filename" | "subdir" | "civtype" | "basemodel" | "ext" | "tags" | "memo" | "enabled"
sortDir: "asc", // "asc" | "desc"
};
export const FETCH_MAP = {
checkpoint: () => comfyUI.fetchCheckpoints(),
lora: () => comfyUI.fetchLoras(),
vae: () => comfyUI.fetchVaes(),
controlnet: () => comfyUI.fetchControlNets(),
unet: () => comfyUI.fetchDiffusionModels(),
textencoder: () => comfyUI.fetchTextEncoders(),
hypernetwork: () => comfyUI.fetchHypernetworks(),
embedding: () => comfyUI.fetchEmbeddings(),
};
export const TYPE_LABELS = {
checkpoint: "Checkpoint",
lora: "LoRA",
vae: "VAE",
controlnet: "ControlNet",
unet: "UNET",
textencoder: "TextEncoder",
hypernetwork: "Hypernetwork",
embedding: "Embedding",
};
// Mapping from models-tab type → comfyui-editor key + inputKey
export const GENUI_TYPE_MAP = {
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
lora: { key: "loras", inputKey: "lora_name" },
vae: { key: "vaes", inputKey: "vae_name" },
controlnet: { key: "controlNets", inputKey: "control_net_name" },
unet: { key: "diffusionModels",inputKey: "unet_name" },
textencoder: { key: "textEncoders", inputKey: "clip_name1" },
hypernetwork: { key: "hypernetworks", inputKey: "hypernetwork_name" },
};
+359
View File
@@ -0,0 +1,359 @@
/**
* Prompt Tab - AI Assistant (Ollama / LM Studio / Lemonade / Unsloth chat, translate, apply to GenerateUI)
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { unslothProxy } from "./util.js";
import { openAiBackendSettingsModal, getAiBackendConfig } from "./ai-settings-modal.js";
// ============================================
// State
// ============================================
const ollamaState = {
chatHistory: [],
attachedImage: null,
};
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
export function getPromptAiConfig() {
return getAiBackendConfig(PROMPT_AI_KEY);
}
// Ollama-style messages (with .images) → OpenAI互換 content 配列に変換
function _toOpenAiMessages(messages) {
return messages.map(msg => {
if (msg.images?.length) {
return {
role: msg.role,
content: [
{ type: "text", text: msg.content },
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
],
};
}
return { role: msg.role, content: msg.content };
});
}
function _stripThinkingTags(text) {
return (text || "")
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
.trim();
}
// Ollamaは `think` (bool) と `options.num_predict`、OpenAI互換は `max_tokens` で制御
function _applyGenOptions(body, backend, cfg) {
if (backend === "ollama") {
body.think = !!cfg.thinkingMode;
if (cfg.maxTokens > 0) body.options = { ...(body.options || {}), num_predict: cfg.maxTokens };
} else if (cfg.maxTokens > 0) {
body.max_tokens = cfg.maxTokens;
}
return body;
}
async function chatWithAi(model, messages) {
const cfg = getPromptAiConfig();
const { backend, url } = cfg;
if (backend === "ollama") {
const res = await fetch(`${url}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(_applyGenOptions({ model, messages, stream: false }, backend, cfg)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = (await res.json()).message?.content || "";
return cfg.thinkingMode ? text : _stripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _applyGenOptions({ model, messages: _toOpenAiMessages(messages), stream: false }, backend, cfg);
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
const msg = data.choices?.[0]?.message;
// Unslothはreasoningを別フィールドで返すため<think>タグに畳んで表示に混ぜる
const text = (msg?.reasoning_content ? `<think>${msg.reasoning_content}</think>` : "") + (msg?.content || "");
return cfg.thinkingMode ? text : _stripThinkingTags(text);
} else {
// LM Studio / Lemonade (OpenAI-compatible)
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(_applyGenOptions({ model, messages: _toOpenAiMessages(messages), stream: false }, backend, cfg)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = (await res.json()).choices?.[0]?.message?.content || "";
return cfg.thinkingMode ? text : _stripThinkingTags(text);
}
}
// ============================================
// Chat UI
// ============================================
function addChatMessage(role, content) {
const chat = document.getElementById("wfm-ollama-chat");
if (!chat) return;
const welcome = chat.querySelector(".wfm-ollama-welcome");
if (welcome) welcome.remove();
const msg = document.createElement("div");
msg.className = `wfm-ollama-msg ${role}`;
const roleLabel = document.createElement("div");
roleLabel.className = "wfm-ollama-msg-role";
roleLabel.textContent = role === "user" ? t("you") : "AI";
msg.appendChild(roleLabel);
const contentDiv = document.createElement("div");
contentDiv.className = "wfm-ollama-msg-content";
contentDiv.textContent = content;
msg.appendChild(contentDiv);
// Apply button for assistant messages
if (role === "assistant") {
const applyRow = document.createElement("div");
applyRow.className = "wfm-ollama-apply-row";
const applyBtn = document.createElement("button");
applyBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
applyBtn.textContent = t("applyToGenerateUI");
applyBtn.addEventListener("click", () => applyToGenerateUI(content));
applyRow.appendChild(applyBtn);
const copyBtn = document.createElement("button");
copyBtn.className = "wfm-btn wfm-btn-sm";
copyBtn.textContent = t("copy");
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(content).then(() => {
copyBtn.textContent = t("copied");
setTimeout(() => { copyBtn.textContent = t("copy"); }, 1500);
});
});
applyRow.appendChild(copyBtn);
msg.appendChild(applyRow);
}
chat.appendChild(msg);
chat.scrollTop = chat.scrollHeight;
}
function clearChat() {
ollamaState.chatHistory = [];
ollamaState.attachedImage = null;
updateAttachmentDisplay();
const chat = document.getElementById("wfm-ollama-chat");
if (chat) {
chat.innerHTML = `
<div class="wfm-ollama-welcome">
<p>AI Assistant</p>
<p>${t("assistantWelcome")}</p>
</div>
`;
}
}
// ============================================
// Image Attachment
// ============================================
function attachFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
ollamaState.attachedImage = { name: file.name, dataUrl: e.target.result };
updateAttachmentDisplay();
};
reader.readAsDataURL(file);
}
function updateAttachmentDisplay() {
const container = document.getElementById("wfm-ollama-attachments");
if (!container) return;
container.innerHTML = "";
if (!ollamaState.attachedImage) return;
const item = document.createElement("div");
item.className = "wfm-ollama-attachment";
const img = document.createElement("img");
img.src = ollamaState.attachedImage.dataUrl;
item.appendChild(img);
const name = document.createElement("span");
name.className = "wfm-ollama-attachment-name";
name.textContent = ollamaState.attachedImage.name;
item.appendChild(name);
const removeBtn = document.createElement("button");
removeBtn.className = "wfm-ollama-attachment-remove";
removeBtn.textContent = "×";
removeBtn.addEventListener("click", () => {
ollamaState.attachedImage = null;
updateAttachmentDisplay();
});
item.appendChild(removeBtn);
container.appendChild(item);
}
// ============================================
// Status label (設定モーダルで選択中のbackend/modelを表示)
// ============================================
function _updateStatusLabel() {
const status = document.getElementById("wfm-ollama-status");
if (!status) return;
const { backend, model } = getPromptAiConfig();
status.textContent = model ? `${backend}: ${model}` : `${backend}: ${t("selectModelFirst")}`;
status.className = model ? "wfm-ollama-status connected" : "wfm-ollama-status error";
}
// ============================================
// Send Message
// ============================================
async function sendMessage() {
const input = document.getElementById("wfm-ollama-input");
if (!input) return;
const message = input.value.trim();
if (!message) return;
const { model } = getPromptAiConfig();
if (!model) {
showToast(t("selectModelFirst"), "error");
return;
}
addChatMessage("user", message);
input.value = "";
const userMsg = { role: "user", content: message };
if (ollamaState.attachedImage) {
userMsg.images = [ollamaState.attachedImage.dataUrl.split(",")[1]];
}
ollamaState.chatHistory.push(userMsg);
const sendBtn = document.getElementById("wfm-ollama-send-btn");
if (sendBtn) sendBtn.disabled = true;
try {
const reply = await chatWithAi(model, ollamaState.chatHistory);
addChatMessage("assistant", reply);
ollamaState.chatHistory.push({ role: "assistant", content: reply });
ollamaState.attachedImage = null;
updateAttachmentDisplay();
} catch (err) {
showToast(t("error") + ": " + err.message, "error");
} finally {
if (sendBtn) sendBtn.disabled = false;
}
}
// ============================================
// Translation
// ============================================
async function sendTranslate(direction) {
const input = document.getElementById("wfm-ollama-input");
if (!input) return;
const text = input.value.trim();
if (!text) return;
const { model } = getPromptAiConfig();
if (!model) {
showToast(t("selectModelFirst"), "error");
return;
}
const labels = { ja2en: "JA→EN", en2ja: "EN→JA", zh2en: "ZH→EN", en2zh: "EN→ZH" };
addChatMessage("user", `[${labels[direction] || direction}]\n${text}`);
input.value = "";
const btns = ["wfm-ollama-send-btn", "wfm-ollama-ja2en-btn", "wfm-ollama-en2ja-btn", "wfm-ollama-zh2en-btn", "wfm-ollama-en2zh-btn"]
.map((id) => document.getElementById(id));
btns.forEach((b) => { if (b) b.disabled = true; });
try {
const prompts = {
ja2en: `Translate the following Japanese text into English. Output only the translated text, nothing else.\n\n${text}`,
en2ja: `以下の英語のテキストを日本語に翻訳してください。翻訳結果のみを出力し、他の説明は不要です。\n\n${text}`,
zh2en: `Translate the following Chinese text into English. Output only the translated text, nothing else.\n\n${text}`,
en2zh: `请将以下英文翻译成中文。只输出翻译结果,不需要其他说明。\n\n${text}`,
};
const prompt = prompts[direction];
const reply = await chatWithAi(model, [{ role: "user", content: prompt }]);
addChatMessage("assistant", reply);
} catch (err) {
showToast(t("error") + ": " + err.message, "error");
} finally {
btns.forEach((b) => { if (b) b.disabled = false; });
}
}
// ============================================
// Apply to GenerateUI
// ============================================
function applyToGenerateUI(text) {
const textareas = document.querySelectorAll("#wfm-gen-prompt-fields textarea");
if (textareas.length === 0) {
showToast(t("noPromptFields"), "error");
return;
}
textareas[0].value = text;
textareas[0].dispatchEvent(new Event("input", { bubbles: true }));
textareas[0].dispatchEvent(new Event("change", { bubbles: true }));
showToast(t("appliedToGenerateUI"), "success");
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initAiChatUI() {
_updateStatusLabel();
// Settings modal
document.getElementById("wfm-ollama-settings-btn")?.addEventListener("click", () => {
openAiBackendSettingsModal(PROMPT_AI_KEY, t("settings"), _updateStatusLabel);
});
// Send message
document.getElementById("wfm-ollama-send-btn")?.addEventListener("click", sendMessage);
// Enter to send
document.getElementById("wfm-ollama-input")?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Clear chat
document.getElementById("wfm-ollama-clear-btn")?.addEventListener("click", clearChat);
// File attachment
const fileInput = document.getElementById("wfm-ollama-file-input");
document.getElementById("wfm-ollama-attach-btn")?.addEventListener("click", () => fileInput?.click());
fileInput?.addEventListener("change", () => {
if (fileInput.files.length > 0) {
attachFile(fileInput.files[0]);
fileInput.value = "";
}
});
// Translation
document.getElementById("wfm-ollama-ja2en-btn")?.addEventListener("click", () => sendTranslate("ja2en"));
document.getElementById("wfm-ollama-en2ja-btn")?.addEventListener("click", () => sendTranslate("en2ja"));
document.getElementById("wfm-ollama-zh2en-btn")?.addEventListener("click", () => sendTranslate("zh2en"));
document.getElementById("wfm-ollama-en2zh-btn")?.addEventListener("click", () => sendTranslate("en2zh"));
}
+567
View File
@@ -0,0 +1,567 @@
/**
* Prompt Tab - Presets (API CRUD, groups, Preset Manager panel)
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage } from "./util.js";
// ============================================
// State
// ============================================
// Preset data (loaded from API, with localStorage migration)
let promptPresets = [];
let pmActiveTab = "all"; // "all" | "favorites" | "groups"
let pmSearchText = "";
let pmSelectedId = null; // currently selected preset id
export let pmGroups = {}; // { groupName: [presetId, ...] }
const PM_GROUPS_KEY = "wfm_prompt_preset_groups";
export const PROMPT_RESERVED_GROUPS = ["Batch"];
// ============================================
// Preset API helpers
// ============================================
export async function fetchPresets() {
try {
const res = await fetch("/api/wfm/prompts");
return res.ok ? await res.json() : [];
} catch { return []; }
}
export async function apiCreatePreset(data) {
try {
const res = await fetch("/api/wfm/prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await res.json();
return result.status === "ok" ? result.prompt : null;
} catch { return null; }
}
export async function apiUpdatePreset(id, updates) {
try {
const res = await fetch("/api/wfm/prompts/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...updates }),
});
const result = await res.json();
return result.status === "ok" ? result.prompt : null;
} catch { return null; }
}
export async function apiDeletePreset(id) {
try {
await fetch("/api/wfm/prompts/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
} catch { /* ignore */ }
}
// ============================================
// Migrate localStorage presets to API
// ============================================
const PRESETS_KEY = "wfm_prompt_presets";
async function migrateLocalStoragePresets() {
try {
const raw = localStorage.getItem(PRESETS_KEY);
if (!raw) return;
const local = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
// Only migrate if API has no presets yet
const existing = await fetchPresets();
if (existing.length > 0) {
localStorage.removeItem(PRESETS_KEY);
return;
}
for (const p of local) {
await apiCreatePreset({
name: p.name || "Untitled",
text: p.posText || p.text || "",
negText: p.negText || "",
category: "",
tags: [],
favorite: false,
});
}
localStorage.removeItem(PRESETS_KEY);
} catch { /* ignore migration errors */ }
}
// ============================================
// Preset data management
// ============================================
export async function loadAllPresets() {
await migrateLocalStoragePresets();
promptPresets = await fetchPresets();
// Load groups from localStorage
pmGroups = readJsonStorage(PM_GROUPS_KEY);
// Clean stale entries from groups (preserve reserved groups even if empty)
const validIds = new Set(promptPresets.map(p => p.id));
for (const g of Object.keys(pmGroups)) {
pmGroups[g] = (pmGroups[g] || []).filter(id => validIds.has(id));
if (pmGroups[g].length === 0 && !PROMPT_RESERVED_GROUPS.includes(g)) delete pmGroups[g];
}
// Ensure reserved groups always exist
for (const g of PROMPT_RESERVED_GROUPS) {
if (!pmGroups[g]) pmGroups[g] = [];
}
saveGroups();
renderPresetSelect();
renderGroupSelect();
renderPresetManager();
}
export function saveGroups() {
localStorage.setItem(PM_GROUPS_KEY, JSON.stringify(pmGroups));
renderGroupSelect();
}
function renderGroupSelect() {
const select = document.getElementById("wfm-preset-group-select");
if (!select) return;
const prevVal = select.value;
select.innerHTML = `<option value="">Select group...</option>`;
for (const g of Object.keys(pmGroups).sort()) {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
select.appendChild(opt);
}
if (prevVal && pmGroups[prevVal]) select.value = prevVal;
}
function renderPresetSelect() {
const select = document.getElementById("wfm-preset-select");
if (!select) return;
const prevVal = select.value;
select.innerHTML = `<option value="">${t("newPreset")}</option>`;
promptPresets.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.name;
select.appendChild(opt);
});
// Restore selection if still valid
if (prevVal && promptPresets.find(p => p.id === prevVal)) {
select.value = prevVal;
}
}
function selectPresetInEditor(preset) {
const presetSelect = document.getElementById("wfm-preset-select");
const presetName = document.getElementById("wfm-preset-name");
const presetCategory = document.getElementById("wfm-preset-category");
const presetPos = document.getElementById("wfm-preset-pos");
const presetNeg = document.getElementById("wfm-preset-neg");
if (presetSelect) presetSelect.value = preset ? preset.id : "";
if (presetName) presetName.value = preset ? preset.name : "";
if (presetCategory) presetCategory.value = preset ? (preset.category || "") : "";
if (presetPos) presetPos.value = preset ? (preset.text || preset.posText || "") : "";
if (presetNeg) presetNeg.value = preset ? (preset.negText || "") : "";
pmSelectedId = preset ? preset.id : null;
renderPresetManager();
}
// ============================================
// Preset Manager rendering
// ============================================
export function esc(s) {
return s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") : "";
}
export function renderPresetManager() {
const container = document.getElementById("wfm-pm-list");
if (!container) return;
container.innerHTML = "";
switch (pmActiveTab) {
case "all": renderPmAll(container); break;
case "favorites": renderPmFavorites(container); break;
case "groups": renderPmGroups(container); break;
}
}
function matchesSearch(p) {
if (!pmSearchText) return true;
const s = pmSearchText.toLowerCase();
return (p.name || "").toLowerCase().includes(s) ||
(p.text || "").toLowerCase().includes(s) ||
(p.category || "").toLowerCase().includes(s) ||
(p.tags || []).some(t => t.toLowerCase().includes(s));
}
// Ordered list of actual preset objects for a group (stale ids already filtered out by
// loadAllPresets, but this is defensive for callers that read pmGroups directly too).
export function getPresetsInGroup(groupName) {
const ids = pmGroups[groupName] || [];
return ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
}
export function isInBatchPreset(id) {
return (pmGroups["Batch"] || []).includes(id);
}
export function toggleBatchPreset(id) {
const batch = pmGroups["Batch"] || [];
const idx = batch.indexOf(id);
if (idx >= 0) { batch.splice(idx, 1); } else { batch.push(id); }
pmGroups["Batch"] = batch;
saveGroups();
}
export function clearBatchPresets() {
pmGroups["Batch"] = [];
saveGroups();
renderPresetManager();
showToast(t("promptBatchClear"), "success");
}
function createPmItem(preset) {
const el = document.createElement("div");
el.className = "wfm-pm-item" + (pmSelectedId === preset.id ? " active" : "");
const previewText = (preset.text || "").length > 50
? preset.text.substring(0, 50) + "..."
: (preset.text || "");
const catBadge = preset.category
? `<span style="font-size:9px;color:var(--wfm-primary);margin-left:4px;">[${esc(preset.category)}]</span>`
: "";
const inBatch = isInBatchPreset(preset.id);
el.innerHTML = `
<div class="wfm-pm-item-body">
<div class="wfm-pm-item-name">${preset.favorite ? '<span style="color:#ffd700;">&#9733;</span> ' : ""}${esc(preset.name)}${catBadge}</div>
<div class="wfm-pm-item-sub">${esc(previewText)}</div>
</div>
<div class="wfm-pm-item-actions">
<button class="wfm-pm-action-btn pm-batch-btn${inBatch ? " batch-active" : ""}" title="Batch">B</button>
<button class="wfm-pm-action-btn pm-fav-btn${preset.favorite ? " fav-active" : ""}" title="Favorite">&#9733;</button>
<button class="wfm-pm-action-btn pm-del-btn" title="Delete" style="color:var(--wfm-danger);">&#10005;</button>
</div>
`;
// Click to select in editor
el.addEventListener("click", (e) => {
if (e.target.closest(".wfm-pm-item-actions")) return;
selectPresetInEditor(preset);
});
// Batch toggle
el.querySelector(".pm-batch-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleBatchPreset(preset.id);
renderPresetManager();
});
// Favorite toggle
el.querySelector(".pm-fav-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const updated = await apiUpdatePreset(preset.id, { favorite: !preset.favorite });
if (updated) {
const idx = promptPresets.findIndex(p => p.id === preset.id);
if (idx >= 0) promptPresets[idx] = updated;
renderPresetManager();
}
});
// Delete
el.querySelector(".pm-del-btn").addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Delete "${preset.name}"?`)) return;
await apiDeletePreset(preset.id);
promptPresets = promptPresets.filter(p => p.id !== preset.id);
if (pmSelectedId === preset.id) {
pmSelectedId = null;
selectPresetInEditor(null);
}
renderPresetSelect();
renderPresetManager();
showToast(t("deleted"), "success");
});
return el;
}
function renderPmAll(container) {
const items = promptPresets.filter(matchesSearch);
if (items.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">${promptPresets.length === 0 ? "No presets yet.<br><small>Create one in the Presets panel</small>" : "No matches"}</div>`;
return;
}
for (const p of items) {
container.appendChild(createPmItem(p));
}
}
function renderPmFavorites(container) {
const favs = promptPresets.filter(p => p.favorite);
const items = favs.filter(matchesSearch);
if (items.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">${favs.length === 0 ? "No favorites yet.<br><small>Star presets to add them here</small>" : "No matches"}</div>`;
return;
}
for (const p of items) {
container.appendChild(createPmItem(p));
}
}
function renderPmGroups(container) {
const groupNames = Object.keys(pmGroups).sort();
if (groupNames.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">No groups yet.<br><small>Create groups from the Presets panel below</small></div>`;
return;
}
for (const groupName of groupNames) {
const ids = pmGroups[groupName] || [];
const presets = ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
const section = document.createElement("div");
const header = document.createElement("div");
header.className = "wfm-pm-group-header collapsed";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-pm-badge">${presets.length}</span>`;
const list = document.createElement("div");
list.style.display = "none";
header.addEventListener("click", () => {
list.style.display = list.style.display === "none" ? "block" : "none";
header.classList.toggle("collapsed");
});
for (const p of presets) {
const item = createPmItem(p);
// Add remove-from-group button
const removeBtn = document.createElement("button");
removeBtn.className = "wfm-pm-action-btn";
removeBtn.title = "Remove from group";
removeBtn.textContent = "➖";
removeBtn.style.fontSize = "10px";
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
// 予約グループ (Batch) は空になってもキーを保持する
if (pmGroups[groupName].length === 0 && !PROMPT_RESERVED_GROUPS.includes(groupName)) delete pmGroups[groupName];
saveGroups();
renderPresetManager();
});
item.querySelector(".wfm-pm-item-actions").prepend(removeBtn);
list.appendChild(item);
}
section.appendChild(header);
section.appendChild(list);
container.appendChild(section);
}
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initPresetsUI() {
loadAllPresets();
const presetSelect = document.getElementById("wfm-preset-select");
const presetName = document.getElementById("wfm-preset-name");
const presetCategory = document.getElementById("wfm-preset-category");
const presetPos = document.getElementById("wfm-preset-pos");
const presetNeg = document.getElementById("wfm-preset-neg");
// Load preset on selection
presetSelect?.addEventListener("change", () => {
const id = presetSelect.value;
const p = promptPresets.find(pp => pp.id === id);
selectPresetInEditor(p || null);
});
// Copy positive prompt to clipboard
document.getElementById("wfm-preset-copy-pos-btn")?.addEventListener("click", () => {
const text = presetPos?.value || "";
if (!text.trim()) {
showToast(t("noTextToCopy"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("copiedToClipboard"), "success");
});
});
// Copy negative prompt to clipboard
document.getElementById("wfm-preset-copy-neg-btn")?.addEventListener("click", () => {
const text = presetNeg?.value || "";
if (!text.trim()) {
showToast(t("noTextToCopy"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("copiedToClipboard"), "success");
});
});
// Save preset (create or update via API)
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", async () => {
const name = presetName?.value.trim();
const category = presetCategory?.value.trim() || "";
const pos = presetPos?.value || "";
const neg = presetNeg?.value || "";
if (!name) {
showToast(t("enterPresetName"), "error");
return;
}
if (!pos.trim() && !neg.trim()) {
showToast(t("noPromptToSave"), "error");
return;
}
const selectedId = presetSelect?.value;
const existing = selectedId ? promptPresets.find(p => p.id === selectedId) : null;
if (existing) {
// Update
const updated = await apiUpdatePreset(existing.id, {
name, text: pos, negText: neg, category,
});
if (updated) {
const idx = promptPresets.findIndex(p => p.id === existing.id);
if (idx >= 0) promptPresets[idx] = updated;
showToast(t("presetSaved"), "success");
}
} else {
// Create
const created = await apiCreatePreset({
name, text: pos, negText: neg, category,
tags: [], favorite: false,
});
if (created) {
promptPresets.push(created);
showToast(t("presetSaved"), "success");
}
}
pmSelectedId = null;
renderPresetSelect();
renderPresetManager();
if (presetSelect) presetSelect.value = "";
});
// Apply preset to GenerateUI
document.getElementById("wfm-preset-apply-btn")?.addEventListener("click", () => {
const pos = presetPos?.value || "";
const neg = presetNeg?.value || "";
const textareas = document.querySelectorAll("#wfm-gen-prompt-fields textarea");
if (textareas.length === 0) {
showToast(t("noPromptFields"), "error");
return;
}
let applied = false;
if (textareas.length > 0 && pos) {
textareas[0].value = pos;
textareas[0].dispatchEvent(new Event("input", { bubbles: true }));
applied = true;
}
if (textareas.length > 1 && neg) {
textareas[1].value = neg;
textareas[1].dispatchEvent(new Event("input", { bubbles: true }));
applied = true;
}
if (applied) showToast(t("appliedToGenerateUI"), "success");
});
// --- Group management (Presets side) ---
document.getElementById("wfm-preset-new-group-btn")?.addEventListener("click", () => {
const name = prompt("Group name:");
if (!name || !name.trim()) return;
const key = name.trim();
if (pmGroups[key]) {
showToast(t("groupExists"), "error");
return;
}
pmGroups[key] = [];
saveGroups();
renderPresetManager();
showToast(t("groupCreated", key), "success");
});
document.getElementById("wfm-preset-add-to-group-btn")?.addEventListener("click", () => {
const groupSelect = document.getElementById("wfm-preset-group-select");
const groupName = groupSelect?.value;
if (!groupName) {
showToast(t("selectGroupFirst"), "error");
return;
}
const id = presetSelect?.value;
if (!id) {
showToast(t("selectPresetFirst"), "error");
return;
}
if (!pmGroups[groupName]) pmGroups[groupName] = [];
if (pmGroups[groupName].includes(id)) {
showToast(t("alreadyInGroup"), "info");
return;
}
pmGroups[groupName].push(id);
saveGroups();
renderPresetManager();
showToast(t("addedToGroup"), "success");
});
document.getElementById("wfm-preset-del-group-btn")?.addEventListener("click", () => {
const groupSelect = document.getElementById("wfm-preset-group-select");
const groupName = groupSelect?.value;
if (!groupName) {
showToast(t("selectGroupFirst"), "error");
return;
}
if (PROMPT_RESERVED_GROUPS.includes(groupName)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
if (!confirm(`Delete group "${groupName}"?`)) return;
delete pmGroups[groupName];
saveGroups();
renderPresetManager();
showToast(t("groupDeleted", groupName), "success");
});
// --- Preset Manager ---
document.querySelectorAll(".wfm-pm-tab").forEach(btn => {
btn.addEventListener("click", () => {
pmActiveTab = btn.dataset.pmtab;
document.querySelectorAll(".wfm-pm-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderPresetManager();
});
});
document.getElementById("wfm-pm-search-input")?.addEventListener("input", (e) => {
pmSearchText = e.target.value.trim();
renderPresetManager();
});
document.getElementById("wfm-pm-batch-clear-btn")?.addEventListener("click", () => {
clearBatchPresets();
});
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Prompt Tab - Style manager (Col3 "Style" タブ)
*/
// Wildcardファイルマネージャーと同じCRUDパターン(一覧→エディタ→保存/削除/キャンセル)。
// 保存・削除後は generate-tab.js の refreshStylesList() を呼び、GenerateUIのStyleドロップダウンや
// Batchタブのチェックリストを再読み込み無しで最新状態へ同期する。
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { setupSearchClearBtn } from "./util.js";
import { refreshStylesList } from "./generate-tab.js";
import { esc } from "./prompt-presets.js";
let styleListData = [];
let styleSearchText = "";
let styleEditingOriginalName = null; // null = 新規作成
let styleEditingFile = null; // 編集中/追加先スタイルの定義元ファイル名(style/配下、null = デフォルトのcustom.json)
export async function styleFetchList() {
try {
const res = await fetch("/api/wfm/styles");
return res.ok ? await res.json() : [];
} catch { return []; }
}
export async function styleApiCreate(name, prompt, negativePrompt, targetFile) {
try {
const body = { name, prompt, negative_prompt: negativePrompt };
if (targetFile) body.file = targetFile;
const res = await fetch("/api/wfm/styles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (e) {
return { ok: false, error: e.message };
}
}
export async function styleApiUpdate(originalName, name, prompt, negativePrompt) {
try {
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(originalName)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, prompt, negative_prompt: negativePrompt }),
});
const data = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (e) {
return { ok: false, error: e.message };
}
}
export async function styleApiDelete(name) {
try {
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(name)}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (e) {
return { ok: false, error: e.message };
}
}
function styleRenderList() {
const list = document.getElementById("wfm-style-list");
if (!list) return;
list.innerHTML = "";
const q = styleSearchText.trim().toLowerCase();
const filtered = q ? styleListData.filter(s => s.name.toLowerCase().includes(q)) : styleListData;
if (filtered.length === 0) {
list.innerHTML = styleListData.length === 0
? `<div class="wfm-pm-empty" style="font-size:12px;">No styles yet.<br><small>Click "+ New" to create one.</small></div>`
: `<div class="wfm-pm-empty" style="font-size:12px;">No matching styles.</div>`;
return;
}
for (const s of filtered) {
const item = document.createElement("div");
item.className = "wfm-wc-file-item";
item.innerHTML = `
<div class="wfm-wc-file-item-info">
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.name)}</span>
${s.file ? `<span class="wfm-wc-file-ext" title="${esc(s.file)}">${esc(s.file)}</span>` : ""}
</div>
<div class="wfm-wc-file-item-actions">
<button class="wfm-pm-action-btn wfm-style-edit-btn" title="Edit style">&#9998;</button>
</div>
`;
item.querySelector(".wfm-style-edit-btn").addEventListener("click", (e) => {
e.stopPropagation();
styleOpenEditor(s);
});
item.addEventListener("click", () => styleOpenEditor(s));
list.appendChild(item);
}
}
// forcedFile: 「+ Add to this file」から呼ばれた場合に、既存スタイルではなく
// 特定ファイルを追加先として指定するために使う(styleは渡さずnullにする)
function styleOpenEditor(style, forcedFile) {
styleEditingOriginalName = style ? style.name : null;
styleEditingFile = style ? (style.file || null) : (forcedFile || null);
const editor = document.getElementById("wfm-style-editor");
const nameInput = document.getElementById("wfm-style-editor-name");
const promptTA = document.getElementById("wfm-style-editor-prompt");
const negativeTA = document.getElementById("wfm-style-editor-negative");
const deleteBtn = document.getElementById("wfm-style-editor-delete-btn");
const addFileBtn = document.getElementById("wfm-style-editor-addfile-btn");
const fileLabel = document.getElementById("wfm-style-editor-file-label");
if (!editor) return;
nameInput.value = style?.name || "";
promptTA.value = style?.prompt || "";
negativeTA.value = style?.negative_prompt || "";
deleteBtn.style.display = style ? "" : "none";
// 「このファイルへ追加」は既存スタイルを編集中(=対象ファイルが分かっている)時だけ表示
addFileBtn.style.display = style ? "" : "none";
if (styleEditingFile) {
fileLabel.textContent = `File: ${styleEditingFile}`;
fileLabel.style.display = "";
} else {
fileLabel.style.display = "none";
}
editor.style.display = "";
nameInput.focus();
}
function styleCloseEditor() {
styleEditingOriginalName = null;
styleEditingFile = null;
const editor = document.getElementById("wfm-style-editor");
if (editor) editor.style.display = "none";
}
export async function styleRefreshList() {
styleListData = await styleFetchList();
styleRenderList();
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initStylesUI() {
document.getElementById("wfm-style-new-btn")?.addEventListener("click", () => {
styleOpenEditor(null);
});
document.getElementById("wfm-style-refresh-btn")?.addEventListener("click", () => {
styleRefreshList();
});
// 編集中の既存スタイルと同じファイルへ、新しい空のスタイルを追加する
document.getElementById("wfm-style-editor-addfile-btn")?.addEventListener("click", () => {
if (!styleEditingFile) return;
styleOpenEditor(null, styleEditingFile);
});
setupSearchClearBtn("wfm-style-search", "wfm-style-search-clear-btn", () => {
styleSearchText = "";
styleRenderList();
});
document.getElementById("wfm-style-search")?.addEventListener("input", (e) => {
styleSearchText = e.target.value;
styleRenderList();
});
document.getElementById("wfm-style-editor-save-btn")?.addEventListener("click", async () => {
const nameInput = document.getElementById("wfm-style-editor-name");
const promptTA = document.getElementById("wfm-style-editor-prompt");
const negativeTA = document.getElementById("wfm-style-editor-negative");
const name = (nameInput?.value || "").trim();
const prompt = promptTA?.value || "";
const negativePrompt = negativeTA?.value || "";
if (!name) { showToast(t("pleaseEnterStyleName"), "error"); return; }
const result = styleEditingOriginalName
? await styleApiUpdate(styleEditingOriginalName, name, prompt, negativePrompt)
: await styleApiCreate(name, prompt, negativePrompt, styleEditingFile);
if (result.ok) {
showToast(t("savedAs", name), "success");
styleCloseEditor();
await styleRefreshList();
await refreshStylesList();
} else {
showToast(t("errorWithMsg", result.error), "error");
}
});
// Editor: delete
document.getElementById("wfm-style-editor-delete-btn")?.addEventListener("click", async () => {
if (!styleEditingOriginalName) return;
if (!confirm(`Delete "${styleEditingOriginalName}"?`)) return;
const result = await styleApiDelete(styleEditingOriginalName);
if (result.ok) {
showToast(t("deletedName", styleEditingOriginalName), "success");
styleCloseEditor();
await styleRefreshList();
await refreshStylesList();
} else {
showToast(t("errorWithMsg", result.error), "error");
}
});
// Editor: cancel
document.getElementById("wfm-style-editor-cancel-btn")?.addEventListener("click", () => {
styleCloseEditor();
});
// Initial style list load
styleRefreshList();
}
+39 -1295
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+475
View File
@@ -0,0 +1,475 @@
/**
* Prompt Tab - Wildcard file manager (list, editor, insert-at-cursor helpers)
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { esc } from "./prompt-presets.js";
// ============================================
// Wildcard API helpers
// ============================================
export async function wcFetchFiles() {
try {
const res = await fetch("/api/wfm/wildcards");
return res.ok ? await res.json() : [];
} catch { return []; }
}
export async function wcFetchContent(filename) {
try {
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(filename)}`);
const data = await res.json();
return data.content ?? null;
} catch { return null; }
}
export async function wcSaveFile(filename, content) {
try {
const res = await fetch("/api/wfm/wildcards/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, content }),
});
const data = await res.json();
return data.status === "ok" ? data.file : null;
} catch { return null; }
}
export async function wcDeleteFile(filename) {
try {
await fetch("/api/wfm/wildcards/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
} catch { /* ignore */ }
}
// ============================================
// Wildcard state & helpers
// ============================================
let wcFiles = [];
let wcEditingFilename = null; // null = new file
// Insert text at cursor; if open+close provided wraps selection
function wcInsertAtCursor(textarea, open, close = "") {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
let inserted, cursorStart, cursorEnd;
if (selected && close) {
inserted = open + selected + close;
cursorStart = start + open.length;
cursorEnd = cursorStart + selected.length;
} else if (close) {
inserted = open + close;
cursorStart = start + open.length;
cursorEnd = cursorStart;
} else {
inserted = open;
cursorStart = start + open.length;
cursorEnd = cursorStart;
}
textarea.value =
textarea.value.substring(0, start) +
inserted +
textarea.value.substring(end);
textarea.setSelectionRange(cursorStart, cursorEnd);
textarea.focus();
textarea.dispatchEvent(new Event("input"));
}
// ============================================
// Wildcard file list rendering
// ============================================
function wcRenderFileList() {
const list = document.getElementById("wfm-wc-file-list");
if (!list) return;
list.innerHTML = "";
if (wcFiles.length === 0) {
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">No wildcard files yet.<br><small>Click "+ New" to create one.</small></div>`;
return;
}
// Group by directory; root ("") first, then alphabetical
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
function addFileItem(f, indented) {
const item = document.createElement("div");
item.className = "wfm-wc-file-item" + (indented ? " wfm-wc-file-item--sub" : "");
item.innerHTML = `
<div class="wfm-wc-file-item-info">
<span class="wfm-wc-file-name">${esc(f.name)}</span>
<span class="wfm-wc-file-ext">.${esc(f.ext)}</span>
</div>
<div class="wfm-wc-file-item-actions">
<button class="wfm-pm-action-btn wfm-wc-use-btn" title="Insert __${esc(f.wc_name)}__ at cursor">Use</button>
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">&#9998;</button>
</div>
`;
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
e.stopPropagation();
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const content = await wcFetchContent(f.filename);
wcOpenEditor(f.wc_name, f.ext, content ?? "", f.filename);
});
item.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
list.appendChild(item);
}
for (const dir of dirs) {
if (dir) {
const header = document.createElement("div");
header.className = "wfm-wc-dir-header";
header.textContent = dir + "/";
list.appendChild(header);
}
for (const f of grouped[dir]) {
addFileItem(f, dir !== "");
}
}
}
// ============================================
// Wildcard file editor
// ============================================
function wcOpenEditor(name, ext, content, existingFilename) {
wcEditingFilename = existingFilename || null;
const editor = document.getElementById("wfm-wc-editor");
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
const deleteBtn = document.getElementById("wfm-wc-editor-delete-btn");
if (!editor) return;
nameInput.value = name || "";
extSelect.value = ext || "txt";
contentTA.value = content || "";
deleteBtn.style.display = existingFilename ? "" : "none";
editor.style.display = "";
nameInput.focus();
}
function wcCloseEditor() {
wcEditingFilename = null;
const editor = document.getElementById("wfm-wc-editor");
if (editor) editor.style.display = "none";
}
export async function wcRefreshFiles() {
wcFiles = await wcFetchFiles();
wcRenderFileList();
wcUpdateFilePicker();
}
// ============================================
// Wildcard file picker popup
// ============================================
// Renders the wildcard-file-reference list into `picker`, inserting into `targetTa` on click.
// Shared by the Form tab's fixed file-picker popup and any dynamically built toolbar
// (see createWildcardToolbar) so both stay in sync with a single implementation.
function renderFilePicker(picker, targetTa) {
picker.innerHTML = "";
if (wcFiles.length === 0) {
picker.innerHTML = `<div style="padding:8px;font-size:11px;color:var(--wfm-text-secondary);">No wildcard files</div>`;
return;
}
// Group by directory same as file list
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
for (const dir of dirs) {
if (dir) {
const sep = document.createElement("div");
sep.className = "wfm-wc-picker-dir";
sep.textContent = dir + "/";
picker.appendChild(sep);
}
for (const f of grouped[dir]) {
const btn = document.createElement("button");
btn.className = "wfm-wc-picker-item" + (dir ? " wfm-wc-picker-item--sub" : "");
btn.textContent = `__${f.wc_name}__`;
btn.addEventListener("click", () => {
wcInsertAtCursor(targetTa, `__${f.wc_name}__`);
picker.style.display = "none";
});
picker.appendChild(btn);
}
}
}
function wcUpdateFilePicker() {
const picker = document.getElementById("wfm-wc-file-picker");
const ta = document.getElementById("wfm-wc-prompt");
if (!picker || !ta) return;
renderFilePicker(picker, ta);
}
// ============================================
// Reusable wildcard syntax toolbar (buttons + file-reference picker)
// ============================================
const WC_SYNTAX_BUTTONS = [
{ open: "{", close: "}", label: "{ }", title: "Random pick set e.g. {cat|dog}" },
{ open: "|", close: "", label: "|", title: "Separator" },
{ open: "__", close: "__", label: "__", title: "Wildcard file reference e.g. __animals__" },
{ open: ":", close: "", label: ":", title: "Colon" },
{ open: ";", close: "", label: ";", title: "Semicolon" },
{ open: "$$", close: "", label: "$$", title: "Multi-pick e.g. {2$$cat|dog|bird}" },
{ open: "[", close: "]", label: "[ ]", title: "Bracket set e.g. [cat|dog]" },
];
// Builds a standalone wildcard-syntax toolbar (syntax buttons, LoRA/n$${} helpers, and the
// file-reference picker) wired to insert into the given textarea. The Form tab's Col3 panel
// uses the static toolbar markup in index.html instead (wired in initWildcardsUI below); this
// is for callers that need the same toolbar attached to a textarea built at runtime, such as
// the Table tab's wildcard edit modal (see prompt-table.js).
export function createWildcardToolbar(textareaEl) {
const toolbar = document.createElement("div");
toolbar.className = "wfm-wc-toolbar";
for (const b of WC_SYNTAX_BUTTONS) {
const btn = document.createElement("button");
btn.className = "wfm-wc-btn";
btn.title = b.title;
btn.textContent = b.label;
btn.addEventListener("click", () => wcInsertAtCursor(textareaEl, b.open, b.close));
toolbar.appendChild(btn);
}
const loraBtn = document.createElement("button");
loraBtn.className = "wfm-wc-btn";
loraBtn.title = "LoRA with LBW e.g. <lora:name:1:LBW=...;...>";
loraBtn.textContent = "<lora>";
loraBtn.addEventListener("click", () => {
wcInsertAtCursor(textareaEl, "<lora::1:LBW=;>");
// Move cursor to after "lora:" for the name
const pos = textareaEl.selectionStart - "<lora::1:LBW=;>".length + 6;
textareaEl.setSelectionRange(pos, pos);
textareaEl.focus();
});
toolbar.appendChild(loraBtn);
const nsetBtn = document.createElement("button");
nsetBtn.className = "wfm-wc-btn";
nsetBtn.title = "Multi-pick set e.g. {2$$cat|dog|bird}";
nsetBtn.textContent = "n$${ }";
nsetBtn.addEventListener("click", () => {
const n = prompt("Number of items to pick (n):", "2");
if (n === null) return;
const num = parseInt(n, 10);
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
const template = "{" + num + "$$|}";
const start = textareaEl.selectionStart;
wcInsertAtCursor(textareaEl, template);
// Move cursor between $$ and |
const inner = start + ("{" + num + "$$").length;
textareaEl.setSelectionRange(inner, inner);
textareaEl.focus();
});
toolbar.appendChild(nsetBtn);
const pickerWrap = document.createElement("div");
pickerWrap.className = "wfm-wc-file-picker-wrap";
const pickBtn = document.createElement("button");
pickBtn.className = "wfm-wc-btn";
pickBtn.title = "Insert wildcard file reference";
pickBtn.innerHTML = "&#128194; File";
const picker = document.createElement("div");
picker.className = "wfm-wc-file-picker";
picker.style.display = "none";
pickBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = picker.style.display !== "none";
picker.style.display = isOpen ? "none" : "";
if (!isOpen) renderFilePicker(picker, textareaEl);
});
document.addEventListener("click", () => { picker.style.display = "none"; });
pickerWrap.appendChild(pickBtn);
pickerWrap.appendChild(picker);
toolbar.appendChild(pickerWrap);
return toolbar;
}
// ============================================
// UI wiring (called from prompt-tab.js's initPromptTab)
// ============================================
export function initWildcardsUI() {
// ── Wildcard syntax buttons ──────────────────────────────
document.querySelectorAll(".wfm-wc-btn[data-wc-open]").forEach(btn => {
btn.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const open = btn.dataset.wcOpen || "";
const close = btn.dataset.wcClose || "";
wcInsertAtCursor(ta, open, close);
});
});
// LoRA LBW template button
document.getElementById("wfm-wc-lora-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
wcInsertAtCursor(ta, "<lora::1:LBW=;>");
// Move cursor to after "lora:" for the name
const pos = ta.selectionStart - "<lora::1:LBW=;>".length + 6;
ta.setSelectionRange(pos, pos);
ta.focus();
});
// n$${ } button — asks for n then inserts multi-pick template
document.getElementById("wfm-wc-nset-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const n = prompt("Number of items to pick (n):", "2");
if (n === null) return;
const num = parseInt(n, 10);
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
const template = "{" + num + "$$|}";
const start = ta.selectionStart;
wcInsertAtCursor(ta, template);
// Move cursor between $$ and |
const inner = start + ("{" + num + "$$").length;
ta.setSelectionRange(inner, inner);
ta.focus();
});
// File picker toggle
const filePickBtn = document.getElementById("wfm-wc-filepick-btn");
const filePicker = document.getElementById("wfm-wc-file-picker");
filePickBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (!filePicker) return;
const isOpen = filePicker.style.display !== "none";
filePicker.style.display = isOpen ? "none" : "";
if (!isOpen) wcUpdateFilePicker();
});
document.addEventListener("click", () => {
if (filePicker) filePicker.style.display = "none";
});
// ── Wildcard prompt actions ──────────────────────────────
document.getElementById("wfm-wc-copy-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
const text = ta?.value || "";
if (!text.trim()) { showToast(t("noTextToCopy"), "error"); return; }
navigator.clipboard.writeText(text).then(() => showToast(t("copiedToClipboard"), "success"));
});
document.getElementById("wfm-wc-to-pos-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-pos");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast(t("sentToPositive"), "success");
});
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-neg");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast(t("sentToNegative"), "success");
});
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) { ta.value = ""; ta.focus(); }
});
// ── Wildcard file manager ────────────────────────────────
document.getElementById("wfm-wc-new-file-btn")?.addEventListener("click", () => {
wcOpenEditor("", "txt", "", null);
});
document.getElementById("wfm-wc-files-refresh-btn")?.addEventListener("click", () => {
wcRefreshFiles();
});
// Editor: save
document.getElementById("wfm-wc-editor-save-btn")?.addEventListener("click", async () => {
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
// Normalize separators and strip leading/trailing slashes
const rawName = (nameInput?.value || "").trim().replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
const ext = extSelect?.value || "txt";
const content = contentTA?.value || "";
if (!rawName) { showToast(t("pleaseEnterFilename"), "error"); return; }
// Validate each path component (allow letters, numbers, -, _, ., space)
const nameParts = rawName.split("/");
if (nameParts.some(p => !p || !/^[\w\-. ]+$/.test(p))) {
showToast(t("invalidPathFormat"), "error");
return;
}
const name = rawName;
const filename = `${name}.${ext}`;
const saved = await wcSaveFile(filename, content);
if (saved) {
showToast(t("savedAs", filename), "success");
wcCloseEditor();
wcRefreshFiles();
} else {
showToast(t("saveFailed"), "error");
}
});
// Editor: delete
document.getElementById("wfm-wc-editor-delete-btn")?.addEventListener("click", async () => {
if (!wcEditingFilename) return;
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
await wcDeleteFile(wcEditingFilename);
showToast(t("deletedName", wcEditingFilename), "success");
wcCloseEditor();
wcRefreshFiles();
});
// Editor: cancel
document.getElementById("wfm-wc-editor-cancel-btn")?.addEventListener("click", () => {
wcCloseEditor();
});
// Initial wildcard file load
wcRefreshFiles();
}
+168 -123
View File
@@ -336,6 +336,34 @@ export function applyTextareaFontSize(size) {
el.textContent = `${TEXTAREA_FONT_SIZE_TARGETS} { font-size: ${px}px !important; }`;
}
// Video playback volume — shared by every <video> player in the app (Video tab preview,
// Gallery detail/lightbox/compare). The browser default for a freshly created <video> element
// is always 100%, and Gallery's players are recreated from scratch each time a different video
// is opened, so without this the volume silently jumps back to max on every video switch.
const VIDEO_VOLUME_DEFAULT = 1;
export function getVideoVolume() {
const v = loadLocalSettings().videoVolume;
return typeof v === "number" && v >= 0 && v <= 1 ? v : VIDEO_VOLUME_DEFAULT;
}
function setVideoVolume(volume) {
const clamped = Math.max(0, Math.min(1, volume));
saveLocalSettings({ videoVolume: clamped });
return clamped;
}
// Applies the saved volume to a <video> element, and keeps it in sync with any further change
// the user makes via that element's own native controls — so adjusting the volume on any video
// player anywhere in the app is remembered for the next video opened anywhere else.
export function applyStoredVideoVolume(videoEl) {
if (!videoEl) return;
videoEl.volume = getVideoVolume();
if (videoEl.dataset.wfmVolumeWired) return;
videoEl.dataset.wfmVolumeWired = "1";
videoEl.addEventListener("volumechange", () => setVideoVolume(videoEl.volume));
}
// JSON highlight color settings
const JSON_COLOR_STYLE_ID = "wfm-json-color-style";
@@ -366,6 +394,21 @@ export function applyJsonColors(colors) {
].join("\n");
}
// GenerateUI Model tab: highlight color for section labels that match the loaded workflow
const MODEL_ACTIVE_COLOR_STYLE_ID = "wfm-model-active-color-style";
const MODEL_ACTIVE_COLOR_DEFAULT = "#4ade80";
export function applyModelTabActiveColor(color) {
const val = color || MODEL_ACTIVE_COLOR_DEFAULT;
let el = document.getElementById(MODEL_ACTIVE_COLOR_STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
document.head.appendChild(el);
}
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active,\n.wfm-video-plan-content label.wfm-model-label-active { color: ${val}; }`;
}
export function applyTheme(themeId) {
if (themeId) {
document.documentElement.setAttribute("data-theme", themeId);
@@ -560,6 +603,24 @@ export async function initSettingsTab() {
</div>
</details>
<!-- Video Volume -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("videoVolumeLabel") || "Video Volume"}</summary>
<div class="wfm-form-group">
<label style="display:flex;align-items:center;justify-content:space-between;">
<span>${t("videoVolumeLabel") || "Video Volume"}</span>
<span id="wfm-video-volume-val" style="font-size:12px;color:var(--wfm-text-secondary);">${Math.round(getVideoVolume() * 100)}%</span>
</label>
<input type="range" class="wfm-range" id="wfm-video-volume"
min="0" max="100" step="1"
value="${Math.round(getVideoVolume() * 100)}"
style="width:100%;margin-top:4px;">
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("videoVolumeHint") || "Applies to: Video tab preview, Gallery detail/lightbox/compare video players"}
</small>
</div>
</details>
<!-- RAW JSON Colors -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("jsonColorsSection") || "RAW JSON Colors"}</summary>
@@ -577,6 +638,21 @@ export async function initSettingsTab() {
</div>
</details>
<!-- GenerateUI Model Tab Highlight Color -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("modelTabColorSection") || "GenerateUI Modelタブ ハイライト色"}</summary>
<div class="wfm-form-group">
<label>${t("modelTabColorLabel") || "ワークフローに含まれる項目の色"}</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="color" id="wfm-model-active-color" value="${settings.modelTabActiveColor || MODEL_ACTIVE_COLOR_DEFAULT}">
<button class="wfm-btn wfm-btn-sm" id="wfm-model-active-color-reset">${t("jsonColorReset") || "デフォルトに戻す"}</button>
</div>
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("modelTabColorHint") || "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します"}
</small>
</div>
</details>
<!-- Workflow Data Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
@@ -661,41 +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>
</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>
@@ -791,6 +832,25 @@ export async function initSettingsTab() {
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
</label>
</div>
<p style="font-size:12px;color:var(--wfm-text-secondary);margin:12px 0 8px 0;">${t("fullBackupHint")}</p>
<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:8px;">
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
<input type="checkbox" id="wfm-settings-backup-include-workflows">
${t("fullBackupIncludeWorkflows")}
</label>
<label class="wfm-lab-checkbox-label" style="font-size:12px;">
<input type="checkbox" id="wfm-settings-backup-include-wildcard">
${t("fullBackupIncludeWildcard")}
</label>
<span style="font-size:11px;color:var(--wfm-text-secondary);">${t("fullBackupExternalHint")}</span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export-full">${t("exportFullBackup")}</button>
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
${t("importFullBackup")}
<input type="file" id="wfm-settings-import-full-file" accept=".zip" style="display:none;">
</label>
</div>
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
</details>
@@ -1106,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 = promptAiBackendSaved === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
}
// Backend change → update URL placeholder
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.placeholder = backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
});
});
// 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() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).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() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).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;
@@ -1309,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;
@@ -1330,6 +1314,22 @@ export async function initSettingsTab() {
showToast(t("settingsSaved"), "success");
});
// --- GenerateUI Model tab highlight color ---
document.getElementById("wfm-model-active-color")?.addEventListener("input", (e) => {
saveLocalSettings({ modelTabActiveColor: e.target.value });
applyModelTabActiveColor(e.target.value);
});
document.getElementById("wfm-model-active-color-reset")?.addEventListener("click", () => {
const cur = loadLocalSettings();
delete cur.modelTabActiveColor;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyModelTabActiveColor(undefined);
const inp = document.getElementById("wfm-model-active-color");
if (inp) inp.value = MODEL_ACTIVE_COLOR_DEFAULT;
showToast(t("settingsSaved"), "success");
});
// Save (local settings)
document.getElementById("wfm-settings-save")?.addEventListener("click", () => {
const taFontSize = parseInt(document.getElementById("wfm-ta-font-size")?.value) || TEXTAREA_FONT_SIZE_DEFAULT;
@@ -1483,8 +1483,53 @@ export async function initSettingsTab() {
statusEl.textContent = t("importError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
// Reset file input so same file can be re-selected
e.target.value = "";
});
// --- Full Backup Export (ZIP) ---
document.getElementById("wfm-settings-export-full")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-data-status");
try {
const includeWorkflows = document.getElementById("wfm-settings-backup-include-workflows")?.checked ? "1" : "0";
const includeWildcard = document.getElementById("wfm-settings-backup-include-wildcard")?.checked ? "1" : "0";
const res = await fetch(`/api/wfm/settings/export-full?include_workflows=${includeWorkflows}&include_wildcard=${includeWildcard}`);
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "wfm-full-backup.zip";
a.click();
URL.revokeObjectURL(url);
statusEl.textContent = t("exportSuccess");
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("exportError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
});
// --- Full Backup Import (ZIP) ---
document.getElementById("wfm-settings-import-full-file")?.addEventListener("change", async (e) => {
const statusEl = document.getElementById("wfm-settings-data-status");
const file = e.target.files?.[0];
if (!file) return;
try {
const fd = new FormData();
fd.append("file", file, file.name);
const res = await fetch("/api/wfm/settings/import-full", {
method: "POST",
body: fd,
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || res.statusText);
statusEl.textContent = `${t("importSuccess")}: ${result.extracted?.length ?? 0} ${t("fullBackupFilesUnit")}`;
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("importError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
} finally {
e.target.value = "";
}
});
// Save G'MIC path setting
+373
View File
@@ -0,0 +1,373 @@
/**
* Style Catalog Gallery — Gallery タブのサブタブ。
* ws_style_catalog フォルダ配下の画像(GenerateUIの「カタログ作成」で生成した、
* ファイル名=スタイル名のプレビュー画像)を閲覧し、埋め込みPositive/Negative
* プロンプトを表示・コピーする。「Select as Style」でGenerateUIの登録済み
* Styleドロップダウンをファイル名と同名のStyleに切り替える。
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
import { extractAllMetadata, loadFileIntoMetadataTab } from "./metadata-tab.js";
import { selectStyleByName, loadWorkflowIntoEditor } from "./generate-tab.js";
const API = {
root: () => `/wfm/gallery/style-catalog/root`,
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
workflow: (path) => `/wfm/gallery/image/workflow?path=${encodeURIComponent(path)}`,
};
const state = {
root: null,
currentFolder: null,
search: "",
images: [],
selectedImage: null,
};
let _initialized = false;
async function apiFetch(url, opts = {}) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
// ── 初期化 ────────────────────────────────────────────────────
export function initStyleCatalogTab() {
document.getElementById("wfm-stylecatalog-refresh-btn")?.addEventListener("click", () => {
loadFolderTree();
loadImages();
});
setupSearchClearBtn("wfm-stylecatalog-search", "wfm-stylecatalog-search-clear-btn", () => {
state.search = "";
loadImages();
});
const searchInput = document.getElementById("wfm-stylecatalog-search");
searchInput?.addEventListener("input", debounce(() => {
state.search = searchInput.value.trim();
loadImages();
}, 300));
document.getElementById("wfm-stylecatalog-copy-positive-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-positive-text"));
document.getElementById("wfm-stylecatalog-copy-negative-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-negative-text"));
document.getElementById("wfm-stylecatalog-select-style-btn")?.addEventListener("click", onSelectStyleClick);
document.getElementById("wfm-stylecatalog-load-genui-btn")?.addEventListener("click", onLoadGenUIClick);
document.getElementById("wfm-stylecatalog-open-metadata-btn")?.addEventListener("click", onOpenMetadataClick);
}
export async function activateStyleCatalogTab() {
if (_initialized) return;
_initialized = true;
try {
const data = await apiFetch(API.root());
state.root = data.root;
await loadFolderTree();
} catch (e) {
const tree = document.getElementById("wfm-stylecatalog-tree");
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
// ── フォルダツリー ────────────────────────────────────────────
async function loadFolderTree() {
if (!state.root) return;
const tree = document.getElementById("wfm-stylecatalog-tree");
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
try {
const data = await apiFetch(API.folders(state.root));
if (data.error) {
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
return;
}
tree.innerHTML = "";
renderTreeNode(data, tree, 0, true);
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
if (firstLabel) firstLabel.click();
} catch (e) {
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderTreeNode(node, container, depth, isRoot) {
const item = document.createElement("div");
item.className = "wfm-gallery-tree-item";
item.style.paddingLeft = `${depth * 12}px`;
item.dataset.path = node.abs_path;
const hasChildren = node.children && node.children.length > 0;
const absPath = node.abs_path;
const arrow = document.createElement("span");
arrow.className = "wfm-gallery-tree-arrow";
if (hasChildren) {
arrow.textContent = "▶";
arrow.dataset.expanded = "false";
} else {
arrow.textContent = " ";
arrow.style.visibility = "hidden";
}
item.appendChild(arrow);
const label = document.createElement("span");
label.className = "wfm-gallery-tree-label";
label.textContent = isRoot ? "[root]" : node.name;
const totalCount = node.image_count_total ?? node.image_count;
if (totalCount > 0) {
const badge = document.createElement("span");
badge.className = "wfm-gallery-tree-count";
badge.textContent = totalCount;
label.appendChild(badge);
}
item.appendChild(label);
label.addEventListener("click", () => {
document.querySelectorAll("#wfm-stylecatalog-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
item.classList.add("selected");
state.currentFolder = absPath;
loadImages();
});
if (hasChildren) {
const childContainer = document.createElement("div");
childContainer.className = "wfm-gallery-tree-children";
childContainer.style.display = "none";
arrow.addEventListener("click", (e) => {
e.stopPropagation();
const expanded = arrow.dataset.expanded === "true";
if (!expanded) {
arrow.dataset.expanded = "true";
arrow.textContent = "▼";
childContainer.style.display = "";
if (childContainer.children.length === 0) {
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
}
} else {
arrow.dataset.expanded = "false";
arrow.textContent = "▶";
childContainer.style.display = "none";
}
});
container.appendChild(item);
container.appendChild(childContainer);
} else {
container.appendChild(item);
}
}
// ── 画像一覧 ─────────────────────────────────────────────────
async function loadImages() {
if (!state.currentFolder) return;
const grid = document.getElementById("wfm-stylecatalog-grid");
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
if (state.search) params.search = state.search;
try {
const data = await apiFetch(API.images(params));
state.images = data.images || [];
document.getElementById("wfm-stylecatalog-count").textContent = `${state.images.length} images`;
renderImages();
} catch (e) {
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderImages() {
const grid = document.getElementById("wfm-stylecatalog-grid");
if (state.images.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
return;
}
grid.innerHTML = "";
const fragment = document.createDocumentFragment();
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
grid.appendChild(fragment);
}
function createThumbCard(img) {
const card = document.createElement("div");
card.className = "wfm-gallery-thumb-card";
card.title = img.filename;
card.dataset.path = img.path;
if (state.selectedImage && state.selectedImage.path === img.path) {
card.classList.add("selected");
}
const imgEl = document.createElement("img");
imgEl.className = "wfm-gallery-thumb-img";
imgEl.loading = "lazy";
imgEl.src = API.thumb(img.path);
imgEl.alt = img.filename;
imgEl.onerror = () => {
imgEl.style.display = "none";
const placeholder = document.createElement("div");
placeholder.className = "wfm-gallery-thumb-placeholder";
placeholder.textContent = "?";
card.insertBefore(placeholder, imgEl.nextSibling);
};
card.appendChild(imgEl);
card.addEventListener("click", () => selectImage(img));
card.addEventListener("dblclick", () => openLightbox(img));
return card;
}
// ── 選択画像 / Positive・Negative表示 ─────────────────────────
function styleNameFromFilename(filename) {
return filename.replace(/\.[^.]+$/, "");
}
async function selectImage(img) {
state.selectedImage = img;
document.querySelectorAll("#wfm-stylecatalog-grid .wfm-gallery-thumb-card").forEach(el => {
el.classList.toggle("selected", el.dataset.path === img.path);
});
document.getElementById("wfm-stylecatalog-selected-name").textContent = img.filename;
const preview = document.getElementById("wfm-stylecatalog-selected-preview");
preview.innerHTML = "";
const previewImg = document.createElement("img");
previewImg.src = API.serveImage(img.path);
previewImg.alt = img.filename;
previewImg.title = "Double-click to enlarge";
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;cursor:zoom-in;";
previewImg.addEventListener("dblclick", () => openLightbox(img));
preview.appendChild(previewImg);
const positiveText = document.getElementById("wfm-stylecatalog-positive-text");
const negativeText = document.getElementById("wfm-stylecatalog-negative-text");
const copyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
const copyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
const selectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
const loadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
const openMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
positiveText.textContent = "";
negativeText.textContent = "";
copyPositiveBtn.disabled = true;
copyNegativeBtn.disabled = true;
selectStyleBtn.disabled = false; // ファイル名からのStyle選択はメタデータ抽出に依存しない
loadGenuiBtn.disabled = false;
openMetadataBtn.disabled = false;
try {
const res = await fetch(API.serveImage(img.path));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
const meta = await extractAllMetadata(file);
const positive = meta?.positives?.[0] || "";
const negative = meta?.negatives?.[0] || "";
if (!positive && !negative) {
positiveText.textContent = t("stylecatalogNoPromptFound");
} else {
positiveText.textContent = positive;
negativeText.textContent = negative;
copyPositiveBtn.disabled = !positive;
copyNegativeBtn.disabled = !negative;
}
} catch (e) {
positiveText.textContent = t("stylecatalogNoPromptFound");
}
}
// gallery-tab.jsのライトボックスと同じCSSクラスを使う(ESCで閉じる処理もgallery-tab.js側の
// グローバルkeydownリスナーが共通で担う)。
function openLightbox(img) {
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox";
overlay.innerHTML = `
<div class="wfm-gallery-lightbox-inner">
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
<button class="wfm-gallery-lightbox-close">&times;</button>
</div>
`;
overlay.addEventListener("click", (e) => {
if (e.target === overlay || e.target.classList.contains("wfm-gallery-lightbox-close")) {
overlay.remove();
}
});
document.body.appendChild(overlay);
}
function copyText(elementId) {
const el = document.getElementById(elementId);
const text = el?.textContent || "";
if (!text.trim()) return;
navigator.clipboard.writeText(text)
.then(() => showToast(t("imagePromptCopiedToast"), "success"))
.catch((e) => showToast(t("errorWithMsg", e.message), "error"));
}
function onSelectStyleClick() {
const img = state.selectedImage;
if (!img) return;
const name = styleNameFromFilename(img.filename);
const ok = selectStyleByName(name);
if (ok) {
showToast(t("stylecatalogStyleSelected", name), "success");
} else {
showToast(t("stylecatalogNoMatch"), "error");
}
}
// カタログ画像は生成元の出力画像と同一バイト列で保存されているため、埋め込みワークフローも
// そのまま残っている。Outputフォルダ内の元画像を探す必要なく、この画像から直接読み込める。
async function onLoadGenUIClick() {
const img = state.selectedImage;
if (!img) return;
try {
const data = await apiFetch(API.workflow(img.path));
if (!data.has_workflow) {
showToast(t("galleryNoEmbeddedWorkflow"), "warning");
return;
}
const loaded = await loadWorkflowIntoEditor(data.workflow, img.filename);
if (loaded !== false) {
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
}
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
async function onOpenMetadataClick() {
const img = state.selectedImage;
if (!img) return;
try {
const res = await fetch(API.serveImage(img.path));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
await loadFileIntoMetadataTab(file);
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
+108 -79
View File
@@ -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) {
+171
View File
@@ -2,6 +2,76 @@
* Shared utilities
*/
// ============================================
// PNG metadata embedding (iTXt chunk)
// ============================================
// Embeds an arbitrary key/UTF-8-text pair into a canvas-generated PNG data URL as
// an iTXt chunk, inserted right after IHDR — same insertion point/CRC approach as
// ComfyUI's own workflow-embedding (web/comfyui/top_menu_extension.js's
// embedWorkflowInPng), but iTXt instead of tEXt so non-ASCII text (Japanese notes,
// prompts, etc.) round-trips correctly through this codebase's PNG chunk reader
// (metadata-tab.js's readAllPNGTextChunks already decodes iTXt text as UTF-8;
// tEXt text there is decoded as Latin-1, which would mangle non-ASCII payloads).
function _n2b(n) {
return new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
}
function _joinBytes(...arrs) {
const result = new Uint8Array(arrs.reduce((total, a) => total + a.byteLength, 0));
arrs.reduce((offset, a) => { result.set(a, offset); return offset + a.byteLength; }, 0);
return result;
}
let _pngCrcTable = null;
function _pngCrc32(data) {
if (!_pngCrcTable) {
_pngCrcTable = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
_pngCrcTable[n] = c;
}
}
let crc = 0 ^ -1;
for (let i = 0; i < data.byteLength; i++) crc = (crc >>> 8) ^ _pngCrcTable[(crc ^ data[i]) & 0xff];
return (crc ^ -1) >>> 0;
}
function _dataUrlToBytes(dataUrl) {
const binary = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function _bytesToDataUrl(bytes, mime) {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
}
return `data:${mime};base64,${btoa(binary)}`;
}
export function embedPngTextChunk(dataUrl, keyword, text) {
const bytes = _dataUrlToBytes(dataUrl);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const data = _joinBytes(
new TextEncoder().encode(`iTXt${keyword}`),
new Uint8Array([0, 0, 0, 0, 0]), // keyword-NUL, compression flag(0), compression method(0), empty lang tag+NUL, empty translated keyword+NUL
new TextEncoder().encode(text),
);
const chunk = _joinBytes(_n2b(data.byteLength - 4), data, _n2b(_pngCrc32(data)));
// Insert after IHDR (8-byte signature + 4-byte length + 4-byte type + IHDR data + 4-byte CRC)
const ihdrDataLen = view.getUint32(8);
const insertAt = ihdrDataLen + 20;
const result = _joinBytes(bytes.subarray(0, insertAt), chunk, bytes.subarray(insertAt));
return _bytesToDataUrl(result, "image/png");
}
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
export function escapeHtml(s) {
return String(s)
@@ -27,6 +97,107 @@ export function getSettings() {
return readJsonStorage("wfm_settings");
}
// AIバックエンド(Ollama/LM Studio/Lemonade)ごとのデフォルトURL
export const AI_BACKEND_DEFAULT_URLS = {
ollama: "http://localhost:11434",
lmstudio: "http://localhost:1234",
lemonade: "http://localhost:13305",
unsloth: "http://localhost:8888",
};
export function getAiBackendDefaultUrl(backend) {
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
}
// Unslothはローカルでも常にAPIキーが必要なため、フロントから直接叩かず
// サーバー側プロキシ(/api/wfm/unsloth/proxy)経由でキーを付与して中継する
// (see py/routes/unsloth_routes.py)
export async function unslothProxy(baseUrl, path, method, payload) {
const res = await fetch("/api/wfm/unsloth/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl, path, method, payload }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
return data;
}
// ローカルVLM/LLMバックエンドからモデルをVRAM/メモリからアンロードする。
// 公式APIが存在しない場合(Unsloth)は "UNSUPPORTED" エラーを投げる。
export async function unloadAiModel(url, backend, model) {
if (!model) throw new Error("No model selected");
if (backend === "ollama") {
// keep_alive:0 + 空プロンプトが公式に文書化されたアンロード方法
// (専用エンドポイントは存在しない) https://docs.ollama.com/api
const res = await fetch(`${url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt: "", keep_alive: 0 }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} else if (backend === "lmstudio") {
const res = await fetch(`${url}/api/v1/models/unload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance_id: model }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} else if (backend === "lemonade") {
const res = await fetch(`${url}/api/v1/unload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: model }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} else {
throw new Error("UNSUPPORTED");
}
}
// ============================================
// Eagle Auto-Save
// ============================================
export function getEagleSettings() {
const s = getSettings();
return {
url: s.eagleUrl || "http://localhost:41595",
autoSave: !!s.eagleAutoSave,
};
}
export async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
const eagle = getEagleSettings();
if (!eagle.autoSave) return;
try {
const res = await fetch("/api/wfm/eagle/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eagleUrl: eagle.url,
url: imageUrl,
name,
tags: ["wfm-comfyui", ...tags],
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
filename: fileInfo?.filename || "",
subfolder: fileInfo?.subfolder || "",
type: fileInfo?.type || "output",
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
localPath: fileInfo?.localPath || "",
}),
});
const data = await res.json();
if (data.status === "success") {
console.log("[Eagle] Saved:", name);
} else {
console.warn("[Eagle] Save failed:", data.message);
}
} catch (err) {
console.warn("[Eagle] Save error:", err.message);
}
}
/**
* 検索inputにオーバーレイXボタンを設定する。
* @param {string} inputId - 検索inputのID
+433
View File
@@ -0,0 +1,433 @@
/**
* 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, setBgmFromFile } 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>
<button type="button" class="wfm-btn wfm-btn-sm" id="wfm-video-asset-set-bgm" style="width:100%;margin-top:6px;display:${isVideoFile(img) ? "block" : "none"};">${t("videoAssetSetAsBgm")}</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");
}
});
// Gallery lists no audio files, so a video asset's own audio track is the
// Asset-side way to pick a BGM (LoadAudio decodes audio from video files).
panel.querySelector("#wfm-video-asset-set-bgm")?.addEventListener("click", async () => {
try {
const res = await fetch(`/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`);
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
const file = new File([blob], img.filename, { type: blob.type || "video/mp4" });
if (await setBgmFromFile(file, img.filename)) {
document.querySelector('.wfm-video-center-panel .wfm-video-subtab-btn[data-video-subtab="edit"]')?.click();
showToast(t("videoAssetSetAsBgmDone", img.filename), "success");
}
} catch (err) {
showToast(t("errorWithMsg", err.message), "error");
}
});
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();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
/**
* Video Tab - shared state for the two center-panel preview panes:
* "source" (#wfm-video-source-preview-video — an Asset selection or a
* locally-dropped file) and "result" (#wfm-video-preview-video — the latest
* generated clip). video-asset-tab.js (Asset selection), video-plan-tab.js
* (batch run results) and video-tab.js (the Video Source drop zone, Frame/GIF
* tools) all need to read or write these. Pulling this into its own module
* lets all three depend on it without needing to import each other —
* video-tab.js already imports both of the others for init/refresh, so a
* reverse import from either back into video-tab.js would create a cycle.
*
* Each pane also has a companion <img> element (wfm-video-*-preview-img) so a
* still/animated image (from Asset or a Video Source drop) can be previewed
* without forcing it through a <video> tag, which can't render image formats
* at all. mediaType controls which element is shown; Frame/GIF's
* getActivePreviewVideoElement() returns null while a pane is showing an
* image, since those tools only make sense against real video.
*/
const _PANES = {
source: { frame: "wfm-video-source-preview-frame", video: "wfm-video-source-preview-video", img: "wfm-video-source-preview-img", placeholder: "wfm-video-source-preview-placeholder" },
result: { frame: "wfm-video-preview-frame", video: "wfm-video-preview-video", img: "wfm-video-preview-img", placeholder: "wfm-video-preview-placeholder" },
};
const _objectUrls = { source: null, result: null };
// Whichever pane most recently received media is what the Frame/GIF property
// tabs act on (see getActivePreviewSource/getActivePreviewVideoElement below) —
// an Asset selection or Video Source drop activates "source", a batch run
// finishing a block (or the Edit tab's export/preview) activates "result".
let _activePane = null; // "source" | "result" | null
let _activeSource = null;
let _activeMediaType = null; // "video" | "image" | null — mirrors _activePane's current content
function _setPreview(pane, url, source, mediaType = "video") {
const ids = _PANES[pane];
const video = document.getElementById(ids.video);
const img = document.getElementById(ids.img);
const placeholder = document.getElementById(ids.placeholder);
if (!video || !placeholder) return;
if (_objectUrls[pane]) {
URL.revokeObjectURL(_objectUrls[pane]);
_objectUrls[pane] = null;
}
if (url && url.startsWith("blob:")) _objectUrls[pane] = url;
if (url) {
if (mediaType === "image") {
video.pause();
video.removeAttribute("src");
video.load();
video.style.display = "none";
if (img) { img.src = url; img.style.display = "block"; }
} else {
if (img) { img.removeAttribute("src"); img.style.display = "none"; }
video.src = url;
video.style.display = "block";
}
placeholder.style.display = "none";
_activePane = pane;
_activeSource = source;
_activeMediaType = mediaType;
} else {
video.pause();
video.removeAttribute("src");
video.load();
video.style.display = "none";
if (img) { img.removeAttribute("src"); img.style.display = "none"; }
placeholder.style.display = "";
if (_activePane === pane) { _activePane = null; _activeSource = null; _activeMediaType = null; }
}
}
// Sets (or clears, with url=null) the Asset-selection/Video-Source-drop pane.
// mediaType: "video" (default) or "image".
export function setSourcePreview(url, source, mediaType = "video") { _setPreview("source", url, source, mediaType); }
// Sets (or clears) the generated-result pane — called once per block as a
// batch run's video output comes in, and by the Edit tab's timeline preview
// (which may show either a video clip or a held still image).
export function setResultPreview(url, source, mediaType = "video") { _setPreview("result", url, source, mediaType); }
// What the Frame/GIF property tabs operate on: { kind: "output"|"input", filename,
// subfolder, type } for anything already on the server, or { kind: "local", file }
// for a picked/dropped file not yet uploaded. null if neither pane has media.
export function getActivePreviewSource() { return _activeSource; }
// The GIF tab uploads a still-local source on first use and needs to remember
// the resulting server-side reference so a repeat conversion doesn't re-upload —
// this rewrites the active source in place without touching either <video> element.
export function updateActivePreviewSourceRef(source) { _activeSource = source; }
// The <video> element the Frame/GIF tools should read/capture from, or null if
// neither pane currently has a video loaded (including when the active pane is
// showing a still image instead).
export function getActivePreviewVideoElement() {
if (!_activePane || _activeMediaType === "image") return null;
return document.getElementById(_PANES[_activePane].video);
}
// Both panes' <video> elements share the same persistent volume setting.
export function getAllPreviewVideoElements() {
return Object.values(_PANES).map((ids) => document.getElementById(ids.video)).filter(Boolean);
}
// Direct access to the "result" pane's <video> element — used by
// video-edit-tab.js's timeline preview, which needs to drive playback
// (seek to a clip's trim-in, listen for the trim-out point, swap to the
// next clip) rather than just set a src via setResultPreview().
export function getResultPreviewVideoElement() {
return document.getElementById(_PANES.result.video);
}
// The frame/<video>/<img> elements of one pane ("source" | "result") — used by
// video-edit-tab.js to position its text-overlay preview layer over whichever
// media element is currently showing in that pane.
export function getPreviewPaneElements(pane) {
const ids = _PANES[pane];
if (!ids) return null;
return {
frame: document.getElementById(ids.frame),
video: document.getElementById(ids.video),
img: document.getElementById(ids.img),
};
}
+175
View File
@@ -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());
}

Some files were not shown because too many files have changed in this diff Show More