Compare commits
@@ -1,35 +1,64 @@
|
||||
# ComfyUI-Workflow-Studio
|
||||
|
||||
A comprehensive workflow management and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
|
||||
A comprehensive workflow, asset management, and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
|
||||
|
||||
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
|
||||
**📁 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
|
||||
|
||||
**📚 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)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
| Workflow Tab | Models Tab |
|
||||
|:---:|:---:|
|
||||
| Workflow Tab | Models Tab |
|
||||
| :-------------------------------------: | :---------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Prompt Input Assistance | Gen UI Feeder |
|
||||
|:---:|:---:|
|
||||
| Prompt Input Assistance | Gen UI Feeder |
|
||||
| :----------------------------------------------------------: | :---------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Top Bar | WS Library |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
| GenUI LoRA Stack | GenUI Batch |
|
||||
| :---------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Library Information | Customize |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
| Models Multi-select Menu | Top Bar |
|
||||
| :-----------------------------------------------------------: | :---------------------------: |
|
||||
|  |  |
|
||||
|
||||
| WS Library | Library Information |
|
||||
| :----------------------------------: | :---------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Tagger Tab | Image Edit Tab |
|
||||
| :-------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Customize | |
|
||||
| :--------------------------------: | :-: |
|
||||
|  | |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Workflow Tab
|
||||
|
||||
- **Thumbnail / Table views** — switch between view modes to browse your workflow library
|
||||
- **Thumbnail side panel** — preview workflow canvas snapshots in the side panel
|
||||
- **Badge filtering** — filter by user-defined badges (free labels you assign to each workflow)
|
||||
@@ -37,34 +66,48 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Side panel tabs** — Thumbnail preview, JSON viewer with syntax highlighting, and Group management
|
||||
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
|
||||
- **AI summary** — generate workflow descriptions using Ollama
|
||||
- **Import / Export** — import workflows from files or clipboard, open in ComfyUI directly
|
||||
- **Import / Export** — import workflows from files or clipboard; **Send to Canvas** button sends the selected workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **Default view setting** — persist your preferred view mode (Thumbnail / Table)
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, group filter, and badge filter (ALL) in one click
|
||||
|
||||
### Canvas Snapshot (v0.1.2)
|
||||
|
||||
- **One-click capture** — click the camera button in ComfyUI's top bar to snapshot the current workflow canvas
|
||||
- **Auto-save as thumbnail** — the snapshot is saved directly to the workflow data folder as a PNG thumbnail
|
||||
- **Embedded workflow metadata** — workflow JSON is embedded in the PNG (tEXt chunk), compatible with ComfyUI's drag-and-drop import
|
||||
- **Auto-import** — the captured workflow is automatically imported and appears in the Workflow tab
|
||||
|
||||
### GenerateUI Tab (v0.3.5)
|
||||
|
||||
- **5-tab layout** — Input / Model / Settings / Feeder / Batch tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
|
||||
- **Save button** — located at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API
|
||||
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
|
||||
- **Input tab** — Prompt and Image inner tabs (drag-and-drop upload); Prompt tab shows Positive Prompt and Negative Prompt textareas, plus an **Embeddings selector** at the bottom (Filter + Select + Weight input + Paste button); Paste inserts `(embedding:Name:weight)` at the cursor position of the last focused textarea (defaults to Positive when neither is focused); Raw JSON (540px) in the right column
|
||||
- **Revert button** (v0.3.70) — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value (the reverse of Apply)
|
||||
- **Emphasis weight editing** (v0.3.70) — select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05, A1111/native-ComfyUI style; parentheses are removed automatically when the weight returns to 1.0
|
||||
- **Mask slot for inpainting** (v0.3.71) — LoadImage slots whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) show an additional Mask drop zone; when a mask is set, image + mask are composited into a single RGBA upload (mask baked into the alpha channel) matching ComfyUI's native alpha→MASK extraction — no extra nodes required
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device), **Hypernetwork** (with Strength field) selectors with filter; Raw JSON on the right
|
||||
- **Settings tab** — KSampler and Latent Image side by side at 50% width each; Raw JSON on the right
|
||||
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow; built-in **search bar** (always shown) finds all matches as you type with count display (`3/12`); navigate with ↑/↓ buttons or Enter / Shift+Enter; Escape or ✕ clears; current match highlighted in orange, other matches in yellow
|
||||
- **Bypass/Mute node handling** (v0.3.75) — loading a UI-format workflow with Bypass-mode nodes now reroutes their wires to the upstream source (matching same-type input/output slots, recursively through chained bypasses) instead of leaving a dangling reference that failed validation; Mute-mode nodes are excluded without rerouting, same as ComfyUI itself; when either is present, a note listing the affected nodes appears above the Raw JSON search bar in the Input/Model/Settings tabs
|
||||
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
|
||||
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
|
||||
- **Batch type selector** — check one of the column header checkboxes in the Batch Queue pane (Checkpoint / Lora / Prompt / Workflow) to activate that batch type; only one type can be active at a time; the Batch panel below Generate shows the active type, progress, and Pause/Resume/Stop controls
|
||||
- **Style selector** — checkbox and dropdown next to the Reset Workflow button; enable to apply a Fooocus-style JSON to positive and negative prompts at generation time; style files (`*.json`) are loaded from `user/default/Workflow-Studio/style/`; the style's `{prompt}` placeholder is replaced with the original prompt text, or the style is appended if no placeholder exists; `negative_prompt` is appended to the existing negative prompt; styles are applied to a per-generation copy and do not modify the loaded workflow
|
||||
- **Batch type selector** — check one of the column header checkboxes in the Batch Queue pane (Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / Style) to activate that batch type; only one type can be active at a time; the Batch panel below Generate shows the active type, progress, and Pause/Resume/Stop controls
|
||||
- **Batch tab** (v0.3.18) — dedicated 3-pane layout for assembling the batch queue:
|
||||
- **Left pane** — file-tree checkpoint selection; Filter input to search; All / None buttons; initial state is all unchecked
|
||||
- **Left pane** — 4 tabs: **Checkpoints** (file-tree; Filter / All / None), **Sampler** (KSampler sampler list), **Scheduler** (KSampler scheduler list), **Style** (flat list of all styles from `Workflow-Studio/style/`; All / None buttons)
|
||||
- **Center pane** — group-based selection with inner tabs (Checkpoint | Lora | Prompt | Workflow); Checkpoint/Lora groups come from the Models tab, Prompt groups from the Prompt tab, Workflow groups from the Workflow tab — check a group to add all its members, expand ▶ to select individually
|
||||
- **Right pane (Batch Queue)** — shows items queued for each batch type; column headers (Checkpoint / Lora / Prompt / Workflow) each have a checkbox — check one to activate that batch type (radio behavior: only one at a time); count shown at top
|
||||
- **Right pane (Batch Queue)** — 7 columns: Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / **Style**; each column header has an enable checkbox (radio behavior: only one at a time); count shown per column; Style batch applies each selected style sequentially to a workflow copy
|
||||
- **Lora batch prompt sync** (v0.3.69) — for each selected LoRA, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt (rebuilt from the original prompt each time, not accumulated), then restored to the pre-batch state when the batch completes or is stopped; if a selected LoRA is not yet recognized by ComfyUI's model list, a warning toast suggests a Refresh or restart
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and more
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
|
||||
### Feeder subtab (v0.3.5)
|
||||
Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder)** custom node.
|
||||
### Feeder subtab (v0.3.5 / v0.3.42)
|
||||
|
||||
Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons at the top of the left pane (persisted in `localStorage`).
|
||||
|
||||
**Image Loop mode** — requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder)** custom node.
|
||||
|
||||
- **ImageFeeder node control** — select the target node from a dropdown auto-populated from the loaded workflow; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection) and Apply to the workflow
|
||||
- **Image library** — 3-pane layout: folder tree (left) browsing `user/default/image-loop-data/`, image grid with checkbox selection (center), preview panel with resolution and file size (right)
|
||||
- **Selection management** — check individual images; All / None buttons for the current folder; selected files are reflected in `selected_files` on Apply
|
||||
@@ -76,7 +119,16 @@ Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image
|
||||
- **Index sync** — after each generation the node returns `next_index` via WebSocket (`image_loop_node_sync`); the Index field updates automatically
|
||||
- **Seed** — Run loop uses the right-pane seed setting (Random / Fixed / Increment / Decrement); the node's own Seed field only affects random sort order
|
||||
|
||||
**Gallery mode** (v0.3.42) — no external plugin required; uses the built-in **WFS_GalleryFeeder** custom node.
|
||||
|
||||
- **WFS_GalleryFeeder node** — reads images directly from a Gallery group and outputs them one at a time; inputs: `group_name`, `index`, `sort_mode` (filename_asc / filename_desc / random), `seed`
|
||||
- **Node & group selector** — pick the WFS_GalleryFeeder node from the loaded workflow and choose which Gallery group to feed images from; `__Feeder__` group is auto-created on first open
|
||||
- **Image grid** — center pane shows all images in the selected group; click any image to update the Index to that position
|
||||
- **After Gen modes** — Loop (wrap at end and continue indefinitely), Increment (stop automatically when last image is reached), Fixed (always use the same index)
|
||||
- **Run / Stop controls** — left pane Run / Stop buttons manage the generation loop; After Gen combo (loop / increment / fixed), ▶ Run, and ■ Stop widgets are also available directly on the WFS_GalleryFeeder node in the ComfyUI canvas (`gallery_feeder_extension.js`)
|
||||
|
||||
### Prompt Tab
|
||||
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
|
||||
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
- **Image attachment** — attach reference images for vision-capable models
|
||||
@@ -90,6 +142,7 @@ Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image
|
||||
- **Wildcard file manager** — create, view, and edit `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; click a filename in the file picker to insert `__filename__` at cursor
|
||||
|
||||
### Metadata Tab (v0.3.8)
|
||||
|
||||
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
|
||||
- **File drop** — drop a ComfyUI-generated PNG / WebP or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
|
||||
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
|
||||
@@ -100,6 +153,7 @@ Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image
|
||||
- **Format note** — supported formats and covered model types are always shown in the left column
|
||||
|
||||
### Settings Tab
|
||||
|
||||
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
|
||||
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
|
||||
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
|
||||
@@ -111,37 +165,54 @@ Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image
|
||||
- **CivitAI Host** — choose which site opens when clicking a model link: `civitai.com` (SFW only) or `civitai.red` (unrestricted); saved to `settings.json` and synced to `localStorage` for the Models tab to use without extra fetches
|
||||
- **CivitAI API Key** — optional Bearer token for authenticated CivitAI access; stored in `settings.json` (excluded from data exports); environment variable `CIVITAI_API_KEY` takes priority if set
|
||||
- **Default workflow** — set a workflow to auto-load on startup
|
||||
- **Default Checkpoint** (v0.3.73) — optional checkbox to force every checkpoint loader node's model to a chosen default whenever a workflow is loaded into GenerateUI; a safeguard against generation errors or garbled images from a stale/mismatched checkpoint saved in the workflow file
|
||||
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
|
||||
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
|
||||
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
|
||||
- **G'MIC-Qt Integration** — set the path to `gmic_qt.exe` for the Image Edit Filter tool; download G'MIC-Qt Standalone from `gmic.eu/download.html` (Windows: `gmic_qt-win64.zip`), extract anywhere, then enter the full path to `gmic_qt.exe` and click Save
|
||||
- **Language** — English / Japanese / Chinese
|
||||
|
||||
### Gallery Tab (v0.3.1)
|
||||
### Gallery Tab (v0.3.44)
|
||||
|
||||
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
|
||||
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
|
||||
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
|
||||
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To... and Delete from the multi-select bar
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations (group, favorite, move, delete)
|
||||
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To..., Export, and Delete File from the multi-select bar
|
||||
- **Download** — hover over the image preview in the detail panel to reveal a download icon (⬇); click to download the single image
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations: select group → "Add to Group" / "Remove from Group"; Favorite All / Unfavorite All; Compare (2–4 images); Move To...; **Export** (downloads all selected images as a ZIP file); Delete File
|
||||
- **Image Compare** — select 2–4 images with Ctrl+click, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; grid adapts to the number of selected images
|
||||
- **Prompt search** — text search covers filename, tags, memo, and prompt text (A1111 `parameters` field cached on first detail panel open)
|
||||
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
|
||||
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
|
||||
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab; **`__Feeder__`** is a reserved group (🔒 prefix) that cannot be renamed or deleted; used by the Feeder Gallery mode to define the generation queue; **FC** button in the toolbar clears all members without deleting the group
|
||||
- **Thumbnail F button** — cyan overlay button (top-left of each image card) toggles the image's membership in the `__Feeder__` group; cyan and always-visible when the image is in the group; visible on hover only when inactive
|
||||
- **Favorites** — star images inline without reopening the detail panel
|
||||
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
|
||||
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab
|
||||
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab; **Copy & Send Canvas** button copies the JSON to the clipboard and sends the workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **Load GenUI button** — loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; shows a warning toast if no workflow is embedded or the format is unsupported; Metadata button is styled green, Load GenUI button uses the primary accent color
|
||||
- **Image Edit button** — toolbar button; sends the selected image directly to the Image Edit tab as the base layer
|
||||
- **Send GenUI Image button** — toolbar button (next to Image Edit); uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded
|
||||
- **Send CC button** (v0.3.69) — toolbar button, shown only when this Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
|
||||
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
|
||||
- **Output folder configurable** — set the scanned output folder from Settings tab
|
||||
- **Performance** — folder-level mtime cache (30s TTL) for fast incremental refresh; tree expansion state preserved across folder operations
|
||||
- **Performance** — server-side 256px JPEG thumbnail generation with disk cache (`data/thumb_cache/`); infinite-scroll paging (50 images per page, IntersectionObserver); folder-level mtime cache (60s TTL); bulk operations use single-request API endpoints
|
||||
|
||||
### Nodes Tab (v0.1.7)
|
||||
|
||||
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
|
||||
- **Search & Filter** — full-text search, filter by category, package, tags, groups, and favorites
|
||||
- **Package badges** — color-coded badges generated from package names
|
||||
- **Node detail panel** — view I/O specifications, edit tags, manage groups
|
||||
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
|
||||
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
|
||||
- **Multi-select** — Ctrl+click to toggle individual nodes, Shift+click for range selection; bulk action bar appears with: add to group / remove from group / create & add group / bulk favorite / unfavorite
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, category, package, tag, group, and favorites filter in one click
|
||||
|
||||
### Models Tab (v0.2.3)
|
||||
|
||||
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding) with sub-tab switching
|
||||
- **Thumbnail / Table views** — switch between view modes with pagination (24 items per page); Table view includes **Type** and **Base Model** columns sourced from cached CivitAI data, displayed between the Subdirectory and Extension columns; Enable/Disable column labeled **E/D**
|
||||
- **Table column sort** — click any column header to sort ascending (▲), click again for descending (▼), click a third time to clear; active column highlighted in accent color
|
||||
@@ -153,7 +224,7 @@ Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image
|
||||
- **CivitAI panel states** — three distinct states: not yet checked (fetch button), checked but not found on CivitAI (re-check button with notice), and found (full info display); clicking the CivitAI tab always refreshes to the latest state
|
||||
- **Batch CivitAI fetch** — one-click batch fetch using `POST /model-versions/by-hash` (up to 100 models per request) with SSE progress streaming; previews are auto-saved for models without one
|
||||
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload; **Delete** button permanently removes the model file and all associated sidecar files (preview images, `.json`, `.civitai.info`, `.metadata.json`, `.cm-info.json`)
|
||||
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
|
||||
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork); **Embedding** type shows **GenUI PP** / **GenUI NP** buttons instead — appends `(embedding:Name:1.0)` to the Positive or Negative prompt of the loaded workflow; also accessible from the side panel nav bar and the detail modal
|
||||
- **Group management** — create, rename, delete groups and assign/remove models; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab); Checkpoint and LoRA tabs include **B (Batch)** and **S (Stack)** quick-assign buttons per card/row for one-click group membership toggle without grid re-render
|
||||
- **Table view memo** — memo column displayed in table view for quick reference
|
||||
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
|
||||
@@ -162,17 +233,63 @@ Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image
|
||||
- **Group row** — select an existing group and **Add** / **Remove**, or type a new name and **Create & Add**
|
||||
- **Badge row** — select a badge and **+Badge** / **−Badge** to apply or remove from all selected
|
||||
- **File row** — select a destination subfolder (or type a new folder name and create it) then **Move** to relocate the model file with all sidecar files; **Delete Files** (right end) permanently removes selected models and all associated files
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, tag filter, folder filter, group filter, favorites filter, and status filter in one click
|
||||
|
||||
### Tagger Tab (v0.3.38)
|
||||
|
||||
- **3 sub-tabs** — Single / Batch / DB for single-image tagging, folder batch processing, and tag database management
|
||||
- **Model support** — WD Tagger (ONNX, NCHW/NHWC auto-detect), SwinV2 (ONNX), DeepDanbooru (.h5, requires TensorFlow); place each model in its own subfolder under `ComfyUI/models/tagger/<model-name>/` containing the `.onnx` + `selected_tags.csv` (or `.h5` + label file)
|
||||
- **Threshold sliders** — General threshold (default 0.35) and Character threshold for `character:` prefix tags (default 0.85); sliders update the display value in real time
|
||||
- **Ollama VLM** — optional vision model alongside WD Tagger; set API URL and select model (↻ to refresh list); results from both models are merged into one comma-separated tag string
|
||||
- **Single tab** — drag & drop an image onto the preview area or click Upload; Gallery detail panel **Tagger** button opens the selected image directly here
|
||||
- **Single output options** — (1) **GenUI:P** — appends tags to the GenerateUI tab's positive prompt and immediately applies to the loaded workflow; (2) **Send to Prompt** — appends tags to the Prompt tab's positive textarea; (3) **Save to Gallery** — saves tags to Gallery image metadata (requires image opened from Gallery); (4) **Write to File** — embeds tags into JPEG EXIF (`ImageDescription`) or PNG `tEXt` chunk (`Tags` key), other formats get a `.tags.json` sidecar; (5) **Save to DB** — stores in internal SQLite database
|
||||
- **Batch tab** — enter a folder path, configure WD Tagger and Ollama settings; output options: **Save to DB** (default on), **Write to File** (EXIF/PNG metadata), **Write .txt** (creates `<filename>.txt` alongside each image with all tags); real-time progress bar and log; Stop cancels after the current image
|
||||
- **DB tab** — searchable SQLite database of all tagged images; click a row to open the edit panel and modify WD Tags / VLM Tags; Save updates the record, Delete removes it; Export CSV downloads all records
|
||||
- **Dependencies** — install into ComfyUI's embedded Python: `python_embedded\python.exe -m pip install -r requirements.txt`; for GPU inference use `onnxruntime-gpu`; TensorFlow (DeepDanbooru) is optional and commented out in `requirements.txt`
|
||||
|
||||
### Image Edit Tab (v0.3.65)
|
||||
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG
|
||||
- **Loading images** — drag & drop onto the canvas, Upload button, or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers and automatically adds an empty Layer 1 (v0.3.70)
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Select** — click to select; drag to move; drag corner handles to resize; drag circle handle to rotate; Flip H / Flip V / Rotate angle in the options bar; double-click a text layer to re-edit its content
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
|
||||
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
|
||||
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
|
||||
- **Inpaint** (v0.3.71) — composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like `VAEEncodeForInpaint`); also supports workflows built around the **Mask Editor One** custom node (`comfyui-mask-editor-one`) instead of LoadImage — image/mask are pushed via its node_id-keyed server cache and `layer_data` widget; **Properties pane**: Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), Denoise (applied to KSampler), Run button; **Use dedicated workflow** checkbox — when off, generates against the workflow currently loaded in GenerateUI; when on, pick a saved workflow from the dropdown to load, edit, and generate entirely in the background without disturbing what's shown in GenerateUI; the result image is shown back in the Properties pane when generation completes; requires a Mask layer painted first (Mask tool)
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load
|
||||
- **Layer lock** — locked layers show an orange bounding box and 🔒 icon on the canvas; move/resize/rotate are disabled while locked; click the 🔓 button in the layer row to unlock
|
||||
- **Text quality** — text layers are rendered at their measured bounding-box size; resizing with the Select tool regenerates the canvas at the new display resolution so text stays sharp
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Send to ComfyUI** (uploads to ComfyUI input folder for use in Load Image nodes)
|
||||
- **Canvas navigation** — scroll-wheel zoom, Space + drag to pan; zoom indicator in the bottom bar
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), Delete (remove selected layer when 2+ exist)
|
||||
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
- **3-pane layout** — Translation (40%) | TOOLS (40%) | Settings (20%); all panes always visible simultaneously; no sub-tab switching required
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama or LM Studio; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
|
||||
- **4-pane layout** — Translation | Chat | TOOLS | Settings; all panes always visible simultaneously; no sub-tab switching required
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio and Lemonade use `/v1/chat/completions`
|
||||
- **Chat pane — image generation via Tool Calling** (v0.3.73) — if the selected model supports tool calling and the user asks for an image, a `generate_image` tool is invoked and the request runs through the same generation pipeline as the GenerateUI Generate button, with the result shown inline in the chat; an "Allow image generation" checkbox in the input row toggles this on/off (default on); the workflow used is the one currently loaded in GenerateUI, or a dedicated saved workflow configured in the Settings pane; models verified to work well for tool calling and vision: **Gemma 4:e4b**, **Qwen 3.5:9b**, **Ministral 3:3b** (Ollama)
|
||||
- **TOOLS pane (VLM)** — drop an image into the 110px drop zone, select a task (Describe Image / Create Prompt / Create Tags), and click Run to analyze with a vision model; result shown in the output area with a Copy button
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **TOOLS pane — shared Chat attachment** (v0.3.74) — an image dropped in the TOOLS drop zone is also usable as the Chat pane's attachment: it's sent to the LLM as vision input, and used as the base image when `generate_image` runs image-to-image; a ✕ button clears it (from either the TOOLS preview or the Chat attachment indicator); it stays attached across turns until explicitly cleared
|
||||
- **TOOLS pane (Wildcards)** (v0.3.40) — select "Create wildcards" from the task dropdown; enter a category name and count; click Run to generate plain-text wildcard entries one per line (no markdown, no numbering); result can be copied directly into wildcard `.txt` files
|
||||
- **Chat pane — image-to-image (I2I)** (v0.3.74) — when an image is attached and `generate_image` is invoked, the attachment is uploaded and swapped into the target workflow's LoadImage node instead of running text-to-image; the target workflow is the one currently loaded in GenerateUI, or a dedicated I2I workflow configured in the Settings pane
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
|
||||
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/tags` for model list); LM Studio OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio)
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade)
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
|
||||
- **Lemonade's other endpoints (not integrated)** — Lemonade Server also exposes OpenAI-compatible image generation (`/v1/images/generations`), text-to-speech (`/v1/audio/speech`), and transcription (`/v1/audio/transcriptions`) endpoints; these are not wired up — image generation would duplicate the existing ComfyUI-workflow-based `generate_image` tool call, and audio/TTS has no corresponding UI yet, so both are future considerations rather than a natural extension of the current backend switch
|
||||
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
|
||||
|
||||
- **Tab layout (W / N / P / M / I / A)** — compact single-letter tabs with full name shown on hover
|
||||
- **W — Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
|
||||
- **N — Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
|
||||
@@ -181,21 +298,23 @@ Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image
|
||||
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs); LoRA groups show an **All N LoRAs** item — drag to canvas to place a `Lora Loader (LoraManager)` node with all LoRAs pre-loaded
|
||||
- **P — Prompts tab** — browse prompt presets with All / ★ Favorites / Categories sub-tabs; **Groups sub-tab** (row 2) — view presets by group (shared with the Batch tab's `wfm_prompt_preset_groups`)
|
||||
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
|
||||
- **A — AI TOOL tab** — 3-pane layout (Translation | TOOLS | Settings) always visible; Translation and TOOLS (VLM) powered by Ollama or LM Studio; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **model sub-tab** — Checkpoint, VAE, Diffusion Model, and Text Encoder; drag items to canvas to place the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`); double-click also places at canvas center
|
||||
- **lora sub-tab** — detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format `inputs.loras.__value__` supported); shows `strength_model / strength_clip` values; drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (appears for 1+ LoRAs) drags all LoRAs into a single `Lora Loader (LoraManager)` node with LoRA syntax pre-filled
|
||||
- **Prompts sub-tab** — POS / NEG badge list; drag a prompt to place `CLIPTextEncode` with text pre-filled; click any entry to view full text + Copy button
|
||||
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
|
||||
- **Drag & drop nodes** — drag nodes/node sets onto the canvas to place them
|
||||
- **Drag & drop prompts** — drag a preset onto the canvas to create a WFS_PromptText node with positive/negative prompts
|
||||
- **Send to Canvas** — clicking "Send to Canvas" (Workflow tab toolbar / detail modal) or "Copy & Send Canvas" (Gallery tab JSON panel) sends the workflow directly to the ComfyUI canvas via `window.opener` (UI and API formats both supported); if Workflow Studio was opened from a bookmark instead of the ComfyUI toolbar, UI-format workflows fall back to title drag (panel title highlights blue with a green ● — drag it onto the canvas to load)
|
||||
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
|
||||
- **Double-click** — load workflows or place nodes without dragging
|
||||
- **Search** — search within each sub-tab to quickly find items
|
||||
- **⚙ Theme settings** — customize panel background, sub-header background, text, border, and secondary text colors; saved to localStorage and applied on every open
|
||||
|
||||
### Help & Support Tab (v0.1.3)
|
||||
- **Sidebar navigation** — 2-column layout: left sidebar (14 topics) + right content pane; click any topic to switch the displayed content
|
||||
- **Support** — fixed at the bottom of the sidebar; shows GitHub and Ko-fi links in the right pane
|
||||
|
||||
- **Sidebar navigation** — 2-column layout: left sidebar (15 topics) + right content pane; click any topic to switch the displayed content
|
||||
- **Support** — fixed at the bottom of the sidebar; shows GitHub, Ko-fi, **ComfyUI Image Feeder**, and **Mask Editor One** repository links in the right pane
|
||||
- **Feature list** — overview of all features organized by tab
|
||||
- **Keyboard Shortcuts** and **Troubleshooting** sections included
|
||||
|
||||
@@ -270,14 +389,16 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[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
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages
|
||||
|
||||
| Language | Status |
|
||||
|----------|--------|
|
||||
| -------- | ------ |
|
||||
| English | Full |
|
||||
| Japanese | Full |
|
||||
| Chinese | Full |
|
||||
@@ -286,12 +407,310 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.76
|
||||
|
||||
- **Comic Creator — I2I execution bridge** — added `window._wfmReceiveI2IRunRequest` (`gallery-tab.js`) and `runI2IExternal()`/`_runI2IWithImage()` (`image-edit-tab.js`), a mask-free counterpart to the existing Inpaint bridge (`_wfmReceiveInpaintRequest`/`runInpaintExternal`); lets Comic Creator's Image tab send an image with Positive/Negative prompts and Denoise, run generation in place, and get the result image URL back without opening the Workflow Studio UI
|
||||
|
||||
### v0.3.75
|
||||
|
||||
- **Lemonade Server support — third AI backend** — added [Lemonade Server](https://lemonade-server.ai/) alongside Ollama and LM Studio as a selectable backend for Translation, Chat, TOOLS (VLM/Wildcards), and the Prompt tab's AI Assistant, in both the SPA and the Library side panel; Lemonade's OpenAI-compatible API (`/v1/models`, `/v1/chat/completions`) reuses the same request/response handling already used for LM Studio, so no new wire-format code was needed
|
||||
- **Shared default-URL lookup** — `static/js/util.js` gains `getAiBackendDefaultUrl(backend)` (`AI_BACKEND_DEFAULT_URLS`: Ollama `http://localhost:11434`, LM Studio `http://localhost:1234`, Lemonade `http://localhost:13305`), replacing the previous two-way ternary in `ai-tab.js`, `prompt-tab.js`, and `settings-tab.js`; the ComfyUI side panel (`node_sets_menu.js`) keeps its own local copy since it can't import from the SPA's `static/js` modules
|
||||
- **Backend switch now always resets the URL field** — switching the backend radio (Ollama / LM Studio / Lemonade) in any of the three UIs now unconditionally fills the URL field with that backend's default, instead of only when the field was empty; the side panel previously had no such handler at all
|
||||
- **Bug fix — Bypass-mode nodes broke generation with a dangling-reference validation error** — `convertUiToApi()` used to drop Bypass-mode (`mode: 4`) nodes without reconnecting the links that ran through them, so any node downstream of a bypassed node's output referenced a node ID that no longer existed in the API graph, and ComfyUI rejected the prompt (`Exception when validating inner node: 'N'`). Bypassed nodes' wires are now traced through to their upstream source (matching same-type input/output slots, recursively for chained bypasses) before being dropped, exactly like ComfyUI's own Bypass behavior. Mute-mode nodes (`mode: 2`) are now also excluded from the API graph (previously they were sent as if fully active); unlike Bypass they are not rerouted, matching ComfyUI's own behavior where muting a node with required downstream dependents is expected to error.
|
||||
- **GenerateUI — Bypass/Mute indicator above Raw JSON** — when a loaded workflow has Bypass or Mute nodes, a note listing them (title + node ID) now appears above the Raw JSON search bar in the Input/Model/Settings tabs, since the API-format JSON shown there has no `mode` field and can no longer reveal this on its own
|
||||
|
||||
### v0.3.74
|
||||
|
||||
- **AI TOOL Chat — image attachment shared with TOOLS drop zone** — an image dropped in the TOOLS pane's existing drop zone is now also usable as the Chat pane's attachment (no separate drop zone); it is sent to the LLM as vision input (Ollama `images:[]`, LM Studio `image_url` content block) and shown as an indicator (thumbnail + label + ✕) above the Chat input; it persists across turns until explicitly cleared from either pane
|
||||
- **AI TOOL Chat — image-to-image (I2I) generation** — when an image is attached and the `generate_image` tool is invoked, the attachment is uploaded and swapped into the target workflow's `LoadImage` node instead of running text-to-image; the `generate_image` tool description was updated so the LLM knows attaching an image switches it to I2I
|
||||
- **AI TOOL Settings — Chat I2I Generation dedicated workflow** — same "Use dedicated workflow" pattern as the existing T2I setting, but independent and specific to I2I generation
|
||||
- **`comfyui-editor.js` — `applyImageToSlot()` now supports `opts.workflow`/`opts.analysis`** — mirrors the existing pattern in `applyImageAndMaskToSlot()`; lets callers upload+swap an image into a cloned workflow without touching GenerateUI's displayed state (used by the new Chat I2I bridge)
|
||||
- **Chat bubble image sizing** — the attached-image echo shown in the user's own message is now a small thumbnail (max 120px) instead of filling the bubble; generated result images are now capped at 20% width instead of 100%
|
||||
- **Recommended models** — Gemma 4:e4b, Qwen 3.5:9b, and Ministral 3:3b (Ollama) verified to work well for both tool calling and vision in the Chat pane
|
||||
|
||||
### v0.3.73
|
||||
|
||||
- **AI TOOL Chat — image generation via Tool Calling** — if the selected Ollama/LM Studio model supports tool calling, asking Chat for an image (e.g. "generate a picture of a cat") invokes a `generate_image` tool and runs the request through the same generation pipeline as the GenerateUI Generate button (`window._wfmGenerateTab.generate()`); the result is shown inline in the chat as an image bubble; the conversation is not sent back to the LLM after generation
|
||||
- **AI TOOL Chat — "Allow image generation" toggle** — checkbox next to the Send/Clear buttons (default on, persisted to `wfm_ai_settings`); when off, the `generate_image` tool is not offered to the LLM at all
|
||||
- **AI TOOL Settings — Chat Image Generation dedicated workflow** — "Use dedicated workflow" checkbox + saved-workflow picker; when off (default), Chat-triggered generation uses the workflow currently loaded in GenerateUI; when on, it loads the selected saved workflow fresh each time via `/api/wfm/workflows/raw`, without touching what's shown in GenerateUI (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings — Default Checkpoint** — optional checkbox to force every checkpoint loader node (`CheckpointLoaderSimple` / `CheckpointLoader` / `ImageMetadataPromptLoader`) to a chosen model whenever a workflow is loaded into GenerateUI; a safeguard for environments with many saved workflows referencing models that were later moved/renamed
|
||||
- **Bug fix — silent checkpoint substitution now surfaced** — `convertUiToApi()`'s COMBO-value fallback (originally added to tolerate drifting Impact Pack wildcard options) was silently replacing *any* unrecognized `ckpt_name` with the first entry in the installed-checkpoints list, making the Model tab show an unrelated model with no indication anything was wrong; workflow loading now detects this specific case and shows a warning toast naming the missing checkpoint instead of the usual "workflow loaded" toast
|
||||
- **GenerateUI — return value for programmatic generation** — `_coreGenerate`/`handleGenerate` now return `{images, seed}` on success (previously void); existing callers are unaffected, and this enables features like the new Chat image bridge to retrieve the generated image without scraping the DOM
|
||||
|
||||
### v0.3.72
|
||||
|
||||
- **Comic Creator integration — Inpaint via external entry point** — the Image tab in Comic Creator (`comfyui-comic-creator`) can now trigger Inpaint the same way it already triggers I2I, via `window._wfmReceiveInpaintRequest(imageBlob, maskBlob, params, workflowData?, workflowFilename?)` called across the same-origin iframe boundary; common Inpaint logic was factored out into `_runInpaintWithImages()` and shared by the UI-driven flow and this new external entry point
|
||||
- **Bug fix — cross-realm Blob detection** — images/masks generated in a different window (Comic Creator) failed to load (`[object Blob]` was interpreted as a URL string, causing a 404) because `instanceof Blob` checks fail across window realms; switched to a `typeof src !== "string"` check
|
||||
- Hidden mask layers are now excluded from Inpaint in both the UI-driven and external-call paths
|
||||
|
||||
### v0.3.71
|
||||
|
||||
- **Image Edit — Inpaint tool (🩹)** — new tool composites the canvas (all non-mask layers) plus the active mask layer, uploads to an inpaint-capable LoadImage node (or a Mask Editor One node) in the GenerateUI workflow, applies Positive/Negative prompts, Grow Mask By, and Denoise, then triggers generation — all without leaving the Image Edit tab; the result is shown in the Properties pane
|
||||
- **Image Edit — Inpaint: dedicated workflow** — "Use dedicated workflow" checkbox lets you pick a saved workflow to run the inpaint against instead of the one currently loaded in GenerateUI, without disturbing what's shown there
|
||||
- **Image Edit — Inpaint: Mask Editor One support** — workflows built around the `comfyui-mask-editor-one` custom node (no LoadImage) are also supported; image/mask are pushed via its node_id-keyed server cache and `layer_data` widget
|
||||
- **GenerateUI — Input/Image tab mask slots** — LoadImage nodes whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) now show an additional Mask drop zone; image + mask are composited into a single RGBA upload matching ComfyUI's native alpha→MASK extraction
|
||||
- New example workflows: `ws_inpaint_basic.json`, `ws_inpaint_mask_editor_one.json`
|
||||
|
||||
### v0.3.70
|
||||
|
||||
- **Workflow AI summary — Ollama 404 fix** — the summarize button in the workflow detail modal now uses the Ollama URL/model saved in Settings → AI Assistant Settings (previously it silently fell back to a hardcoded default model tag that didn't match locally installed models, causing a 404)
|
||||
- **Image Edit — New button auto-adds Layer 1** — creating a blank canvas via the New button now automatically adds an empty "Layer 1", matching the existing image-load behavior
|
||||
- **Image Edit — mask overlay shown only for the active mask layer** — previously every visible mask layer's overlay was rendered regardless of selection; now only the currently active mask layer shows its overlay, other mask layers stay hidden until selected
|
||||
- **GenerateUI — Revert button for Positive/Negative Prompt** — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value
|
||||
- **GenerateUI — Ctrl+↑/↓ prompt emphasis weight editing** — A1111 / native ComfyUI CLIP Text Encode style: select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05; parentheses are removed automatically when the weight returns to 1.0
|
||||
|
||||
### v0.3.69
|
||||
|
||||
- **Gallery — Send CC button** — new toolbar button, shown only when the Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay via its existing `insertImageFromUrl(url)` helper (no changes required on the Comic Creator side)
|
||||
- **Lora batch — validation fix** — fixed a `Prompt outputs failed validation` error where LoRA files that actually existed were rejected by the standard `LoraLoader` node; the root cause was a path-separator mismatch (ComfyUI on Windows returns `\`-separated enum values while WFS tracked LoRAs internally with `/`); a shared `resolveLoraName()` helper now converts to ComfyUI's actual notation before applying, used consistently by Lora Single apply, Stack apply, the Models tab GenUI Model button, and Lora batch generation
|
||||
- **Lora batch — pre-flight check** — before running a batch on a standard `LoraLoader` workflow, selected LoRAs are checked against ComfyUI's current model list; if any are not yet recognized, a warning toast suggests a Refresh or ComfyUI restart (skipped for Lora Manager–based workflows, which have no enum constraint)
|
||||
- **Lora batch — automatic prompt sync** — for each LoRA applied during a batch, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt, rebuilt from the original prompt each time (not accumulated); the prompt is automatically restored to its pre-batch state when the batch completes, is stopped, or errors
|
||||
- **Models tab — Close button in detail modal** — added a Close button at the right end of the Save / GenUI PP·NP / Delete button row in the model detail modal (all sub-tabs), reducing mouse travel to the top-right ✕ during repeated batch edits
|
||||
- **Help** — new help entry (`helpGen19`) added in EN / JA / ZH describing the Lora batch prompt sync, restore, and pre-flight check behavior
|
||||
|
||||
### v0.3.68
|
||||
|
||||
- **Image Edit — MASK EDITOR ONE section always visible** — the ABR brush section in the Draw tool's (`MASK EDITOR ONE (COLOR)`) and Mask Paint mode's (`MASK EDITOR ONE`) Properties pane is now always shown instead of being hidden when no brushes are imported; the header text turns green when at least one ABR brush is available and gray when none are imported yet; the Select button is disabled until a brush exists
|
||||
- **Help — MASK EDITOR ONE status** — updated `helpImageEdit5` (Draw tool) and `helpImageEditMask4` (Mask Paint mode) in EN / JA / ZH to describe the always-visible section and its color-coded availability state
|
||||
|
||||
### v0.3.67
|
||||
|
||||
- **GenerateUI — Alt+Click Apply & Generate** — Alt+Click any Apply button to apply the parameter and immediately queue a generation in one step; applies to Positive Prompt Apply (Input tab), all model Apply buttons (Model tab: Checkpoint / VAE / Diffusion Model / ControlNet / Hypernetwork), and KSampler Apply (Settings tab); only triggers generation when the target node exists in the loaded workflow (no action on missing nodes); each Apply button shows a tooltip `Apply (Alt+Click: Apply & Generate)` on hover
|
||||
- **Help — Alt+Click Apply & Generate** — new help entry (`helpGen18`) added in EN / JA / ZH
|
||||
|
||||
### v0.3.66
|
||||
|
||||
- **Image Edit — Mask Tool: 5 new sub-tools** — Paint / Color / Alpha / Text / Vector / Shape / SAM3 (7 sub-tools total); Color, Alpha, Text, Vector, and Shape work without Mask Editor One
|
||||
- **Color** — click the canvas to flood-select pixels similar in color to the clicked point; Properties pane: Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100)
|
||||
- **Alpha** — extract the active layer's alpha channel as a mask; Properties pane: Threshold, Invert checkbox, Extract Alpha button
|
||||
- **Text** — click to open a stamp overlay; enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), confirm with Stamp or Ctrl+Enter; multi-line text supported
|
||||
- **Vector** — click to add Catmull-Rom spline control points; Enter (or click the first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase)
|
||||
- **Shape** — drag to draw a rectangle or ellipse; hold Shift for a perfect square/circle; blue dashed outline preview while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)
|
||||
- **Help — Support page** — added **ComfyUI Image Feeder** and **Mask Editor One** GitHub repository links to the Support card
|
||||
|
||||
### v0.3.65
|
||||
|
||||
- **Image Edit — Mask Editor One integration fixes** — `store_image` API field mismatch (`image_b64` → `bg_image_b64`) corrected so BiRefNet and SAM3 no longer report "no image available"; `server.py` updated to accept both field names for backward compatibility
|
||||
- **Image Edit — top bar icon persistence** — WFS / Snapshot / Node Sets SVG icons in the ComfyUI action bar disappeared when the Properties Panel was opened (Vue re-render reset custom `innerHTML` to `<i>`); fixed with a `MutationObserver` that re-applies SVG icons whenever a reset is detected
|
||||
- **Image Edit — Mask Tool: ABR brush library** (requires Mask Editor One) — Select button in the Properties pane opens a folder-tree + thumbnail-grid brush picker; stamp brushes are loaded from Mask Editor One's `/mask_editor/brushes/` folder; ✕ clears back to the default circle brush
|
||||
- **Image Edit — Mask Tool: ABR brush controls** — when an image brush is active, Hardness is disabled and new controls appear in the Properties pane: Spacing (stamp interval 5–100%), Angle (fixed rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random angle per stamp on/off); size jitter clamped to minimum 1 px to prevent zero-dimension canvas errors
|
||||
- **Image Edit — Mask brush cursor** — brush size circle is now hidden outside the canvas; only shown when the pointer is within the canvas bounds (window-level mousemove no longer shows the cursor overlay outside)
|
||||
|
||||
### v0.3.64
|
||||
|
||||
- **Image Edit — Mask layer Add/Subtract** — A/S toggle button on each mask layer row; Add (green, default) composites the mask normally; Subtract (red) cuts the mask area from the layer below; multiple mask layers are composited back-to-front matching Mask Editor One's CanvasCompositor logic
|
||||
- **Image Edit — BiRefNet BG Remove** (requires Mask Editor One + `birefnet.safetensors` in `ComfyUI/models/background_removal/`) — BiRefNet model now fully functional via Mask Editor One's Python backend; grayscale mask returned as RGBA PNG and applied as a new layer or in-place per the New Layer toggle
|
||||
- **Image Edit — SAM3 segmentation** (requires Mask Editor One) — new SAM3 sub-tool in the Mask tool options bar; enter a text prompt, set Max candidates (3/6/9/12), click Segment; candidate mask thumbnails shown in the Properties pane; click to select (multi-select), Apply Selected (N) applies all chosen masks with Add or Erase mode; new mask layer auto-created if active layer is not a mask
|
||||
|
||||
### v0.3.63
|
||||
|
||||
- **Nodes tab — multi-select** — Ctrl+click to toggle individual nodes, Shift+click for range selection; bulk action bar appears with add to group / remove from group / create & add group / bulk favorite / unfavorite; works in both Card and Table view modes
|
||||
- **All tabs — search clear (✕)** — inline ✕ overlay button inside each search box (Workflow / Nodes / Models / Gallery); shown only when text is entered, click to clear and re-filter immediately
|
||||
- **All tabs — clear all filters button** — new ✕ Clear button at the rightmost end of each tab's toolbar; resets all active filters (search, group, tag, folder, favorites, badge) in one click; Gallery sort order is preserved
|
||||
|
||||
### v0.3.62
|
||||
|
||||
- **Gallery tab — Send GenUI Image button** — new toolbar button (next to Image Edit); uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; `comfyEditor.applyImageToSlot(file, slotIndex)` added to `comfyui-editor.js` for reusable image-slot injection
|
||||
- **Bug fix: tagger_settings.json missing from data export** — `tagger_settings.json` was absent from `_DATA_FILES` in `settings_routes.py`; Tagger model directory and threshold settings were silently excluded from Settings → Data Management export/import; fixed by adding the file to the export list
|
||||
- **Help — Send GenUI Image** — new help card (`helpGallery17`) added in EN/JA/ZH; registered in `helpIdMap`
|
||||
- **Screenshots — Tagger and Image Edit tabs** — `docs/12_Tagger.png` and `docs/13_ImageEdit.png` added to the README screenshots section
|
||||
|
||||
### v0.3.61
|
||||
|
||||
- **Image Edit — Draw / Mask brush cursor** — cursor replaced with a size-accurate circle overlay matching the current brush size; the circle scales correctly with canvas zoom so the visible area matches exactly what will be painted
|
||||
- **Image Edit — Draw / Mask out-of-canvas strokes** — strokes can now start from the canvas margin area (outside the image boundary) and continue past the edge without interruption; `window`-level `mousemove` / `mouseup` tracking keeps the stroke alive when the pointer leaves the canvas element
|
||||
- **G'MIC — argument order fix** — corrected `gmic_qt.exe` launch arguments to `-o <output> <input>` (the only order G'MIC-Qt Standalone accepts); the previous order caused the GUI to exit immediately without opening
|
||||
- **G'MIC — settings path placeholder** — replaced the hard-coded user-specific default path with a generic `C:\path\to\gmic_qt.exe` placeholder; settings load without a pre-filled path for new users
|
||||
- **G'MIC — security: path traversal fix** — `POST /api/wfm/gmic/result` now validates that `result_path` is inside the `gmic_temp` directory before reading the file; requests for paths outside that directory are rejected with 403
|
||||
- **Help — G'MIC Filter section** — added two new help cards (G'MIC Filter usage and G'MIC-Qt Installation) with full i18n support (English / Japanese / Chinese)
|
||||
|
||||
### v0.3.60
|
||||
|
||||
- **Image Edit — Filter tool (G'MIC-Qt integration)** — the previously inactive ★ Filter tool now launches G'MIC-Qt GUI with the active layer image; select a filter and click OK, then close the "G'MIC-Qt filter output" window to apply the result back as the active layer; polling-based job system with progress display, Cancel button, and Undo support; requires G'MIC-Qt Standalone (`gmic_qt.exe`) configured in Settings
|
||||
- **Settings — G'MIC-Qt Executable Path** — new collapsible section in Settings for entering and saving the path to `gmic_qt.exe`
|
||||
|
||||
### v0.3.59
|
||||
|
||||
- **Image Edit — Mask Tool** — new Mask tool (🎭) in the Image Edit sidebar; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the layer list also switches to the Mask tool; **Tool Options bar**: Paint button, Invert checkbox (inverts overlay preview), Overlay color picker (default red), Blur slider (0–50 px) for overlay edge softening; **Properties pane** (left of the Layers panel, shown when Mask tool is active): Size (1–200 px), Hardness (0–100%), Mode — **Add** / **Erase** toggle buttons (active mode highlighted in blue)
|
||||
- **Image Edit — Clipping mask (✂ button)** — each mask layer row shows a ✂ toggle; when enabled (blue), the mask clips the layer directly below it so only the painted area is visible; clipping is applied to PNG save / Save to Gallery / Send to ComfyUI exports
|
||||
- **Image Edit — Draw & Mask paint bugfix** — Draw and Mask tools now paint directly onto `layer.canvas`; all other layers remain fully visible during brush strokes (previously the composite view was replaced by the active layer only while painting)
|
||||
- **Image Edit — Layer panel width** — increased from 190 px to 220 px to accommodate the additional ✂ button on mask layer rows
|
||||
- **Help — Mask Tool section** — added Mask Tool card with full i18n support (English / Japanese / Chinese); updated Tools list, layer type icon list, and Layer panel "M button" description
|
||||
|
||||
### v0.3.58
|
||||
|
||||
- **GenerateUI Model tab — Diffusion Model GGUF support** — `LoaderGGUF` and `LoaderGGUFAdvanced` nodes are now recognized alongside `UNETLoader` / `UnetLoaderGGUF`; the correct input key (`gguf_name` vs `unet_name`) is selected automatically on Apply; the Models tab "GenUI Model" button also applies correctly to `LoaderGGUF` workflows
|
||||
- **GenerateUI Model tab — Text Encoder expanded (CLIPLoader / DualCLIPLoader / GGUF variants)** — the Text Encoder section now renders a node-type-aware UI: **single CLIP** (`CLIPLoader` / `ClipLoaderGGUF`) shows one clip selector + **type** dropdown + **device** (GGUF only); **Dual CLIP** (`DualCLIPLoader` / `DualClipLoaderGGUF`) shows two clip selectors (clip_name1 / clip_name2) + **type** dropdown + **device** (GGUF only); type options are fetched dynamically from `/object_info`; filter applies to the first selector only
|
||||
|
||||
### v0.3.57
|
||||
|
||||
- **Image Edit — Blur tool** — new Blur tool (≈) in the Image Edit sidebar; **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: toggle to enter rect-draw mode then drag to select a region — blur or mosaic is applied on mouse release; Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo (Ctrl+Z)
|
||||
- **Image Edit — BG Remove tool** — new BG Remove tool (⬚); **Lightweight (@imgly)**: background removal running entirely in the browser via CDN (~40 MB model cached after first load); **BiRefNet**: stub wired to a future Python backend endpoint (coming soon); **New Layer** checkbox (default on) adds the result as a new layer above the original — uncheck to replace the active layer in-place; supports Undo
|
||||
- **Image Edit — sidebar icons** — removed `ie-tool-placeholder` dimming from Blur and BG Remove buttons; both now display at full brightness matching other active tools
|
||||
- **Help — Blur / BG Remove sections** — added two new help cards (Blur Tool and BG Remove Tool) with full i18n support (English / Japanese / Chinese); Tools list updated with Blur and BG Remove overview items
|
||||
|
||||
### v0.3.56
|
||||
|
||||
- **GenerateUI — Style selector** — new checkbox and dropdown next to the Reset Workflow button; when enabled, a Fooocus-style JSON style is applied to positive and negative prompts at generation time; style files (`*.json`) are read from `user/default/Workflow-Studio/style/`; multiple files are merged into a single list sorted by filename; the style's `{prompt}` placeholder is replaced with the original prompt text, otherwise the style text is appended; `negative_prompt` is appended to the existing negative prompt; styles are applied to a per-generation workflow copy and never modify the loaded workflow
|
||||
- **Batch tab — Style batch** — new **Style** tab in the Batch left pane lists all styles loaded from the style directory (flat list with All / None buttons); select any combination, enable the **Style** column in Batch Queue, then Generate to run each selected style sequentially; Batch Queue expanded from 6 to 7 columns (Style added as the rightmost column)
|
||||
|
||||
### v0.3.55
|
||||
|
||||
- **Group feature — fix data loss on Windows (backslash/slash mismatch)** — on Windows, ComfyUI returns model paths with backslashes (`sub\model.safetensors`) while the backend normalized to forward slashes; this caused all subdir group members to be flagged as missing and deleted on save; fixed by normalizing both stored and scanned paths to forward slashes in `get_model_groups` and `save_model_groups`
|
||||
- **Group feature — network drive protection** — if no model directories are accessible (e.g. external drive disconnected), the group cleanup step is now skipped entirely to prevent wiping all group memberships
|
||||
- **Group feature — scan cache** — `_scan_model_names` now caches results for 30 seconds to eliminate redundant full directory scans on startup (caused high CPU load / crash on large model sets)
|
||||
- **Group feature — RESERVED_GROUPS protection** — `bulkRemoveFromGroup` and `renderPmGroups` now prevent Batch/Stack reserved group keys from being deleted even when all members are removed
|
||||
- **Group feature — stale groupFilter reset** — after deleting a group, `state.groupFilter` is automatically cleared if it pointed to the now-deleted group (previously showed 0 models)
|
||||
- **Group feature — toggleGroupEnable partial failure** — only models listed in `data.ok` are updated client-side, preventing state divergence when some enable/disable calls fail
|
||||
- **GenerateUI batch groups — restore Batch/Stack visibility** — reverts an incorrect filter that hid Batch and Stack groups from the batch group selection UI; these groups are now selectable for batch execution as intended
|
||||
- **GenerateUI LoRA groups — path normalization** — backslash-to-slash normalization added after fetching LoRA groups from the API, matching the Checkpoint fix
|
||||
- **Batch complete toast — i18n** — replaced hardcoded English toast with `t("batchComplete", ...)` supporting all 3 languages
|
||||
- **Prompt tab migration — fix negText loss** — `migrateLocalStoragePresets` now correctly passes `negText` when calling `apiCreatePreset`, preventing negative prompts from being silently dropped during localStorage→API migration
|
||||
- **Help tab — Image Edit section i18n** — the Image Edit help page was hardcoded in English with no language switching support; all 6 sections (30 items + 7 headings) now have i18n IDs wired to English / Japanese / Chinese translations
|
||||
|
||||
### v0.3.54
|
||||
|
||||
- **Image Edit — Shape tool (S)** — new Shape tool added to the Image Edit toolbar; draw Rect, Ellipse, Line, and FreeLine shapes by dragging; each committed shape becomes an independent draw layer that can be undone in a single step; Tool Options: shape type selector, Rounded toggle (Rect/Ellipse only), Fill color + None toggle (hidden for Line/FreeLine), Stroke color + width (Stroke None hidden for Line/FreeLine where stroke is always active), Opacity slider, Undo button; blue dashed outline previews the shape during drag; keyboard shortcut **S** activates the tool
|
||||
- **Models tab — fix B/S overlay buttons not highlighting** — the Batch (B) and Stack (S) card overlay buttons failed to turn yellow after clicking because `e.currentTarget` is nulled by the browser after an `async` event handler yields at `await`; fixed by capturing `const btn = e.currentTarget` before the await in both handlers
|
||||
|
||||
### v0.3.53
|
||||
|
||||
- **Image Edit Tab** — new layer-based image editing tab with Select / Draw / Text tools, multi-layer compositing, canvas zoom & pan, and Undo/Redo; text layers are sized to exact bounding-box dimensions and regenerated at display resolution on resize to prevent blurring; double-click a text layer to re-edit its content; Gallery tab gains an "Image Edit" toolbar button to send the selected image directly to the editor
|
||||
- **Image Edit — Layer lock** — lock icon (🔒/🔓) on each layer row; locked layers display an orange bounding box and ignore all Select-tool transforms; Layer 1 is auto-locked when the first image is loaded to prevent accidental moves
|
||||
- **Image Edit — Save to Gallery** — "Save to Gallery" button (next to Save PNG) saves the composite image to the Gallery root folder; filename defaults to `wfs-image-YYYYMMDDHHmmss`; backed by new `POST /wfm/gallery/image/save` API endpoint
|
||||
- **Help tab — Image Edit page** — new dedicated help page covering tools, layer panel, text quality, export options, and keyboard shortcuts
|
||||
|
||||
### v0.3.52
|
||||
|
||||
- **GenerateUI Model tab — fix LoRA selection reset when switching subtabs** — applying a LoRA via "GenUI Model" button and then switching to another subTab (Prompt/Image/Settings) and back to Model caused the LoRA selector, syntax display, and trigger words to revert to the original workflow values; fixed by reading `currentVal` directly from `currentWorkflow` instead of the stale `currentAnalysis`, and by saving/restoring the Single tab's syntax and trigger words across re-renders
|
||||
|
||||
### v0.3.51
|
||||
|
||||
- **Models tab — Embedding type: GenUI PP / GenUI NP buttons** — Embedding models now show two buttons ("GenUI PP" and "GenUI NP") in the side panel nav, detail side panel, and detail modal (replacing the single "GenUI Model" button); clicking either appends `(embedding:Name:1.0)` to the Positive or Negative prompt of the loaded workflow
|
||||
- **GenerateUI Prompt tab — Embeddings selector** — new section at the bottom of the Prompt tab: Filter input, model select dropdown, Weight input (default 1.0), and Paste button; Paste inserts `(embedding:Stem:weight)` at the cursor position of the last focused textarea (Positive or Negative); focus state is tracked on click/keyup/blur so the button works even after clicking away
|
||||
- **GenerateUI Model tab — Hypernetwork support** — Hypernetwork section added with model selector (filter) and Strength field; applies to `HypernetworkLoader` nodes in the workflow; GenUI Model button in Models tab also triggers Hypernetwork apply
|
||||
- **Models tab — fix Embedding and Hypernetwork models not shown** — `_fetchModelList` now handles the ComfyUI V3 node format (`options` key) in addition to V1/V2 formats; `fetchEmbeddings` switched from the `/embeddings` ComfyUI endpoint to the WFS backend API (`/api/wfm/models/files?type=embedding`) to avoid SPA URL routing conflicts
|
||||
- **Models tab — GenUI Model button in side panel nav** — "GenUI Model" button added to the tab navigation bar (Info / Group / CivitAI) of the side panel; appears for supported model types (Checkpoint, LoRA, VAE, etc.) and hides for unsupported types; Embedding type shows PP/NP nav buttons instead
|
||||
|
||||
### v0.3.50
|
||||
|
||||
- **Gallery tab — fix prompt search causing ComfyUI hang under large folders** — the v0.3.49 on-demand metadata read ran synchronously on the aiohttp event loop, blocking ComfyUI when folders contained many uncached images; fixed by: (1) removing on-demand reads from `list_images()`; (2) wrapping `list_images()` in `asyncio.to_thread()` to keep the event loop free; (3) adding a background indexer (`start_background_index`) that automatically indexes uncached images after a folder is loaded — processes 10 images per batch with a 50 ms sleep between batches; cancellable on folder switch
|
||||
|
||||
### v0.3.49
|
||||
|
||||
- **GenerateUI tab — A1111-style SPA-side wildcard expansion** — when prompt text in non-ImpactWildcard nodes (e.g. CLIPTextEncode) contains `__name__` syntax, the SPA expands it at generation time by randomly picking a line from the matching file in the WFS wildcard folder; nested wildcards are re-expanded up to 5 passes; unresolved wildcards are passed through unchanged; ImpactWildcardEncode/Processor nodes are skipped (handled server-side by Impact Pack); works in both single and batch generation
|
||||
- **Gallery tab — fix Export button missing label** — the bulk action bar's Export button had no text label because the `textContent` assignment was missing from `updateUITexts()`; fixed by adding the `galleryBulkExport` i18n key assignment alongside the existing Move/Delete entries
|
||||
- **Gallery tab — fix prompt search not matching images with no cached index** — `prompt_cache` was only built when an image was opened in the detail panel; A1111 images (or any images never opened) were invisible to prompt search; now when search is active and `prompt_cache` is empty, the image file is read on-demand and the cache is populated and persisted for future searches
|
||||
|
||||
### v0.3.48
|
||||
|
||||
- **Gallery tab — single image download** — hover over a detail-panel preview image to reveal a download overlay (⬇ icon); click to download the original file with its original filename preserved
|
||||
- **Gallery tab — bulk export to ZIP** — multiple selected images can be exported as a ZIP archive via the "Export" button in the bulk action bar; filename: `gallery_export_<timestamp>.zip`; backend: `POST /wfm/gallery/images/export-zip` with path-traversal protection
|
||||
|
||||
### v0.3.47
|
||||
|
||||
- **Models tab — fix stale group state after file move** — when models were moved to a subdirectory via "Move To...", `state.modelGroups` in the JS client was not updated to reflect the new model paths; the group filter view would lose the moved models immediately after the operation. Now the client-side group membership is updated in sync with the server-side rename (mirrors the existing Python `move_models` logic). Generate tab Batch groups are unaffected as they always fetch fresh from the server.
|
||||
|
||||
### v0.3.46
|
||||
|
||||
- **Prompt tab AI Assistant — LM Studio support** — switched from Python proxy (`/api/wfm/ollama/*`) to direct browser-to-backend API calls (same pattern as the AI TOOL tab); supports Ollama (`/api/chat`) and LM Studio (`/v1/chat/completions`); image attachments are automatically converted to the correct format per backend
|
||||
- **Settings tab — AI Assistant Settings (Prompt Tab)** — renamed from "Ollama Settings"; added backend selector (Ollama / LM Studio), URL input with per-backend placeholder, model select with Refresh, and Test button; settings saved to localStorage `wfm_prompt_ai_settings` (independent of AI TOOL tab settings)
|
||||
- **Auto-refresh on backend change** — if the backend is switched in Settings tab while Prompt tab is already open, `sendMessage()` now detects the mismatch and automatically re-fetches the model list before sending; model status shows the active backend name (`[ollama]` / `[lmstudio]`)
|
||||
- **Help text updated** (EN/JA/ZH) — Prompt tab, Settings tab, and Troubleshooting sections updated to reflect Ollama / LM Studio dual-backend support
|
||||
|
||||
### v0.3.45
|
||||
|
||||
- **Group auto-cleanup — Models tab** — stale group entries (models that no longer exist on disk) are automatically removed when the Models tab is loaded or the model type is switched; models moved within the app via "Move To..." also update their group membership immediately
|
||||
- **Group auto-cleanup — Gallery tab (folder view)** — metadata entries for missing files are cleaned up each time a folder is scanned (`list_images`), synchronized with the 60s TTL + folder-mtime cache
|
||||
- **Group auto-cleanup — Gallery tab (group image list)** — `list_images_in_group` (used by the Feeder tab Gallery mode) now filters out non-existent paths and removes them from the group in a single write operation
|
||||
- **Gallery tab — Shift+click range selection** — Shift+click selects all images between the last clicked item (anchor) and the current item; works in both thumbnail and table views; anchor is updated on Ctrl+click and regular click; resets on folder change, image reload, and Deselect All; help text updated (EN/JA/ZH)
|
||||
|
||||
### v0.3.44
|
||||
|
||||
- **Gallery tab — server-side thumbnail generation** — `GET /wfm/gallery/image/thumb?path=...&w=256` endpoint generates 256px JPEG thumbnails via Pillow and caches them to `data/thumb_cache/` keyed by `md5(path:mtime:width)`; GIF served as-is to preserve animation; falls back to the original file if Pillow is unavailable; thumbnail view and table view both switched from `/image/serve` to `/image/thumb`
|
||||
- **Gallery tab — infinite-scroll paging** — thumbnail view renders 50 images initially; an IntersectionObserver sentinel (rootMargin 300px) appends the next page on scroll; sentinel is disconnected and removed when all images are rendered; paging resets on every `renderImages()` call (folder change, filter, sort)
|
||||
- **Gallery tab — bulk API endpoints** — `POST /wfm/gallery/bulk/favorite` and `POST /wfm/gallery/bulk/group` handle favorite set and group add/remove for any number of images in a single request; replaced N-parallel individual fetches in `bulkSetFavorite()` and `bulkAddToGroup()`
|
||||
- **Gallery tab — image compare mode** — Ctrl+click 2–4 images, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; CSS grid adapts to the image count via `--compare-cols` custom property; close button is `position:fixed` at the top-right corner to avoid `overflow:auto` clipping
|
||||
- **Gallery tab — "Remove from Group" bulk button** — new button in the Bulk Bar (right of "Add to Group"); uses the same group selector dropdown; calls `POST /wfm/gallery/bulk/group` with `action:"remove"`
|
||||
- **Gallery tab — bulk bar UI labels** — dropdown placeholder "Add to Group..." → "Select Group"; "Add" button → "Add to Group"; "Delete" button → "Delete File" (distinguishes file deletion from group removal)
|
||||
- **Gallery tab — prompt search** — text search now covers A1111 `parameters` text embedded in PNG/JPEG metadata; prompt text is extracted on first detail-panel open and stored as `prompt_cache` in `metadata.json`; subsequent `list_images` searches include `prompt_cache` without re-reading image files
|
||||
- **Help tab — Gallery mode i18n** — Feeder subtab Gallery mode section (h4, description, 6 bullet points) now translated to Japanese and Chinese via `i18n.js`
|
||||
- **Help tab — font size ×1.5** — nav items, headings, body text, link cards, and support card text all scaled up in `main.css`
|
||||
- **Help tab — search box** — live search input above the nav sidebar filters matching pages and auto-clicks the first result; placeholder text localized (EN/JA/ZH)
|
||||
|
||||
### v0.3.43
|
||||
|
||||
- **Send to Canvas — direct canvas load via `window.opener`** — clicking "Send to Canvas" (Workflow tab) or "Copy & Send Canvas" (Gallery tab) now sends the workflow directly to the ComfyUI canvas without requiring a title drag; uses `window.opener.wfmReceiveWorkflow()` registered by `node_sets_menu.js` via `app.handleFile()`; both UI and API formats are supported; fallback to localStorage + title drag when `window.opener` is unavailable (e.g. opened from a bookmark), UI-format only
|
||||
- **Workflow Studio Library — API-format DnD enabled** — API-format workflows in the W (Workflows) sub-tab are no longer grayed out; drag & drop and double-click now work for all formats via `app.handleFile()`
|
||||
|
||||
### v0.3.42
|
||||
|
||||
- **Feeder tab — Gallery mode** — new [Image Loop] / [Gallery] toggle in the Feeder tab; Gallery mode drives a WFS_GalleryFeeder node without any external plugin: node selector, group select, sort mode / index / seed, After Gen (Loop / Increment / Fixed), Run / Stop; center pane shows the selected group's images — click any image to jump to that index; `__Feeder__` group is auto-created on first open
|
||||
- **WFS_GalleryFeeder custom node** — new ComfyUI node (Workflow Studio category) that reads images from a Gallery group and outputs one `IMAGE` at a time; inputs: `group_name`, `index`, `sort_mode` (filename_asc / filename_desc / random), `seed`; skips missing files; `IS_CHANGED` ensures re-execution on every queue
|
||||
- **WFS_GalleryFeeder — ComfyUI canvas controls** — `gallery_feeder_extension.js` adds After Gen combo (loop / increment / fixed), ▶ Run button, and ■ Stop button as non-serialized widgets directly on the node in the ComfyUI canvas; manages per-node loop state with `waitForExecution()` Promise pattern; `onRemoved` hook auto-stops the loop on node deletion
|
||||
- **Gallery tab — thumbnail F button** — cyan overlay button (top-left of each image card) toggles `__Feeder__` group membership; active state is always visible in cyan, inactive shows only on hover; initial state derived from `img.groups` returned by `list_images()`
|
||||
- **Gallery tab — `__Feeder__` reserved group** — shown with 🔒 prefix in group dropdowns; rename and delete blocked server-side (403) and client-side (buttons disabled); **FC** toolbar button clears all group members without deleting the group
|
||||
- **Security: XSS fixes in `gallery-tab.js`** — tag names, filenames, and server error messages now escaped via `escapeHtml()` before insertion into `innerHTML` (tag badge list, detail panel `alt` attributes, lightbox footer, folder tree errors, grid errors); `util.js` `escapeHtml` updated to also escape single quotes (`'` → `'`)
|
||||
- **Security: path validation in `gallery_service.py`** — `save_image_meta`, `toggle_favorite`, `add_to_group`, `remove_from_group` now call `_check_path_allowed()` before any file operation; previously these four methods were missing the guard that other write methods already had
|
||||
- **Security: `_init_allowed_root()` in `gallery_routes.py`** — `_allowed_root` is now initialized at module load from saved `gallery_output_dir` (falls back to ComfyUI default output dir); prevents `list_folder_tree` from overriding `_allowed_root` via a crafted `?root=` request, and also fixes serve_image returning 404 in Feeder Gallery mode before the Gallery tab is opened
|
||||
- **Bug fix: WFS_GalleryFeeder seed validation error** — seed `max` changed from `0x7FFFFFFF` to `0xffffffffffffffff` (matching KSampler); `applySeedToWorkflow()` was writing values exceeding the old max, causing `value_bigger_than_max` validation failures
|
||||
|
||||
### v0.3.41
|
||||
|
||||
- **Generate UI — ImpactWildcardEncode / ImpactWildcardProcessor prompt detection** — fixed: wildcard text inputs from comfy-impact-pack nodes were not shown in the Prompt tab of Generate UI
|
||||
- `CLIPTextEncodeEditPlus` node (used as the text encoder in these workflows) now correctly propagates positive/negative roles upstream to ImpactWildcard nodes via BFS
|
||||
- `CLIPTextEncodeEditPlus` with an empty `text_edit` field is no longer added to `prompt_nodes` ahead of the upstream ImpactWildcard node, preventing a blank text area
|
||||
- `widgets[0]` is now always written to `wildcard_text` in the API conversion step, regardless of `object_info` widget-order differences between impact-pack versions
|
||||
- Static fallback mapping added for `ImpactWildcardProcessor` and `ImpactWildcardEncode` so detection works even when the ComfyUI server is offline
|
||||
|
||||
### v0.3.40
|
||||
|
||||
- **AI TOOL tab — Chat pane** — new pane (between Translation and TOOLS) for multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio uses `/v1/chat/completions`
|
||||
- **AI TOOL tab — Wildcard generation** — new "Create wildcards" task in the TOOLS pane dropdown; enter a category name and count, click Run to generate plain-text entries one per line (no markdown, no numbering); output can be pasted directly into wildcard `.txt` files
|
||||
- **Library A tab — Chat and Wildcards** — both features added to the side panel's A tab: Chat as a new sub-tab (Translation → Chat → TOOLS → Settings), wildcard generation as a new option in the TOOLS sub-tab dropdown
|
||||
- **Help updated** — AI TOOL tab help card updated to 6 items (Chat and Wildcards added); Library panel sidepanel-16 updated; EN/JA/ZH
|
||||
|
||||
### v0.3.39
|
||||
|
||||
- **Workflow tab — Send to Canvas button** — replaced "Open in ComfyUI" (toolbar and detail modal) with "Send to Canvas"; stores the selected workflow in `localStorage` for title-drag to the canvas without opening a new browser tab
|
||||
- **Gallery tab — Copy & Send Canvas button** — renamed "Copy Workflow" to "Copy & Send Canvas"; copies the embedded workflow JSON to the clipboard and simultaneously stores it for title-drag
|
||||
- **Workflow Studio Library — title drag** — after "Send to Canvas" or "Copy & Send Canvas" is clicked, the "Workflow Studio Library" panel title turns blue with a green ● indicator; drag the title onto the ComfyUI canvas to load the workflow; title resets automatically after loading
|
||||
|
||||
### v0.3.38
|
||||
|
||||
- **Tagger tab** — new tab for automatic image tagging; supports WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and Ollama VLM; 3 sub-tabs: Single (drag & drop / upload, 5 output targets), Batch (folder processing with real-time progress), DB (searchable SQLite database with CSV export)
|
||||
- **Tagger single — GenUI:P button** — new button (left of "Send to Prompt") that appends generated tags to the GenerateUI positive prompt and immediately applies them to the loaded workflow; shows a warning if no workflow is loaded in GenerateUI
|
||||
- **Tagger batch — Write .txt output** — new output option that creates `<filename>.txt` alongside each processed image containing all generated tags (comma-separated)
|
||||
- **Gallery detail panel — Tagger button** — new action button in the Gallery detail panel opens the selected image directly in the Tagger tab for instant single-image tagging
|
||||
- **Gallery detail panel layout** — tabs (Info / JSON / Groups) and action buttons (Metadata / Load GenUI / Tagger) are now on separate rows, preventing button overflow on narrow panels
|
||||
- **Gallery — Save to Gallery tags fix** — tags saved from the Tagger tab were stored as a string instead of an array, causing `forEach is not a function` errors on the next Gallery refresh; now correctly stored as a string array
|
||||
- **GenerateUI — ZIT/Lumina2 workflow prompt fix** — `ConditioningZeroOut` removed from the CONDITIONING passthrough set; previously it propagated the "negative" role upstream through the shared CLIPTextEncode node, marking it as "unknown" and hiding the positive prompt in the GenerateUI Input tab; ZIT (Z-Image Turbo) workflows now correctly show the positive prompt
|
||||
- **Workflow analyzer — lumina2 model type** — CLIPLoader `type: "lumina2"` is now correctly classified as Z-IMAGE in the workflow type detector
|
||||
|
||||
### v0.3.37
|
||||
|
||||
- **Models tab — Select All button** — new button added to the right of "Deselect All" in the multi-select action bar; selects all models currently visible after applying the active search / filter / group filters
|
||||
- **Gallery tab — Select All button** — new button added to the right of "Deselect All" in the bulk action bar; selects all images in the current folder view
|
||||
- **Gallery bulk bar — i18n** — all bulk action bar buttons and the selection count were hard-coded English strings; they now use the `t()` translation system (EN / JA / ZH) so the bar respects the selected UI language
|
||||
|
||||
### v0.3.36
|
||||
|
||||
- **GenerateUI Save — overwrite protection** — saving now checks for an existing file and asks for confirmation before overwriting; if the target file is in UI format, a dedicated warning explains that saving in API format will lose the node layout
|
||||
- **GenerateUI Save — Raw JSON sync** — unapplied Raw JSON edits are now included in the save (parsed and synced back to the editor); invalid JSON aborts the save with an error instead of silently saving stale content
|
||||
- **GenerateUI Save — hardening** — filename is set via DOM (no HTML-attribute injection), image extensions (`.png` etc.) are stripped from the default name, and a double-submit guard prevents duplicate POSTs
|
||||
- **Help i18n fixed** — v0.3.35 help text updates (Save button, table sort, JSON tab rename) were being overwritten at startup by stale i18n strings and never displayed; all help keys synced in EN / JA / ZH and new entries registered in the help ID map
|
||||
- **Toast i18n** — ~115 hard-coded English toasts replaced with translated messages (~60 new shared keys in EN / JA / ZH); Settings labels for Text Size / RAW JSON Colors that displayed raw key names are now properly defined
|
||||
- **Refactoring** — new shared `util.js` (`escapeHtml` unified across 5 files — the Metadata tab copy was missing `"` escaping; `readJsonStorage` / `getSettings` replace 15 direct `localStorage` parses); Models table sort now precomputes sort keys (faster on large libraries); Gallery action buttons moved off the tab-button class, removing all `!important` CSS overrides
|
||||
- **README refreshed** — intro rewritten around the three pillars (Management / GenerateUI / Library side panel); new screenshots: GenUI LoRA Stack, GenUI Batch, Models Multi-select Menu; WS Library image updated
|
||||
|
||||
### v0.3.35
|
||||
|
||||
- **Models tab — Table column sort** — click any column header to sort ascending (▲), click again for descending (▼), click a third time to clear; active column name highlighted in accent color; Enable/Disable column header now labeled **E/D**
|
||||
- **Gallery tab — Load GenUI button** — new button to the right of the Metadata button in the detail panel; loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; warning toast shown if no workflow is embedded or the format is unsupported; Metadata button restyled green, Load GenUI uses primary accent color
|
||||
- **GenerateUI tab — Save button** — new button at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API; workflow name label updates to the saved filename on success
|
||||
|
||||
### v0.3.33
|
||||
|
||||
- **LoRA pane — workflow load no longer overwrites LoraManager** — removed the auto-apply block that replaced `inputs.loras` / `inputs.text` with Stack group contents on every workflow load; Stack is now applied only when the Apply button is pressed
|
||||
- **LoRA pane — 🔄 button no longer resets to Single tab** — active tab state is saved before `innerHTML` rebuild and restored afterwards; staying on Stack tab after refresh
|
||||
- **Models tab — Stack group hidden for non-LoRA types** — when switching to Checkpoint or other non-LoRA types, the Stack group is now excluded from the group filter dropdown and side-panel group management (LoRA-only concept)
|
||||
@@ -299,20 +718,24 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Stack Apply — trigger words now applied correctly on first press** — trigger words (`currentAllTriggers` / `currentActiveTriggers`) are now recomputed at click time from the latest `_stackActive` + `metadata` + `civitaiCache`; previously used a stale snapshot captured at render time, causing trigger words to be missing on the first Apply and to linger after disabling a model
|
||||
|
||||
### v0.3.32
|
||||
|
||||
- **Lora Loader (LoraManager) LoRA detection** — I tab and Metadata tab now correctly detect LoRAs from `Lora Loader (LoraManager)` nodes in API format (`inputs.loras.__value__`); `strength`/`clipStrength` stored as strings (e.g. `"0.20"`) are now parsed correctly
|
||||
- **LoraManager node placement — LoRA syntax** — placing a `Lora Loader (LoraManager)` node via drag now also updates the `text` widget with `<lora:name:strength>` syntax (previously only the `loras` array widget was set)
|
||||
- **M tab Groups — LoRA → LoraManager drop** — LoRA groups in the Library M tab show an **All N LoRAs** item; drag to canvas places a `Lora Loader (LoraManager)` node with all group LoRAs; double-click also places at canvas center
|
||||
- **P tab — Groups sub-tab** — new Groups sub-tab (row 2) in the Library P tab; groups are read from `localStorage["wfm_prompt_preset_groups"]` (shared with Batch); expanding a group shows draggable preset items
|
||||
|
||||
### v0.3.31
|
||||
|
||||
- **LoRA pane — Single/Stack tabs** — LoRA pane split into two tabs: Single (filter, model dropdown, M/C strength, target node, Apply, P, LORA SYNTAX, TRIGGER WORDS) and Stack (target node, Apply, P, Toggle-all, global strength adjuster, LORA SYNTAX, TRIGGER WORDS, model list); each tab has independent strength settings
|
||||
- **GenUI Model — LoRA improvements** — now correctly handles `Lora Loader (LoraManager)` nodes (previously failed to find the node); on apply: switches to Single tab, disables all Stack models, and updates LORA SYNTAX / TRIGGER WORDS in the Single tab panel
|
||||
|
||||
### v0.3.30
|
||||
|
||||
- **LoRA Stack trigger words fix** — inactive stack models no longer show their trigger words in the Trigger words display; both initial render and per-model toggle/toggle-all now filter to active models only
|
||||
- **Help troubleshooting** — added entry for "No such file or directory" error when running LoRA Stack (comfyui-lora-manager compatibility issue)
|
||||
|
||||
### v0.3.29
|
||||
|
||||
- **LoRA section unified** — single LoRA and Stack sections merged into one pane; Apply button handles both cases (stack models present → apply all active + prompt sync; no stack → apply selected single LoRA + prompt sync); Ctrl+Apply removed, now plain Apply by default
|
||||
- **LoRA prompt sync** — Apply inserts `<lora:stem:M:C>` syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by diff (removes stale tokens, re-adds active ones); works for both single LoRA and Stack
|
||||
- **P button** — placed to the right of Apply; immediately writes the current Positive prompt textarea to the target workflow node without switching tabs
|
||||
@@ -331,22 +754,26 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Models thumbnail bug fixes** — preview endpoint now sends `Cache-Control: no-cache`; files under 100 bytes skipped; `.disabled` model files included in preview change lookup
|
||||
|
||||
### v0.3.23
|
||||
|
||||
- **Bug fix: workflow batch 404** — `_runBatchGenerate()` workflow case was fetching `/api/wfm/workflows/{filename}` (non-existent route); corrected to `/api/wfm/workflows/raw?filename={filename}`
|
||||
- **Bug fix: rename double-submit** — `addEventListener("blur", commitRename)` was accumulating on the persistent `#wfm-modal-title-input` element each time the modal opened; subsequent renames fired the handler twice, sending two rename requests back-to-back (first succeeds, second returns 409 or 404); fixed by storing the listener reference on `titleInput._commitRename` and calling `removeEventListener` before re-registering
|
||||
|
||||
### v0.3.22
|
||||
|
||||
- **Card view removed** — Workflow tab and Models tab no longer offer Card view; Thumbnail and Table remain; if `"card"` was previously stored in localStorage it falls back to Thumbnail automatically
|
||||
- **Batch type selector** — replaced the Checkpoint Batch toggle with per-type checkboxes on the Batch Queue column headers (Checkpoint / Lora / Prompt / Workflow); radio behavior ensures only one type is active at a time; the Batch panel below Generate repurposed as a status display showing the active type, amber progress bar, and Pause/Resume / Stop controls
|
||||
- **4-type batch generation** — `_runBatchGenerate()` dispatches to Checkpoint (file-tree + group selection), Lora (group selection), Prompt (preset group items applied to positive/negative nodes), and Workflow (group selection, restores original workflow after batch); all types use the same generic `_runBatchLoop()` with pause/abort support
|
||||
- **Help updated** — EN / JA / ZH strings updated for `helpWf1`, `helpModels2`, `helpGen11`, `helpGen13`, `helpGen14`
|
||||
|
||||
### v0.3.21
|
||||
|
||||
- **CivitAI Host setting** — new dropdown in Settings tab (CivitAI section): choose `civitai.com` (SFW only) or `civitai.red` (unrestricted); selection is saved to `settings.json` and synced to `localStorage` so the Models tab uses it immediately without extra server calls
|
||||
- **CivitAI panel — Info / Sample sub-tabs** — the CivitAI side panel is split into two sub-tabs; Info tab contains model details and actions; Sample tab shows all sample images (count displayed in label); images open full-size in a new browser tab on click
|
||||
- **Bug fix: model page URL for existing cache** — `modelId: null` entries (the majority of the cache) previously generated broken links (`/models?modelVersionId=…`); both the Python service and the JS renderer now fall back to `/{host}/model-versions/{versionId}` which redirects correctly to the model page; all 324 existing cache entries are fixed immediately without re-fetching
|
||||
- **Help updated** — Batch tab entries (gen-12 / gen-13 / gen-14) and Settings entries (settings-11 Text Size / settings-12 RAW JSON Colors / settings-13 CivitAI Host) are now fully localized in EN / JA / ZH; `helpGen3` updated to reflect 5-tab layout; `helpGen11` rewritten to describe the Batch toggle; JA/ZH translations no longer contain untranslated English terms (Filter, Pause/Resume, Stop)
|
||||
|
||||
### v0.3.20
|
||||
|
||||
- **CivitAI panel — Type / Base Model / Hash display** — side panel CivitAI tab now shows a Type badge (e.g. CHECKPOINT, LORA), Base Model row, and Hash row (BLAKE3 preferred, SHA256 fallback from CivitAI API or local metadata); click the hash to copy the full value to clipboard
|
||||
- **CivitAI panel — images open in new tab** — sample images in the CivitAI tab are now wrapped in links; clicking an image opens the full-size version in a new browser tab
|
||||
- **CivitAI panel — 3-state UI** — the panel now distinguishes between "not yet checked" (blue fetch button), "checked but not found on CivitAI" (re-check button with a notice), and "found" (full info); no more ambiguity after running batch fetch
|
||||
@@ -355,6 +782,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **CivitAI cache — `fileHashes` field added** — `_extract_info` now stores the primary file's full hash map (BLAKE3, SHA256, AutoV2, etc.) so the Hash row is populated for newly fetched or refreshed models
|
||||
|
||||
### v0.3.19
|
||||
|
||||
- **CivitAI batch fetch — major speedup** — switched from one-by-one `GET` requests (0.5 s delay each) to `POST /model-versions/by-hash` batch endpoint; up to 100 hashes per request, reducing 100-model fetch from ~50 s to a few seconds
|
||||
- **Retry with exponential backoff** — `fetch_by_hash` and the new batch POST both retry on HTTP 429 / 500 / 502 / 503 / 504 with 1 s → 2 s → 4 s delays (max 3 attempts), as recommended in the CivitAI API docs
|
||||
- **CivitAI API key support** — optional Bearer token can be set in Settings tab (CivitAI API Key section); environment variable `CIVITAI_API_KEY` takes priority over the stored key; API keys are excluded from data exports
|
||||
@@ -363,29 +791,35 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Bug fix: CivitAI tab blank after batch fetch** — the `done` SSE event now applies `data.hashes` directly to `state.modelMetadata` so the side-panel CivitAI tab renders correctly even when the subsequent `fetchModelMetadata()` call races ahead of the server flush
|
||||
|
||||
### v0.3.18
|
||||
|
||||
- **Batch tab** — new 5th subtab in GenerateUI with a dedicated 3-pane layout for assembling checkpoint batch queues; left pane: file-tree checkpoint selection (filter, All/None, initially unchecked); center pane: group-based selection with inner tabs (Checkpoint / Lora / Prompt / Workflow — Lora/Prompt/Workflow coming soon); right pane: live Batch Queue preview combining both pane selections with duplicates removed
|
||||
- **Checkpoint selector moved** — the checkpoint dropdown is removed from the right panel; selection is now done exclusively in the Batch tab left and center panes; the right panel retains only the enable/disable toggle and progress display
|
||||
- **Group selection fix** — batch group selection now stores selected group names (not raw model names), so changes to group membership in the Models tab are always reflected in the queue without stale entries
|
||||
- **Models tab bulk operations expanded** — bulk action bar now includes: Deselect All (clear all selections without exiting select mode), ★ Favorite / ☆ Unfavorite, +Badge / −Badge (apply or remove a badge from all selected models); existing group and delete operations unchanged
|
||||
|
||||
### v0.3.17
|
||||
|
||||
- **CivitAI SSL fix** — resolved `CERTIFICATE_VERIFY_FAILED` errors when fetching model info on Windows Portable Python environments; uses `certifi` CA bundle when available, falls back to system store, and disables verification only as a last resort with a warning log
|
||||
- **Lora Manager support** — `Lora Loader (LoraManager)` nodes (ComfyUI-Lora-Manager) are now correctly converted from UI format to API format in the GenerateUI tab; the `loras` widget array is wrapped to `{"__value__": [...]}` as required by the node, enabling generation with LoRA Manager workflows
|
||||
|
||||
### v0.3.16
|
||||
|
||||
- **CivitAI preview fallback** — model cards and the detail panel's Info tab now display the first CivitAI image directly when no local preview file exists; if the backend download fails (e.g. network restriction server-side), the browser fetches the image URL from the CivitAI cache as a fallback so the preview is always shown after fetching
|
||||
|
||||
### v0.3.15
|
||||
|
||||
- **Sample workflows** — added `workflows/` directory containing 13 sample workflows (SD1.5 / SDXL / DWPose / Face Detailer / Image Editing) with PNG thumbnails, bundled in the package
|
||||
- **CivitAI auto-preview** — when fetching CivitAI info (individual or batch), the first CivitAI image is automatically downloaded and saved as `{model_stem}.preview.png` if no preview exists; batch summary reports the count of auto-saved previews
|
||||
- **TOOLS pane — Create Tags** — new task option in the VLM dropdown (both SPA AI TOOL tab and Library A tab): generates a comma-separated list of descriptive English tags from the image
|
||||
|
||||
### v0.3.14
|
||||
|
||||
- **AI TOOL tab redesign** — tab renamed from "A" to "AI TOOL"; sub-tab navigation removed in favor of a permanent 3-pane layout (Translation 40% | TOOLS 40% | Settings 20%) — all panes always visible simultaneously; "VLM" pane renamed to "TOOLS"
|
||||
- **Settings — RAW JSON Colors** — new accordion section with 6 color pickers to customize the syntax highlight colors for the Raw JSON editor in Generate UI: Default Text, Name/Scheduler, Title, Width/Height, Prompt/Text, Image/File; changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
- **Help tab updated** — AI TOOL tab card rewritten; Settings card updated with RAW JSON Colors entry; Library panel sidepanel-16 entry updated
|
||||
|
||||
### v0.3.13
|
||||
|
||||
- **AI Tab (A)** — new tab added to both the SPA and the Workflow Studio Library side panel (rightmost tab)
|
||||
- **Translation sub-tab** — translate text via Ollama or LM Studio between Japanese, English, Chinese, or a custom Free language; ⇄ swap button exchanges both language selectors and text content simultaneously; language selections persisted automatically
|
||||
- **VLM sub-tab** — drop zone (110px, matching the I tab) accepts images via drag & drop or click; task selector (Describe Image / Create Prompt); Run button streams result into output area with Copy button; Ollama uses `images:[base64]` payload, LM Studio uses `image_url` content block
|
||||
@@ -395,15 +829,18 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Help Tab updated** — added AI Tab (A) feature card and Library panel A tab entry; i18n support in EN/JA/ZH
|
||||
|
||||
### v0.3.12
|
||||
|
||||
- **GenerateUI — Raw JSON search** — always-visible search bar above the Raw JSON editor; finds all matches as you type with match count (`3/12`); navigate forward/back with ↑/↓ buttons, Enter / Shift+Enter, or Escape / ✕ to clear; current match highlighted in orange, other matches in yellow; search overlay syncs with editor scroll and stays updated while editing
|
||||
- **Workflow analyzer — extended model type detection** — added CLIPLoader `type` field mapping to model family (`flux`, `hidream_i`, `wan`, `cosmos`, `lumina`, `ovis`, etc.) via `_CLIP_TYPE_TO_MODEL`; subgraph nodes (`definitions.subgraphs`) now scanned for model detection; `UnetLoaderGGUF` and similar loaders matched via `"UnetLoader" in ntype`; new families added: NewBie, Ovis, HiDream, Wan, Cosmos, Lumina; model-name detection refactored into reusable `_detect_model_type_from_name()`
|
||||
- **Screenshots refreshed** — replaced tab-by-tab screenshots with 8 feature-focused images
|
||||
|
||||
### v0.3.11
|
||||
|
||||
- **Metadata Tab — extended loader support** — `metadata-tab.js` now recognizes `UnetLoaderGGUF` and `UNETLoaderGGUF` as Diffusion Model (e.g. HiDream GGUF workflows), and `QuadrupleCLIPLoader` as Text Encoder (e.g. HiDream 4-CLIP); previously these were detected only in the side panel I tab, not in the Metadata tab itself
|
||||
- **Settings Tab — Text Size** — new slider (10–28 px) in the Settings tab adjusts font size for all prompt and chat text areas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard text area and file editor, and Metadata prompt full preview; applied immediately on drag and persisted with Save Settings
|
||||
|
||||
### v0.3.10
|
||||
|
||||
- **Information tab — drag to canvas** — model, lora, and Prompts sub-tabs in the side panel I tab now support drag & drop onto the ComfyUI canvas; double-click also places nodes at canvas center
|
||||
- model sub-tab: each item places the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`)
|
||||
- lora sub-tab: drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (shown for 1+ LoRAs) places all LoRAs in a single `Lora Loader (LoraManager)` node
|
||||
@@ -413,44 +850,52 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Preview area fixed height** — Information tab drop zone fixed at 110px so model/prompt lists are no longer compressed when a large image is loaded
|
||||
|
||||
### v0.3.9
|
||||
|
||||
- **Side panel I tab** — new Information tab in the Workflow Studio Library side panel; drop a ComfyUI PNG/WebP/JSON directly in the panel to inspect models, LoRAs, and prompts without opening the Metadata tab; sub-tabs: model / lora / Prompts with full-text preview and Copy button
|
||||
- **Side panel tab redesign** — tabs shortened to single letters (W / N / P / M / I) with full name shown on hover
|
||||
- **Bug fix: top bar icons** — icons (Workflow Studio / camera / Node Sets) were invisible in newer ComfyUI versions because Iconify converted the MDI CSS class to an inline SVG before the custom SVG could be injected; fixed by removing the early-return guard and adopting the same simple `requestAnimationFrame` retry pattern used by lora-manager
|
||||
|
||||
### v0.3.8
|
||||
|
||||
- **Metadata Tab: extended subgraph support** — added extraction for Flux.2 Dev fp8 (`SamplerCustomAdvanced`), Flux.2 Klein 4B Distilled (image edit), Ernie Image, Qwen-Image-Edit 2511 (`TextEncodeQwenImageEditPlus`), and WAN2.2 14B Animate (top-level CLIPTextEncode + subgraph KSampler)
|
||||
- **Prompt type: Text (no badge)** — when positive/negative polarity cannot be determined (indirect sampler connections, cross-level links, `PrimitiveStringMultiline` etc.), prompts are listed without a POS/NEG badge; action buttons still work normally
|
||||
- **Prompt extraction improvements** — `extractPromptsFromNodeSet` now resolves link sources from any STRING-output node (e.g. `ComfySwitchNode`, `PrimitiveStringMultiline`) without type restriction; `PrimitiveStringMultiline` search and final text-encoder fallback both extended to include subgraph nodes via `collectAllNodes`
|
||||
- **i18n** — added `metaPromptText` key (EN: "Text" / JA: "テキスト" / ZH: "文本")
|
||||
|
||||
### v0.3.7
|
||||
|
||||
- **Metadata Tab: subgraph workflow support** — Flux.2 (Dev fp8 / Klein), Qwen-Image-Edit (/ 2511 / Layered), and Z-Image (Base / Turbo) official templates now correctly extract models and prompts; these use ComfyUI's `definitions.subgraphs` format with `UNETLoader`, `CLIPLoader`, and `VAELoader` inside the subgraph
|
||||
- **Prompt extraction improvements** — 7-stage fallback chain: ImageMetadataPromptLoader → WFS_PromptText → top-level CLIPTextEncode+KSampler → `PrimitiveStringMultiline` (flux2-klein) → subgraph CLIPTextEncode+KSampler → PromptStyler → all CLIPTextEncode text
|
||||
- **MarkdownNote model fallback** — `extractMarkdownNoteModels()` parses `**section** → - [model](url)` patterns from MarkdownNote nodes as a supplemental model source
|
||||
- **Format note updated** — "Flux.2 / Qwen-Image / Z-Image subgraph workflows supported" (EN/JA/ZH)
|
||||
|
||||
### v0.3.6
|
||||
|
||||
- **Metadata Tab** — new tab (between Prompt and Gallery) that extracts model and prompt information from ComfyUI-generated PNG/WebP images and workflow JSON files; 3-column layout: drop zone with image preview (left), Checkpoint/VAE/Diffusion Model/Text Encoder lists (center), LoRA list with strength values + Prompt list with full-text viewer (right)
|
||||
- **Prompt action buttons** — Copy, GenUI:P/N (set GenerateUI prompts), Prompt:P/N (set Prompt tab preset prompts) directly from the extracted prompt text
|
||||
- **SDXL Prompt Styler support** — improved prompt extraction resolves `PromptStyler → CLIPTextEncode` link chains in both LiteGraph and API workflow formats
|
||||
- **Format support** — ComfyUI (PNG/WebP/JSON), SD WebUI, SD Forge, Fooocus
|
||||
|
||||
### v0.3.5
|
||||
|
||||
- **Feeder subtab** — new subtab in GenerateUI for controlling [comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder) nodes; left pane: node selector + all ImageFeeder parameters + presets + Run/Stop controls; center pane: folder tree, image grid with checkbox selection, and preview panel
|
||||
- **Run loop with After gen modes** — Loop (wrap and continue), Increment (advance and auto-stop), Fixed (constant index); index updates automatically via WebSocket `image_loop_node_sync` after each generation; seed comes from the right-pane seed setting
|
||||
- **Workflow analysis improvements** — `analyzeWorkflow` now handles multi-hop CONDITIONING chains via BFS (up to 5 iterations), CLIPTextEncodeSDXL / CLIPTextEncodeSDXLRefiner, SDXLPromptStyler / SDXLPromptStylerAdvanced, KSamplerAdvanced (`noise_seed`), TextEncodeQwenImageEditPlus, PrimitiveStringMultiline, Power Lora Loader (rgthree), and `Checkpoint Loader` (WAS) with space in the class name
|
||||
|
||||
### v0.3.4
|
||||
|
||||
- **Wildcard support panel** — new right column in the Prompt tab with a one-click toolbar for inserting wildcard syntax (`{|}`, `{n$|}`, `__|__`, `:`, `;`, `$`, `<lora::1:LBW=;>`, `[]`, single/multi pick) and wrapping selected text; dedicated prompt textarea and wildcard file picker (click to insert `__filename__`)
|
||||
- **Wildcard file manager** — create, view, edit, and delete `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; editor opens inline with filename input and save/cancel controls
|
||||
- **Preset/Preset Manager tab switch** — the center pane of the Prompt tab is now a two-tab panel (Presets / Preset Manager) to reclaim horizontal space for the new wildcard panel
|
||||
- **Wildcard Integration setting** — new accordion section in Settings tab; auto-detects ComfyUI-Impact-Pack; when installed, provides a button to create a directory junction (Windows) or symlink (other OS) from the WFS wildcard directory to Impact Pack's `wildcards/` directory; existing WFS files are migrated automatically; a Remove Link button reverses the operation
|
||||
|
||||
### v0.3.2
|
||||
|
||||
- **Checkpoint Batch** — new panel in the GenerateUI right column (below Seed, above results); enable via checkbox to auto-generate once per checkpoint model; filter included/excluded subfolders by comma-separated names (empty = all checkpoints); real-time preview of matched model count; amber progress bar shows current model name and index; Stop button aborts the loop after the current generation completes; failed models are counted and reported in the summary toast
|
||||
- **Seed layout fix** — seed value input and mode selector (Random / Fixed / Increment / Decrement) now stacked vertically so neither is clipped in narrow panels
|
||||
|
||||
### v0.3.1
|
||||
|
||||
- **Folder operations** — create subfolders and delete folders (with all contents) directly from the folder tree header buttons
|
||||
- **File operations** — move or delete individual images from the detail panel; bulk move and bulk delete from the multi-select bar
|
||||
- **Tree state preserved** — folder expansion state is maintained after create/delete/move/refresh operations
|
||||
@@ -458,10 +903,12 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Workflow auto-save** — after each generation in the Generate UI tab, the current workflow is automatically saved to gallery metadata for the generated images; shown in the Metadata tab even if PNG embedding is absent
|
||||
|
||||
### v0.3.0
|
||||
|
||||
- **Cross-type group filter** — the group filter dropdown in the Models tab now shows groups from all model types simultaneously, each prefixed with a type label (`[Checkpoint]`, `[LoRA]`, etc.); selecting a group automatically switches to the corresponding model type
|
||||
- **Sidebar group display fixed** — the ComfyUI sidebar "Model Groups" view now correctly renders per-type groups (introduced in v0.2.9) with a type label on each group header; fixes a crash where `.filter is not a function` was thrown on the new nested group structure
|
||||
|
||||
### v0.2.9
|
||||
|
||||
- **Model Enable/Disable** — hide models from ComfyUI by renaming the file extension to `.disabled`; toggle per model (⏸ button on each card/row), per group (Enable All / Disable All buttons in the Group panel), or filter the grid by status (All / Enabled only / Disabled only). Changes take effect after ComfyUI refresh.
|
||||
- **Per-type model groups** — groups are now scoped to each model type; checkpoint groups only appear in the Checkpoint tab, LoRA groups only in the LoRA tab, etc. Existing flat groups are automatically migrated.
|
||||
- **Multi-select mode** — click the Select button in the toolbar to enter selection mode; check individual models across Thumbnail / Card / Table views
|
||||
@@ -471,10 +918,12 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Bug fix** — Hypernetwork tab showed `C`, `O`, `M`, `B` as fake model items; caused by the new ComfyUI API format returning `"COMBO"` as the first element; fixed in `comfyui-client.js`
|
||||
|
||||
### v0.2.8
|
||||
|
||||
- **Data storage moved** — plugin data (settings, metadata, prompts, etc.) is now stored in `ComfyUI/user/default/Workflow-Studio/` instead of `custom_nodes/ComfyUI-Workflow-Studio/data/`; falls back to the old `data/` directory if `user/default/` does not exist
|
||||
- **Data Management section** — new Settings tab section to export all plugin data to a single JSON file and import it back; useful for migration, backup, and reinstallation
|
||||
|
||||
### v0.2.7
|
||||
|
||||
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
|
||||
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
|
||||
- **Settings 2-column layout** — Theme panel fixed on the right (50% width, sticky); all other settings flow continuously on the left with no visual gap
|
||||
@@ -482,18 +931,21 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Help tab** — Gallery tab description card added
|
||||
|
||||
### v0.2.5
|
||||
|
||||
- **Sidebar Category/Package sub-tabs** — added 📂 Category and 🧩 Package sub-tabs to the Nodes tab in Workflow Studio Library; each shows a dropdown to filter nodes by category or custom node package (reads `/object_info` for accurate package detection)
|
||||
- **Sidebar theme settings** — added ⚙ button in the panel header to customize background, sub-header background, text, border, and secondary text colors with live preview; settings saved to localStorage
|
||||
- **Sidebar TextEncoder (By Type)** — fixed TextEncoder not appearing in Models → By Type; now uses `DualCLIPLoader` → `CLIPLoader` fallback to retrieve `clip_name1` list
|
||||
- **Hide `.index.json`** — excluded `.index.json` from both the Workflow tab and the Workflow Studio Library side panel
|
||||
|
||||
### v0.2.4
|
||||
|
||||
- **GenerateUI tab redesign** — reorganized from 5 tabs (Prompt/Image/Model/Settings/Raw JSON) to 3 tabs (Input/Model/Settings); each tab shows a Raw JSON column (540px) for instant JSON preview and direct editing from any tab
|
||||
- **Input tab** — Prompt and Image stacked vertically in the left column
|
||||
- **Settings tab** — KSampler and LatentImage stacked vertically in the left column
|
||||
- **Raw JSON tab removed** — Raw JSON is now always visible as a right-column panel within each tab
|
||||
|
||||
### v0.2.3
|
||||
|
||||
- **Badge system unified** — Workflow and Models tabs now share a single badge palette (`⚙ Badge` button); workflow auto-analysis model type system removed in favor of user-defined free-label badges
|
||||
- **GenUI Model button** — apply any model from the Models tab directly to the corresponding node in GenerateUI's current workflow (available in detail modal and side panel; Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)
|
||||
- **Sidebar Models tab** — added All / ★ Favorites / Groups / By Type sub-tabs to the Models section of Workflow Studio Library
|
||||
@@ -501,6 +953,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Sidebar panel width** — widened from 280px to 310px
|
||||
|
||||
### v0.2.2
|
||||
|
||||
- **Models tab** — browse, search, and manage all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with Thumbnail/Card/Table views
|
||||
- **CivitAI integration** — fetch model metadata by SHA256 hash (individual or batch per model type) with SSE progress streaming
|
||||
- **Model groups** — create, rename, delete groups and assign/remove models for organization
|
||||
@@ -513,6 +966,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Help tab updated** — Models Tab section added to feature list
|
||||
|
||||
### v0.2.0
|
||||
|
||||
- **Prompt presets in sidebar** — added Prompts tab to Workflow Studio Library (ComfyUI side panel) with All / Favorites / Categories sub-tabs
|
||||
- **WFS_PromptText custom node** — drag prompt presets onto the canvas to create nodes with positive/negative prompt outputs
|
||||
- **Preset Manager** — 3-column layout in Prompt tab: AI Assistant | Presets editor | Preset Manager (All / Favorites / Groups)
|
||||
@@ -524,47 +978,56 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **Help tab updated** — Prompt Tab and Workflow Studio Library sections reflect new features
|
||||
|
||||
### v0.1.9
|
||||
|
||||
- **Side panel 2-row sub-tabs** — Workflows: row 1 (Workflows / Favorites / Groups), row 2 (Model Type); Nodes: row 1 (Nodes / Favorites / Groups), row 2 (Sets)
|
||||
- **Save dialog** — canvas snapshot now shows a filename edit dialog instead of auto-naming
|
||||
- **API/App format badge** — workflow items in side panel show red (API) or orange (APP) badge
|
||||
- **Group auto-cleanup** — deleted workflows are automatically removed from groups on refresh
|
||||
|
||||
### v0.1.8
|
||||
|
||||
- **WF & Node Library side panel** — renamed from "Node Library", added two top-level tabs (Workflows / Nodes) accessible from ComfyUI top bar
|
||||
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group with collapsible sections
|
||||
- **Workflow drag & drop** — drag workflows from the side panel onto the canvas to load them instantly
|
||||
- **Help tab update** — added Nodes Tab and WF & Node Library sections to the feature list
|
||||
|
||||
### v0.1.7
|
||||
|
||||
- **Nodes tab** — browse, search, and filter all installed ComfyUI nodes with Card/Table views, pagination (50 nodes/page), package color badges, and node detail side panel with I/O specs
|
||||
- **Node Sets** — save selected nodes + connections from ComfyUI canvas as reusable sets via right-click context menu
|
||||
- **Node Library side panel** — accessible from ComfyUI top bar with Favorites, Sets, and Groups tabs, drag & drop placement
|
||||
- **3 top bar buttons** — Workflow Studio, Canvas Snapshot, and Node Library buttons in ComfyUI's action bar
|
||||
|
||||
### v0.1.6
|
||||
|
||||
- **Security fix** — path traversal vulnerability in `workflow_service.py` (reported via ComfyUI-Manager PR review)
|
||||
|
||||
### v0.1.5
|
||||
|
||||
- **Theme system** — 13 built-in themes selectable from Settings tab with instant preview (Deep Ocean Dark, Pop & Vibrant, Light Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro 8-bit, Pastel Dream, Brutalism, Earthy, Material UI, Monotone + Accent, Corporate Trust)
|
||||
- Theme preference persisted in localStorage and restored on page load (no flash)
|
||||
- Special CSS effects per theme: neon glow (Cyberpunk), backdrop blur (Glassmorphism), dual shadow (Neumorphism), pixel borders (Retro/Brutalism)
|
||||
|
||||
### v0.1.4
|
||||
|
||||
- **App format support** — detect `.app.json` workflows (ComfyUI App mode), show "App Format" badge, block loading in GenerateUI with guidance message
|
||||
- **Preset clipboard copy** — added PP Copy / NP Copy buttons to copy positive/negative prompts to clipboard
|
||||
- **Analysis bugfix** — fixed workflow analysis crash when `widgets_values` contains non-string values (e.g. integers)
|
||||
|
||||
### v0.1.3
|
||||
|
||||
- Added **Help & Support tab** — feature list, tips, and support links (GitHub, Ko-fi)
|
||||
- Multi-language support (EN/JA/ZH) for all help content
|
||||
|
||||
### v0.1.2
|
||||
|
||||
- Added **Canvas Snapshot** button to ComfyUI top bar — capture workflow canvas as PNG thumbnail with embedded workflow metadata
|
||||
- Added **Thumbnail tab** to the workflow side panel for quick visual preview
|
||||
- Added **Thumbnail section** to the workflow detail modal
|
||||
- Snapshot images are auto-imported as workflows with thumbnails
|
||||
|
||||
### v0.1.1
|
||||
|
||||
- Initial feature set: Workflow management, GenerateUI, Prompt assistant, Settings
|
||||
|
||||
---
|
||||
@@ -578,7 +1041,8 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── wfm.py # Main class & route registration
|
||||
│ ├── config.py # Path configuration
|
||||
│ ├── nodes/
|
||||
│ │ └── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
|
||||
│ │ ├── 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
|
||||
@@ -605,21 +1069,25 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── app.js # App initialization & routing
|
||||
│ ├── workflow-tab.js # Workflow browser
|
||||
│ ├── generate-tab.js # Generation UI
|
||||
│ ├── feeder-tab.js # Feeder subtab (ImageFeeder node control + image library)
|
||||
│ ├── feeder-tab.js # Feeder subtab (Image Loop + Gallery modes)
|
||||
│ ├── gallery-tab.js # Gallery tab (image browser, groups, metadata)
|
||||
│ ├── 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 | TOOLS/VLM | Settings — Ollama / LM Studio)
|
||||
│ ├── 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
|
||||
│ ├── node_sets_menu.js # Workflow Studio Library side panel
|
||||
│ └── gallery_feeder_extension.js # WFS_GalleryFeeder canvas widgets (After Gen / Run / Stop)
|
||||
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
|
||||
```
|
||||
|
||||
@@ -638,3 +1106,8 @@ MIT License
|
||||
- [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
|
||||
|
||||
|
||||
@@ -9,15 +9,16 @@ WorkflowStudio.add_routes()
|
||||
|
||||
# Register custom nodes (isolated loading)
|
||||
_NODE_MODULES = {
|
||||
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText"),
|
||||
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText", "Prompt Text (WFS)"),
|
||||
"WFS_GalleryFeeder": (".py.nodes.gallery_feeder_node", "WFS_GalleryFeeder", "Gallery Feeder (WFS)"),
|
||||
}
|
||||
|
||||
for _name, (_mod_path, _cls_name) in _NODE_MODULES.items():
|
||||
for _name, (_mod_path, _cls_name, _display_name) in _NODE_MODULES.items():
|
||||
try:
|
||||
import importlib
|
||||
_mod = importlib.import_module(_mod_path, package=__name__)
|
||||
NODE_CLASS_MAPPINGS[_name] = getattr(_mod, _cls_name)
|
||||
NODE_DISPLAY_NAME_MAPPINGS[_name] = "Prompt Text (WFS)"
|
||||
NODE_DISPLAY_NAME_MAPPINGS[_name] = _display_name
|
||||
except Exception as _e:
|
||||
print(f"[WARNING] Workflow Studio: Failed to load '{_name}': {_e}")
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -69,3 +69,8 @@ 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"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
TAGGER_SETTINGS_FILE = DATA_DIR / "tagger_settings.json"
|
||||
TAGGER_MODELS_DIR = _COMFYUI_ROOT / "models" / "tagger"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""WFS_GalleryFeeder – Feeds images from a gallery group into a workflow."""
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feeder専用グループ名(gallery_metadata.json 内で予約される)
|
||||
FEEDER_GROUP = "__Feeder__"
|
||||
|
||||
|
||||
class WFS_GalleryFeeder:
|
||||
"""Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"group_name": ("STRING", {"default": FEEDER_GROUP}),
|
||||
"index": ("INT", {"default": 0, "min": 0, "max": 99999}),
|
||||
"sort_mode": (["filename_asc", "filename_desc", "random"],),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "Workflow Studio"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# 毎回実行させる(インデックスが変わるため)
|
||||
return float("NaN")
|
||||
|
||||
def execute(self, group_name: str, index: int, sort_mode: str, seed: int):
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
paths = self._get_sorted_paths(group_name, sort_mode, seed)
|
||||
if not paths:
|
||||
logger.warning("WFS_GalleryFeeder: group '%s' is empty or not found", group_name)
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
img_path = paths[index % len(paths)]
|
||||
try:
|
||||
img = PILImage.open(img_path).convert("RGB")
|
||||
except Exception as e:
|
||||
logger.error("WFS_GalleryFeeder: failed to load '%s': %s", img_path, e)
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
tensor = torch.from_numpy(arr).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
# ── 内部ヘルパー ──────────────────────────────────────────────
|
||||
|
||||
def _get_sorted_paths(self, group_name: str, sort_mode: str, seed: int) -> list[str]:
|
||||
metadata_file = self._find_metadata_file()
|
||||
if not metadata_file.exists():
|
||||
return []
|
||||
try:
|
||||
with open(metadata_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error("WFS_GalleryFeeder: metadata read error: %s", e)
|
||||
return []
|
||||
|
||||
paths = [
|
||||
key for key, meta in data.get("images", {}).items()
|
||||
if group_name in meta.get("groups", []) and Path(key).exists()
|
||||
]
|
||||
|
||||
if sort_mode == "filename_asc":
|
||||
paths.sort(key=lambda p: Path(p).name.lower())
|
||||
elif sort_mode == "filename_desc":
|
||||
paths.sort(key=lambda p: Path(p).name.lower(), reverse=True)
|
||||
elif sort_mode == "random":
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(paths)
|
||||
|
||||
return paths
|
||||
|
||||
@staticmethod
|
||||
def _find_metadata_file() -> Path:
|
||||
# py/nodes/ -> py/ -> plugin root -> ComfyUI/custom_nodes/ -> ComfyUI root
|
||||
plugin_root = Path(__file__).resolve().parent.parent.parent
|
||||
comfyui_root = plugin_root.parent.parent
|
||||
user_default = comfyui_root / "user" / "default"
|
||||
if user_default.is_dir():
|
||||
return user_default / "Workflow-Studio" / "gallery_metadata.json"
|
||||
return plugin_root / "data" / "gallery_metadata.json"
|
||||
@@ -1,9 +1,12 @@
|
||||
"""
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
@@ -15,26 +18,63 @@ logger = logging.getLogger(__name__)
|
||||
_service = GalleryService(DATA_DIR)
|
||||
|
||||
|
||||
def _init_allowed_root() -> None:
|
||||
"""起動時に保存済みの出力ディレクトリで _allowed_root を初期化する。
|
||||
Gallery タブを開く前に Feeder タブの Gallery モードが serve_image を呼んでも
|
||||
404 にならないようにするために必要。"""
|
||||
try:
|
||||
settings_file = DATA_DIR / "settings.json"
|
||||
if settings_file.exists():
|
||||
with open(settings_file, "r", encoding="utf-8") as f:
|
||||
settings = json.load(f)
|
||||
saved_root = settings.get("gallery_output_dir", "").strip()
|
||||
if saved_root and Path(saved_root).is_dir():
|
||||
_service.update_output_root(saved_root)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to load saved gallery root: %s", e)
|
||||
|
||||
# フォールバック: ComfyUI のデフォルト output フォルダ
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir and Path(output_dir).is_dir():
|
||||
_service.update_output_root(output_dir)
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
|
||||
|
||||
|
||||
_init_allowed_root()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/folders", list_folders)
|
||||
app.router.add_get("/wfm/gallery/images", list_images)
|
||||
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
|
||||
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_post("/wfm/gallery/image/save", save_image_to_gallery)
|
||||
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)
|
||||
app.router.add_put("/wfm/gallery/groups/{name}", rename_group)
|
||||
app.router.add_delete("/wfm/gallery/groups/{name}", delete_group)
|
||||
app.router.add_post("/wfm/gallery/groups/ensure", ensure_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/clear", clear_group_images)
|
||||
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
|
||||
# バルク操作
|
||||
app.router.add_post("/wfm/gallery/bulk/favorite", bulk_favorite)
|
||||
app.router.add_post("/wfm/gallery/bulk/group", bulk_group)
|
||||
# フォルダ・ファイル操作
|
||||
app.router.add_post("/wfm/gallery/folder", create_folder_route)
|
||||
app.router.add_delete("/wfm/gallery/folder", delete_folder_route)
|
||||
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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -69,7 +109,8 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
|
||||
try:
|
||||
images = _service.list_images(
|
||||
images = await asyncio.to_thread(
|
||||
_service.list_images,
|
||||
folder,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
@@ -77,6 +118,9 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
)
|
||||
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
|
||||
if not search:
|
||||
_service.start_background_index(folder)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except Exception as e:
|
||||
logger.error("list_images error: %s", e)
|
||||
@@ -127,6 +171,70 @@ async def serve_image(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_thumb(request: web.Request) -> web.Response:
|
||||
"""サムネイル配信。Pillowで縮小したJPEGをディスクキャッシュして返す。"""
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
try:
|
||||
width = int(request.rel_url.query.get("w", "256"))
|
||||
width = max(32, min(512, width))
|
||||
except ValueError:
|
||||
width = 256
|
||||
|
||||
thumb_path = _service.serve_thumbnail(path, width)
|
||||
if thumb_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
ext = thumb_path.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
content_type = "image/jpeg"
|
||||
elif ext == ".gif":
|
||||
content_type = "image/gif"
|
||||
else:
|
||||
content_type = mimetypes.guess_type(str(thumb_path))[0] or "image/jpeg"
|
||||
|
||||
return web.FileResponse(thumb_path, headers={
|
||||
"Content-Type": content_type,
|
||||
"Cache-Control": "max-age=86400",
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バルク操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def bulk_favorite(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/bulk/favorite — 複数画像のお気に入りを一括設定"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
value = bool(body.get("value", True))
|
||||
if not paths:
|
||||
return web.json_response({"error": "paths required"}, status=400)
|
||||
result = _service.bulk_set_favorite(paths, value)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def bulk_group(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/bulk/group — 複数画像のグループ追加/削除を一括処理"""
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
group = body.get("group", "").strip()
|
||||
action = body.get("action", "add") # "add" or "remove"
|
||||
if not paths or not group:
|
||||
return web.json_response({"error": "paths and group required"}, status=400)
|
||||
if action not in ("add", "remove"):
|
||||
return web.json_response({"error": "action must be 'add' or 'remove'"}, status=400)
|
||||
result = _service.bulk_group_op(paths, group, action)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def save_image_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -152,6 +260,40 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
"""Image Edit Tab からの PNG をギャラリーのルートフォルダに保存する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "").strip()
|
||||
image_data = body.get("imageData", "")
|
||||
|
||||
if not filename or not image_data:
|
||||
return web.json_response({"error": "filename and imageData required"}, status=400)
|
||||
|
||||
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"
|
||||
|
||||
# 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)})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_gallery error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -174,10 +316,15 @@ async def create_group(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_RESERVED_GROUPS = {"__Feeder__"}
|
||||
|
||||
|
||||
async def rename_group(request: web.Request) -> web.Response:
|
||||
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
|
||||
try:
|
||||
old_name = request.match_info.get("name", "")
|
||||
if old_name in _RESERVED_GROUPS:
|
||||
return web.json_response({"error": f"Group '{old_name}' is reserved and cannot be renamed"}, status=403)
|
||||
body = await request.json()
|
||||
new_name = body.get("new_name", "").strip()
|
||||
if not old_name or not new_name:
|
||||
@@ -192,6 +339,8 @@ async def rename_group(request: web.Request) -> web.Response:
|
||||
|
||||
async def delete_group(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name", "")
|
||||
if name in _RESERVED_GROUPS:
|
||||
return web.json_response({"error": f"Group '{name}' is reserved and cannot be deleted"}, status=403)
|
||||
ok = _service.delete_group(name)
|
||||
return web.json_response({"ok": ok})
|
||||
|
||||
@@ -224,6 +373,28 @@ async def list_group_images(request: web.Request) -> web.Response:
|
||||
return web.json_response({"images": images})
|
||||
|
||||
|
||||
async def clear_group_images(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/groups/{name}/clear - グループ内の全画像を除外"""
|
||||
name = request.match_info.get("name", "")
|
||||
if not name:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
ok = _service.clear_group(name)
|
||||
return web.json_response({"ok": ok})
|
||||
|
||||
|
||||
async def ensure_group(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/groups/ensure - グループが存在しない場合のみ作成"""
|
||||
try:
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
_service.ensure_group(name)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダ・ファイル操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -280,3 +451,37 @@ async def move_images_route(request: web.Request) -> web.Response:
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"moved": [], "errors": [str(e)]}, status=500)
|
||||
|
||||
|
||||
async def export_images_zip(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/images/export-zip - 複数画像をZIPファイルにしてダウンロード"""
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
return web.json_response({"error": "paths required"}, status=400)
|
||||
|
||||
# ZIPファイルをメモリ上に作成
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in paths:
|
||||
file_path = Path(path)
|
||||
# セキュリティ: 許可されたディレクトリ内のみ
|
||||
if not _service._check_path_allowed(file_path):
|
||||
continue
|
||||
if file_path.exists() and file_path.is_file():
|
||||
# ファイル名のみをアーカイブに入れる(パス情報は含めない)
|
||||
zf.write(file_path, arcname=file_path.name)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return web.Response(
|
||||
body=zip_buffer.getvalue(),
|
||||
content_type="application/zip",
|
||||
headers={"Content-Disposition": "attachment; filename=gallery_export.zip"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting images to ZIP: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""G'MIC-Qt integration API routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_settings = SettingsService()
|
||||
|
||||
# Global job store
|
||||
# key: job_id, value: dict {status, message, result_path, error}
|
||||
_gmic_jobs = {}
|
||||
_gmic_jobs_lock = threading.Lock()
|
||||
|
||||
def _gmic_run_gui(job_id: str, input_path: str, output_path: str, gmic_exe: str):
|
||||
"""Run gmic_qt.exe in a background process/thread and wait for completion.
|
||||
|
||||
G'MIC-Qt Standalone argument order: -o <output> <input>
|
||||
After OK the "filter output" window appears; closing it saves to output_path.
|
||||
"""
|
||||
args = [gmic_exe, "-o", output_path, input_path]
|
||||
logger.info("[gmic] Launching: %s", " ".join(args))
|
||||
try:
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id]["status"] = "processing"
|
||||
_gmic_jobs[job_id]["message"] = "G'MIC GUIで編集中..."
|
||||
|
||||
import time
|
||||
|
||||
kwargs = {}
|
||||
if os.name == "nt":
|
||||
# Required on Windows to show the GUI window from a background process
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE
|
||||
|
||||
proc = subprocess.Popen(args, **kwargs)
|
||||
proc.wait()
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id]["status"] = "completed"
|
||||
_gmic_jobs[job_id]["result_path"] = output_path
|
||||
_gmic_jobs[job_id]["message"] = "完了"
|
||||
logger.info("[gmic] Result saved: %s", output_path)
|
||||
else:
|
||||
raise ValueError("G'MIC GUIがキャンセルされました")
|
||||
except Exception as e:
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id]["status"] = "failed"
|
||||
_gmic_jobs[job_id]["error"] = str(e)
|
||||
_gmic_jobs[job_id]["message"] = f"エラー: {str(e)}"
|
||||
logger.error("[gmic] Error: %s", e)
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register G'MIC API routes."""
|
||||
app.router.add_post("/api/wfm/gmic/open", handle_open)
|
||||
app.router.add_get("/api/wfm/gmic/status/{job_id}", handle_status)
|
||||
app.router.add_post("/api/wfm/gmic/result", handle_result)
|
||||
|
||||
async def handle_open(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/gmic/open - Open image in G'MIC GUI."""
|
||||
try:
|
||||
body = await request.json()
|
||||
image_b64 = body.get("image_b64", "")
|
||||
if not image_b64:
|
||||
return web.json_response({"error": "image_b64 field is required"}, status=400)
|
||||
|
||||
# Retrieve setting for gmic path
|
||||
data = _settings.load()
|
||||
gmic_exe = data.get("gmic_qt_path", "")
|
||||
if not os.path.exists(gmic_exe):
|
||||
return web.json_response({
|
||||
"error": f"G'MIC executable not found at: {gmic_exe}. Please configure the path in settings."
|
||||
}, status=400)
|
||||
|
||||
ext = ".png"
|
||||
if image_b64.startswith("data:"):
|
||||
header, image_b64 = image_b64.split(",", 1)
|
||||
mime = header.split(";")[0].split(":")[1]
|
||||
guessed = mimetypes.guess_extension(mime)
|
||||
if guessed:
|
||||
ext = ".jpg" if guessed == ".jpe" else guessed
|
||||
|
||||
img_bytes = base64.b64decode(image_b64)
|
||||
|
||||
temp_dir = Path(DATA_DIR) / "gmic_temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
input_path = str(temp_dir / f"gmic_input_{now_str}{ext}")
|
||||
output_path = str(temp_dir / f"gmic_output_{now_str}_{str(uuid.uuid4())[:8]}{ext}")
|
||||
|
||||
with open(input_path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
with _gmic_jobs_lock:
|
||||
_gmic_jobs[job_id] = {
|
||||
"status": "pending",
|
||||
"message": "G'MIC GUIを起動中...",
|
||||
"result_path": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Start G'MIC thread
|
||||
t = threading.Thread(
|
||||
target=_gmic_run_gui,
|
||||
args=(job_id, input_path, output_path, gmic_exe),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
return web.json_response({"job_id": job_id, "status": "pending", "message": "起動中"})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] open error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_status(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/gmic/status/{job_id} - Check job status."""
|
||||
try:
|
||||
job_id = request.match_info.get("job_id", "")
|
||||
with _gmic_jobs_lock:
|
||||
job = _gmic_jobs.get(job_id)
|
||||
if job is None:
|
||||
return web.json_response({"error": "Job not found"}, status=404)
|
||||
return web.json_response({
|
||||
"job_id": job_id,
|
||||
"status": job["status"],
|
||||
"message": job.get("message", ""),
|
||||
"result_path": job.get("result_path"),
|
||||
"error": job.get("error"),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] status error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_result(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/gmic/result - Get result image base64."""
|
||||
try:
|
||||
body = await request.json()
|
||||
result_path = body.get("result_path", "")
|
||||
if not result_path or not os.path.exists(result_path):
|
||||
return web.json_response({"error": f"Result file not found: {result_path}"}, status=404)
|
||||
|
||||
# Path traversal guard: only allow files inside gmic_temp
|
||||
allowed_dir = (Path(DATA_DIR) / "gmic_temp").resolve()
|
||||
try:
|
||||
Path(result_path).resolve().relative_to(allowed_dir)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Access denied: path outside gmic_temp"}, status=403)
|
||||
|
||||
with open(result_path, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
|
||||
ext = os.path.splitext(result_path)[1].lower()
|
||||
mime_map = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff"
|
||||
}
|
||||
mime = mime_map.get(ext, "image/png")
|
||||
b64 = base64.b64encode(img_bytes).decode("ascii")
|
||||
return web.json_response({"image_b64": f"data:{mime};base64,{b64}"})
|
||||
except Exception as e:
|
||||
logger.error("[gmic] result error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -33,6 +33,7 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
|
||||
app.router.add_get("/api/wfm/models/subdirs", handle_get_subdirs)
|
||||
app.router.add_post("/api/wfm/models/move", handle_move_models)
|
||||
app.router.add_get("/api/wfm/models/files", handle_list_model_files)
|
||||
|
||||
|
||||
# ── Model Metadata ─────────────────────────────────────────
|
||||
@@ -616,3 +617,23 @@ async def handle_move_models(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error in move_models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Model File List ───────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_list_model_files(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/files?type=<model_type>
|
||||
Returns sorted list of model file names (with extension) for the given type.
|
||||
Used as a fallback when ComfyUI's /embeddings endpoint is not accessible.
|
||||
"""
|
||||
from ..services.models_service import MODEL_TYPE_FOLDER_KEYS
|
||||
model_type = request.query.get("type", "")
|
||||
if not model_type or model_type not in MODEL_TYPE_FOLDER_KEYS:
|
||||
return web.json_response({"error": "invalid type"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_model_files, model_type)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing model files for %s: %s", model_type, e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -82,7 +82,8 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
cfg = _get_ollama_config()
|
||||
model = body.get("model", cfg["model"])
|
||||
model = body.get("model") or cfg["model"]
|
||||
base_url = (body.get("url") or cfg["url"]).rstrip("/")
|
||||
messages = body.get("messages", [])
|
||||
|
||||
def _chat():
|
||||
@@ -92,7 +93,7 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
"stream": False,
|
||||
}).encode("utf-8")
|
||||
|
||||
url = f"{cfg['url']}/api/chat"
|
||||
url = f"{base_url}/api/chat"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
|
||||
@@ -23,6 +23,7 @@ _DATA_FILES = [
|
||||
"prompts.json",
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
"tagger_settings.json",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,6 +37,7 @@ 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/styles", handle_get_styles)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -241,3 +243,30 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error importing data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory."""
|
||||
style_dir = DATA_DIR / "style"
|
||||
styles = []
|
||||
if not style_dir.is_dir():
|
||||
return styles
|
||||
for json_file in sorted(style_dir.glob("*.json")):
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
styles.extend(data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
return styles
|
||||
|
||||
|
||||
async def handle_get_styles(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/styles - Get all styles from style directory."""
|
||||
try:
|
||||
styles = await asyncio.to_thread(_load_styles)
|
||||
return web.json_response(styles)
|
||||
except Exception as e:
|
||||
logger.error("Error loading styles: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tagger API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import TAGGER_DB_FILE, TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE
|
||||
from ..services.tagger_db_service import TaggerDbService
|
||||
from ..services.tagger_service import TaggerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_svc = TaggerService(TAGGER_MODELS_DIR, TAGGER_SETTINGS_FILE)
|
||||
_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_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)
|
||||
app.router.add_get("/wfm/tagger/db/list", handle_db_list)
|
||||
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_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)
|
||||
app.router.add_get("/wfm/tagger/settings", handle_settings_get)
|
||||
app.router.add_post("/wfm/tagger/settings", handle_settings_save)
|
||||
|
||||
|
||||
async def handle_models(request: web.Request) -> web.Response:
|
||||
try:
|
||||
models = await asyncio.to_thread(_svc.list_models)
|
||||
return web.json_response({"models": models})
|
||||
except Exception as e:
|
||||
logger.error("handle_models: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_predict(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
b64 = body.get("image_b64", "")
|
||||
model = body.get("model", "")
|
||||
if not b64 or not model:
|
||||
return web.json_response({"error": "image_b64 and model required"}, status=400)
|
||||
result = await asyncio.to_thread(
|
||||
_svc.predict, b64, model,
|
||||
float(body.get("threshold", 0.35)),
|
||||
float(body.get("char_threshold", 0.85)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("handle_predict: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_ollama_models(request: web.Request) -> web.Response:
|
||||
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)
|
||||
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:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.ollama_predict,
|
||||
body.get("image_b64", ""),
|
||||
body.get("api_url", "http://127.0.0.1:11434"),
|
||||
body.get("model", ""),
|
||||
body.get("prompt", ""),
|
||||
int(body.get("max_tags", 40)),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_batch_start(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.batch_start,
|
||||
body.get("folder", ""),
|
||||
body.get("model", ""),
|
||||
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("ollama_model", ""),
|
||||
body.get("ollama_prompt", ""),
|
||||
int(body.get("ollama_max_tags", 40)),
|
||||
bool(body.get("save_db", True)),
|
||||
bool(body.get("write_file", False)),
|
||||
bool(body.get("write_txt", False)),
|
||||
_db,
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_batch_status(request: web.Request) -> web.Response:
|
||||
return web.json_response(_svc.batch_status())
|
||||
|
||||
|
||||
async def handle_batch_stop(request: web.Request) -> web.Response:
|
||||
_svc.batch_stop()
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def handle_db_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.rel_url.query.get("limit", 100))
|
||||
offset = int(request.rel_url.query.get("offset", 0))
|
||||
rows = await asyncio.to_thread(_db.list, limit, offset)
|
||||
total = await asyncio.to_thread(_db.total)
|
||||
return web.json_response({"rows": rows, "total": total})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_search(request: web.Request) -> web.Response:
|
||||
try:
|
||||
q = request.rel_url.query.get("q", "")
|
||||
rows = await asyncio.to_thread(_db.search, q)
|
||||
return web.json_response({"rows": rows})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_update(request: web.Request) -> web.Response:
|
||||
try:
|
||||
row_id = int(request.match_info["id"])
|
||||
body = await request.json()
|
||||
await asyncio.to_thread(
|
||||
_db.update, row_id,
|
||||
body.get("interrogator_tags", ""),
|
||||
body.get("vlm_tags", ""),
|
||||
)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
row_id = int(request.match_info["id"])
|
||||
await asyncio.to_thread(_db.delete, row_id)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_export(request: web.Request) -> web.Response:
|
||||
try:
|
||||
csv_data = await asyncio.to_thread(_db.export_csv)
|
||||
return web.Response(
|
||||
body=csv_data.encode("utf-8"),
|
||||
content_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=tagger_tags.csv"},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_db_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
row_id = await asyncio.to_thread(
|
||||
_db.save,
|
||||
body.get("filename", ""),
|
||||
body.get("interrogator_tags", ""),
|
||||
body.get("vlm_tags", ""),
|
||||
)
|
||||
return web.json_response({"ok": True, "id": row_id})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_write_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(
|
||||
_svc.write_meta_to_file,
|
||||
body.get("path", ""),
|
||||
body.get("tags", ""),
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_settings_get(request: web.Request) -> web.Response:
|
||||
return web.json_response(_svc.load_settings())
|
||||
|
||||
|
||||
async def handle_settings_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
await asyncio.to_thread(_svc.save_settings, body)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -117,6 +117,18 @@ class GalleryMetadataStore:
|
||||
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
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
@@ -124,6 +136,19 @@ class GalleryMetadataStore:
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
def delete(self, image_path: str) -> bool:
|
||||
"""画像のメタデータエントリを削除する"""
|
||||
key = self._normalize_path(image_path)
|
||||
@@ -140,3 +165,22 @@ class GalleryMetadataStore:
|
||||
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:
|
||||
"""folder_path 配下の存在しないファイルのメタデータを削除する。
|
||||
existing_paths: そのフォルダの現在のファイルパス集合(_normalize_path 済み)
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -26,7 +28,7 @@ _PNG_CHUNK_MAX = 32 * 1024 * 1024
|
||||
_JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 30.0
|
||||
_CACHE_TTL = 60.0
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
@@ -72,17 +74,82 @@ class GalleryService:
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
self._allowed_root: Path | None = None
|
||||
self._folder_cache = _FolderCache()
|
||||
self._bg_cancel = threading.Event()
|
||||
self._bg_cancel.set() # 初期状態: キャンセル済み
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._allowed_root = p
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バックグラウンドインデックス
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def start_background_index(self, folder_path: str) -> None:
|
||||
"""フォルダ内の未キャッシュ画像をバックグラウンドでインデックスする。
|
||||
フォルダロード時に呼び出す。前回のインデックス処理は自動キャンセル。"""
|
||||
self._bg_cancel.set() # 前のスレッドをキャンセル
|
||||
cancel = threading.Event()
|
||||
self._bg_cancel = cancel
|
||||
t = threading.Thread(
|
||||
target=self._bg_index_folder,
|
||||
args=(folder_path, cancel),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
def _bg_index_folder(self, folder_path: str, cancel: threading.Event) -> None:
|
||||
"""バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。
|
||||
10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。"""
|
||||
try:
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir() or not self._check_path_allowed(folder):
|
||||
return
|
||||
entries = self._scan_folder(folder)
|
||||
batch: dict[str, dict] = {}
|
||||
processed = 0
|
||||
for name, abs_path, _, _ in entries:
|
||||
if cancel.is_set():
|
||||
return
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext not in (".png", ".jpg", ".jpeg"):
|
||||
continue
|
||||
meta = self.metadata_store.get(abs_path)
|
||||
if meta.get("prompt_cache"):
|
||||
continue
|
||||
embedded: dict = {}
|
||||
if ext == ".png":
|
||||
embedded = self._read_png_metadata(Path(abs_path))
|
||||
else:
|
||||
embedded = self._read_jpeg_metadata(Path(abs_path))
|
||||
if not embedded:
|
||||
continue
|
||||
prompt_cache = " ".join(
|
||||
str(v) for k, v in embedded.items()
|
||||
if k != "workflow" and isinstance(v, str) and len(v) < 50_000
|
||||
).strip()
|
||||
if not prompt_cache:
|
||||
continue
|
||||
batch[abs_path] = {"prompt_cache": prompt_cache}
|
||||
processed += 1
|
||||
if len(batch) >= 10:
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
batch = {}
|
||||
time.sleep(0.05) # 50ms 他の処理に譲る
|
||||
if batch and not cancel.is_set():
|
||||
for p, d in batch.items():
|
||||
self.metadata_store.save(p, d)
|
||||
if processed:
|
||||
logger.debug("BG index done: %s (%d indexed)", folder_path, processed)
|
||||
except Exception as e:
|
||||
logger.debug("BG index error: %s", e)
|
||||
|
||||
def _check_path_allowed(self, path: Path) -> bool:
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
|
||||
if self._allowed_root is None:
|
||||
resolved = path.resolve()
|
||||
return resolved == path.resolve()
|
||||
return False
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
@@ -188,6 +255,10 @@ class GalleryService:
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder(folder)
|
||||
|
||||
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
self.metadata_store.cleanup_stale_images(str(folder), existing_paths)
|
||||
|
||||
results = []
|
||||
for name, abs_path, size, mtime in raw_entries:
|
||||
# グループフィルタ(サーバーサイド)
|
||||
@@ -218,13 +289,14 @@ class GalleryService:
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
# 検索フィルタ
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト)
|
||||
if search:
|
||||
s = search.lower()
|
||||
if not (
|
||||
s in name.lower()
|
||||
or s in item["memo"].lower()
|
||||
or any(s in t.lower() for t in tags)
|
||||
or s in meta.get("prompt_cache", "").lower()
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -263,6 +335,14 @@ class GalleryService:
|
||||
|
||||
saved = self.metadata_store.get(str(path))
|
||||
|
||||
# workflow キーを除く文字列フィールドをプロンプトキャッシュとして保存(検索用)
|
||||
prompt_text = " ".join(
|
||||
str(v) for k, v in embedded.items()
|
||||
if k != "workflow" and isinstance(v, str) and len(v) < 50_000
|
||||
).strip()
|
||||
if prompt_text and prompt_text != saved.get("prompt_cache", ""):
|
||||
self.metadata_store.save(str(path), {"prompt_cache": prompt_text})
|
||||
|
||||
stat = path.stat()
|
||||
return {
|
||||
"filename": path.name,
|
||||
@@ -385,14 +465,42 @@ class GalleryService:
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def save_image_meta(self, image_path: str, data: dict) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
return self.metadata_store.save(image_path, data)
|
||||
|
||||
def toggle_favorite(self, image_path: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
new_val = not meta.get("favorite", False)
|
||||
self.metadata_store.save(image_path, {"favorite": new_val})
|
||||
return new_val
|
||||
|
||||
def bulk_set_favorite(self, paths: list[str], value: bool) -> dict:
|
||||
"""複数画像のお気に入りを一括設定する"""
|
||||
ok = fail = 0
|
||||
for p in paths:
|
||||
if not self._check_path_allowed(Path(p).resolve()):
|
||||
fail += 1
|
||||
continue
|
||||
if self.metadata_store.save(p, {"favorite": value}):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
return {"ok": ok, "fail": fail}
|
||||
|
||||
def bulk_group_op(self, paths: list[str], group_name: str, action: str = "add") -> dict:
|
||||
"""複数画像のグループ追加 / 削除を一括処理する。action は "add" または "remove"。"""
|
||||
ok = fail = 0
|
||||
for p in paths:
|
||||
success = self.add_to_group(p, group_name) if action == "add" else self.remove_from_group(p, group_name)
|
||||
if success:
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
return {"ok": ok, "fail": fail}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -410,6 +518,8 @@ class GalleryService:
|
||||
return self.metadata_store.delete_group(name)
|
||||
|
||||
def add_to_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = meta.get("groups", [])
|
||||
if group_name not in groups:
|
||||
@@ -418,12 +528,26 @@ class GalleryService:
|
||||
return True
|
||||
|
||||
def remove_from_group(self, image_path: str, group_name: str) -> bool:
|
||||
if not self._check_path_allowed(Path(image_path).resolve()):
|
||||
return False
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = [g for g in meta.get("groups", []) if g != group_name]
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
return self.metadata_store.list_images_in_group(group_name)
|
||||
all_paths = self.metadata_store.list_images_in_group(group_name)
|
||||
existing = [p for p in all_paths if Path(p).is_file()]
|
||||
stale = [p for p in all_paths if not Path(p).is_file()]
|
||||
if stale:
|
||||
self.metadata_store.remove_stale_paths_from_group(group_name, stale)
|
||||
logger.info("Cleaned up %d stale paths from group '%s'", len(stale), group_name)
|
||||
return existing
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
return self.metadata_store.clear_group(group_name)
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
return self.metadata_store.ensure_group(name)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像配信
|
||||
@@ -540,3 +664,46 @@ class GalleryService:
|
||||
logger.warning("serve_image: path not allowed: %s", p)
|
||||
return None
|
||||
return p
|
||||
|
||||
def serve_thumbnail(self, image_path: str, width: int = 256) -> Path | None:
|
||||
"""縮小サムネイルのPathを返す。
|
||||
ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。
|
||||
GIFはアニメーション保持のため元ファイルをそのまま返す。
|
||||
Pillowが使えない場合は元ファイルにフォールバック。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
return None
|
||||
if not self._check_path_allowed(p):
|
||||
logger.warning("serve_thumbnail: path not allowed: %s", p)
|
||||
return None
|
||||
|
||||
# GIF はアニメーション保持のためそのまま返す
|
||||
if p.suffix.lower() == ".gif":
|
||||
return p
|
||||
|
||||
try:
|
||||
mtime = int(p.stat().st_mtime * 1000)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
cache_key = hashlib.md5(f"{p}:{mtime}:{width}".encode()).hexdigest()
|
||||
cache_dir = self.data_dir / "thumb_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
thumb_path = cache_dir / f"{cache_key}.jpg"
|
||||
|
||||
if thumb_path.exists():
|
||||
return thumb_path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(p) as img:
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((width, width), Image.LANCZOS)
|
||||
img.save(thumb_path, "JPEG", quality=85, optimize=True)
|
||||
return thumb_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_thumbnail: failed for %s: %s", p, e)
|
||||
return p # フォールバック: 元ファイル
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -10,6 +11,8 @@ from ..config import MODEL_METADATA_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCAN_CACHE_TTL = 30 # seconds — reuse scan result within this window
|
||||
|
||||
# Preview image extensions to search for (in priority order)
|
||||
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
|
||||
".png", ".jpg", ".jpeg", ".webp"]
|
||||
@@ -23,6 +26,8 @@ _SIDECAR_EXTENSIONS = [
|
||||
|
||||
_DISABLED_SUFFIX = ".disabled"
|
||||
|
||||
_MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".pt2"}
|
||||
|
||||
# ComfyUI model type → folder_paths key mapping
|
||||
MODEL_TYPE_FOLDER_KEYS = {
|
||||
"checkpoint": "checkpoints",
|
||||
@@ -69,6 +74,7 @@ class ModelsService:
|
||||
|
||||
def __init__(self):
|
||||
self.metadata_file = MODEL_METADATA_FILE
|
||||
self._scan_cache: dict = {} # model_type -> (timestamp, frozenset)
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -110,6 +116,60 @@ class ModelsService:
|
||||
self._save_metadata(data)
|
||||
return entry
|
||||
|
||||
def _scan_model_names(self, model_type: str) -> set:
|
||||
"""モデルタイプの全ファイル名をスキャンして返す(相対パス、/区切り、.disabled除く)。
|
||||
結果は30秒キャッシュされ、起動時の多重スキャンによる高負荷を防ぐ。"""
|
||||
now = time.monotonic()
|
||||
cached = self._scan_cache.get(model_type)
|
||||
if cached is not None:
|
||||
ts, names = cached
|
||||
if now - ts < _SCAN_CACHE_TTL:
|
||||
return names
|
||||
|
||||
dirs = _get_model_dirs(model_type)
|
||||
names = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
rel = rel[: -len(_DISABLED_SUFFIX)]
|
||||
names.add(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self._scan_cache[model_type] = (now, names)
|
||||
return names
|
||||
|
||||
def list_model_files(self, model_type: str) -> list:
|
||||
"""モデルタイプのモデルファイル名をソートして返す(拡張子付き、/区切り)。
|
||||
プレビュー画像やサイドカーファイルを除外し、モデル拡張子のみを返す。"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
seen = set()
|
||||
for d in dirs:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
name = f.name
|
||||
if name.endswith(_DISABLED_SUFFIX):
|
||||
name = name[: -len(_DISABLED_SUFFIX)]
|
||||
if Path(name).suffix.lower() not in _MODEL_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
rel = str(f.relative_to(d)).replace("\\", "/")
|
||||
if rel.endswith(_DISABLED_SUFFIX):
|
||||
rel = rel[: -len(_DISABLED_SUFFIX)]
|
||||
seen.add(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
return sorted(seen)
|
||||
|
||||
def get_model_groups(self, model_type=None):
|
||||
data = self._load_metadata()
|
||||
raw = data.get("_groups", {})
|
||||
@@ -123,16 +183,55 @@ class ModelsService:
|
||||
raw = {}
|
||||
|
||||
if model_type:
|
||||
return raw.get(model_type, {})
|
||||
groups = raw.get(model_type, {})
|
||||
|
||||
# Guard: if no model directories are accessible (e.g. network drive disconnected),
|
||||
# skip cleanup entirely to prevent mass-deletion of group members.
|
||||
accessible_dirs = [d for d in _get_model_dirs(model_type) if d.is_dir()]
|
||||
if not accessible_dirs:
|
||||
configured_dirs = _get_model_dirs(model_type)
|
||||
if configured_dirs:
|
||||
logger.warning(
|
||||
"No accessible directories for model_type=%s — skipping group cleanup "
|
||||
"to avoid data loss (drives may be disconnected)", model_type
|
||||
)
|
||||
return {g: [m.replace("\\", "/") for m in members]
|
||||
for g, members in groups.items()}
|
||||
# No directories configured at all — return as-is (nothing to validate against)
|
||||
return {g: [m.replace("\\", "/") for m in members] for g, members in groups.items()}
|
||||
|
||||
valid_names = self._scan_model_names(model_type)
|
||||
cleaned = {}
|
||||
dirty = False
|
||||
for g_name, members in groups.items():
|
||||
# Normalize backslashes (Windows ComfyUI paths) to forward slashes
|
||||
normalized = [m.replace("\\", "/") for m in members]
|
||||
filtered = [m for m in normalized if m in valid_names]
|
||||
cleaned[g_name] = filtered
|
||||
if len(filtered) != len(members) or normalized != list(members):
|
||||
dirty = True
|
||||
if dirty:
|
||||
removed = sum(len(groups[g]) - len(cleaned[g]) for g in groups)
|
||||
if removed > 0:
|
||||
logger.info("Cleaned up %d stale group entries for model_type=%s", removed, model_type)
|
||||
if not isinstance(data.get("_groups"), dict):
|
||||
data["_groups"] = {}
|
||||
data["_groups"][model_type] = cleaned
|
||||
self._save_metadata(data)
|
||||
return cleaned
|
||||
return raw
|
||||
|
||||
def save_model_groups(self, groups, model_type):
|
||||
# Normalize all member names to forward slashes (ComfyUI on Windows uses backslashes)
|
||||
normalized = {g: [m.replace("\\", "/") for m in members] for g, members in groups.items()}
|
||||
data = self._load_metadata()
|
||||
if not isinstance(data.get("_groups"), dict):
|
||||
data["_groups"] = {}
|
||||
data["_groups"][model_type] = groups
|
||||
data["_groups"][model_type] = normalized
|
||||
self._save_metadata(data)
|
||||
return groups
|
||||
# Also invalidate scan cache so next get_model_groups sees the fresh data
|
||||
self._scan_cache.pop(model_type, None)
|
||||
return normalized
|
||||
|
||||
def find_model_file(self, model_type, model_name):
|
||||
"""Find a model file, checking both enabled and disabled states.
|
||||
@@ -311,6 +410,7 @@ class ModelsService:
|
||||
|
||||
meta_data = self._load_metadata()
|
||||
meta_changed = False
|
||||
renames = [] # [(old_name, new_name)]
|
||||
|
||||
for model_name in model_names:
|
||||
if ".." in model_name:
|
||||
@@ -389,11 +489,28 @@ class ModelsService:
|
||||
meta_changed = True
|
||||
|
||||
moved.append({"from": model_name, "to": new_rel})
|
||||
renames.append((model_name, new_rel))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error moving model %s: %s", model_name, e)
|
||||
errors.append({"model": model_name, "error": str(e)})
|
||||
|
||||
# Update group entries for renamed/moved models
|
||||
if renames:
|
||||
groups_for_type = meta_data.get("_groups", {}).get(model_type, {})
|
||||
groups_dirty = False
|
||||
for old_name, new_name in renames:
|
||||
for members in groups_for_type.values():
|
||||
for i, m in enumerate(members):
|
||||
if m == old_name:
|
||||
members[i] = new_name
|
||||
groups_dirty = True
|
||||
if groups_dirty:
|
||||
if not isinstance(meta_data.get("_groups"), dict):
|
||||
meta_data["_groups"] = {}
|
||||
meta_data["_groups"][model_type] = groups_for_type
|
||||
meta_changed = True
|
||||
|
||||
if meta_changed:
|
||||
self._save_metadata(meta_data)
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tagger タグ DB サービス(SQLite)。"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaggerDbService:
|
||||
def __init__(self, db_file: Path):
|
||||
self.db_file = db_file
|
||||
self._init_db()
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(str(self.db_file))
|
||||
|
||||
def _init_db(self):
|
||||
self.db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT,
|
||||
interrogator_tags TEXT,
|
||||
vlm_tags TEXT,
|
||||
all_tags TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
for sql in [
|
||||
"CREATE INDEX IF NOT EXISTS idx_all_tags ON images(all_tags)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_filename ON images(filename)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_created_at ON images(created_at DESC)",
|
||||
]:
|
||||
conn.execute(sql)
|
||||
conn.commit()
|
||||
|
||||
def save(self, filename: str, interrogator_tags: str, vlm_tags: str) -> int:
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
with self._conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO images (filename, interrogator_tags, vlm_tags, all_tags) VALUES (?, ?, ?, ?)",
|
||||
(filename, interrogator_tags, vlm_tags, all_tags),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def list(self, limit: int = 100, offset: int = 0) -> list:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at "
|
||||
"FROM images ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [self._row(r) for r in rows]
|
||||
|
||||
def total(self) -> int:
|
||||
with self._conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM images").fetchone()[0]
|
||||
|
||||
def search(self, q: str) -> list:
|
||||
pattern = f"%{q}%"
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at "
|
||||
"FROM images WHERE all_tags LIKE ? OR filename LIKE ? ORDER BY id DESC LIMIT 200",
|
||||
(pattern, pattern),
|
||||
).fetchall()
|
||||
return [self._row(r) for r in rows]
|
||||
|
||||
def update(self, row_id: int, interrogator_tags: str, vlm_tags: str):
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE images SET interrogator_tags=?, vlm_tags=?, all_tags=? WHERE id=?",
|
||||
(interrogator_tags, vlm_tags, all_tags, row_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete(self, row_id: int):
|
||||
with self._conn() as conn:
|
||||
conn.execute("DELETE FROM images WHERE id=?", (row_id,))
|
||||
conn.commit()
|
||||
|
||||
def export_csv(self) -> str:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, filename, interrogator_tags, vlm_tags, all_tags, created_at FROM images ORDER BY id"
|
||||
).fetchall()
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["id", "filename", "interrogator_tags", "vlm_tags", "all_tags", "created_at"])
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _row(r) -> dict:
|
||||
return {
|
||||
"id": r[0],
|
||||
"filename": r[1] or "",
|
||||
"interrogator_tags": r[2] or "",
|
||||
"vlm_tags": r[3] or "",
|
||||
"all_tags": r[4] or "",
|
||||
"created_at": r[5] or "",
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"""WD Tagger / DeepDanbooru / Ollama VLM 推論サービス。"""
|
||||
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, PngImagePlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_model_lock = threading.Lock()
|
||||
|
||||
|
||||
class TaggerService:
|
||||
def __init__(self, models_dir: Path, settings_file: Path):
|
||||
self.models_dir = models_dir
|
||||
self.settings_file = settings_file
|
||||
self._cache: dict = {}
|
||||
self._batch_state: dict = {
|
||||
"running": False, "total": 0, "done": 0, "log": [], "stop": False,
|
||||
}
|
||||
|
||||
# ── 設定 ──────────────────────────────────────────────────
|
||||
|
||||
def load_settings(self) -> dict:
|
||||
if self.settings_file.exists():
|
||||
try:
|
||||
with open(self.settings_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_settings(self, data: dict):
|
||||
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _effective_models_dir(self) -> Path:
|
||||
custom = self.load_settings().get("models_dir", "").strip()
|
||||
if custom:
|
||||
p = Path(custom)
|
||||
if p.is_dir():
|
||||
return p
|
||||
return self.models_dir
|
||||
|
||||
# ── モデル一覧 ─────────────────────────────────────────────
|
||||
|
||||
def list_models(self) -> list:
|
||||
mdir = self._effective_models_dir()
|
||||
models = []
|
||||
if not mdir.is_dir():
|
||||
return models
|
||||
for item in sorted(mdir.iterdir()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
exts = {f.suffix.lower() for f in item.iterdir() if f.is_file()}
|
||||
if ".onnx" in exts:
|
||||
models.append({"name": item.name, "type": "onnx"})
|
||||
elif ".h5" in exts:
|
||||
models.append({"name": item.name, "type": "deepdanbooru"})
|
||||
return models
|
||||
|
||||
# ── モデルロード ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _validate_model_name(name: str) -> bool:
|
||||
"""パストラバーサル防止: セパレータ・NUL・相対参照を拒否"""
|
||||
return bool(name) and "/" not in name and "\\" not in name and "\x00" not in name and name not in (".", "..")
|
||||
|
||||
def _load_model(self, model_name: str) -> Optional[dict]:
|
||||
if not self._validate_model_name(model_name):
|
||||
logger.error("Invalid model name rejected: %r", model_name)
|
||||
return None
|
||||
if model_name in self._cache:
|
||||
return self._cache[model_name]
|
||||
with _model_lock:
|
||||
if model_name in self._cache:
|
||||
return self._cache[model_name]
|
||||
mdir = self._effective_models_dir()
|
||||
model_dir = mdir / model_name
|
||||
if not model_dir.is_dir():
|
||||
logger.error("Model dir not found: %s", model_dir)
|
||||
return None
|
||||
files = list(model_dir.iterdir())
|
||||
onnx = next((f for f in files if f.suffix == ".onnx"), None)
|
||||
h5 = next((f for f in files if f.suffix == ".h5"), None)
|
||||
if onnx:
|
||||
return self._load_onnx(model_name, onnx, model_dir)
|
||||
if h5:
|
||||
return self._load_deepdanbooru(model_name, h5, model_dir)
|
||||
logger.error("No .onnx or .h5 in %s", model_dir)
|
||||
return None
|
||||
|
||||
def _load_onnx(self, name: str, onnx_file: Path, model_dir: Path) -> Optional[dict]:
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
providers = []
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
providers.append("CUDAExecutionProvider")
|
||||
except ImportError:
|
||||
pass
|
||||
providers.append("CPUExecutionProvider")
|
||||
session = ort.InferenceSession(str(onnx_file), providers=providers)
|
||||
labels = self._load_labels(model_dir)
|
||||
data = {
|
||||
"session": session,
|
||||
"labels": labels,
|
||||
"input_name": session.get_inputs()[0].name,
|
||||
"input_shape": session.get_inputs()[0].shape,
|
||||
"type": "onnx",
|
||||
}
|
||||
self._cache[name] = data
|
||||
logger.info("ONNX loaded: %s (%d labels)", name, len(labels))
|
||||
return data
|
||||
except ImportError:
|
||||
logger.error("onnxruntime not installed")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("ONNX load error %s: %s", name, e)
|
||||
return None
|
||||
|
||||
def _load_deepdanbooru(self, name: str, h5_file: Path, model_dir: Path) -> Optional[dict]:
|
||||
try:
|
||||
import tensorflow as tf
|
||||
except ImportError:
|
||||
logger.warning("TensorFlow not installed — DeepDanbooru unavailable")
|
||||
return None
|
||||
try:
|
||||
model = tf.keras.models.load_model(str(h5_file))
|
||||
labels = self._load_labels(model_dir)
|
||||
data = {"session": model, "labels": labels, "type": "deepdanbooru"}
|
||||
self._cache[name] = data
|
||||
logger.info("DeepDanbooru loaded: %s (%d labels)", name, len(labels))
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error("DeepDanbooru load error %s: %s", name, e)
|
||||
return None
|
||||
|
||||
def _load_labels(self, model_dir: Path) -> list:
|
||||
for fname in ("selected_tags.csv", "tags.csv", "tags.txt", "tags-general.txt"):
|
||||
p = model_dir / fname
|
||||
if not p.exists():
|
||||
continue
|
||||
try:
|
||||
if p.suffix == ".csv":
|
||||
with open(p, "r", encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader, None)
|
||||
return [row[1] for row in reader if len(row) > 1]
|
||||
else:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return [ln.strip() for ln in f if ln.strip()]
|
||||
except Exception as e:
|
||||
logger.warning("Label load failed %s: %s", p, e)
|
||||
return []
|
||||
|
||||
# ── 推論(WD Tagger / DeepDanbooru) ────────────────────
|
||||
|
||||
def predict(self, image_b64: str, model_name: str, threshold: float = 0.35, char_threshold: float = 0.85) -> dict:
|
||||
img = self._decode_b64(image_b64)
|
||||
data = self._load_model(model_name)
|
||||
if data is None:
|
||||
return {"error": f"Model '{model_name}' could not be loaded"}
|
||||
try:
|
||||
if data["type"] == "deepdanbooru":
|
||||
tags = self._predict_dd(img, data, threshold, char_threshold)
|
||||
else:
|
||||
tags = self._predict_onnx(img, data, threshold, char_threshold)
|
||||
return {"tags": ", ".join(tags), "count": len(tags)}
|
||||
except Exception as e:
|
||||
logger.error("predict error: %s", e)
|
||||
return {"error": str(e)}
|
||||
|
||||
def _predict_onnx(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
|
||||
shape = data["input_shape"]
|
||||
fmt = "NHWC"
|
||||
size = (224, 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]))
|
||||
elif shape[3] == 3 and isinstance(shape[1], int) and shape[1] > 0:
|
||||
fmt = "NHWC"
|
||||
size = (int(shape[2]), 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
|
||||
if fmt == "NCHW":
|
||||
arr = arr.transpose((2, 0, 1))
|
||||
arr = np.expand_dims(arr, 0)
|
||||
preds = data["session"].run(None, {data["input_name"]: arr})[0][0]
|
||||
return self._filter_tags(preds, data["labels"], thr, char_thr)
|
||||
|
||||
def _predict_dd(self, img: Image.Image, data: dict, thr: float, char_thr: float) -> list:
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
arr = np.array(img.resize((512, 512), Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
|
||||
arr = np.expand_dims(arr, 0)
|
||||
preds = data["session"].predict(arr)[0]
|
||||
return self._filter_tags(preds, data["labels"], thr, char_thr)
|
||||
|
||||
def _filter_tags(self, preds, labels: list, thr: float, char_thr: float) -> list:
|
||||
tags = []
|
||||
for i, score in enumerate(preds):
|
||||
if i >= len(labels):
|
||||
break
|
||||
label = labels[i]
|
||||
if float(score) >= (char_thr if label.startswith("character:") else thr):
|
||||
tags.append(label.replace("character:", ""))
|
||||
return tags
|
||||
|
||||
# ── Ollama VLM ────────────────────────────────────────────
|
||||
|
||||
def ollama_models(self, api_url: str) -> list:
|
||||
try:
|
||||
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", []))
|
||||
except Exception as e:
|
||||
logger.error("ollama_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")
|
||||
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", "")
|
||||
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)
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── ファイルメタデータ書込 ────────────────────────────────
|
||||
|
||||
_ALLOWED_WRITE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
|
||||
|
||||
def write_meta_to_file(self, image_path: str, tags: str) -> dict:
|
||||
path = Path(image_path).resolve()
|
||||
if path.suffix.lower() not in self._ALLOWED_WRITE_EXTS:
|
||||
return {"error": "Unsupported file type"}
|
||||
if not path.is_file():
|
||||
return {"error": f"File not found: {image_path}"}
|
||||
try:
|
||||
import piexif
|
||||
except ImportError:
|
||||
piexif = None
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
img.load()
|
||||
ext = path.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg") and piexif:
|
||||
self._write_jpeg(img, path, tags, piexif)
|
||||
elif ext == ".png":
|
||||
self._write_png(img, path, tags)
|
||||
else:
|
||||
self._write_sidecar(path, tags)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
logger.error("write_meta: %s", e)
|
||||
return {"error": str(e)}
|
||||
|
||||
def _write_jpeg(self, img: Image.Image, path: Path, tags: str, piexif):
|
||||
exif_data = img.info.get("exif")
|
||||
if exif_data:
|
||||
try:
|
||||
exif_dict = piexif.load(exif_data)
|
||||
except Exception:
|
||||
exif_dict = {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}}
|
||||
else:
|
||||
exif_dict = {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}}
|
||||
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = tags.encode("utf-8")
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
img.save(str(tmp_path), "JPEG", quality=95, exif=exif_bytes)
|
||||
tmp_path.replace(path)
|
||||
|
||||
def _write_png(self, img: Image.Image, path: Path, tags: str):
|
||||
meta = PngImagePlugin.PngInfo()
|
||||
for k, v in img.info.items():
|
||||
if isinstance(k, str) and isinstance(v, str):
|
||||
meta.add_text(k, v)
|
||||
meta.add_text("Tags", tags)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
img.save(str(tmp_path), "PNG", pnginfo=meta)
|
||||
tmp_path.replace(path)
|
||||
|
||||
def _write_sidecar(self, path: Path, tags: str):
|
||||
sidecar = path.with_suffix(".tags.json")
|
||||
with open(sidecar, "w", encoding="utf-8") as f:
|
||||
json.dump({"tags": tags}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── バッチ処理 ────────────────────────────────────────────
|
||||
|
||||
def batch_status(self) -> dict:
|
||||
s = self._batch_state
|
||||
return {
|
||||
"running": s["running"],
|
||||
"total": s["total"],
|
||||
"done": s["done"],
|
||||
"log": s["log"][-50:],
|
||||
}
|
||||
|
||||
def batch_stop(self):
|
||||
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:
|
||||
if self._batch_state["running"]:
|
||||
return {"error": "Batch already running"}
|
||||
folder_path = Path(folder)
|
||||
if not folder_path.is_dir():
|
||||
return {"error": f"Folder not found: {folder}"}
|
||||
|
||||
exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
|
||||
files = sorted(f for f in folder_path.iterdir() if f.is_file() and f.suffix.lower() in exts)
|
||||
if not files:
|
||||
return {"error": "No image files found"}
|
||||
|
||||
self._batch_state = {
|
||||
"running": True, "total": len(files), "done": 0, "log": [], "stop": False,
|
||||
}
|
||||
|
||||
def _run():
|
||||
for fpath in files:
|
||||
if self._batch_state["stop"]:
|
||||
self._batch_state["log"].append("Stopped.")
|
||||
break
|
||||
try:
|
||||
with Image.open(fpath) as img:
|
||||
img.load()
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
interrogator_tags = ""
|
||||
if model_name:
|
||||
res = self.predict(b64, model_name, threshold, char_threshold)
|
||||
interrogator_tags = res.get("tags", "")
|
||||
|
||||
vlm_tags = ""
|
||||
if use_ollama and ollama_model:
|
||||
res2 = self.ollama_predict(b64, ollama_api, ollama_model, ollama_prompt, ollama_max_tags)
|
||||
vlm_tags = res2.get("tags", "")
|
||||
|
||||
all_tags = ", ".join(filter(None, [interrogator_tags, vlm_tags]))
|
||||
|
||||
if save_db:
|
||||
db_service.save(str(fpath), interrogator_tags, vlm_tags)
|
||||
if write_file and all_tags:
|
||||
self.write_meta_to_file(str(fpath), all_tags)
|
||||
if write_txt and all_tags:
|
||||
txt_path = fpath.with_suffix(".txt")
|
||||
with open(txt_path, "w", encoding="utf-8") as f:
|
||||
f.write(all_tags)
|
||||
|
||||
n = len([t for t in all_tags.split(",") if t.strip()])
|
||||
self._batch_state["log"].append(f"✓ {fpath.name}: {n} tags")
|
||||
except Exception as e:
|
||||
self._batch_state["log"].append(f"✗ {fpath.name}: {e}")
|
||||
self._batch_state["done"] += 1
|
||||
|
||||
self._batch_state["running"] = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"ok": True, "total": len(files)}
|
||||
|
||||
# ── ユーティリティ ────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _decode_b64(b64: str) -> Image.Image:
|
||||
return Image.open(io.BytesIO(base64.b64decode(b64)))
|
||||
@@ -18,6 +18,7 @@ _CLIP_TYPE_TO_MODEL = {
|
||||
"wan": "Wan",
|
||||
"cosmos": "Cosmos",
|
||||
"lumina": "Lumina",
|
||||
"lumina2": "Z-IMAGE",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class WorkflowStudio:
|
||||
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
|
||||
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
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
@@ -72,5 +72,7 @@ class WorkflowStudio:
|
||||
models_routes.setup_routes(app)
|
||||
gallery_routes.setup_routes(app)
|
||||
wildcard_routes.setup_routes(app)
|
||||
tagger_routes.setup_routes(app)
|
||||
gmic_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
|
||||
version = "0.3.35"
|
||||
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.76"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# ComfyUI-Workflow-Studio - Tagger tab dependencies
|
||||
#
|
||||
# numpy and Pillow are already bundled with ComfyUI and do not need to be installed.
|
||||
# torch is managed by ComfyUI and must NOT be pinned here.
|
||||
#
|
||||
# Install into ComfyUI's embedded Python:
|
||||
# python_embeded\python.exe -m pip install -r requirements.txt
|
||||
#
|
||||
# For GPU-accelerated ONNX inference, install onnxruntime-gpu instead of onnxruntime:
|
||||
# python_embeded\python.exe -m pip install onnxruntime-gpu
|
||||
|
||||
# -- Required for WD Tagger / SwinV2 ONNX inference --
|
||||
onnxruntime>=1.16
|
||||
|
||||
# -- Required for JPEG EXIF tag writing --
|
||||
piexif>=1.1.3
|
||||
|
||||
# -- Optional: DeepDanbooru (.h5 model) support --
|
||||
# Omit if you do not use DeepDanbooru models.
|
||||
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
|
||||
# tensorflow>=2.10
|
||||
@@ -219,6 +219,43 @@
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* Feederグループボタン(サムネイルカード左上) */
|
||||
.wfm-gallery-thumb-feeder-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-feeder-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-feeder-btn.active {
|
||||
color: #38bdf8;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-feeder-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
/* テーブルビューのお気に入りボタン */
|
||||
.wfm-gallery-table-fav-btn {
|
||||
background: none;
|
||||
@@ -378,6 +415,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-preview-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
@@ -385,6 +431,43 @@
|
||||
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;
|
||||
@@ -602,39 +685,107 @@
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ── 詳細パネル アクションボタン (タブナビ内) ────────────────── */
|
||||
/* ── 画像比較ライトボックス ──────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-action-btn-green,
|
||||
.wfm-gallery-action-btn-primary {
|
||||
padding: 4px 10px !important;
|
||||
border-radius: 4px !important;
|
||||
border-style: solid !important;
|
||||
border-width: 1px !important;
|
||||
cursor: pointer !important;
|
||||
font-size: 12px !important;
|
||||
.wfm-lightbox-compare .wfm-lightbox-compare-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 96vw;
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--compare-cols, 2), 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-img {
|
||||
max-width: calc(92vw / var(--compare-cols, 2) - 12px);
|
||||
max-height: 78vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-label {
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lightbox-compare-close {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
/* ── 詳細パネル アクションボタン行 ───────────────────────────── */
|
||||
|
||||
.wfm-gallery-detail-action-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 詳細パネル アクションボタン ─────────────────────────────── */
|
||||
|
||||
.wfm-gallery-action-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
margin: 2px 3px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-gallery-action-btn-green {
|
||||
background: var(--wfm-success, #22c55e) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--wfm-success, #22c55e) !important;
|
||||
background: var(--wfm-success, #22c55e);
|
||||
border-color: var(--wfm-success, #22c55e);
|
||||
}
|
||||
.wfm-gallery-action-btn-green:hover {
|
||||
background: #16a34a !important;
|
||||
border-color: #16a34a !important;
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.wfm-gallery-action-btn-primary {
|
||||
background: var(--wfm-primary) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--wfm-primary) !important;
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-gallery-action-btn-primary:hover {
|
||||
background: var(--wfm-primary-hover) !important;
|
||||
border-color: var(--wfm-primary-hover) !important;
|
||||
background: var(--wfm-primary-hover);
|
||||
border-color: var(--wfm-primary-hover);
|
||||
}
|
||||
|
||||
.wfm-gallery-action-btn-tagger {
|
||||
background: var(--wfm-accent, #7c5cfc);
|
||||
border-color: var(--wfm-accent, #7c5cfc);
|
||||
}
|
||||
.wfm-gallery-action-btn-tagger:hover {
|
||||
background: #6a4de0;
|
||||
border-color: #6a4de0;
|
||||
}
|
||||
|
||||
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
/* =============================================
|
||||
Image Edit Tab
|
||||
============================================= */
|
||||
|
||||
#wfm-tab-image-edit {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-editor {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Left Toolbar
|
||||
============================================= */
|
||||
|
||||
.ie-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 5px;
|
||||
background: var(--wfm-surface);
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-tool-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ie-tool-btn:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
border-color: var(--wfm-border);
|
||||
}
|
||||
|
||||
.ie-tool-btn.active {
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ie-tool-placeholder {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.ie-tool-placeholder::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--wfm-text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Center column
|
||||
============================================= */
|
||||
|
||||
.ie-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Action bar */
|
||||
.ie-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tool options bar */
|
||||
.ie-tool-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
min-height: 36px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ie-opt-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ie-opt-group label {
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-opt-select {
|
||||
font-size: 12px;
|
||||
padding: 2px 5px;
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ie-opt-input {
|
||||
font-size: 12px;
|
||||
padding: 2px 5px;
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ie-opt-active {
|
||||
background: var(--wfm-primary) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--wfm-primary) !important;
|
||||
}
|
||||
|
||||
/* Canvas wrap */
|
||||
.ie-canvas-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
background-image:
|
||||
linear-gradient(45deg, #222 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #222 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #222 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #222 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
}
|
||||
|
||||
.ie-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: top left;
|
||||
line-height: 0;
|
||||
box-shadow: 0 2px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.ie-canvas-container canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#ie-canvas-bg {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#ie-canvas-draw {
|
||||
z-index: 2;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
#ie-canvas-overlay {
|
||||
z-index: 3;
|
||||
cursor: default;
|
||||
pointer-events: none; /* SelectTool.activate()でautoに変更 */
|
||||
}
|
||||
|
||||
.ie-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ie-placeholder-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Zoom bar */
|
||||
.ie-zoom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
#ie-zoom-label {
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Properties Pane (left of Layers panel)
|
||||
============================================= */
|
||||
|
||||
.ie-props-pane {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-props-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-props-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ie-props-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ie-props-row label {
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-props-row input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ie-props-row span {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Mask layer item highlight */
|
||||
.ie-layer-item[data-type="mask"] .ie-layer-type-icon {
|
||||
color: #e25252;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Right Panel (Layers)
|
||||
============================================= */
|
||||
|
||||
.ie-right-panel {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ie-layers-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ie-layers-header > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ie-layer-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.ie-layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ie-layer-item:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.ie-layer-item.active {
|
||||
background: rgba(90, 110, 220, 0.22);
|
||||
outline: 1px solid rgba(90, 110, 220, 0.4);
|
||||
}
|
||||
|
||||
.ie-layer-thumb {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
background:
|
||||
repeating-conic-gradient(#888 0% 25%, transparent 0% 50%)
|
||||
0 0 / 8px 8px;
|
||||
}
|
||||
|
||||
.ie-layer-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ie-layer-vis-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--wfm-text);
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.ie-layer-vis-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ie-opacity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.ie-opacity-row input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#ie-layer-opacity-label {
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Text Tool Overlay
|
||||
============================================= */
|
||||
|
||||
.ie-text-overlay {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ie-text-textarea {
|
||||
width: 200px;
|
||||
resize: vertical;
|
||||
background: var(--wfm-bg);
|
||||
color: var(--wfm-text);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.ie-text-btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Scrollbar
|
||||
============================================= */
|
||||
|
||||
.ie-layer-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.ie-layer-list::-webkit-scrollbar-thumb {
|
||||
background: var(--wfm-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -217,6 +217,39 @@ body {
|
||||
Search
|
||||
============================================ */
|
||||
|
||||
.wfm-search-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-search-clear-btn {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
padding: 2px 3px;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wfm-search-clear-btn:hover {
|
||||
color: var(--wfm-text-primary);
|
||||
background: rgba(255,255,255,0.1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-search-wrap .wfm-search-input {
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
.wfm-search-input {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
@@ -478,6 +511,18 @@ body {
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.wfm-select-missing {
|
||||
border-color: var(--wfm-warning);
|
||||
color: var(--wfm-warning);
|
||||
}
|
||||
|
||||
.wfm-model-missing-hint {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-warning);
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-input:focus,
|
||||
.wfm-textarea:focus,
|
||||
.wfm-select:focus {
|
||||
@@ -1011,6 +1056,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;
|
||||
@@ -1579,6 +1635,17 @@ body {
|
||||
background: color-mix(in srgb, var(--wfm-primary) 10%, var(--wfm-surface));
|
||||
}
|
||||
|
||||
.wfm-card.multi-selected {
|
||||
border-color: #f59e0b !important;
|
||||
box-shadow: 0 0 0 2px #f59e0b !important;
|
||||
background: color-mix(in srgb, #f59e0b 10%, var(--wfm-surface));
|
||||
}
|
||||
|
||||
.wfm-nodes-table-row.multi-selected {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
outline: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Side Panel
|
||||
============================================ */
|
||||
@@ -2894,6 +2961,41 @@ body {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-section {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-filename {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-preview-wrap .wfm-i2i-preview-img {
|
||||
max-height: 70px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-drop-zone {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Side Panel - Thumbnail Tab
|
||||
============================================ */
|
||||
@@ -2992,7 +3094,7 @@ body {
|
||||
}
|
||||
|
||||
.wfm-help-sidebar {
|
||||
width: 170px;
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3006,12 +3108,36 @@ body {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.wfm-help-search-wrap {
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
|
||||
.wfm-help-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--wfm-bg);
|
||||
border: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-text);
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wfm-help-search:focus {
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-help-nav-item.search-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wfm-help-nav-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
font-size: 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
@@ -3065,19 +3191,28 @@ body {
|
||||
}
|
||||
|
||||
.wfm-help-card h3 {
|
||||
font-size: 14px;
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-help-card h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.wfm-help-card p {
|
||||
font-size: 20px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.wfm-help-card ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.wfm-help-card li {
|
||||
font-size: 13px;
|
||||
font-size: 20px;
|
||||
color: var(--wfm-text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
@@ -3088,7 +3223,7 @@ body {
|
||||
}
|
||||
|
||||
.wfm-help-support-card > p {
|
||||
font-size: 14px;
|
||||
font-size: 21px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -3135,18 +3270,18 @@ body {
|
||||
}
|
||||
|
||||
.wfm-help-link-title {
|
||||
font-size: 15px;
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wfm-help-link-desc {
|
||||
font-size: 12px;
|
||||
font-size: 18px;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wfm-help-thanks {
|
||||
font-size: 13px;
|
||||
font-size: 20px;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -5116,7 +5251,7 @@ body {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 3-pane layout */
|
||||
/* 4-pane layout */
|
||||
.wfm-ai-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5124,11 +5259,15 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-pane-translate { flex: 4; }
|
||||
.wfm-ai-pane-tools { flex: 4; }
|
||||
.wfm-ai-pane-translate { flex: 3; }
|
||||
.wfm-ai-pane-chat { flex: 4; }
|
||||
.wfm-ai-pane-tools { flex: 3; }
|
||||
.wfm-ai-pane-settings { flex: 2; }
|
||||
|
||||
.wfm-ai-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
@@ -5140,6 +5279,19 @@ body {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
|
||||
.wfm-ai-chat-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-ai-pane-divider {
|
||||
width: 1px;
|
||||
background: var(--wfm-border);
|
||||
@@ -5300,6 +5452,25 @@ body {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Wildcard inputs */
|
||||
.wfm-ai-wc-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-wc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-ai-wc-count {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* VLM Tab */
|
||||
.wfm-ai-vlm-container {
|
||||
display: flex;
|
||||
@@ -5338,6 +5509,29 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-ai-vlm-clear {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-vlm-clear:hover {
|
||||
background: var(--wfm-danger);
|
||||
}
|
||||
|
||||
.wfm-ai-vlm-task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5369,3 +5563,137 @@ body {
|
||||
align-self: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chat pane */
|
||||
.wfm-ai-chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-msg {
|
||||
max-width: 90%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-msg-user {
|
||||
align-self: flex-end;
|
||||
background: var(--wfm-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-msg-assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-ai-chat-img {
|
||||
max-width: 20%;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attach-thumb {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
background: var(--wfm-surface);
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment-clear {
|
||||
margin-left: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment-clear:hover {
|
||||
background: var(--wfm-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-input-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-input {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
padding: 8px 10px;
|
||||
background: var(--wfm-bg);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
color: var(--wfm-text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-ai-chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/* ============================================================
|
||||
Tagger Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── サブタブナビ ────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-subtab-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn:hover {
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-tagger-subtab-btn.active {
|
||||
color: var(--wfm-primary);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
/* ── サブタブ共通 ────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-subtab {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ── セクション ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-section {
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-tagger-section-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── フォームレイアウト ─────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-tagger-form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── スライダー ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
accent-color: var(--wfm-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-tagger-slider-val {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Single サブタブ ─────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-single-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-image-pane {
|
||||
width: 300px;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-image-sources {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-tagger-preview-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--wfm-surface);
|
||||
border: 2px dashed var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-tagger-preview-wrap.drag-over {
|
||||
border-color: var(--wfm-primary);
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
.wfm-tagger-image-name {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.wfm-tagger-controls-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-result-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Batch サブタブ ──────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-batch-layout {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.wfm-tagger-batch-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wfm-tagger-progress-bar-wrap {
|
||||
height: 6px;
|
||||
background: var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-tagger-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--wfm-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── DB サブタブ ─────────────────────────────────────────────── */
|
||||
|
||||
.wfm-tagger-db-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px) - 80px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table th,
|
||||
.wfm-tagger-db-table td {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table th {
|
||||
background: var(--wfm-bg-secondary);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-table tbody tr.selected {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.wfm-tagger-db-edit-panel {
|
||||
flex-shrink: 0;
|
||||
margin-top: 10px;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* ── ユーティリティ ─────────────────────────────────────────── */
|
||||
|
||||
#wfm-tab-tagger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--wfm-header-height, 56px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#wfm-tab-tagger.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wfm-placeholder {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* 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, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_ai_settings";
|
||||
|
||||
@@ -19,11 +23,7 @@ const LANG_NAMES = {
|
||||
// ============================================
|
||||
|
||||
function loadAiSettings() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return readJsonStorage(SETTINGS_KEY);
|
||||
}
|
||||
|
||||
function saveAiSettings(patch) {
|
||||
@@ -199,7 +199,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");
|
||||
@@ -288,12 +288,11 @@ function initSettingsTab() {
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
// Update URL placeholder when backend changes
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,6 +343,47 @@ function initSettingsTab() {
|
||||
if (freeSrcInput && saved.freeSrcLang) freeSrcInput.value = saved.freeSrcLang;
|
||||
if (freeDstInput && saved.freeDstLang) freeDstInput.value = saved.freeDstLang;
|
||||
|
||||
// Chat image generation — dedicated workflow selection (T2I)
|
||||
const chatGenCheckbox = document.getElementById("wfm-ai-chatgen-dedicated-checkbox");
|
||||
const chatGenRow = document.getElementById("wfm-ai-chatgen-dedicated-row");
|
||||
const chatGenSelect = document.getElementById("wfm-ai-chatgen-dedicated-select");
|
||||
|
||||
if (chatGenCheckbox) chatGenCheckbox.checked = !!saved.chatGenDedicatedEnabled;
|
||||
if (chatGenRow) chatGenRow.style.display = saved.chatGenDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenCheckbox?.addEventListener("change", () => {
|
||||
if (chatGenRow) chatGenRow.style.display = chatGenCheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
// Chat I2I generation — dedicated workflow selection
|
||||
const chatGenI2ICheckbox = document.getElementById("wfm-ai-chatgen-i2i-dedicated-checkbox");
|
||||
const chatGenI2IRow = document.getElementById("wfm-ai-chatgen-i2i-dedicated-row");
|
||||
const chatGenI2ISelect = document.getElementById("wfm-ai-chatgen-i2i-dedicated-select");
|
||||
|
||||
if (chatGenI2ICheckbox) chatGenI2ICheckbox.checked = !!saved.chatGenI2IDedicatedEnabled;
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = saved.chatGenI2IDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenI2ICheckbox?.addEventListener("change", () => {
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = chatGenI2ICheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
if (chatGenSelect) {
|
||||
fetch(`${comfyUI.baseUrl}/api/wfm/workflows`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list) => {
|
||||
const filenames = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
const optionsHtml = `<option value="">-- select workflow --</option>` +
|
||||
filenames.map((f) => `<option value="${f}">${f}</option>`).join("");
|
||||
chatGenSelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenDedicatedFilename) chatGenSelect.value = saved.chatGenDedicatedFilename;
|
||||
if (chatGenI2ISelect) {
|
||||
chatGenI2ISelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenI2IDedicatedFilename) chatGenI2ISelect.value = saved.chatGenI2IDedicatedFilename;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Save settings
|
||||
document.getElementById("wfm-ai-settings-save-btn")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
@@ -351,13 +391,21 @@ function initSettingsTab() {
|
||||
const model = document.getElementById("wfm-ai-model-select")?.value || "";
|
||||
const freeSrcLang = freeSrcInput?.value?.trim() || "";
|
||||
const freeDstLang = freeDstInput?.value?.trim() || "";
|
||||
const chatGenDedicatedEnabled = !!chatGenCheckbox?.checked;
|
||||
const chatGenDedicatedFilename = chatGenSelect?.value || "";
|
||||
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
|
||||
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
saveAiSettings({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
});
|
||||
|
||||
@@ -367,31 +415,321 @@ function initSettingsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chat tab
|
||||
// ============================================
|
||||
|
||||
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.",
|
||||
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." },
|
||||
},
|
||||
required: ["prompt"],
|
||||
},
|
||||
},
|
||||
}];
|
||||
|
||||
// chatHistory の内部共通形式 {role, content, images?:[{base64,mimeType}]} を
|
||||
// バックエンドごとのワイヤーフォーマットへ変換する(callVLM() と同じ規約)
|
||||
function _formatMessagesForBackend(messages, backend) {
|
||||
return messages.map((m) => {
|
||||
if (!m.images?.length) return { role: m.role, content: m.content };
|
||||
if (backend === "ollama") {
|
||||
return { role: m.role, content: m.content, images: m.images.map((i) => i.base64) };
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content: [
|
||||
{ type: "text", text: m.content },
|
||||
...m.images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mimeType};base64,${i.base64}` } })),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function callChat(url, backend, model, messages, tools) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) body.tools = tools;
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
function _parseToolArgs(toolCall) {
|
||||
const raw = toolCall.function?.arguments ?? toolCall.arguments;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw); } catch { return {}; }
|
||||
}
|
||||
return raw || {};
|
||||
}
|
||||
|
||||
async function _loadDedicatedChatGenWorkflow(filename) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let workflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(workflow, filename);
|
||||
if (format === "ui") {
|
||||
workflow = await comfyWorkflow.convertUiToApi(workflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
|
||||
}
|
||||
|
||||
async function generateImageFromChat(prompt, negativePrompt) {
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const isI2I = !!_toolsImage;
|
||||
let workflow, analysis;
|
||||
|
||||
if (isI2I && settings.chatGenI2IDedicatedEnabled && settings.chatGenI2IDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenI2IDedicatedFilename));
|
||||
} else if (!isI2I && settings.chatGenDedicatedEnabled && settings.chatGenDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenDedicatedFilename));
|
||||
} else {
|
||||
if (!comfyUI.currentWorkflow || !comfyUI.currentAnalysis) throw new Error(t("aiChatNoWorkflowLoaded"));
|
||||
workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
|
||||
analysis = comfyUI.currentAnalysis;
|
||||
}
|
||||
|
||||
if (isI2I) {
|
||||
if (!(analysis.load_image_nodes?.length > 0)) throw new Error(t("aiChatNoLoadImageNode"));
|
||||
await comfyEditor.applyImageToSlot(_toolsImage.file, 0, { workflow, analysis });
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
|
||||
|
||||
const result = await window._wfmGenerateTab.generate(workflow);
|
||||
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")}`;
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
|
||||
if (content) div.textContent = content;
|
||||
if (opts.imageUrl) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.imageUrl;
|
||||
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);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function initChatTab() {
|
||||
const messagesEl = document.getElementById("wfm-ai-chat-messages");
|
||||
const inputEl = document.getElementById("wfm-ai-chat-input");
|
||||
const sendBtn = document.getElementById("wfm-ai-chat-send-btn");
|
||||
const clearBtn = document.getElementById("wfm-ai-chat-clear-btn");
|
||||
const statusEl = document.getElementById("wfm-ai-chat-status");
|
||||
const imgGenToggle = document.getElementById("wfm-ai-chat-imggen-toggle");
|
||||
|
||||
if (!messagesEl || !inputEl || !sendBtn) return;
|
||||
|
||||
if (imgGenToggle) {
|
||||
const savedToggle = loadAiSettings();
|
||||
imgGenToggle.checked = savedToggle.chatImageGenEnabled !== false;
|
||||
imgGenToggle.addEventListener("change", () => {
|
||||
saveAiSettings({ chatImageGenEnabled: imgGenToggle.checked });
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("wfm-ai-chat-attachment-clear")?.addEventListener("click", () => {
|
||||
_clearToolsImage();
|
||||
});
|
||||
|
||||
let chatHistory = [];
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
if (!model) {
|
||||
showToast(t("aiToastNoModel"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
inputEl.value = "";
|
||||
inputEl.style.height = "";
|
||||
const userEntry = { role: "user", content: text };
|
||||
if (_toolsImage) userEntry.images = [{ base64: _toolsImage.base64, mimeType: _toolsImage.mimeType }];
|
||||
chatHistory.push(userEntry);
|
||||
appendChatBubble(messagesEl, "user", text, _toolsImage ? { attachThumb: `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}` } : {});
|
||||
|
||||
sendBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusChatting");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
|
||||
try {
|
||||
const tools = imgGenToggle?.checked !== false ? IMAGE_GEN_TOOLS : undefined;
|
||||
const reply = await callChat(url, backend, model, chatHistory, tools);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
|
||||
if (toolCall) {
|
||||
chatHistory.push({ role: "assistant", content: reply.content || "" });
|
||||
if (reply.content) appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
|
||||
statusEl.textContent = t("aiStatusGeneratingImage");
|
||||
const args = _parseToolArgs(toolCall);
|
||||
try {
|
||||
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
|
||||
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
|
||||
} catch (genErr) {
|
||||
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
|
||||
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
|
||||
}
|
||||
} else {
|
||||
chatHistory.push({ role: "assistant", content: reply.content });
|
||||
appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
}
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wfm-ai-trans-status";
|
||||
} catch (err) {
|
||||
chatHistory.pop();
|
||||
if (messagesEl.lastChild) messagesEl.removeChild(messagesEl.lastChild);
|
||||
inputEl.value = text;
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastChatFailed") + err.message, "error");
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener("click", sendMessage);
|
||||
|
||||
inputEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
chatHistory = [];
|
||||
messagesEl.innerHTML = "";
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wfm-ai-trans-status";
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TOOLS ↔ Chat shared image attachment
|
||||
// ============================================
|
||||
|
||||
let _toolsImage = null; // { file: File, base64: string, mimeType: string }
|
||||
|
||||
function _syncAttachmentUI() {
|
||||
const vlmPreview = document.getElementById("wfm-ai-vlm-preview");
|
||||
const vlmLabel = document.getElementById("wfm-ai-vlm-label");
|
||||
const vlmClear = document.getElementById("wfm-ai-vlm-clear");
|
||||
const chatAttach = document.getElementById("wfm-ai-chat-attachment");
|
||||
const chatThumb = document.getElementById("wfm-ai-chat-attachment-thumb");
|
||||
const chatLabel = document.getElementById("wfm-ai-chat-attachment-label");
|
||||
|
||||
if (_toolsImage) {
|
||||
const dataUrl = `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}`;
|
||||
if (vlmPreview) { vlmPreview.src = dataUrl; vlmPreview.style.display = "block"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "none";
|
||||
if (vlmClear) vlmClear.style.display = "flex";
|
||||
if (chatAttach) chatAttach.style.display = "flex";
|
||||
if (chatThumb) chatThumb.src = dataUrl;
|
||||
if (chatLabel) chatLabel.textContent = t("aiChatAttachmentLabel");
|
||||
} else {
|
||||
if (vlmPreview) { vlmPreview.src = ""; vlmPreview.style.display = "none"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "";
|
||||
if (vlmClear) vlmClear.style.display = "none";
|
||||
if (chatAttach) chatAttach.style.display = "none";
|
||||
if (chatThumb) chatThumb.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _clearToolsImage() {
|
||||
_toolsImage = null;
|
||||
_syncAttachmentUI();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VLM tab
|
||||
// ============================================
|
||||
|
||||
function initVlmTab() {
|
||||
const dropEl = document.getElementById("wfm-ai-vlm-drop");
|
||||
const previewEl = document.getElementById("wfm-ai-vlm-preview");
|
||||
const labelEl = document.getElementById("wfm-ai-vlm-label");
|
||||
const clearEl = document.getElementById("wfm-ai-vlm-clear");
|
||||
const fileInput = document.getElementById("wfm-ai-vlm-file");
|
||||
const taskSel = document.getElementById("wfm-ai-vlm-task");
|
||||
const runBtn = document.getElementById("wfm-ai-vlm-run");
|
||||
const statusEl = document.getElementById("wfm-ai-vlm-status");
|
||||
const resultEl = document.getElementById("wfm-ai-vlm-result");
|
||||
const copyBtn = document.getElementById("wfm-ai-vlm-copy");
|
||||
const wcInputs = document.getElementById("wfm-ai-wc-inputs");
|
||||
const wcNameEl = document.getElementById("wfm-ai-wc-name");
|
||||
const wcCountEl = document.getElementById("wfm-ai-wc-count");
|
||||
|
||||
if (!dropEl) return;
|
||||
|
||||
let vlmImage = null; // { base64, mimeType }
|
||||
function updateTaskUI() {
|
||||
const isWildcard = taskSel?.value === "wildcard";
|
||||
dropEl.style.display = isWildcard ? "none" : "";
|
||||
if (wcInputs) wcInputs.style.display = isWildcard ? "" : "none";
|
||||
}
|
||||
taskSel?.addEventListener("change", updateTaskUI);
|
||||
updateTaskUI();
|
||||
|
||||
const loadImage = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
vlmImage = await fileToBase64(file);
|
||||
previewEl.src = `data:${vlmImage.mimeType};base64,${vlmImage.base64}`;
|
||||
previewEl.style.display = "block";
|
||||
labelEl.style.display = "none";
|
||||
const { base64, mimeType } = await fileToBase64(file);
|
||||
_toolsImage = { file, base64, mimeType };
|
||||
_syncAttachmentUI();
|
||||
};
|
||||
|
||||
dropEl.addEventListener("click", () => fileInput.click());
|
||||
@@ -403,28 +741,61 @@ function initVlmTab() {
|
||||
dropEl.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]);
|
||||
});
|
||||
clearEl?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_clearToolsImage();
|
||||
if (fileInput) fileInput.value = "";
|
||||
});
|
||||
|
||||
runBtn.addEventListener("click", async () => {
|
||||
if (!vlmImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
|
||||
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 (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
|
||||
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
|
||||
|
||||
const task = taskSel?.value || "describe";
|
||||
if (task === "wildcard") {
|
||||
const name = wcNameEl?.value.trim() || "";
|
||||
const count = Math.max(1, parseInt(wcCountEl?.value) || 20);
|
||||
if (!name) { showToast(t("aiToastWcNoName"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
resultEl.value = "";
|
||||
|
||||
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);
|
||||
resultEl.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
.filter(l => l.length > 0)
|
||||
.join("\n");
|
||||
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";
|
||||
showToast(t("aiToastVlmFailed") + err.message, "error");
|
||||
} finally {
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_toolsImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
resultEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType);
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
@@ -450,6 +821,7 @@ function initVlmTab() {
|
||||
|
||||
export function initAiTab() {
|
||||
initTranslateTab();
|
||||
initChatTab();
|
||||
initVlmTab();
|
||||
initSettingsTab();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { initI18n, t } from "./i18n.js";
|
||||
import { getSettings } from "./util.js";
|
||||
|
||||
// Initialize i18n before anything else
|
||||
initI18n();
|
||||
@@ -90,7 +91,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp", ai: "tabAi" };
|
||||
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" };
|
||||
document.querySelectorAll(".wfm-tab").forEach((tab) => {
|
||||
const key = tabMap[tab.dataset.tab];
|
||||
if (key) tab.textContent = t(key);
|
||||
@@ -114,7 +115,7 @@ function applyI18nToHtml() {
|
||||
|
||||
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
|
||||
if (listOpenComfyBtn) {
|
||||
listOpenComfyBtn.textContent = t("openInComfyUI");
|
||||
listOpenComfyBtn.textContent = t("sendToCanvas");
|
||||
if (listOpenComfyBtn.disabled) listOpenComfyBtn.title = t("selectCardFirst");
|
||||
}
|
||||
|
||||
@@ -195,6 +196,31 @@ function applyI18nToHtml() {
|
||||
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
|
||||
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
});
|
||||
const galleryBulkDeselect = document.getElementById("wfm-gallery-bulk-deselect");
|
||||
if (galleryBulkDeselect) galleryBulkDeselect.textContent = t("galleryBulkDeselectAll");
|
||||
const galleryBulkSelectAll = document.getElementById("wfm-gallery-bulk-select-all");
|
||||
if (galleryBulkSelectAll) galleryBulkSelectAll.textContent = t("galleryBulkSelectAll");
|
||||
const galleryBulkGroupSelect = document.getElementById("wfm-gallery-bulk-group-select");
|
||||
if (galleryBulkGroupSelect) {
|
||||
const opt = galleryBulkGroupSelect.querySelector("option[value='']");
|
||||
if (opt) opt.textContent = t("galleryBulkAddToGroup");
|
||||
}
|
||||
const galleryBulkGroupAdd = document.getElementById("wfm-gallery-bulk-group-add");
|
||||
if (galleryBulkGroupAdd) galleryBulkGroupAdd.textContent = t("galleryBulkAdd");
|
||||
const galleryBulkGroupRemove = document.getElementById("wfm-gallery-bulk-group-remove");
|
||||
if (galleryBulkGroupRemove) galleryBulkGroupRemove.textContent = t("galleryBulkGroupRemove");
|
||||
const galleryBulkFav = document.getElementById("wfm-gallery-bulk-fav");
|
||||
if (galleryBulkFav) galleryBulkFav.textContent = t("galleryBulkFavAll");
|
||||
const galleryBulkUnfav = document.getElementById("wfm-gallery-bulk-unfav");
|
||||
if (galleryBulkUnfav) galleryBulkUnfav.textContent = t("galleryBulkUnfavAll");
|
||||
const galleryBulkMove = document.getElementById("wfm-gallery-bulk-move");
|
||||
if (galleryBulkMove) galleryBulkMove.textContent = t("galleryBulkMoveTo");
|
||||
const galleryBulkExport = document.getElementById("wfm-gallery-bulk-export");
|
||||
if (galleryBulkExport) galleryBulkExport.textContent = t("galleryBulkExport");
|
||||
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");
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
@@ -284,6 +310,16 @@ function applyI18nToHtml() {
|
||||
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
|
||||
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
|
||||
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
|
||||
const aiChatImgGenLabel = document.getElementById("wfm-ai-chat-imggen-label-text");
|
||||
if (aiChatImgGenLabel) aiChatImgGenLabel.textContent = t("aiChatImgGenToggle");
|
||||
const aiChatGenTitle = document.getElementById("wfm-ai-settings-chatgen-title");
|
||||
if (aiChatGenTitle) aiChatGenTitle.textContent = t("aiSettingsChatGenTitle");
|
||||
const aiChatGenDedicatedLabel = document.getElementById("wfm-ai-chatgen-dedicated-label");
|
||||
if (aiChatGenDedicatedLabel) aiChatGenDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
const aiChatGenI2ITitle = document.getElementById("wfm-ai-settings-chatgen-i2i-title");
|
||||
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
|
||||
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
|
||||
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
@@ -328,14 +364,23 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
|
||||
"wfm-help-gen-11": "helpGen11", "wfm-help-gen-12": "helpGen12",
|
||||
"wfm-help-gen-13": "helpGen13", "wfm-help-gen-14": "helpGen14",
|
||||
"wfm-help-gen-15": "helpGen15",
|
||||
"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-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
"wfm-help-feeder-imgloop-desc": "helpFeederImgloopDesc",
|
||||
"wfm-help-feeder-1": "helpFeeder1", "wfm-help-feeder-2": "helpFeeder2",
|
||||
"wfm-help-feeder-3": "helpFeeder3", "wfm-help-feeder-4": "helpFeeder4",
|
||||
"wfm-help-feeder-5": "helpFeeder5", "wfm-help-feeder-6": "helpFeeder6",
|
||||
"wfm-help-feeder-7": "helpFeeder7", "wfm-help-feeder-8": "helpFeeder8",
|
||||
"wfm-help-feeder-9": "helpFeeder9",
|
||||
"wfm-help-feeder-gal-title": "helpFeederGalTitle",
|
||||
"wfm-help-feeder-gal-desc": "helpFeederGalDesc",
|
||||
"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-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",
|
||||
@@ -351,19 +396,29 @@ function applyI18nToHtml() {
|
||||
"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",
|
||||
"wfm-help-nodes-7": "helpNodes7",
|
||||
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
|
||||
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
|
||||
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
|
||||
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
|
||||
"wfm-help-models-9": "helpModels9", "wfm-help-models-10": "helpModels10",
|
||||
"wfm-help-models-11": "helpModels11",
|
||||
"wfm-help-models-11": "helpModels11", "wfm-help-models-12": "helpModels12",
|
||||
"wfm-help-gallery-title": "helpGalleryTitle",
|
||||
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
|
||||
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
|
||||
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
|
||||
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
|
||||
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
|
||||
"wfm-help-gallery-11": "helpGallery11",
|
||||
"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-tagger-title": "helpTaggerTitle",
|
||||
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
|
||||
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
|
||||
"wfm-help-tagger-5": "helpTagger5", "wfm-help-tagger-6": "helpTagger6",
|
||||
"wfm-help-tagger-7": "helpTagger7", "wfm-help-tagger-8": "helpTagger8",
|
||||
"wfm-help-tagger-9": "helpTagger9",
|
||||
"wfm-help-sidepanel-title": "helpSidepanelTitle",
|
||||
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
|
||||
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
|
||||
@@ -374,16 +429,70 @@ function applyI18nToHtml() {
|
||||
"wfm-help-sidepanel-13": "helpSidepanel13",
|
||||
"wfm-help-sidepanel-14": "helpSidepanel14", "wfm-help-sidepanel-15": "helpSidepanel15",
|
||||
"wfm-help-sidepanel-16": "helpSidepanel16",
|
||||
"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-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
"wfm-help-imageedit-text-title": "helpImageEditTextTitle",
|
||||
"wfm-help-imageedit-export-title": "helpImageEditExportTitle",
|
||||
"wfm-help-imageedit-kbd-title": "helpImageEditKbdTitle",
|
||||
"wfm-help-imageedit-1": "helpImageEdit1", "wfm-help-imageedit-2": "helpImageEdit2", "wfm-help-imageedit-3": "helpImageEdit3",
|
||||
"wfm-help-imageedit-4": "helpImageEdit4", "wfm-help-imageedit-5": "helpImageEdit5",
|
||||
"wfm-help-imageedit-6": "helpImageEdit6", "wfm-help-imageedit-7": "helpImageEdit7",
|
||||
"wfm-help-imageedit-8": "helpImageEdit8", "wfm-help-imageedit-9": "helpImageEdit9",
|
||||
"wfm-help-imageedit-10": "helpImageEdit10", "wfm-help-imageedit-11": "helpImageEdit11",
|
||||
"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-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-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
|
||||
"wfm-help-imageedit-8b": "helpImageEdit8b",
|
||||
"wfm-help-imageedit-blur-title": "helpImageEditBlurTitle",
|
||||
"wfm-help-imageedit-blur-1": "helpImageEditBlur1", "wfm-help-imageedit-blur-2": "helpImageEditBlur2",
|
||||
"wfm-help-imageedit-blur-3": "helpImageEditBlur3", "wfm-help-imageedit-blur-4": "helpImageEditBlur4",
|
||||
"wfm-help-imageedit-blur-5": "helpImageEditBlur5", "wfm-help-imageedit-blur-6": "helpImageEditBlur6",
|
||||
"wfm-help-imageedit-bgremove-title": "helpImageEditBgRemoveTitle",
|
||||
"wfm-help-imageedit-bgremove-1": "helpImageEditBgRemove1", "wfm-help-imageedit-bgremove-2": "helpImageEditBgRemove2",
|
||||
"wfm-help-imageedit-bgremove-3": "helpImageEditBgRemove3", "wfm-help-imageedit-bgremove-4": "helpImageEditBgRemove4",
|
||||
"wfm-help-imageedit-bgremove-5": "helpImageEditBgRemove5",
|
||||
"wfm-help-imageedit-mask-title": "helpImageEditMaskTitle",
|
||||
"wfm-help-imageedit-mask-1": "helpImageEditMask1", "wfm-help-imageedit-mask-2": "helpImageEditMask2",
|
||||
"wfm-help-imageedit-mask-3": "helpImageEditMask3", "wfm-help-imageedit-mask-4": "helpImageEditMask4",
|
||||
"wfm-help-imageedit-mask-5": "helpImageEditMask5", "wfm-help-imageedit-mask-6": "helpImageEditMask6",
|
||||
"wfm-help-imageedit-mask-7": "helpImageEditMask7", "wfm-help-imageedit-mask-8": "helpImageEditMask8",
|
||||
"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-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",
|
||||
"wfm-help-imageedit-gmic-install-title": "helpImageEditGmicInstallTitle",
|
||||
"wfm-help-imageedit-gmic-install-1": "helpImageEditGmicInstall1",
|
||||
"wfm-help-imageedit-gmic-install-2": "helpImageEditGmicInstall2",
|
||||
"wfm-help-imageedit-gmic-install-3": "helpImageEditGmicInstall3",
|
||||
"wfm-help-imageedit-tabkbd-title": "helpImageEditTabKbdTitle",
|
||||
"wfm-help-imageedit-tabkbd-1": "helpImageEdit22", "wfm-help-imageedit-tabkbd-2": "helpImageEdit23",
|
||||
"wfm-help-imageedit-tabkbd-3": "helpImageEdit24", "wfm-help-imageedit-tabkbd-4": "helpImageEdit25",
|
||||
"wfm-help-imageedit-tabkbd-5": "helpImageEdit26", "wfm-help-imageedit-tabkbd-6": "helpImageEdit27",
|
||||
"wfm-help-imageedit-tabkbd-7": "helpImageEdit28", "wfm-help-imageedit-tabkbd-8": "helpImageEdit29",
|
||||
"wfm-help-shortcuts-title": "helpShortcutsTitle",
|
||||
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
|
||||
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
|
||||
"wfm-help-trouble-title": "helpTroubleTitle",
|
||||
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
|
||||
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
|
||||
"wfm-help-trouble-5": "helpTrouble5",
|
||||
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
|
||||
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
@@ -394,6 +503,41 @@ function applyI18nToHtml() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
// Search box placeholder
|
||||
const helpSearch = document.getElementById("wfm-help-search");
|
||||
if (helpSearch) {
|
||||
helpSearch.placeholder = t("helpSearchPlaceholder");
|
||||
helpSearch.addEventListener("input", _onHelpSearch);
|
||||
}
|
||||
}
|
||||
|
||||
function _onHelpSearch(e) {
|
||||
const q = e.target.value.trim().toLowerCase();
|
||||
const navItems = document.querySelectorAll("[data-help-page]");
|
||||
if (!q) {
|
||||
navItems.forEach(btn => btn.classList.remove("search-hidden"));
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedPages = new Set();
|
||||
document.querySelectorAll(".wfm-help-page").forEach(page => {
|
||||
const pageId = page.id.replace("wfm-help-page-", "");
|
||||
if (page.textContent.toLowerCase().includes(q)) matchedPages.add(pageId);
|
||||
});
|
||||
|
||||
let firstBtn = null;
|
||||
navItems.forEach(btn => {
|
||||
const pageId = btn.dataset.helpPage;
|
||||
if (matchedPages.has(pageId)) {
|
||||
btn.classList.remove("search-hidden");
|
||||
if (!firstBtn) firstBtn = btn;
|
||||
} else {
|
||||
btn.classList.add("search-hidden");
|
||||
}
|
||||
});
|
||||
|
||||
if (firstBtn && !firstBtn.classList.contains("active")) firstBtn.click();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -436,19 +580,16 @@ 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";
|
||||
|
||||
// Apply saved theme immediately to prevent flash of default theme
|
||||
applyTheme(getSavedTheme());
|
||||
|
||||
// Apply saved textarea font size
|
||||
// Apply saved textarea font size & JSON highlight colors
|
||||
try {
|
||||
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
} catch {}
|
||||
|
||||
// Apply saved JSON highlight colors
|
||||
try {
|
||||
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
applyJsonColors(_s.jsonColors);
|
||||
} catch {}
|
||||
|
||||
@@ -478,6 +619,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initMetadataTab();
|
||||
initGalleryTab();
|
||||
initAiTab();
|
||||
initTaggerTab();
|
||||
imageEditTab.init();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -90,11 +90,16 @@ export const comfyUI = {
|
||||
if (info?.[cls]?.input?.required?.[inputKey]) {
|
||||
const inputDef = info[cls].input.required[inputKey];
|
||||
const first = inputDef[0];
|
||||
// V1: [["model1", "model2"], {}]
|
||||
if (Array.isArray(first)) return first;
|
||||
// Newer ComfyUI format: ["COMBO", { "values": [...] }]
|
||||
// V2: ["COMBO", { "values": [...] }]
|
||||
if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) {
|
||||
return inputDef[1].values;
|
||||
}
|
||||
// V3 ComfyNode: ["COMBO", { "options": [...] }]
|
||||
if (typeof first === "string" && Array.isArray(inputDef[1]?.options)) {
|
||||
return inputDef[1].options;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -127,17 +132,19 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
async fetchDiffusionModels() {
|
||||
return this._fetchModelList(
|
||||
["UNETLoader", "UnetLoaderGGUF"],
|
||||
"unet_name"
|
||||
);
|
||||
const [standard, gguf] = await Promise.all([
|
||||
this._fetchModelList(["UNETLoader", "UnetLoaderGGUF"], "unet_name"),
|
||||
this._fetchModelList(["LoaderGGUF", "LoaderGGUFAdvanced"], "gguf_name"),
|
||||
]);
|
||||
return [...new Set([...standard, ...gguf])];
|
||||
},
|
||||
|
||||
async fetchTextEncoders() {
|
||||
return this._fetchModelList(
|
||||
["DualCLIPLoader", "CLIPLoader"],
|
||||
"clip_name1"
|
||||
);
|
||||
const [dual, single] = await Promise.all([
|
||||
this._fetchModelList(["DualCLIPLoader", "DualClipLoaderGGUF"], "clip_name1"),
|
||||
this._fetchModelList(["CLIPLoader", "ClipLoaderGGUF"], "clip_name"),
|
||||
]);
|
||||
return [...new Set([...dual, ...single])];
|
||||
},
|
||||
|
||||
async fetchHypernetworks() {
|
||||
@@ -148,14 +155,17 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
async fetchEmbeddings() {
|
||||
// ComfyUI exposes embeddings via /embeddings endpoint
|
||||
// Use WFS backend API (reliable, goes through same aiohttp server)
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/embeddings`);
|
||||
const res = await fetch("/api/wfm/models/files?type=embedding");
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
} catch {}
|
||||
console.warn("[WFS] /api/wfm/models/files?type=embedding returned", res.status);
|
||||
} catch (e) {
|
||||
console.error("[WFS] fetchEmbeddings error:", e);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
@@ -168,14 +178,16 @@ export const comfyUI = {
|
||||
},
|
||||
|
||||
// Generation
|
||||
async queuePrompt(workflow) {
|
||||
async queuePrompt(workflow, extraData = null) {
|
||||
const body = {
|
||||
prompt: workflow,
|
||||
client_id: this.clientId,
|
||||
};
|
||||
if (extraData) body.extra_data = extraData;
|
||||
const res = await fetch(`${this.baseUrl}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: workflow,
|
||||
client_id: this.clientId,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
@@ -275,8 +287,8 @@ export const comfyUI = {
|
||||
throw new Error("Failed to connect WebSocket");
|
||||
}
|
||||
|
||||
// Queue prompt
|
||||
const result = await this.queuePrompt(workflow);
|
||||
// Queue prompt — pass workflow as extra_pnginfo so SaveImage embeds it in PNG metadata
|
||||
const result = await this.queuePrompt(workflow, { extra_pnginfo: { workflow } });
|
||||
this.currentPromptId = result.prompt_id;
|
||||
|
||||
// Track progress
|
||||
|
||||
@@ -5,6 +5,50 @@
|
||||
// Cache for /object_info (loaded once, shared)
|
||||
let _objectInfoCache = null;
|
||||
|
||||
// convertUiToApi() の COMBO フォールバック(未一致時に選択肢の先頭へ無警告で差し替え)が
|
||||
// ckpt_name に対して発生した場合の記録。呼び出し元が読み込み直後に確認して警告表示できるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastCheckpointSubstitutions = [];
|
||||
|
||||
// convertUiToApi() で除外された Bypass(mode:4) / Mute(mode:2) ノードの記録。
|
||||
// API形式には mode 情報が残らないため、呼び出し元がRaw JSON表示時に
|
||||
// 「このワークフローにはバイパス/ミュート中のノードがあった」と伝えられるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastBypassedNodes = [];
|
||||
let _lastMutedNodes = [];
|
||||
|
||||
/**
|
||||
* Trace a link's source through any Bypass-mode (mode 4) nodes, matching each bypassed
|
||||
* node's output slot to an input slot of the same type and following the connection
|
||||
* upstream (recursively, for chained bypasses). Mirrors ComfyUI's own Bypass behavior,
|
||||
* where a bypassed node is removed but its wires are reconnected end-to-end.
|
||||
* Returns null when no pass-through source exists (the matching input is unconnected),
|
||||
* in which case the caller should treat the input as unlinked.
|
||||
*/
|
||||
function _resolveBypassSource(nodeById, linkMap, nodeId, slot, visited) {
|
||||
const idStr = String(nodeId);
|
||||
const node = nodeById[idStr];
|
||||
if (!node || node.mode !== 4) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass chain guard
|
||||
|
||||
visited.add(idStr);
|
||||
const outputs = node.outputs || [];
|
||||
const inputs = node.inputs || [];
|
||||
const outType = outputs[slot]?.type;
|
||||
|
||||
let inIdx = -1;
|
||||
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
|
||||
inIdx = slot;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
}
|
||||
if (inIdx === -1) return null;
|
||||
|
||||
const upstream = linkMap[idStr][inIdx];
|
||||
if (!upstream) return null;
|
||||
return _resolveBypassSource(nodeById, linkMap, upstream[0], upstream[1], visited);
|
||||
}
|
||||
|
||||
async function _loadObjectInfo() {
|
||||
if (_objectInfoCache) return _objectInfoCache;
|
||||
try {
|
||||
@@ -267,7 +311,22 @@ export const comfyWorkflow = {
|
||||
/**
|
||||
* Convert UI-format workflow to API-format using /object_info for accurate widget mapping.
|
||||
*/
|
||||
getLastCheckpointSubstitutions() {
|
||||
return _lastCheckpointSubstitutions;
|
||||
},
|
||||
|
||||
getLastBypassedNodes() {
|
||||
return _lastBypassedNodes;
|
||||
},
|
||||
|
||||
getLastMutedNodes() {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
_lastMutedNodes = [];
|
||||
if (!workflow.nodes || !workflow.links) return {};
|
||||
|
||||
// Flatten subgraphs before conversion
|
||||
@@ -282,10 +341,23 @@ export const comfyWorkflow = {
|
||||
if (!linkMap[dstNode]) linkMap[dstNode] = {};
|
||||
linkMap[dstNode][dstSlot] = [String(srcNode), srcSlot];
|
||||
}
|
||||
const nodeById = {};
|
||||
for (const n of flatWorkflow.nodes) nodeById[String(n.id)] = n;
|
||||
|
||||
const api = {};
|
||||
for (const node of flatWorkflow.nodes) {
|
||||
if (node.mode === 4) continue; // muted/bypassed
|
||||
// Mute (mode 2): excluded, no pass-through — matches ComfyUI itself, where
|
||||
// muting a node with required downstream dependents is expected to error.
|
||||
// Bypass (mode 4): excluded here too, but its wires are rerouted below via
|
||||
// _resolveBypassSource() so downstream nodes reconnect to its upstream source.
|
||||
if (node.mode === 2) {
|
||||
_lastMutedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
if (node.mode === 4) {
|
||||
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
// Skip note/display-only nodes that are unknown to ComfyUI backend
|
||||
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
|
||||
const nodeId = String(node.id);
|
||||
@@ -301,7 +373,9 @@ export const comfyWorkflow = {
|
||||
const linkedSlotNames = new Set();
|
||||
inputDefs.forEach((inp, idx) => {
|
||||
if (nodeLinks[idx]) {
|
||||
inputs[inp.name] = nodeLinks[idx];
|
||||
const resolved = _resolveBypassSource(nodeById, linkMap, nodeLinks[idx][0], nodeLinks[idx][1], new Set());
|
||||
if (!resolved) return; // Bypass chain with no upstream source — leave unlinked
|
||||
inputs[inp.name] = resolved;
|
||||
linkedInputNames.add(inp.name);
|
||||
linkedSlotNames.add(inp.name);
|
||||
}
|
||||
@@ -379,6 +453,14 @@ export const comfyWorkflow = {
|
||||
if (spec) {
|
||||
const choices = Array.isArray(spec[0]) ? spec[0] : null;
|
||||
if (choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (name === "ckpt_name") {
|
||||
_lastCheckpointSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
title: node.title || node.type,
|
||||
original: val,
|
||||
replaced: choices[0],
|
||||
});
|
||||
}
|
||||
val = choices[0];
|
||||
}
|
||||
}
|
||||
@@ -399,6 +481,14 @@ export const comfyWorkflow = {
|
||||
} // end else (non-LoraManager path)
|
||||
}
|
||||
|
||||
// ImpactWildcardEncode / ImpactWildcardProcessor: widgets[0] is always wildcard_text.
|
||||
// Always overwrite: object_info ordering may misalign when widget order differs between
|
||||
// impact-pack versions; widgets[0] is stable across all known versions.
|
||||
if ((node.type === "ImpactWildcardEncode" || node.type === "ImpactWildcardProcessor")
|
||||
&& widgets.length > 0) {
|
||||
inputs["wildcard_text"] = widgets[0];
|
||||
}
|
||||
|
||||
api[nodeId] = {
|
||||
class_type: node.type,
|
||||
inputs,
|
||||
@@ -479,9 +569,12 @@ export const comfyWorkflow = {
|
||||
vae_nodes: [],
|
||||
save_nodes: [],
|
||||
load_image_nodes: [],
|
||||
inpaint_encode_nodes: [],
|
||||
mask_editor_one_nodes: [],
|
||||
text_encoder_nodes: [],
|
||||
diffusion_model_nodes: [],
|
||||
controlnet_nodes: [],
|
||||
hypernetwork_nodes: [],
|
||||
all_nodes: [],
|
||||
};
|
||||
|
||||
@@ -520,7 +613,10 @@ export const comfyWorkflow = {
|
||||
// Nodes that pass CONDITIONING through without changing positive/negative role
|
||||
const COND_PASSTHROUGH = new Set([
|
||||
"ConditioningCombine", "ConditioningConcat", "ConditioningAverage",
|
||||
"ConditioningZeroOut", "ConditioningSetTimestepRange",
|
||||
"ConditioningSetTimestepRange",
|
||||
// ConditioningZeroOut is intentionally excluded: it discards upstream text entirely
|
||||
// (used as a no-text negative in ZIT/Lumina2 workflows). Propagating the negative
|
||||
// role upstream would incorrectly mark the shared positive CLIPTextEncode as "unknown".
|
||||
"ControlNetApply", "ControlNetApplyAdvanced",
|
||||
"IPAdapterApply", "IPAdapterApplyFaceID",
|
||||
"StyleModelApply",
|
||||
@@ -568,6 +664,16 @@ export const comfyWorkflow = {
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
// CLIPTextEncodeEditPlus — propagate role to its STRING inputs (text1, text2)
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
for (const key of ["text1", "text2"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,10 +748,12 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// CLIPTextEncodeEditPlus — text_edit is the locally editable override; text1 is always a link
|
||||
// CLIPTextEncodeEditPlus — text_edit is the locally editable override; text1 is always a link.
|
||||
// Skip when text_edit is empty: it means upstream nodes (ImpactWildcardEncode etc.) supply
|
||||
// the actual prompt text and the edit field is just an optional local override.
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
const textVal = inputs.text_edit;
|
||||
if (typeof textVal === "string") {
|
||||
if (typeof textVal === "string" && textVal !== "") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "text_edit",
|
||||
@@ -689,6 +797,22 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// WFS_PromptText — 2出力ノード。output[0]=positive / output[1]=negative
|
||||
if (ct === "WFS_PromptText") {
|
||||
if (typeof inputs.positive === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.positive, textKey: "positive",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative, textKey: "negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PrimitiveStringMultiline / PrimitiveString — when feeding a prompt node
|
||||
if ((ct === "PrimitiveStringMultiline" || ct === "PrimitiveString") && (isPos || isNeg)) {
|
||||
result.prompt_nodes.push({
|
||||
@@ -769,19 +893,42 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// --- VAE Encode (for Inpainting) nodes ---
|
||||
if (ct === "VAEEncodeForInpaint") {
|
||||
result.inpaint_encode_nodes.push({
|
||||
id, type: ct, title,
|
||||
grow_mask_by: inputs.grow_mask_by,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mask Editor One nodes (comfyui-mask-editor-one) ---
|
||||
// 画像とマスクをノード自身が内包する(LoadImageを介さない)インペイント画像ソース。
|
||||
// image出力はサーバー側キャッシュ(bg_image_b64、node_idキー)から、mask出力は
|
||||
// layer_data(JSON文字列)ウィジェットの各レイヤーのアルファチャンネルから合成される。
|
||||
if (ct === "MaskEditorOne") {
|
||||
result.mask_editor_one_nodes.push({ id, type: ct, title });
|
||||
}
|
||||
|
||||
// --- Text encoder (CLIP) nodes ---
|
||||
if (ct === "DualCLIPLoader" || ct === "CLIPLoader") {
|
||||
if (ct === "DualCLIPLoader" || ct === "CLIPLoader" || ct === "ClipLoaderGGUF" || ct === "DualClipLoaderGGUF") {
|
||||
result.text_encoder_nodes.push({
|
||||
id, type: ct, title,
|
||||
clip_name1: inputs.clip_name1 || inputs.clip_name,
|
||||
clip_name2: inputs.clip_name2,
|
||||
clip_type: inputs.type,
|
||||
device: inputs.device,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Diffusion model nodes ---
|
||||
if (ct === "UNETLoader" || ct === "UnetLoaderGGUF") {
|
||||
result.diffusion_model_nodes.push({
|
||||
id, type: ct, title, unet_name: inputs.unet_name,
|
||||
id, type: ct, title, unet_name: inputs.unet_name, inputKey: "unet_name",
|
||||
});
|
||||
}
|
||||
if (ct === "LoaderGGUF" || ct === "LoaderGGUFAdvanced") {
|
||||
result.diffusion_model_nodes.push({
|
||||
id, type: ct, title, unet_name: inputs.gguf_name, inputKey: "gguf_name",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -792,6 +939,33 @@ export const comfyWorkflow = {
|
||||
control_net_name: inputs.control_net_name,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hypernetwork loader nodes ---
|
||||
if (ct === "HypernetworkLoader") {
|
||||
result.hypernetwork_nodes.push({
|
||||
id, type: ct, title,
|
||||
hypernetwork_name: inputs.hypernetwork_name,
|
||||
strength: inputs.strength ?? 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Post-pass: flag LoadImage nodes whose MASK output (slot 1) is wired to something
|
||||
// downstream (directly, or via a pass-through node like MaskEditorNode). These are the
|
||||
// "inpaint-capable" slots — image + mask both come from the same node's two outputs, so
|
||||
// painting a mask for them only requires re-uploading that one node's image with the mask
|
||||
// baked into the alpha channel (matches ComfyUI's native LoadImage alpha→MASK extraction). ---
|
||||
const loadImageIds = new Set(result.load_image_nodes.filter(n => n.type === "LoadImage").map(n => n.id));
|
||||
if (loadImageIds.size > 0) {
|
||||
for (const node of Object.values(workflow)) {
|
||||
if (!node?.inputs) continue;
|
||||
for (const v of Object.values(node.inputs)) {
|
||||
if (Array.isArray(v) && v.length === 2 && Number(v[1]) === 1 && loadImageIds.has(String(v[0]))) {
|
||||
const target = result.load_image_nodes.find(n => n.id === String(v[0]));
|
||||
if (target) target.mask_used = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.all_nodes.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
@@ -846,11 +1020,20 @@ function _getWidgetMapping(nodeType) {
|
||||
SaveImage: ["filename_prefix"],
|
||||
LoadImage: ["image", "upload"],
|
||||
UNETLoader: ["unet_name", "weight_dtype"],
|
||||
LoaderGGUF: ["gguf_name"],
|
||||
LoaderGGUFAdvanced: ["gguf_name", "dequant_dtype", "patch_dtype", "patch_on_device"],
|
||||
ResolutionSelector: ["aspect_ratio", "megapixels"],
|
||||
EmptySD3LatentImage: ["width", "height", "batch_size"],
|
||||
CLIPLoader: ["clip_name", "type", "device"],
|
||||
ClipLoaderGGUF: ["clip_name", "type"],
|
||||
DualClipLoaderGGUF: ["clip_name1", "clip_name2", "type"],
|
||||
FluxGuidance: ["guidance"],
|
||||
CFGNorm: ["strength"],
|
||||
// Impact Pack wildcard nodes — only map wildcard_text (index 0); remaining widgets
|
||||
// vary between versions and include frontend-only extras, so we leave them unmapped.
|
||||
ImpactWildcardProcessor: ["wildcard_text"],
|
||||
ImpactWildcardEncode: ["wildcard_text"],
|
||||
WFS_PromptText: ["positive", "negative"],
|
||||
};
|
||||
return mappings[nodeType] || null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
/**
|
||||
* Feeder Tab - ImageFeeder node control with embedded image library
|
||||
* Feeder Tab - ImageFeeder node control (Image Loop mode)
|
||||
* + WFS_GalleryFeeder node control (Gallery mode)
|
||||
*/
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { FEEDER_GROUP, ensureFeederGroup } from "./gallery-tab.js";
|
||||
|
||||
// ── Module State ──────────────────────────────────────────────────
|
||||
const _s = {
|
||||
dir: "", // current folder relative path
|
||||
images: [], // filenames in current folder
|
||||
selected: new Set(),// selected filenames (across all interactions)
|
||||
dir: "", // current folder relative path (loop mode)
|
||||
images: [], // filenames in current folder (loop mode)
|
||||
selected: new Set(),// selected filenames (loop mode)
|
||||
presets: {}, // {presetName: {directory, selected_files}}
|
||||
running: false, // feeder run loop active
|
||||
mode: localStorage.getItem("wfm_feeder_mode") || "loop", // "loop" | "gallery"
|
||||
};
|
||||
|
||||
// Gallery mode state
|
||||
const _gs = {
|
||||
groups: [], // [{name}]
|
||||
group: FEEDER_GROUP, // selected group name
|
||||
images: [], // absolute paths in selected group
|
||||
idx: 0, // current index
|
||||
};
|
||||
|
||||
// ── Node helpers ──────────────────────────────────────────────────
|
||||
@@ -24,12 +36,40 @@ function _feederNodes() {
|
||||
.map(([id, n]) => ({ id, title: n._meta?.title || `ImageFeeder` }));
|
||||
}
|
||||
|
||||
function _galFeederNodes() {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf) return [];
|
||||
return Object.entries(wf)
|
||||
.filter(([, n]) => n?.class_type === "WFS_GalleryFeeder")
|
||||
.map(([id, n]) => ({ id, title: n._meta?.title || `GalleryFeeder` }));
|
||||
}
|
||||
|
||||
export function refreshFeederNodeList() {
|
||||
const sel = document.getElementById("wfm-feeder-node-sel");
|
||||
if (_s.mode === "gallery") {
|
||||
_refreshGalNodeList();
|
||||
} else {
|
||||
const sel = document.getElementById("wfm-feeder-node-sel");
|
||||
if (!sel) return;
|
||||
const nodes = _feederNodes();
|
||||
if (nodes.length === 0) {
|
||||
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
|
||||
return;
|
||||
}
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = nodes.map(n =>
|
||||
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
|
||||
).join("");
|
||||
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
|
||||
_loadFromNode(sel.value || nodes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
function _refreshGalNodeList() {
|
||||
const sel = document.getElementById("wfm-feeder-gal-node-sel");
|
||||
if (!sel) return;
|
||||
const nodes = _feederNodes();
|
||||
const nodes = _galFeederNodes();
|
||||
if (nodes.length === 0) {
|
||||
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
|
||||
sel.innerHTML = `<option value="">-- No WFS_GalleryFeeder node --</option>`;
|
||||
return;
|
||||
}
|
||||
const prevVal = sel.value;
|
||||
@@ -37,7 +77,7 @@ export function refreshFeederNodeList() {
|
||||
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
|
||||
).join("");
|
||||
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
|
||||
_loadFromNode(sel.value || nodes[0].id);
|
||||
_loadFromGalNode(sel.value || nodes[0].id);
|
||||
}
|
||||
|
||||
function _loadFromNode(nodeId) {
|
||||
@@ -54,7 +94,6 @@ function _loadFromNode(nodeId) {
|
||||
_setVal("wfm-feeder-seed", inp.seed ?? 0);
|
||||
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
|
||||
|
||||
// Restore selected_files
|
||||
_s.selected.clear();
|
||||
try {
|
||||
const arr = JSON.parse(inp.selected_files || "[]");
|
||||
@@ -72,15 +111,44 @@ function _loadFromNode(nodeId) {
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
function _loadFromGalNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf?.[nodeId]) return;
|
||||
const inp = wf[nodeId].inputs || {};
|
||||
|
||||
const group = inp.group_name || FEEDER_GROUP;
|
||||
_setVal("wfm-feeder-gal-sort", inp.sort_mode || "filename_asc");
|
||||
_setVal("wfm-feeder-gal-index", inp.index ?? 0);
|
||||
_setVal("wfm-feeder-gal-seed", inp.seed ?? 0);
|
||||
_gs.idx = inp.index ?? 0;
|
||||
|
||||
if (_gs.group !== group) {
|
||||
_gs.group = group;
|
||||
_setGalGroupSelect(group);
|
||||
_loadGalImages(group);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyToWorkflow() {
|
||||
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!nodeId || !wf?.[nodeId]) {
|
||||
showToast("No ImageFeeder node selected", "error");
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
_applyToNode(nodeId);
|
||||
showToast("Applied to workflow", "success");
|
||||
showToast(t("appliedToWorkflow"), "success");
|
||||
}
|
||||
|
||||
function _applyGalToWorkflow() {
|
||||
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!nodeId || !wf?.[nodeId]) {
|
||||
showToast(t("feederGalNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
_applyToGalNode(nodeId);
|
||||
showToast(t("appliedToWorkflow"), "success");
|
||||
}
|
||||
|
||||
// ── Run Loop ──────────────────────────────────────────────────────
|
||||
@@ -92,6 +160,13 @@ function _setRunUI(running) {
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
}
|
||||
|
||||
function _setGalRunUI(running) {
|
||||
const runBtn = document.getElementById("wfm-feeder-gal-run-btn");
|
||||
const stopBtn = document.getElementById("wfm-feeder-gal-stop-btn");
|
||||
if (runBtn) runBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
}
|
||||
|
||||
function _applyToNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
@@ -106,20 +181,28 @@ function _applyToNode(nodeId) {
|
||||
inp.selected_files = JSON.stringify([..._s.selected]);
|
||||
}
|
||||
|
||||
function _applyToGalNode(nodeId) {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
const inp = wf[nodeId].inputs;
|
||||
inp.group_name = _gs.group;
|
||||
inp.index = _gs.idx;
|
||||
inp.sort_mode = document.getElementById("wfm-feeder-gal-sort")?.value || "filename_asc";
|
||||
inp.seed = parseInt(document.getElementById("wfm-feeder-gal-seed")?.value) || 0;
|
||||
}
|
||||
|
||||
async function _startRun() {
|
||||
if (_s.running) return;
|
||||
if (!comfyUI.currentWorkflow) { showToast("No workflow loaded", "error"); return; }
|
||||
if (!comfyUI.connected) { showToast("Not connected to ComfyUI", "error"); return; }
|
||||
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
|
||||
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
|
||||
|
||||
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
|
||||
showToast("No ImageFeeder node selected", "error");
|
||||
showToast(t("feederNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// WebSocket を先に接続してノード sync メッセージを受け取れるようにする
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) { showToast("WebSocket connection failed", "error"); return; }
|
||||
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
|
||||
|
||||
_s.running = true;
|
||||
_setRunUI(true);
|
||||
@@ -128,7 +211,6 @@ async function _startRun() {
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
|
||||
// image_loop_node_sync メッセージを捕捉するリスナー
|
||||
let _lastSync = null;
|
||||
const _syncHandler = (ev) => {
|
||||
try {
|
||||
@@ -156,7 +238,6 @@ async function _startRun() {
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = `#${++count} 0%`;
|
||||
|
||||
// 右ペインのseed設定を使用(KSamplerに適用される)
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
|
||||
@@ -172,7 +253,6 @@ async function _startRun() {
|
||||
}
|
||||
);
|
||||
|
||||
// 右ペインのseed表示を更新(通常の生成と同じ挙動)
|
||||
const seedEl = document.getElementById("wfm-gen-seed-value");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
@@ -186,7 +266,6 @@ async function _startRun() {
|
||||
|
||||
if (!_s.running) break;
|
||||
|
||||
// control after generate: ノードから返された next_index を反映
|
||||
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
|
||||
if (control === "fixed") {
|
||||
// index を変えない
|
||||
@@ -195,14 +274,13 @@ async function _startRun() {
|
||||
if (indexEl) indexEl.value = _lastSync.next_index;
|
||||
if (control === "increment" && !_lastSync.has_next) {
|
||||
_s.running = false;
|
||||
showToast(`Feeder complete (${count} generated)`, "success");
|
||||
showToast(t("feederComplete", count), "success");
|
||||
break;
|
||||
}
|
||||
// loop: has_next=false でも次は index=0 からなので続行
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (_s.running) showToast("Feeder run error: " + err.message, "error");
|
||||
if (_s.running) showToast(t("feederRunError", err.message), "error");
|
||||
} finally {
|
||||
comfyUI.socket?.removeEventListener("message", _syncHandler);
|
||||
_s.running = false;
|
||||
@@ -215,10 +293,107 @@ async function _stopRun() {
|
||||
_s.running = false;
|
||||
try { await comfyUI.interrupt(); } catch {}
|
||||
_setRunUI(false);
|
||||
showToast("Feeder stopped", "info");
|
||||
showToast(t("feederStopped"), "info");
|
||||
}
|
||||
|
||||
// ── Folder Tree ───────────────────────────────────────────────────
|
||||
// ── Gallery Mode Run Loop ─────────────────────────────────────────
|
||||
|
||||
async function _startGalRun() {
|
||||
if (_s.running) return;
|
||||
if (!comfyUI.currentWorkflow) { showToast(t("noWorkflowLoaded"), "error"); return; }
|
||||
if (!comfyUI.connected) { showToast(t("notConnectedToComfyUI"), "error"); return; }
|
||||
|
||||
const nodeId = document.getElementById("wfm-feeder-gal-node-sel")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
|
||||
showToast(t("feederGalNoNode"), "error");
|
||||
return;
|
||||
}
|
||||
if (_gs.images.length === 0) {
|
||||
showToast(t("feederGalEmptyGroup"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const wsOk = await comfyUI.connectWebSocket();
|
||||
if (!wsOk) { showToast(t("wsConnectionFailed"), "error"); return; }
|
||||
|
||||
_s.running = true;
|
||||
_setGalRunUI(true);
|
||||
|
||||
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 indexEl = document.getElementById("wfm-feeder-gal-index");
|
||||
|
||||
const total = _gs.images.length;
|
||||
let count = 0;
|
||||
try {
|
||||
while (_s.running) {
|
||||
_applyToGalNode(nodeId);
|
||||
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = `#${++count} [${_gs.idx + 1}/${total}] 0%`;
|
||||
|
||||
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
|
||||
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
|
||||
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
{ ...comfyUI.currentWorkflow },
|
||||
{
|
||||
seedMode,
|
||||
seedValue,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `#${count} [${_gs.idx + 1}/${total}] ${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seedEl = document.getElementById("wfm-gen-seed-value");
|
||||
if (seedEl) seedEl.value = seed;
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0 && resultImg) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
resultImg.src = URL.createObjectURL(blob);
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (!_s.running) break;
|
||||
|
||||
const control = document.getElementById("wfm-feeder-gal-control-after")?.value || "loop";
|
||||
const nextIdx = (_gs.idx + 1) % total;
|
||||
const isLast = _gs.idx === total - 1;
|
||||
|
||||
if (control === "fixed") {
|
||||
// idx を変えない
|
||||
} else if (control === "increment" && isLast) {
|
||||
_s.running = false;
|
||||
showToast(t("feederComplete", count), "success");
|
||||
break;
|
||||
} else {
|
||||
_gs.idx = nextIdx;
|
||||
if (indexEl) indexEl.value = _gs.idx;
|
||||
_highlightGalCard(_gs.images[_gs.idx]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (_s.running) showToast(t("feederRunError", err.message), "error");
|
||||
} finally {
|
||||
_s.running = false;
|
||||
_setGalRunUI(false);
|
||||
if (progressText) progressText.textContent = `Done (${count} generated)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _stopGalRun() {
|
||||
_s.running = false;
|
||||
try { await comfyUI.interrupt(); } catch {}
|
||||
_setGalRunUI(false);
|
||||
showToast(t("feederStopped"), "info");
|
||||
}
|
||||
|
||||
// ── Folder Tree (loop mode) ───────────────────────────────────────
|
||||
|
||||
async function _loadTree() {
|
||||
const container = document.getElementById("wfm-feeder-tree");
|
||||
@@ -275,7 +450,7 @@ function _highlightTreeRow(path) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image Grid ────────────────────────────────────────────────────
|
||||
// ── Image Grid (loop mode) ────────────────────────────────────────
|
||||
|
||||
async function _loadImages(dir) {
|
||||
const grid = document.getElementById("wfm-feeder-grid");
|
||||
@@ -366,8 +541,6 @@ function _deselectAll() {
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
// ── Status Bar ───────────────────────────────────────────────────
|
||||
|
||||
function _updateStatus() {
|
||||
const el = document.getElementById("wfm-feeder-status");
|
||||
if (!el) return;
|
||||
@@ -375,6 +548,104 @@ function _updateStatus() {
|
||||
el.textContent = `📂 ${_s.dir || "(root)"} | ${inCurrent} / ${_s.images.length} selected`;
|
||||
}
|
||||
|
||||
// ── Gallery Mode: Group & Image Grid ─────────────────────────────
|
||||
|
||||
async function _loadGalGroups() {
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/groups");
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_gs.groups = data.groups || [];
|
||||
_renderGalGroupSelect();
|
||||
} catch (err) {
|
||||
console.warn("[Feeder] loadGalGroups failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGalGroupSelect() {
|
||||
const sel = document.getElementById("wfm-feeder-gal-group");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = _gs.groups
|
||||
.map(g => `<option value="${g.name}"${g.name === _gs.group ? " selected" : ""}>${g.name}</option>`)
|
||||
.join("");
|
||||
if (!_gs.groups.find(g => g.name === _gs.group) && _gs.groups.length > 0) {
|
||||
_gs.group = _gs.groups[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
function _setGalGroupSelect(group) {
|
||||
const sel = document.getElementById("wfm-feeder-gal-group");
|
||||
if (sel) sel.value = group;
|
||||
}
|
||||
|
||||
async function _loadGalImages(group) {
|
||||
const grid = document.getElementById("wfm-feeder-gal-grid");
|
||||
const statusEl = document.getElementById("wfm-feeder-gal-status");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/groups/${encodeURIComponent(group)}/images`);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
_gs.images = data.images || [];
|
||||
_gs.idx = 0;
|
||||
const idxEl = document.getElementById("wfm-feeder-gal-index");
|
||||
if (idxEl) idxEl.value = 0;
|
||||
_renderGalGrid();
|
||||
if (statusEl) statusEl.textContent = `${_gs.images.length} image(s) in "${group}"`;
|
||||
} catch (err) {
|
||||
_gs.images = [];
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGalGrid() {
|
||||
const grid = document.getElementById("wfm-feeder-gal-grid");
|
||||
if (!grid) return;
|
||||
if (_gs.images.length === 0) {
|
||||
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images in this group</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
_gs.images.forEach((absPath, i) => grid.appendChild(_makeGalCard(absPath, i)));
|
||||
}
|
||||
|
||||
function _makeGalCard(absPath, idx) {
|
||||
const fname = absPath.replace(/\\/g, "/").split("/").pop();
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-feeder-card" + (idx === _gs.idx ? " selected" : "");
|
||||
card.dataset.path = absPath;
|
||||
card.dataset.idx = idx;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "wfm-feeder-card-img";
|
||||
img.loading = "lazy";
|
||||
img.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
|
||||
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "wfm-feeder-card-name";
|
||||
name.textContent = fname;
|
||||
name.title = fname;
|
||||
|
||||
card.append(img, name);
|
||||
card.addEventListener("click", () => {
|
||||
_gs.idx = idx;
|
||||
const idxEl = document.getElementById("wfm-feeder-gal-index");
|
||||
if (idxEl) idxEl.value = idx;
|
||||
_highlightGalCard(absPath);
|
||||
_showGalPreview(absPath, fname);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function _highlightGalCard(absPath) {
|
||||
document.querySelectorAll("#wfm-feeder-gal-grid .wfm-feeder-card").forEach(c => {
|
||||
c.classList.toggle("selected", c.dataset.path === absPath);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Preview Panel ────────────────────────────────────────────────
|
||||
|
||||
async function _showPreview(relPath, fname) {
|
||||
@@ -399,7 +670,20 @@ async function _showPreview(relPath, fname) {
|
||||
} catch { if (infoEl) infoEl.textContent = ""; }
|
||||
}
|
||||
|
||||
// ── Presets ──────────────────────────────────────────────────────
|
||||
async function _showGalPreview(absPath, fname) {
|
||||
const imgEl = document.getElementById("wfm-feeder-preview-img");
|
||||
const nameEl = document.getElementById("wfm-feeder-preview-name");
|
||||
const infoEl = document.getElementById("wfm-feeder-preview-info");
|
||||
|
||||
if (imgEl) {
|
||||
imgEl.src = `/wfm/gallery/image/serve?path=${encodeURIComponent(absPath)}`;
|
||||
imgEl.style.display = "block";
|
||||
}
|
||||
if (nameEl) nameEl.textContent = fname;
|
||||
if (infoEl) infoEl.textContent = "";
|
||||
}
|
||||
|
||||
// ── Presets (loop mode) ───────────────────────────────────────────
|
||||
|
||||
async function _loadPresets() {
|
||||
try {
|
||||
@@ -420,7 +704,7 @@ function _renderPresets() {
|
||||
|
||||
async function _savePreset() {
|
||||
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
|
||||
if (!name) { showToast("Enter a preset name", "error"); return; }
|
||||
if (!name) { showToast(t("enterPresetName"), "error"); return; }
|
||||
const sel = _s.images.filter(f => _s.selected.has(f));
|
||||
try {
|
||||
const res = await fetch("/image_feeder/presets", {
|
||||
@@ -429,20 +713,20 @@ async function _savePreset() {
|
||||
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(`Preset saved: ${name}`, "success");
|
||||
showToast(t("presetSavedName", name), "success");
|
||||
const nameInput = document.getElementById("wfm-feeder-preset-name");
|
||||
if (nameInput) nameInput.value = "";
|
||||
await _loadPresets();
|
||||
const selEl = document.getElementById("wfm-feeder-preset-sel");
|
||||
if (selEl) selEl.value = name;
|
||||
} else { showToast("Save failed", "error"); }
|
||||
} catch (err) { showToast("Save error: " + err.message, "error"); }
|
||||
} else { showToast(t("saveFailed"), "error"); }
|
||||
} catch (err) { showToast(t("saveFailed", err.message), "error"); }
|
||||
}
|
||||
|
||||
async function _applyPreset() {
|
||||
const selEl = document.getElementById("wfm-feeder-preset-sel");
|
||||
const name = selEl?.value;
|
||||
if (!name || !_s.presets[name]) { showToast("Select a preset first", "error"); return; }
|
||||
if (!name || !_s.presets[name]) { showToast(t("selectPresetFirst"), "error"); return; }
|
||||
const preset = _s.presets[name];
|
||||
const dir = preset.directory || "";
|
||||
|
||||
@@ -457,7 +741,7 @@ async function _applyPreset() {
|
||||
}
|
||||
_refreshGridCbs();
|
||||
_updateStatus();
|
||||
showToast(`Preset loaded: ${name}`, "success");
|
||||
showToast(t("presetLoaded", name), "success");
|
||||
}
|
||||
|
||||
async function _deletePreset() {
|
||||
@@ -465,9 +749,37 @@ async function _deletePreset() {
|
||||
if (!name) return;
|
||||
try {
|
||||
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
showToast(`Preset deleted: ${name}`, "info");
|
||||
showToast(t("presetDeleted", name), "info");
|
||||
await _loadPresets();
|
||||
} catch (err) { showToast("Delete error", "error"); }
|
||||
} catch (err) { showToast(t("deleteFailed"), "error"); }
|
||||
}
|
||||
|
||||
// ── Mode Switch ───────────────────────────────────────────────────
|
||||
|
||||
function _setMode(mode) {
|
||||
_s.mode = mode;
|
||||
localStorage.setItem("wfm_feeder_mode", mode);
|
||||
|
||||
const loopBtn = document.getElementById("wfm-feeder-mode-loop");
|
||||
const galBtn = document.getElementById("wfm-feeder-mode-gallery");
|
||||
const loopSec = document.getElementById("wfm-feeder-loop-section");
|
||||
const galSec = document.getElementById("wfm-feeder-gal-section");
|
||||
const loopLib = document.getElementById("wfm-feeder-loop-library");
|
||||
const galLib = document.getElementById("wfm-feeder-gal-library");
|
||||
|
||||
const isGallery = mode === "gallery";
|
||||
|
||||
if (loopBtn) loopBtn.classList.toggle("wfm-btn-primary", !isGallery);
|
||||
if (galBtn) galBtn.classList.toggle("wfm-btn-primary", isGallery);
|
||||
if (loopSec) loopSec.style.display = isGallery ? "none" : "";
|
||||
if (galSec) galSec.style.display = isGallery ? "" : "none";
|
||||
if (loopLib) loopLib.style.display = isGallery ? "none" : "";
|
||||
if (galLib) galLib.style.display = isGallery ? "" : "none";
|
||||
|
||||
if (isGallery) {
|
||||
_loadGalGroups().then(() => _loadGalImages(_gs.group));
|
||||
_refreshGalNodeList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
@@ -484,6 +796,11 @@ function _setCheck(id, val) {
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function initFeederTab() {
|
||||
// Mode toggle
|
||||
document.getElementById("wfm-feeder-mode-loop")?.addEventListener("click", () => _setMode("loop"));
|
||||
document.getElementById("wfm-feeder-mode-gallery")?.addEventListener("click", () => _setMode("gallery"));
|
||||
|
||||
// ── Image Loop mode events ──
|
||||
document.getElementById("wfm-feeder-node-sel")?.addEventListener("change", e => {
|
||||
_loadFromNode(e.target.value);
|
||||
});
|
||||
@@ -510,6 +827,33 @@ export async function initFeederTab() {
|
||||
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
|
||||
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
|
||||
|
||||
await Promise.all([_loadTree(), _loadPresets()]);
|
||||
_selectDir("");
|
||||
// ── Gallery mode events ──
|
||||
document.getElementById("wfm-feeder-gal-node-sel")?.addEventListener("change", e => {
|
||||
_loadFromGalNode(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-apply-btn")?.addEventListener("click", _applyGalToWorkflow);
|
||||
|
||||
document.getElementById("wfm-feeder-gal-group")?.addEventListener("change", e => {
|
||||
_gs.group = e.target.value;
|
||||
_gs.idx = 0;
|
||||
_loadGalImages(_gs.group);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-index")?.addEventListener("change", e => {
|
||||
_gs.idx = Math.max(0, parseInt(e.target.value) || 0);
|
||||
if (_gs.images[_gs.idx]) _highlightGalCard(_gs.images[_gs.idx]);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-feeder-gal-run-btn")?.addEventListener("click", _startGalRun);
|
||||
document.getElementById("wfm-feeder-gal-stop-btn")?.addEventListener("click", _stopGalRun);
|
||||
|
||||
// 初期化
|
||||
await ensureFeederGroup();
|
||||
_setMode(_s.mode);
|
||||
|
||||
if (_s.mode === "loop") {
|
||||
await Promise.all([_loadTree(), _loadPresets()]);
|
||||
_selectDir("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,18 @@ import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
|
||||
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
function getEagleSettings() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
} catch {
|
||||
return { url: "http://localhost:41595", autoSave: false };
|
||||
}
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveToEagle(imageUrl, name, tags = []) {
|
||||
@@ -109,48 +106,77 @@ function updateStatus(connected) {
|
||||
|
||||
async function saveCurrentWorkflow() {
|
||||
if (!comfyUI.currentWorkflow) {
|
||||
showToast("No workflow loaded", "warning");
|
||||
showToast(t("noWorkflowLoaded"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFilename = document.getElementById("wfm-gen-wf-name")?.dataset?.filename || "";
|
||||
const defaultStem = currentFilename.replace(/\.json$/, "") || "workflow";
|
||||
const defaultStem = currentFilename.replace(/\.(json|png|jpg|jpeg|webp|gif)$/i, "") || "workflow";
|
||||
|
||||
const html = `
|
||||
<div style="display:flex;flex-direction:column;gap:14px;min-width:300px;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:var(--wfm-text-secondary);display:block;margin-bottom:4px;">Filename</label>
|
||||
<label style="font-size:12px;color:var(--wfm-text-secondary);display:block;margin-bottom:4px;">${t("filenameLabel")}</label>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="text" id="wfm-save-wf-name" class="wfm-input" value="${defaultStem}" style="flex:1;" placeholder="workflow name">
|
||||
<input type="text" id="wfm-save-wf-name" class="wfm-input" style="flex:1;" placeholder="workflow name">
|
||||
<span style="color:var(--wfm-text-secondary);font-size:13px;">.json</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-save-wf-cancel-btn">Cancel</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-save-wf-confirm-btn">Save</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-save-wf-cancel-btn">${t("cancel")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-save-wf-confirm-btn">${t("save")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal("Save Workflow", html);
|
||||
openModal(t("saveWorkflowTitle"), html);
|
||||
|
||||
// 属性値に埋め込まず DOM 経由で初期値を設定(ファイル名中の引用符等によるHTML注入防止)
|
||||
const input = document.getElementById("wfm-save-wf-name");
|
||||
if (input) input.value = defaultStem;
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("wfm-save-wf-name");
|
||||
if (input) { input.focus(); input.select(); }
|
||||
}, 50);
|
||||
|
||||
document.getElementById("wfm-save-wf-cancel-btn")?.addEventListener("click", () => closeModal());
|
||||
|
||||
let saving = false;
|
||||
const doSave = async () => {
|
||||
const input = document.getElementById("wfm-save-wf-name");
|
||||
if (saving) return;
|
||||
const stem = input?.value?.trim();
|
||||
if (!stem) {
|
||||
showToast("Please enter a filename", "warning");
|
||||
showToast(t("pleaseEnterFilename"), "warning");
|
||||
return;
|
||||
}
|
||||
const filename = stem.endsWith(".json") ? stem : `${stem}.json`;
|
||||
saving = true;
|
||||
try {
|
||||
// Raw JSON に未適用の編集があれば保存対象に反映する
|
||||
let workflow = comfyUI.currentWorkflow;
|
||||
const rawText = document.getElementById("wfm-gen-raw-json")?.value ?? "";
|
||||
if (rawText.trim() && rawText !== JSON.stringify(workflow, null, 2)) {
|
||||
try {
|
||||
workflow = JSON.parse(rawText);
|
||||
} catch {
|
||||
showToast(t("rawJsonUnappliedInvalid"), "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 上書き確認(保存先がUI形式ならAPI形式化でレイアウトが失われる旨を警告)
|
||||
try {
|
||||
const listRes = await fetch("/api/wfm/workflows");
|
||||
const listData = await listRes.json();
|
||||
const existing = (Array.isArray(listData) ? listData : []).find(w => w.filename === filename);
|
||||
if (existing) {
|
||||
const msg = existing.analysis?.format === "ui"
|
||||
? t("uiFormatOverwriteWarn")
|
||||
: t("overwriteConfirm", filename);
|
||||
if (!confirm(msg)) return;
|
||||
}
|
||||
} catch { /* 一覧取得失敗時は確認をスキップして保存を続行 */ }
|
||||
|
||||
const blob = new Blob(
|
||||
[JSON.stringify(comfyUI.currentWorkflow, null, 2)],
|
||||
[JSON.stringify(workflow, null, 2)],
|
||||
{ type: "application/json" }
|
||||
);
|
||||
const file = new File([blob], filename, { type: "application/json" });
|
||||
@@ -160,23 +186,30 @@ async function saveCurrentWorkflow() {
|
||||
const data = await res.json();
|
||||
const ng = (data.results || []).filter(r => r.status === "error");
|
||||
if (ng.length) {
|
||||
showToast(`Save failed: ${ng[0].message || "unknown error"}`, "error");
|
||||
showToast(t("saveFailed", ng[0].message || "unknown error"), "error");
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
const nameEl = document.getElementById("wfm-gen-wf-name");
|
||||
if (nameEl) {
|
||||
nameEl.textContent = filename;
|
||||
nameEl.dataset.filename = filename;
|
||||
if (workflow !== comfyUI.currentWorkflow) {
|
||||
// Raw JSON の編集を保存した場合はエディタにも反映して同期する
|
||||
await loadWorkflowIntoEditor(workflow, filename);
|
||||
} else {
|
||||
const nameEl = document.getElementById("wfm-gen-wf-name");
|
||||
if (nameEl) {
|
||||
nameEl.textContent = filename;
|
||||
nameEl.dataset.filename = filename;
|
||||
}
|
||||
}
|
||||
showToast(`Saved: ${filename}`, "success");
|
||||
showToast(t("savedAs", filename), "success");
|
||||
} catch (err) {
|
||||
showToast(`Save error: ${err.message}`, "error");
|
||||
showToast(t("saveFailed", err.message), "error");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("wfm-save-wf-confirm-btn")?.addEventListener("click", doSave);
|
||||
document.getElementById("wfm-save-wf-name")?.addEventListener("keydown", (e) => {
|
||||
input?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") doSave();
|
||||
});
|
||||
}
|
||||
@@ -185,21 +218,52 @@ async function saveCurrentWorkflow() {
|
||||
// Workflow Loading
|
||||
// ============================================
|
||||
|
||||
// ワークフロー読み込み時、設定タブで有効化されていれば checkpoint loader 系ノード全件の
|
||||
// ckpt_name をデフォルトモデルへ強制上書きする(保存済みワークフローのモデルが
|
||||
// 環境と合わず生成エラー/不鮮明な画像になるのを防ぐ回避策)。
|
||||
// analyzeWorkflow() より前に apiWorkflow 自体を書き換える必要がある — analysis後に
|
||||
// 書き換えると Model タブの表示(analysis.checkpoint_nodes 由来)が古い値のまま残るため。
|
||||
async function _applyDefaultCheckpointIfEnabled(apiWorkflow) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings");
|
||||
if (!res.ok) return;
|
||||
const settings = await res.json();
|
||||
if (!settings.default_checkpoint_enabled || !settings.default_checkpoint_name) return;
|
||||
|
||||
let applied = false;
|
||||
for (const node of Object.values(apiWorkflow)) {
|
||||
const ct = node?.class_type || "";
|
||||
if (node?.inputs && (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader" || ct === "ImageMetadataPromptLoader")) {
|
||||
node.inputs.ckpt_name = settings.default_checkpoint_name;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
if (applied) showToast(t("defaultCheckpointApplied", settings.default_checkpoint_name), "info");
|
||||
} catch { /* non-critical, ignore */ }
|
||||
}
|
||||
|
||||
export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
let apiWorkflow = workflow;
|
||||
const format = comfyWorkflow.detectFormat(workflow, filename);
|
||||
let ckptWarning = null;
|
||||
|
||||
if (format === "app") {
|
||||
showToast(t("appFormatNotSupported"), "error");
|
||||
return false;
|
||||
} else if (format === "ui") {
|
||||
apiWorkflow = await comfyWorkflow.convertUiToApi(workflow);
|
||||
const ckptSubs = comfyWorkflow.getLastCheckpointSubstitutions();
|
||||
if (ckptSubs.length > 0) {
|
||||
const names = [...new Set(ckptSubs.map((s) => s.original))].join(", ");
|
||||
ckptWarning = t("checkpointSubstitutedWarning", names);
|
||||
}
|
||||
} else if (format === "unknown") {
|
||||
showToast("Unknown workflow format", "error");
|
||||
showToast(t("unknownWorkflowFormat"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
comfyUI.currentWorkflow = apiWorkflow;
|
||||
await _applyDefaultCheckpointIfEnabled(apiWorkflow);
|
||||
comfyUI.currentAnalysis = comfyWorkflow.analyzeWorkflow(apiWorkflow);
|
||||
|
||||
// Render editor tabs
|
||||
@@ -214,6 +278,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) {
|
||||
@@ -227,7 +315,11 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
|
||||
refreshFeederNodeList();
|
||||
|
||||
showToast(`Workflow loaded: ${filename || ""}`, "success");
|
||||
if (ckptWarning) {
|
||||
showToast(ckptWarning, "error");
|
||||
} else {
|
||||
showToast(t("workflowLoadedName", filename || ""), "success");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -254,7 +346,7 @@ function _setPauseBtnState(paused) {
|
||||
function _updateBatchTypeLabel(running = false) {
|
||||
const el = document.getElementById("wfm-batch-type-label");
|
||||
if (!el) return;
|
||||
const labels = { checkpoint: "Checkpoint", lora: "Lora", prompt: "Prompt", workflow: "Workflow", sampler: "Sampler", scheduler: "Scheduler" };
|
||||
const labels = { checkpoint: "Checkpoint", lora: "Lora", prompt: "Prompt", workflow: "Workflow", sampler: "Sampler", scheduler: "Scheduler", style: "Style" };
|
||||
if (_activeBatchType) {
|
||||
el.textContent = labels[_activeBatchType];
|
||||
el.style.color = running ? "var(--wfm-primary)" : "";
|
||||
@@ -424,6 +516,14 @@ function _renderBatchPreview() {
|
||||
(s) => s,
|
||||
"scheduler", "schedulers"
|
||||
);
|
||||
// Style
|
||||
const styleList = _stylesData.filter((s) => _batchStyleSelected.has(s.name)).map((s) => s.name);
|
||||
_renderQueueColumn(
|
||||
"wfm-batch-style-count", "wfm-batch-style-preview-list",
|
||||
styleList,
|
||||
(s) => s,
|
||||
"style", "styles"
|
||||
);
|
||||
}
|
||||
|
||||
// モデルパスリストをフォルダ → [modelPath, ...] のMapに変換
|
||||
@@ -848,8 +948,15 @@ function _renderAnyGroupList(listEl, groupsData, selectedGroups, partialSelectio
|
||||
async function _loadBatchLoraGroups() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/groups?type=lora");
|
||||
if (res.ok) _batchGroupState.loraGroups = await res.json();
|
||||
else _batchGroupState.loraGroups = {};
|
||||
if (res.ok) {
|
||||
const raw = await res.json();
|
||||
// Normalize backslashes (Windows ComfyUI paths) to forward slashes
|
||||
const normalized = {};
|
||||
for (const [g, members] of Object.entries(raw)) {
|
||||
normalized[g] = members.map((m) => m.replace(/\\/g, "/"));
|
||||
}
|
||||
_batchGroupState.loraGroups = normalized;
|
||||
} else { _batchGroupState.loraGroups = {}; }
|
||||
} catch { _batchGroupState.loraGroups = {}; }
|
||||
|
||||
const currentNames = new Set(Object.keys(_batchGroupState.loraGroups));
|
||||
@@ -884,9 +991,7 @@ async function _loadPromptGroupsForBatch() {
|
||||
const res = await fetch("/api/wfm/prompts");
|
||||
_batchGroupState.promptPresets = res.ok ? await res.json() : [];
|
||||
} catch { _batchGroupState.promptPresets = []; }
|
||||
try {
|
||||
_batchGroupState.promptGroups = JSON.parse(localStorage.getItem("wfm_prompt_preset_groups") || "{}");
|
||||
} catch { _batchGroupState.promptGroups = {}; }
|
||||
_batchGroupState.promptGroups = readJsonStorage("wfm_prompt_preset_groups");
|
||||
|
||||
const validIds = new Set(_batchGroupState.promptPresets.map((p) => p.id));
|
||||
for (const g of Object.keys(_batchGroupState.promptGroups)) {
|
||||
@@ -922,9 +1027,7 @@ function _renderBatchPromptGroupList() {
|
||||
// Workflow グループ
|
||||
// ============================================
|
||||
function _loadWorkflowGroupsForBatch() {
|
||||
try {
|
||||
_batchGroupState.wfGroups = JSON.parse(localStorage.getItem("wfm_groups") || "{}");
|
||||
} catch { _batchGroupState.wfGroups = {}; }
|
||||
_batchGroupState.wfGroups = readJsonStorage("wfm_groups");
|
||||
|
||||
const currentNames = new Set(Object.keys(_batchGroupState.wfGroups));
|
||||
for (const name of _batchGroupState.wfSelectedGroups) {
|
||||
@@ -1062,6 +1165,7 @@ function initBatchTab() {
|
||||
document.getElementById(`wfm-batch-left-${tabId}`)?.classList.add("active");
|
||||
if (tabId === "sampler") _rebuildSamplerList();
|
||||
else if (tabId === "scheduler") _rebuildSchedulerList();
|
||||
else if (tabId === "style") _rebuildStyleList();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1121,6 +1225,18 @@ function initBatchTab() {
|
||||
_renderBatchPreview();
|
||||
});
|
||||
|
||||
// 全選択 / 全解除(左ペイン Style)
|
||||
document.getElementById("wfm-style-batch-select-all")?.addEventListener("click", () => {
|
||||
_stylesData.forEach((s) => _batchStyleSelected.add(s.name));
|
||||
_rebuildStyleList();
|
||||
_renderBatchPreview();
|
||||
});
|
||||
document.getElementById("wfm-style-batch-deselect-all")?.addEventListener("click", () => {
|
||||
_batchStyleSelected.clear();
|
||||
_rebuildStyleList();
|
||||
_renderBatchPreview();
|
||||
});
|
||||
|
||||
// Batch タブが表示されたときに全グループを読み込む
|
||||
document.querySelector('[data-subtab="batch"]')?.addEventListener("click", () => {
|
||||
_rebuildCkptList();
|
||||
@@ -1128,6 +1244,7 @@ function initBatchTab() {
|
||||
_loadBatchLoraGroups();
|
||||
_loadPromptGroupsForBatch();
|
||||
_loadWorkflowGroupsForBatch();
|
||||
_rebuildStyleList();
|
||||
_renderBatchPreview();
|
||||
});
|
||||
}
|
||||
@@ -1164,11 +1281,182 @@ function initCheckpointBatch() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Style (Fooocus-style JSON from DATA_DIR/style/)
|
||||
// ============================================
|
||||
|
||||
let _stylesData = [];
|
||||
const _batchStyleSelected = new Set();
|
||||
let _batchStyleOverride = null; // Style バッチ実行中に一時的にセット
|
||||
|
||||
async function _loadStyles() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/styles");
|
||||
if (res.ok) _stylesData = await res.json();
|
||||
else _stylesData = [];
|
||||
} catch { _stylesData = []; }
|
||||
_renderStyleDropdown();
|
||||
_rebuildStyleList();
|
||||
}
|
||||
|
||||
function _renderStyleDropdown() {
|
||||
const sel = document.getElementById("wfm-style-select");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
if (_stylesData.length === 0) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "No styles";
|
||||
sel.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
for (const s of _stylesData) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.name;
|
||||
opt.textContent = s.name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function _rebuildStyleList() {
|
||||
_buildSimpleGroupList(
|
||||
document.getElementById("wfm-batch-style-list"),
|
||||
_stylesData.map((s) => s.name),
|
||||
_batchStyleSelected,
|
||||
"No styles (place JSON files in Workflow-Studio/style/)"
|
||||
);
|
||||
}
|
||||
|
||||
function _applyNamedStyle(workflow, style) {
|
||||
if (!style) return workflow;
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!analysis) return workflow;
|
||||
|
||||
const positiveNodes = (analysis.prompt_nodes || []).filter((n) => n.role === "positive");
|
||||
const negativeNodes = (analysis.prompt_nodes || []).filter((n) => n.role === "negative");
|
||||
|
||||
const result = JSON.parse(JSON.stringify(workflow));
|
||||
|
||||
if (style.prompt) {
|
||||
for (const node of positiveNodes) {
|
||||
const nodeData = result[node.id];
|
||||
if (!nodeData) continue;
|
||||
const key = node.textKey || "text";
|
||||
const original = nodeData.inputs[key] || "";
|
||||
nodeData.inputs[key] = style.prompt.includes("{prompt}")
|
||||
? style.prompt.replace("{prompt}", original)
|
||||
: original ? `${original}, ${style.prompt}` : style.prompt;
|
||||
}
|
||||
}
|
||||
|
||||
if (style.negative_prompt) {
|
||||
for (const node of negativeNodes) {
|
||||
const nodeData = result[node.id];
|
||||
if (!nodeData) continue;
|
||||
const key = node.textKey || "text";
|
||||
const original = nodeData.inputs[key] || "";
|
||||
nodeData.inputs[key] = original
|
||||
? `${original}, ${style.negative_prompt}`
|
||||
: style.negative_prompt;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function _applyStyleToWorkflow(workflow) {
|
||||
// バッチ実行中は上書きスタイルを優先
|
||||
if (_batchStyleOverride !== null) return _applyNamedStyle(workflow, _batchStyleOverride);
|
||||
|
||||
const enabled = document.getElementById("wfm-style-enabled")?.checked;
|
||||
if (!enabled) return workflow;
|
||||
const selectedName = document.getElementById("wfm-style-select")?.value;
|
||||
if (!selectedName) return workflow;
|
||||
const style = _stylesData.find((s) => s.name === selectedName);
|
||||
return _applyNamedStyle(workflow, style || null);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SPA-side wildcard expansion (A1111-style __name__ syntax)
|
||||
// Applies to non-ImpactWildcard nodes only; ImpactWildcard* nodes expand server-side.
|
||||
// ============================================
|
||||
|
||||
const _wcLineCache = new Map(); // filename → string[] | null
|
||||
|
||||
async function _fetchWildcardLines(name) {
|
||||
if (_wcLineCache.has(name)) return _wcLineCache.get(name);
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(name + ".txt")}`);
|
||||
if (!res.ok) { _wcLineCache.set(name, null); return null; }
|
||||
const data = await res.json();
|
||||
const lines = (data.content || "")
|
||||
.split("\n")
|
||||
.map(l => l.trim())
|
||||
.filter(l => l && !l.startsWith("#"));
|
||||
const result = lines.length > 0 ? lines : null;
|
||||
_wcLineCache.set(name, result);
|
||||
return result;
|
||||
} catch {
|
||||
_wcLineCache.set(name, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _expandWildcardText(text) {
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
const matches = [...text.matchAll(/__([^_\s][^_]*)__/g)];
|
||||
if (matches.length === 0) break;
|
||||
let changed = false;
|
||||
let offset = 0;
|
||||
let result = text;
|
||||
for (const match of matches) {
|
||||
const lines = await _fetchWildcardLines(match[1]);
|
||||
if (lines) {
|
||||
const replacement = lines[Math.floor(Math.random() * lines.length)];
|
||||
const pos = match.index + offset;
|
||||
result = result.slice(0, pos) + replacement + result.slice(pos + match[0].length);
|
||||
offset += replacement.length - match[0].length;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
text = result;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const _IMPACT_WC_TYPES = new Set(["ImpactWildcardEncode", "ImpactWildcardProcessor"]);
|
||||
|
||||
async function _expandWildcardsInWorkflow(workflow) {
|
||||
let hasWildcard = false;
|
||||
outer: for (const node of Object.values(workflow)) {
|
||||
if (_IMPACT_WC_TYPES.has(node.class_type)) continue;
|
||||
for (const val of Object.values(node.inputs || {})) {
|
||||
if (typeof val === "string" && /__[^_\s][^_]*__/.test(val)) {
|
||||
hasWildcard = true;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasWildcard) return workflow;
|
||||
|
||||
const expanded = JSON.parse(JSON.stringify(workflow));
|
||||
for (const node of Object.values(expanded)) {
|
||||
if (_IMPACT_WC_TYPES.has(node.class_type)) continue;
|
||||
for (const [key, val] of Object.entries(node.inputs || {})) {
|
||||
if (typeof val === "string" && /__[^_\s][^_]*__/.test(val)) {
|
||||
node.inputs[key] = await _expandWildcardText(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Generation (core — throws on error)
|
||||
// ============================================
|
||||
|
||||
async function _coreGenerate(silent = false) {
|
||||
async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
const progressBar = document.getElementById("wfm-gen-progress-bar");
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
@@ -1180,8 +1468,11 @@ async function _coreGenerate(silent = false) {
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = "Starting...";
|
||||
|
||||
const baseWorkflow = workflowOverride || comfyUI.currentWorkflow;
|
||||
const workflowExpanded = await _expandWildcardsInWorkflow({ ...baseWorkflow });
|
||||
const workflowForGenerate = _applyStyleToWorkflow(workflowExpanded);
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
{ ...comfyUI.currentWorkflow },
|
||||
workflowForGenerate,
|
||||
{
|
||||
seedMode,
|
||||
seedValue,
|
||||
@@ -1232,10 +1523,12 @@ async function _coreGenerate(silent = false) {
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
saveGeneratedImagesMeta(images, { ...comfyUI.currentWorkflow }).catch(() => {});
|
||||
saveGeneratedImagesMeta(images, { ...baseWorkflow }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!silent) showToast("Generation complete", "success");
|
||||
if (!silent) showToast(t("generationComplete"), "success");
|
||||
|
||||
return { images, seed };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -1283,7 +1576,7 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
} catch (err) {
|
||||
if (_ckptBatch.aborted) break;
|
||||
failed++;
|
||||
showToast(`[${i + 1}/${items.length}] Failed: ${err.message}`, "error");
|
||||
showToast(t("batchItemFailed", i + 1, items.length, err.message), "error");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -1301,12 +1594,9 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
if (batchCurrentName) batchCurrentName.textContent = _ckptBatch.aborted ? "Stopped" : "Done";
|
||||
|
||||
if (_ckptBatch.aborted) {
|
||||
showToast(`Batch stopped — ${completed} completed, ${failed} failed`, "info");
|
||||
showToast(t("batchStopped", completed, failed), "info");
|
||||
} else {
|
||||
showToast(
|
||||
`Batch complete: ${completed}/${items.length}${failed > 0 ? ` (${failed} failed)` : ""}`,
|
||||
failed > 0 ? "error" : "success"
|
||||
);
|
||||
showToast(t("batchComplete", completed, items.length, failed), failed > 0 ? "error" : "success");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,9 +1605,9 @@ async function _runBatchGenerate() {
|
||||
switch (_activeBatchType) {
|
||||
case "checkpoint": {
|
||||
const ckptNodes = comfyUI.currentAnalysis?.checkpoint_nodes || [];
|
||||
if (ckptNodes.length === 0) { showToast("No checkpoint node found in workflow", "error"); return; }
|
||||
if (ckptNodes.length === 0) { showToast(t("modelsGenUINoNode", "checkpoint"), "error"); return; }
|
||||
const list = _getSelectedCheckpoints();
|
||||
if (list.length === 0) { showToast("No checkpoints selected", "error"); return; }
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "checkpoints"), "error"); return; }
|
||||
await _runBatchLoop(list, (model) => {
|
||||
for (const node of ckptNodes) {
|
||||
if (comfyUI.currentWorkflow?.[node.id])
|
||||
@@ -1328,34 +1618,103 @@ async function _runBatchGenerate() {
|
||||
}
|
||||
case "lora": {
|
||||
const loraNodes = comfyUI.currentAnalysis?.lora_nodes || [];
|
||||
if (loraNodes.length === 0) { showToast("No LoRA node found in workflow", "error"); return; }
|
||||
if (loraNodes.length === 0) { showToast(t("modelsGenUINoNode", "LoRA"), "error"); return; }
|
||||
const list = [..._getSelectedLoraGroupItems()];
|
||||
if (list.length === 0) { showToast("No LoRAs selected", "error"); return; }
|
||||
await _runBatchLoop(list, (loraName) => {
|
||||
for (const node of loraNodes) {
|
||||
if (!comfyUI.currentWorkflow?.[node.id]) continue;
|
||||
if (node.is_lora_manager) {
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "LoRAs"), "error"); return; }
|
||||
// 通常のLoraLoaderはComfyUI側のenumリストに存在しないファイル名だとバリデーションエラーになる。
|
||||
// ComfyUI(Windows)はサブフォルダ区切りに"\"を使うが内部リストは"/"のため、事前に正しい表記へ変換しておく。
|
||||
let loraNameMap = null;
|
||||
if (loraNodes.some((node) => !node.is_lora_manager)) {
|
||||
try {
|
||||
const known = await comfyUI.fetchLoras();
|
||||
loraNameMap = new Map(known.map((n) => [n.replace(/\\/g, "/"), n]));
|
||||
const missing = list.filter((name) => !loraNameMap.has(name.replace(/\\/g, "/")));
|
||||
if (missing.length > 0) {
|
||||
showToast(t("batchLoraMissing", missing.length, list.length, missing[0]), "error");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Positiveプロンプトへlora構文+トリガーワードを反映するための準備(Single/Stack適用と同じ仕組み)
|
||||
const positiveNodes = (comfyUI.currentAnalysis?.prompt_nodes || []).filter((n) => n.role === "positive");
|
||||
let metadata = {}, civitaiCache = {};
|
||||
try {
|
||||
const [metaRes, civRes] = await Promise.all([
|
||||
fetch("/api/wfm/models/metadata"),
|
||||
fetch("/api/wfm/models/civitai/cache"),
|
||||
]);
|
||||
metadata = metaRes.ok ? await metaRes.json() : {};
|
||||
civitaiCache = civRes.ok ? await civRes.json() : {};
|
||||
} catch {}
|
||||
|
||||
// バッチ開始前のプロンプトを保存し、完了・停止後に復元する
|
||||
const posTextarea = document.getElementById("wfm-prompt-pos-text");
|
||||
const savedTextareaValue = posTextarea ? posTextarea.value : null;
|
||||
const savedNodeTexts = new Map();
|
||||
for (const node of positiveNodes) {
|
||||
const wfNode = comfyUI.currentWorkflow?.[node.id];
|
||||
if (wfNode) savedNodeTexts.set(node.id, wfNode.inputs[node.textKey || "text"]);
|
||||
}
|
||||
|
||||
try {
|
||||
await _runBatchLoop(list, (loraName) => {
|
||||
for (const node of loraNodes) {
|
||||
if (!comfyUI.currentWorkflow?.[node.id]) continue;
|
||||
if (node.is_lora_manager) {
|
||||
const stem = loraName.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
|
||||
comfyUI.currentWorkflow[node.id].inputs.loras = {
|
||||
__value__: [{ name: stem, strength: 1.0, active: true, expanded: false, clipStrength: 1.0, locked: false }],
|
||||
};
|
||||
comfyUI.currentWorkflow[node.id].inputs.text = `<lora:${stem}:1:1>`;
|
||||
} else {
|
||||
const actualName = loraNameMap?.get(loraName.replace(/\\/g, "/")) || loraName;
|
||||
comfyUI.currentWorkflow[node.id].inputs.lora_name = actualName;
|
||||
}
|
||||
}
|
||||
|
||||
if (positiveNodes.length > 0) {
|
||||
const stem = loraName.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
|
||||
comfyUI.currentWorkflow[node.id].inputs.loras = {
|
||||
__value__: [{ name: stem, strength: 1.0, active: true, expanded: false, clipStrength: 1.0, locked: false }],
|
||||
};
|
||||
comfyUI.currentWorkflow[node.id].inputs.text = `<lora:${stem}:1:1>`;
|
||||
} else {
|
||||
comfyUI.currentWorkflow[node.id].inputs.lora_name = loraName;
|
||||
const firstStandardNode = loraNodes.find((n) => !n.is_lora_manager);
|
||||
const strModel = firstStandardNode ? (comfyUI.currentWorkflow[firstStandardNode.id]?.inputs.strength_model ?? 1.0) : 1.0;
|
||||
const strClip = firstStandardNode ? (comfyUI.currentWorkflow[firstStandardNode.id]?.inputs.strength_clip ?? 1.0) : 1.0;
|
||||
const loraSyntax = `<lora:${stem}:${strModel}:${strClip}>`;
|
||||
const sha = (metadata[loraName] || {}).sha256;
|
||||
const civInfo = sha && civitaiCache[sha];
|
||||
const triggerWords = civInfo?.trainedWords || [];
|
||||
const append = triggerWords.length > 0 ? `${loraSyntax}, ${triggerWords.join(", ")}` : loraSyntax;
|
||||
|
||||
for (const node of positiveNodes) {
|
||||
const wfNode = comfyUI.currentWorkflow?.[node.id];
|
||||
if (!wfNode) continue;
|
||||
const base = (savedNodeTexts.get(node.id) || "").replace(/,\s*$/, "").trim();
|
||||
wfNode.inputs[node.textKey || "text"] = base ? `${base}, ${append}` : append;
|
||||
}
|
||||
if (posTextarea && savedTextareaValue !== null) {
|
||||
const base = savedTextareaValue.replace(/,\s*$/, "").trim();
|
||||
posTextarea.value = base ? `${base}, ${append}` : append;
|
||||
}
|
||||
}
|
||||
}, (name) => name.replace(/\.[^.]+$/, ""));
|
||||
} finally {
|
||||
// プロンプトをバッチ開始前の状態へ復元
|
||||
for (const node of positiveNodes) {
|
||||
const wfNode = comfyUI.currentWorkflow?.[node.id];
|
||||
if (wfNode && savedNodeTexts.has(node.id)) {
|
||||
wfNode.inputs[node.textKey || "text"] = savedNodeTexts.get(node.id);
|
||||
}
|
||||
}
|
||||
}, (name) => name.replace(/\.[^.]+$/, ""));
|
||||
if (posTextarea && savedTextareaValue !== null) posTextarea.value = savedTextareaValue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "prompt": {
|
||||
const positiveNodes = (comfyUI.currentAnalysis?.prompt_nodes || []).filter((n) => n.role === "positive");
|
||||
const negativeNodes = (comfyUI.currentAnalysis?.prompt_nodes || []).filter((n) => n.role === "negative");
|
||||
if (positiveNodes.length === 0 && negativeNodes.length === 0) {
|
||||
showToast("No prompt node found in workflow", "error"); return;
|
||||
showToast(t("modelsGenUINoNode", "prompt"), "error"); return;
|
||||
}
|
||||
// _getSelectedPromptGroupItems() はすでにプリセットオブジェクトの配列を返す
|
||||
const list = _getSelectedPromptGroupItems();
|
||||
if (list.length === 0) { showToast("No prompts selected", "error"); return; }
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "prompts"), "error"); return; }
|
||||
await _runBatchLoop(list, (preset) => {
|
||||
for (const node of positiveNodes) {
|
||||
if (comfyUI.currentWorkflow?.[node.id])
|
||||
@@ -1370,7 +1729,7 @@ async function _runBatchGenerate() {
|
||||
}
|
||||
case "workflow": {
|
||||
const list = [..._getSelectedWfGroupItems()];
|
||||
if (list.length === 0) { showToast("No workflows selected", "error"); return; }
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "workflows"), "error"); return; }
|
||||
const savedWorkflow = comfyUI.currentWorkflow ? JSON.stringify(comfyUI.currentWorkflow) : null;
|
||||
const savedFilename = document.getElementById("wfm-gen-workflow-name")?.textContent || "";
|
||||
try {
|
||||
@@ -1389,9 +1748,9 @@ async function _runBatchGenerate() {
|
||||
}
|
||||
case "sampler": {
|
||||
const samplerNodes = comfyUI.currentAnalysis?.sampler_nodes || [];
|
||||
if (samplerNodes.length === 0) { showToast("No KSampler node found in workflow", "error"); return; }
|
||||
if (samplerNodes.length === 0) { showToast(t("modelsGenUINoNode", "KSampler"), "error"); return; }
|
||||
const list = [..._samplerSelected].sort();
|
||||
if (list.length === 0) { showToast("No samplers selected", "error"); return; }
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "samplers"), "error"); return; }
|
||||
await _runBatchLoop(list, (samplerName) => {
|
||||
for (const node of samplerNodes) {
|
||||
if (comfyUI.currentWorkflow?.[node.id])
|
||||
@@ -1402,9 +1761,9 @@ async function _runBatchGenerate() {
|
||||
}
|
||||
case "scheduler": {
|
||||
const samplerNodes = comfyUI.currentAnalysis?.sampler_nodes || [];
|
||||
if (samplerNodes.length === 0) { showToast("No KSampler node found in workflow", "error"); return; }
|
||||
if (samplerNodes.length === 0) { showToast(t("modelsGenUINoNode", "KSampler"), "error"); return; }
|
||||
const list = [..._schedulerSelected].sort();
|
||||
if (list.length === 0) { showToast("No schedulers selected", "error"); return; }
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "schedulers"), "error"); return; }
|
||||
await _runBatchLoop(list, (schedulerName) => {
|
||||
for (const node of samplerNodes) {
|
||||
if (comfyUI.currentWorkflow?.[node.id])
|
||||
@@ -1413,6 +1772,18 @@ async function _runBatchGenerate() {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "style": {
|
||||
const list = _stylesData.filter((s) => _batchStyleSelected.has(s.name));
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "styles"), "error"); return; }
|
||||
try {
|
||||
await _runBatchLoop(list, (style) => {
|
||||
_batchStyleOverride = style;
|
||||
}, (s) => s.name);
|
||||
} finally {
|
||||
_batchStyleOverride = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1420,14 +1791,20 @@ async function _runBatchGenerate() {
|
||||
// Generate entry point
|
||||
// ============================================
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!comfyUI.currentWorkflow) {
|
||||
showToast("No workflow loaded", "error");
|
||||
// workflowOverride: 明示的に渡された場合、comfyUI.currentWorkflow の代わりにこのワークフローで
|
||||
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行から利用)。
|
||||
// ボタンのclickハンドラから呼ばれる場合は第一引数がMouseEventになるため、その場合は無視する。
|
||||
async function handleGenerate(workflowOverride = null) {
|
||||
if (workflowOverride instanceof Event) workflowOverride = null;
|
||||
|
||||
if (!workflowOverride && !comfyUI.currentWorkflow) {
|
||||
showToast(t("noWorkflowLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
if (comfyUI.generating) return;
|
||||
|
||||
comfyEditor.syncToWorkflow();
|
||||
// オーバーライド実行時は GenerateUI 側の表示中プロンプトを別ワークフローへ誤って書き込まないよう同期をスキップする
|
||||
if (!workflowOverride) comfyEditor.syncToWorkflow();
|
||||
|
||||
const genBtn = document.getElementById("wfm-gen-generate-btn");
|
||||
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
|
||||
@@ -1438,18 +1815,20 @@ async function handleGenerate() {
|
||||
_ckptBatch.paused = false;
|
||||
_ckptBatch._resumeResolve = null;
|
||||
|
||||
const batchEnabled = _activeBatchType !== null;
|
||||
// オーバーライド実行時はバッチモードの影響を受けない単発生成に固定する
|
||||
const batchEnabled = !workflowOverride && _activeBatchType !== null;
|
||||
|
||||
try {
|
||||
if (batchEnabled) {
|
||||
await _runBatchGenerate();
|
||||
} else {
|
||||
try {
|
||||
await _coreGenerate(false);
|
||||
return await _coreGenerate(false, workflowOverride);
|
||||
} catch (err) {
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
if (progressText) progressText.textContent = "Error";
|
||||
showToast("Generation error: " + err.message, "error");
|
||||
showToast(t("generationError", err.message), "error");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -1472,9 +1851,9 @@ export async function initGenerateTab() {
|
||||
updateStatus(connected);
|
||||
if (connected) {
|
||||
await comfyEditor.loadModelLists();
|
||||
showToast("Connected to ComfyUI", "success");
|
||||
showToast(t("connectedToComfyUI"), "success");
|
||||
} else {
|
||||
showToast("Failed to connect", "error");
|
||||
showToast(t("failedToConnect"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1484,7 +1863,7 @@ export async function initGenerateTab() {
|
||||
if (comfyUI.currentAnalysis) {
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, comfyUI.currentWorkflow);
|
||||
}
|
||||
showToast("Model lists refreshed", "success");
|
||||
showToast(t("modelListsRefreshed"), "success");
|
||||
_rebuildSamplerList();
|
||||
_rebuildSchedulerList();
|
||||
_renderBatchPreview();
|
||||
@@ -1496,7 +1875,7 @@ export async function initGenerateTab() {
|
||||
document.getElementById("wfm-gen-reset-workflow-btn")?.addEventListener("click", async () => {
|
||||
const filename = document.getElementById("wfm-gen-wf-name")?.dataset?.filename;
|
||||
if (!filename || !filename.endsWith(".json")) {
|
||||
showToast("No file-based workflow loaded", "warning");
|
||||
showToast(t("noFileWorkflowLoaded"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1505,12 +1884,20 @@ export async function initGenerateTab() {
|
||||
const data = await resp.json();
|
||||
await loadWorkflowIntoEditor(data, filename);
|
||||
} catch (err) {
|
||||
showToast("Reset failed: " + err.message, "error");
|
||||
showToast(t("resetFailed", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Generate button
|
||||
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", handleGenerate);
|
||||
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", () => {
|
||||
handleGenerate().catch(() => {});
|
||||
});
|
||||
|
||||
// Cross-tab entry point (Image Edit「Inpaint」タブの Run ボタンなど外部から生成を呼び出す用)
|
||||
window._wfmGenerateTab = { generate: handleGenerate };
|
||||
|
||||
// Alt+Apply from Input/Model/Settings tabs → Apply & Generate
|
||||
document.addEventListener("wfm:apply-and-generate", () => handleGenerate());
|
||||
|
||||
// Interrupt button (stops both single generation and batch loop)
|
||||
document.getElementById("wfm-gen-interrupt-btn")?.addEventListener("click", async () => {
|
||||
@@ -1522,7 +1909,7 @@ export async function initGenerateTab() {
|
||||
_ckptBatch._resumeResolve = null;
|
||||
}
|
||||
await comfyUI.interrupt();
|
||||
showToast("Interrupted", "info");
|
||||
showToast(t("interrupted"), "info");
|
||||
});
|
||||
|
||||
// Move shared Raw JSON widget into the active tab's rawjson-col
|
||||
@@ -1585,7 +1972,7 @@ export async function initGenerateTab() {
|
||||
const wf = JSON.parse(textarea.value);
|
||||
await loadWorkflowIntoEditor(wf, "Raw JSON");
|
||||
} catch (err) {
|
||||
showToast("Invalid JSON: " + err.message, "error");
|
||||
showToast(t("invalidJsonMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1729,6 +2116,9 @@ export async function initGenerateTab() {
|
||||
// Feeder tab
|
||||
await initFeederTab();
|
||||
|
||||
// Style dropdown
|
||||
await _loadStyles();
|
||||
|
||||
// Auto-connect on init
|
||||
const connected = await comfyUI.checkConnection();
|
||||
updateStatus(connected);
|
||||
@@ -1740,7 +2130,7 @@ export async function initGenerateTab() {
|
||||
// Auto-load default workflow (doesn't require ComfyUI connection)
|
||||
if (!comfyUI.currentWorkflow) {
|
||||
try {
|
||||
const settings = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
const settings = getSettings();
|
||||
if (settings.defaultWorkflow && settings.defaultWorkflowData) {
|
||||
await loadWorkflowIntoEditor(settings.defaultWorkflowData, settings.defaultWorkflow);
|
||||
console.log("Workflow Studio: Auto-loaded default workflow:", settings.defaultWorkflow);
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Image Edit Tab - Draw Tool
|
||||
* Inspired by comfyui-mask-editor-one PaintTool.js
|
||||
* Supports color drawing and erasing with a soft round brush.
|
||||
* Also supports image brushes loaded from Mask Editor One's ABR brush library.
|
||||
*/
|
||||
|
||||
export class DrawTool {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas?.getContext("2d") ?? null;
|
||||
this.brushSize = 20;
|
||||
this.hardness = 0.8; // 0.0 = very soft, 1.0 = hard edge
|
||||
this.opacity = 1.0;
|
||||
this.color = "#ff0000";
|
||||
this.mode = "draw"; // "draw" | "erase"
|
||||
this.spacing = 0.25; // stamp interval as fraction of brushSize (image brush only)
|
||||
this.angle = 0; // degrees [0, 359]
|
||||
this.sizeJitter = false;
|
||||
this.sizeJitterAmount = 0.5;
|
||||
this.rotationJitter = false;
|
||||
|
||||
// Image brush from ABR library (null = default circle brush)
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
|
||||
this._drawing = false;
|
||||
this._lastX = 0;
|
||||
this._lastY = 0;
|
||||
|
||||
// Cached circle stamp
|
||||
this._stamp = null;
|
||||
this._stampSize = 0;
|
||||
this._stampHard = 0;
|
||||
this._stampColor = null;
|
||||
|
||||
// Cached image stamp
|
||||
this._imgStamp = null;
|
||||
this._imgStampSize = 0;
|
||||
this._imgStampImg = null;
|
||||
this._imgStampColor = null;
|
||||
|
||||
// Stroke buffer: prevents intra-stroke opacity accumulation (image brush only)
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d");
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
setImageBrush(img, name) {
|
||||
this.brushImage = img;
|
||||
this.brushName = name;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
clearImageBrush() {
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this.canvas) this.canvas.style.cursor = "none";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this._drawing) {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
this._baseCanvas = document.createElement("canvas");
|
||||
this._baseCanvas.width = w;
|
||||
this._baseCanvas.height = h;
|
||||
this._baseCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
|
||||
this._strokeCanvas = document.createElement("canvas");
|
||||
this._strokeCanvas.width = w;
|
||||
this._strokeCanvas.height = h;
|
||||
this._strokeCtx = this._strokeCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
_clearStrokeBuffer() {
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
}
|
||||
|
||||
_mergeStroke() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(this._baseCanvas, 0, 0);
|
||||
ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
ctx.globalAlpha = this.opacity;
|
||||
ctx.drawImage(this._strokeCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
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;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
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;
|
||||
const sctx = sc.getContext("2d");
|
||||
const cx = size / 2, cy = size / 2, r = size / 2;
|
||||
|
||||
const innerR = r * (1 - Math.min(this.hardness, 0.99)) * 0.95;
|
||||
const grd = sctx.createRadialGradient(cx, cy, innerR, cx, cy, r);
|
||||
grd.addColorStop(0, this.color);
|
||||
grd.addColorStop(1, this.color + "00");
|
||||
sctx.fillStyle = grd;
|
||||
sctx.beginPath();
|
||||
sctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
sctx.fill();
|
||||
|
||||
this._stamp = sc;
|
||||
this._stampSize = size;
|
||||
this._stampHard = this.hardness;
|
||||
this._stampColor = this.color;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = this.opacity;
|
||||
if (this.mode === "erase") {
|
||||
this.ctx.globalCompositeOperation = "destination-out";
|
||||
}
|
||||
this.ctx.drawImage(stamp, x - s / 2, y - s / 2);
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image brush ──────────────────────────────────────────────────
|
||||
|
||||
_getImageStamp(size) {
|
||||
size = Math.max(1, size);
|
||||
if (
|
||||
this._imgStamp &&
|
||||
this._imgStampSize === size &&
|
||||
this._imgStampImg === this.brushImage &&
|
||||
this._imgStampColor === this.color
|
||||
) return this._imgStamp;
|
||||
|
||||
const img = this.brushImage;
|
||||
const srcW = img.naturalWidth || img.width;
|
||||
const srcH = img.naturalHeight || img.height;
|
||||
const aspect = (srcW > 0 && srcH > 0) ? srcW / srcH : 1;
|
||||
|
||||
const stampH = size;
|
||||
const stampW = Math.max(1, Math.round(size * aspect));
|
||||
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = stampW;
|
||||
sc.height = stampH;
|
||||
const stx = sc.getContext("2d");
|
||||
stx.drawImage(img, 0, 0, stampW, stampH);
|
||||
|
||||
const imgData = stx.getImageData(0, 0, stampW, stampH);
|
||||
const d = imgData.data;
|
||||
|
||||
// Detect alpha channel (ABR-exported PNGs carry alpha = brush density)
|
||||
let hasAlpha = false;
|
||||
for (let i = 3; i < d.length; i += 4) {
|
||||
if (d[i] < 250) { hasAlpha = true; break; }
|
||||
}
|
||||
|
||||
let invertLum = false;
|
||||
if (!hasAlpha) {
|
||||
const corners = [0, stampW - 1, stampW * (stampH - 1), stampW * stampH - 1];
|
||||
let bgLum = 0;
|
||||
for (const ci of corners) {
|
||||
const ii = ci * 4;
|
||||
bgLum += (d[ii] * 0.299 + d[ii + 1] * 0.587 + d[ii + 2] * 0.114) / 255;
|
||||
}
|
||||
invertLum = (bgLum / corners.length) > 0.5;
|
||||
}
|
||||
|
||||
// Parse target color
|
||||
const hex = this.color.replace("#", "");
|
||||
const cr = parseInt(hex.slice(0, 2), 16);
|
||||
const cg = parseInt(hex.slice(2, 4), 16);
|
||||
const cb = parseInt(hex.slice(4, 6), 16);
|
||||
|
||||
// Convert to target color pixels; alpha = brush density
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let alpha;
|
||||
if (hasAlpha) {
|
||||
alpha = d[i + 3] / 255;
|
||||
} else {
|
||||
const lum = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255;
|
||||
alpha = invertLum ? 1 - lum : lum;
|
||||
}
|
||||
d[i] = cr; d[i + 1] = cg; d[i + 2] = cb;
|
||||
d[i + 3] = Math.round(alpha * 255);
|
||||
}
|
||||
stx.putImageData(imgData, 0, 0);
|
||||
|
||||
this._imgStamp = sc;
|
||||
this._imgStampSize = size;
|
||||
this._imgStampImg = this.brushImage;
|
||||
this._imgStampColor = this.color;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
this.ctx.translate(Math.round(x), Math.round(y));
|
||||
this.ctx.rotate((this.angle * Math.PI) / 180);
|
||||
this.ctx.drawImage(stamp, -stamp.width / 2, -stamp.height / 2);
|
||||
this.ctx.restore();
|
||||
} else {
|
||||
this.ctx.drawImage(stamp, Math.round(x - stamp.width / 2), Math.round(y - stamp.height / 2));
|
||||
}
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
static getCanvasPos(canvas, event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Image Edit Tab - Layer & LayerManager
|
||||
* Inspired by comfyui-mask-editor-one LayerManager.js
|
||||
*/
|
||||
|
||||
export class Layer {
|
||||
constructor(name, type, contentW, contentH) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.name = name;
|
||||
this.type = type; // 'draw' | 'image' | 'text'
|
||||
// layer.canvas のサイズ = コンテンツのネイティブ解像度
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.width = contentW;
|
||||
this.canvas.height = contentH;
|
||||
this.ctx = this.canvas.getContext("2d");
|
||||
// 表示プロパティ
|
||||
this.visible = true;
|
||||
this.opacity = 1.0;
|
||||
this.blendMode = "source-over";
|
||||
// 変換プロパティ(キャンバス座標系での配置)
|
||||
this.x = 0; // 左上X
|
||||
this.y = 0; // 左上Y
|
||||
this.displayW = contentW; // 表示幅
|
||||
this.displayH = contentH; // 表示高さ
|
||||
this.rotation = 0; // 度
|
||||
this.flipX = false;
|
||||
this.flipY = false;
|
||||
this.textProps = null; // テキストレイヤー用プロパティ(再編集・再描画に使用)
|
||||
this.locked = false; // ロック中は SelectTool の変形を禁止
|
||||
this.maskApply = false; // マスクレイヤー: true=クリッピングマスクとして機能
|
||||
this.operation = "add"; // マスクレイヤー合成モード: "add" | "subtract"
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
getThumbnailDataURL(size = 32) {
|
||||
const thumb = document.createElement("canvas");
|
||||
thumb.width = size;
|
||||
thumb.height = size;
|
||||
const ctx = thumb.getContext("2d");
|
||||
|
||||
if (this.type === "mask") {
|
||||
// マスクレイヤー: 黒背景に白マスクのグレースケール表示
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.drawImage(this.canvas, 0, 0, size, size);
|
||||
} else {
|
||||
// 通常レイヤー: チェッカーボード背景(透過表示用)
|
||||
ctx.fillStyle = "#aaa";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = "#fff";
|
||||
for (let x = 0; x < size; x += 8)
|
||||
for (let y = 0; y < size; y += 8)
|
||||
if ((x / 8 + y / 8) % 2 === 0) ctx.fillRect(x, y, 8, 8);
|
||||
ctx.drawImage(this.canvas, 0, 0, size, size);
|
||||
}
|
||||
|
||||
return thumb.toDataURL("image/png");
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id, name: this.name, type: this.type,
|
||||
imageData: this.canvas.toDataURL("image/png"),
|
||||
visible: this.visible, opacity: this.opacity, blendMode: this.blendMode,
|
||||
x: this.x, y: this.y, displayW: this.displayW, displayH: this.displayH,
|
||||
rotation: this.rotation, flipX: this.flipX, flipY: this.flipY,
|
||||
textProps: this.textProps ?? null,
|
||||
locked: this.locked ?? false,
|
||||
maskApply: this.maskApply ?? false,
|
||||
operation: this.operation ?? "add",
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json) {
|
||||
const cw = json.contentW ?? json.displayW ?? 512;
|
||||
const ch = json.contentH ?? json.displayH ?? 512;
|
||||
const layer = new Layer(json.name, json.type, cw, ch);
|
||||
layer.id = json.id;
|
||||
layer.visible = json.visible ?? true;
|
||||
layer.opacity = json.opacity ?? 1.0;
|
||||
layer.blendMode = json.blendMode ?? "source-over";
|
||||
layer.x = json.x ?? 0;
|
||||
layer.y = json.y ?? 0;
|
||||
layer.displayW = json.displayW ?? cw;
|
||||
layer.displayH = json.displayH ?? ch;
|
||||
layer.rotation = json.rotation ?? 0;
|
||||
layer.flipX = json.flipX ?? false;
|
||||
layer.flipY = json.flipY ?? false;
|
||||
layer.textProps = json.textProps ?? null;
|
||||
layer.locked = json.locked ?? false;
|
||||
layer.maskApply = json.maskApply ?? false;
|
||||
layer.operation = json.operation ?? "add";
|
||||
|
||||
if (json.imageData) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => { layer.ctx.drawImage(img, 0, 0, cw, ch); resolve(layer); };
|
||||
img.onerror = () => resolve(layer);
|
||||
img.src = json.imageData;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(layer);
|
||||
}
|
||||
|
||||
/** 変換行列を ctx に適用してから drawImage できる状態にする */
|
||||
static applyTransform(ctx, layer) {
|
||||
const cx = layer.x + layer.displayW / 2;
|
||||
const cy = layer.y + layer.displayH / 2;
|
||||
ctx.translate(cx, cy);
|
||||
if (layer.rotation) ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
ctx.scale(
|
||||
(layer.flipX ? -1 : 1) * (layer.displayW / layer.canvas.width),
|
||||
(layer.flipY ? -1 : 1) * (layer.displayH / layer.canvas.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class LayerManager {
|
||||
constructor(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.layers = []; // 先頭 = 最前面
|
||||
this.activeIndex = 0;
|
||||
this._listeners = [];
|
||||
}
|
||||
|
||||
get activeLayer() { return this.layers[this.activeIndex] ?? null; }
|
||||
|
||||
/**
|
||||
* @param {string} type "draw" | "image" | "text"
|
||||
* @param {string} name
|
||||
* @param {object} opts { contentW, contentH, displayW, displayH, x, y }
|
||||
*/
|
||||
addLayer(type = "draw", name = null, opts = {}) {
|
||||
const n = name ?? `Layer ${this.layers.length + 1}`;
|
||||
const cw = opts.contentW ?? this.width;
|
||||
const ch = opts.contentH ?? this.height;
|
||||
const layer = new Layer(n, type, cw, ch);
|
||||
layer.displayW = opts.displayW ?? cw;
|
||||
layer.displayH = opts.displayH ?? ch;
|
||||
layer.x = opts.x ?? 0;
|
||||
layer.y = opts.y ?? 0;
|
||||
this.layers.splice(this.activeIndex, 0, layer);
|
||||
this._emit("change");
|
||||
return layer;
|
||||
}
|
||||
|
||||
deleteLayer(id) {
|
||||
if (this.layers.length <= 1) return;
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx < 0) return;
|
||||
this.layers.splice(idx, 1);
|
||||
this.activeIndex = Math.max(0, Math.min(this.activeIndex, this.layers.length - 1));
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
setActive(id) {
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx >= 0) { this.activeIndex = idx; this._emit("activeChange", this.layers[idx]); }
|
||||
}
|
||||
|
||||
moveUp(id) {
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx <= 0) return;
|
||||
[this.layers[idx - 1], this.layers[idx]] = [this.layers[idx], this.layers[idx - 1]];
|
||||
if (this.activeIndex === idx) this.activeIndex = idx - 1;
|
||||
else if (this.activeIndex === idx - 1) this.activeIndex = idx;
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
moveDown(id) {
|
||||
const idx = this.layers.findIndex(l => l.id === id);
|
||||
if (idx < 0 || idx >= this.layers.length - 1) return;
|
||||
[this.layers[idx], this.layers[idx + 1]] = [this.layers[idx + 1], this.layers[idx]];
|
||||
if (this.activeIndex === idx) this.activeIndex = idx + 1;
|
||||
else if (this.activeIndex === idx + 1) this.activeIndex = idx;
|
||||
this._emit("change");
|
||||
}
|
||||
|
||||
toggleVisible(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer) { layer.visible = !layer.visible; this._emit("change"); }
|
||||
}
|
||||
|
||||
toggleLocked(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer) { layer.locked = !layer.locked; this._emit("change"); }
|
||||
}
|
||||
|
||||
toggleMaskApply(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer && layer.type === "mask") { layer.maskApply = !layer.maskApply; this._emit("change"); }
|
||||
}
|
||||
|
||||
toggleOperation(id) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer && layer.type === "mask") {
|
||||
layer.operation = layer.operation === "add" ? "subtract" : "add";
|
||||
this._emit("change");
|
||||
}
|
||||
}
|
||||
|
||||
setOpacity(id, opacity) {
|
||||
const layer = this.layers.find(l => l.id === id);
|
||||
if (layer) { layer.opacity = Math.max(0, Math.min(1, opacity)); this._emit("change"); }
|
||||
}
|
||||
|
||||
/** 全レイヤーを下→上の順で target canvas に変換付きで合成 */
|
||||
composite(target) {
|
||||
const ctx = target.getContext("2d");
|
||||
ctx.clearRect(0, 0, target.width, target.height);
|
||||
for (let i = this.layers.length - 1; i >= 0; i--) {
|
||||
const layer = this.layers[i];
|
||||
if (!layer.visible) continue;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
on(event, fn) { this._listeners.push({ event, fn }); }
|
||||
|
||||
_emit(event, data) {
|
||||
for (const l of this._listeners) if (l.event === event) l.fn(data);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { layers: this.layers.map(l => l.toJSON()), width: this.width, height: this.height };
|
||||
}
|
||||
|
||||
async fromJSON(json) {
|
||||
this.layers = await Promise.all((json.layers || []).map(lj => Layer.fromJSON(lj)));
|
||||
this.activeIndex = 0;
|
||||
this._emit("change");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Image Edit Tab - Mask Editor One 互換追加ツール
|
||||
* Color, Alpha, Text, Vector, Shape の各マスクツール。
|
||||
* マスクレイヤー canvas に直接白(add)または destination-out(erase)で描画する。
|
||||
*/
|
||||
|
||||
// ── Color Tool ───────────────────────────────────────────────────────────────
|
||||
// bgCanvas(背景合成画像)のクリック色に近いピクセルをマスク選択する。
|
||||
export class MaskColorTool {
|
||||
constructor() {
|
||||
this.tolerance = 40; // 0-255
|
||||
this.feather = 5; // 0-100 (tolerance に対する%)
|
||||
this.mode = "add"; // "add" | "subtract"
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._bgCanvas = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setBgCanvas(canvas) { this._bgCanvas = canvas; }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
|
||||
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown(x, y) { this._selectByColor(Math.round(x), Math.round(y)); }
|
||||
onMouseMove() {}
|
||||
onMouseUp() {}
|
||||
onMouseLeave() {}
|
||||
|
||||
_selectByColor(px, py) {
|
||||
if (!this._bgCanvas || !this._ctx || !this._canvas) return;
|
||||
const bgCtx = this._bgCanvas.getContext("2d");
|
||||
const bw = this._bgCanvas.width;
|
||||
const bh = this._bgCanvas.height;
|
||||
if (bw === 0 || bh === 0) return;
|
||||
|
||||
const srcData = bgCtx.getImageData(0, 0, bw, bh);
|
||||
const dw = this._canvas.width;
|
||||
const dh = this._canvas.height;
|
||||
const sx = bw / dw;
|
||||
const sy = bh / dh;
|
||||
|
||||
const bpx = Math.min(bw - 1, Math.round(px * sx));
|
||||
const bpy = Math.min(bh - 1, Math.round(py * sy));
|
||||
const idx = (bpy * bw + bpx) * 4;
|
||||
if (idx < 0 || idx + 3 >= srcData.data.length) return;
|
||||
|
||||
const tr = srcData.data[idx];
|
||||
const tg = srcData.data[idx + 1];
|
||||
const tb = srcData.data[idx + 2];
|
||||
|
||||
const outData = new Uint8ClampedArray(dw * dh * 4);
|
||||
const tol = this.tolerance;
|
||||
|
||||
for (let dy = 0; dy < dh; dy++) {
|
||||
for (let dx = 0; dx < dw; dx++) {
|
||||
const bx = Math.min(bw - 1, Math.round(dx * sx));
|
||||
const by = Math.min(bh - 1, Math.round(dy * sy));
|
||||
const si = (by * bw + bx) * 4;
|
||||
const r = srcData.data[si];
|
||||
const g = srcData.data[si + 1];
|
||||
const b = srcData.data[si + 2];
|
||||
const dist = Math.sqrt((r - tr) ** 2 + (g - tg) ** 2 + (b - tb) ** 2);
|
||||
|
||||
let alpha;
|
||||
if (tol <= 0) {
|
||||
alpha = dist === 0 ? 255 : 0;
|
||||
} else {
|
||||
const fStart = tol * (1 - this.feather / 100);
|
||||
if (dist <= fStart) alpha = 255;
|
||||
else if (dist <= tol) alpha = Math.round(255 * (1 - (dist - fStart) / (tol - fStart)));
|
||||
else alpha = 0;
|
||||
}
|
||||
|
||||
const j = (dy * dw + dx) * 4;
|
||||
outData[j] = outData[j + 1] = outData[j + 2] = 255;
|
||||
outData[j + 3] = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = dw; tmp.height = dh;
|
||||
tmp.getContext("2d").putImageData(new ImageData(outData, dw, dh), 0, 0);
|
||||
|
||||
const ctx = this._ctx;
|
||||
ctx.globalCompositeOperation = this.mode === "subtract" ? "destination-out" : "lighten";
|
||||
ctx.drawImage(tmp, 0, 0);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alpha Tool ───────────────────────────────────────────────────────────────
|
||||
// 元画像のアルファチャンネルをマスクとして抽出する。
|
||||
export class MaskAlphaTool {
|
||||
constructor() {
|
||||
this.threshold = 128; // 0-255
|
||||
this.invert = false;
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._srcImg = null; // HTMLImageElement or HTMLCanvasElement
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setSourceImage(img) { this._srcImg = img; }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "default"; }
|
||||
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown() {}
|
||||
onMouseMove() {}
|
||||
onMouseUp() {}
|
||||
onMouseLeave() {}
|
||||
|
||||
extract() {
|
||||
if (!this._srcImg || !this._ctx || !this._canvas) return;
|
||||
const w = this._canvas.width;
|
||||
const h = this._canvas.height;
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = w; tmp.height = h;
|
||||
tmp.getContext("2d").drawImage(this._srcImg, 0, 0, w, h);
|
||||
const src = tmp.getContext("2d").getImageData(0, 0, w, h);
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
for (let i = 0; i < src.data.length; i += 4) {
|
||||
let a = src.data[i + 3];
|
||||
if (this.threshold > 0) a = a >= this.threshold ? 255 : 0;
|
||||
if (this.invert) a = 255 - a;
|
||||
out[i] = out[i + 1] = out[i + 2] = 255;
|
||||
out[i + 3] = a;
|
||||
}
|
||||
this._ctx.clearRect(0, 0, w, h);
|
||||
this._ctx.putImageData(new ImageData(out, w, h), 0, 0);
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text Tool ────────────────────────────────────────────────────────────────
|
||||
// クリック位置にテキストを白(add)または消去(erase)でスタンプする。
|
||||
export const MASK_TEXT_FONTS = [
|
||||
"Arial", "Arial Black", "Georgia", "Times New Roman",
|
||||
"Courier New", "Verdana", "Trebuchet MS", "Impact",
|
||||
"Comic Sans MS", "Helvetica", "Tahoma",
|
||||
];
|
||||
|
||||
export class MaskTextTool {
|
||||
constructor() {
|
||||
this.text = "Hello";
|
||||
this.fontFamily = "Arial";
|
||||
this.fontSize = 64;
|
||||
this.bold = false;
|
||||
this.italic = false;
|
||||
this.align = "left";
|
||||
this.mode = "add";
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._overlay = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
|
||||
deactivate() { this._closeOverlay(); if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (this._overlay) { this._closeOverlay(); return; }
|
||||
this._showOverlay(x, y);
|
||||
}
|
||||
onMouseMove() {}
|
||||
onMouseUp() {}
|
||||
onMouseLeave() {}
|
||||
|
||||
_getFont() {
|
||||
const parts = [];
|
||||
if (this.italic) parts.push("italic");
|
||||
if (this.bold) parts.push("bold");
|
||||
parts.push(`${this.fontSize}px`);
|
||||
parts.push(`"${this.fontFamily}", sans-serif`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
_showOverlay(canvasX, canvasY) {
|
||||
const cv = this._canvas;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const cssX = Math.round(canvasX * (rect.width / cv.width)) + rect.left;
|
||||
const cssY = Math.round(canvasY * (rect.height / cv.height)) + rect.top;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = [
|
||||
`position:fixed;left:${cssX}px;top:${cssY}px;`,
|
||||
"background:var(--wfm-surface,#2a2a2a);",
|
||||
"border:1px solid var(--wfm-border,#444);",
|
||||
"border-radius:4px;padding:6px;z-index:99999;",
|
||||
"box-shadow:0 4px 16px rgba(0,0,0,0.5);min-width:200px;",
|
||||
].join("");
|
||||
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = this.text;
|
||||
ta.rows = 3;
|
||||
ta.placeholder = "Enter text…";
|
||||
ta.style.cssText = [
|
||||
"width:100%;resize:vertical;box-sizing:border-box;",
|
||||
"background:var(--wfm-bg,#1a1a1a);color:var(--wfm-text,#ddd);",
|
||||
"border:1px solid var(--wfm-border,#444);border-radius:3px;",
|
||||
"padding:4px;font-size:12px;",
|
||||
].join("");
|
||||
overlay.appendChild(ta);
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.style.cssText = "display:flex;gap:4px;margin-top:4px;";
|
||||
|
||||
const okBtn = document.createElement("button");
|
||||
okBtn.textContent = "Stamp";
|
||||
okBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
|
||||
okBtn.style.flex = "1";
|
||||
okBtn.onclick = () => {
|
||||
if (ta.value.trim()) { this.text = ta.value; this._stamp(canvasX, canvasY); }
|
||||
this._closeOverlay();
|
||||
};
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.className = "wfm-btn wfm-btn-sm";
|
||||
cancelBtn.style.flex = "1";
|
||||
cancelBtn.onclick = () => this._closeOverlay();
|
||||
|
||||
btnRow.appendChild(okBtn);
|
||||
btnRow.appendChild(cancelBtn);
|
||||
overlay.appendChild(btnRow);
|
||||
|
||||
const hint = document.createElement("div");
|
||||
hint.textContent = "Ctrl+Enter: Stamp / Esc: Cancel";
|
||||
hint.style.cssText = "font-size:10px;color:var(--wfm-text-secondary,#888);margin-top:3px;";
|
||||
overlay.appendChild(hint);
|
||||
|
||||
ta.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); okBtn.click(); }
|
||||
else if (e.key === "Escape") cancelBtn.click();
|
||||
e.stopPropagation();
|
||||
});
|
||||
overlay.addEventListener("mousedown", e => e.stopPropagation());
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
ta.focus(); ta.select();
|
||||
}
|
||||
|
||||
_closeOverlay() {
|
||||
if (this._overlay) { this._overlay.remove(); this._overlay = null; }
|
||||
}
|
||||
|
||||
_stamp(x, y) {
|
||||
const ctx = this._ctx;
|
||||
ctx.save();
|
||||
ctx.font = this._getFont();
|
||||
ctx.textAlign = this.align;
|
||||
ctx.textBaseline = "top";
|
||||
if (this.mode === "erase") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "rgba(0,0,0,1)";
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "white";
|
||||
}
|
||||
const lines = this.text.split("\n");
|
||||
const lineHeight = this.fontSize * 1.2;
|
||||
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], x, y + i * lineHeight);
|
||||
ctx.restore();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vector Tool ──────────────────────────────────────────────────────────────
|
||||
// クリックでポイント追加、Catmull-Rom スプラインで閉じたパスを塗りつぶす。
|
||||
const VECTOR_CLOSE_R = 12;
|
||||
|
||||
export class MaskVectorTool {
|
||||
constructor() {
|
||||
this.mode = "add";
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._previewCanvas = null;
|
||||
this._points = [];
|
||||
this._hoverX = null;
|
||||
this._hoverY = null;
|
||||
this.onBeforeCommit = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setPreviewCanvas(canvas) { this._previewCanvas = canvas; }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; this._renderPreview(); }
|
||||
deactivate() { if (this._canvas) this._canvas.style.cursor = ""; this._clearPreview(); }
|
||||
|
||||
reset() { this._points = []; this._hoverX = null; this._hoverY = null; this._clearPreview(); }
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (this._points.length >= 3) {
|
||||
const f = this._points[0];
|
||||
if (Math.hypot(x - f.x, y - f.y) <= VECTOR_CLOSE_R) { this._commitPath(true); return; }
|
||||
}
|
||||
this._points.push({ x, y });
|
||||
this._renderPreview();
|
||||
}
|
||||
|
||||
onMouseMove(x, y) { this._hoverX = x; this._hoverY = y; this._renderPreview(); }
|
||||
onMouseLeave() { this._hoverX = null; this._hoverY = null; this._renderPreview(); }
|
||||
onMouseUp() {}
|
||||
|
||||
onKeyDown(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault(); this.reset();
|
||||
} else if (e.key === "Enter" && this._points.length >= 2) {
|
||||
e.preventDefault(); this._commitPath(false);
|
||||
} else if ((e.key === "Backspace" || e.key === "Delete") && this._points.length > 0) {
|
||||
e.preventDefault(); this._points.pop(); this._renderPreview();
|
||||
}
|
||||
}
|
||||
|
||||
_commitPath(closed) {
|
||||
if (this._points.length < 2) return;
|
||||
if (this.onBeforeCommit) this.onBeforeCommit();
|
||||
const ctx = this._ctx;
|
||||
ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "rgba(0,0,0,1)";
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "white";
|
||||
}
|
||||
ctx.beginPath();
|
||||
this._buildSpline(ctx, this._points, closed);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
this.reset();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
|
||||
_buildSpline(ctx, pts, closed) {
|
||||
const n = pts.length;
|
||||
if (n === 0) return;
|
||||
if (n === 1) { ctx.moveTo(pts[0].x, pts[0].y); return; }
|
||||
if (n === 2) {
|
||||
ctx.moveTo(pts[0].x, pts[0].y); ctx.lineTo(pts[1].x, pts[1].y);
|
||||
if (closed) ctx.closePath(); return;
|
||||
}
|
||||
let ext;
|
||||
if (closed) {
|
||||
ext = [pts[n - 1], ...pts, pts[0], pts[1]];
|
||||
} else {
|
||||
ext = [
|
||||
{ x: 2 * pts[0].x - pts[1].x, y: 2 * pts[0].y - pts[1].y },
|
||||
...pts,
|
||||
{ x: 2 * pts[n-1].x - pts[n-2].x, y: 2 * pts[n-1].y - pts[n-2].y },
|
||||
];
|
||||
}
|
||||
ctx.moveTo(ext[1].x, ext[1].y);
|
||||
const count = closed ? n : n - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [p0, p1, p2, p3] = [ext[i], ext[i+1], ext[i+2], ext[i+3]];
|
||||
ctx.bezierCurveTo(
|
||||
p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
|
||||
p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
|
||||
p2.x, p2.y,
|
||||
);
|
||||
}
|
||||
if (closed) ctx.closePath();
|
||||
}
|
||||
|
||||
_renderPreview() {
|
||||
if (!this._previewCanvas) return;
|
||||
const pctx = this._previewCanvas.getContext("2d");
|
||||
const w = this._previewCanvas.width, h = this._previewCanvas.height;
|
||||
pctx.clearRect(0, 0, w, h);
|
||||
const pts = this._points, hx = this._hoverX, hy = this._hoverY;
|
||||
if (pts.length === 0 && hx === null) return;
|
||||
|
||||
if (pts.length >= 2) {
|
||||
pctx.save();
|
||||
pctx.strokeStyle = "rgba(255,210,40,0.9)"; pctx.lineWidth = 1.5; pctx.setLineDash([]);
|
||||
pctx.beginPath(); this._buildSpline(pctx, pts, false); pctx.stroke();
|
||||
pctx.restore();
|
||||
}
|
||||
|
||||
if (pts.length >= 1 && hx !== null) {
|
||||
const canClose = pts.length >= 3 && Math.hypot(hx - pts[0].x, hy - pts[0].y) <= VECTOR_CLOSE_R;
|
||||
pctx.save();
|
||||
if (canClose) {
|
||||
pctx.strokeStyle = "rgba(255,100,100,0.55)"; pctx.lineWidth = 1.5; pctx.setLineDash([4, 3]);
|
||||
pctx.beginPath(); this._buildSpline(pctx, pts, true); pctx.stroke();
|
||||
} else {
|
||||
pctx.strokeStyle = "rgba(255,210,40,0.5)"; pctx.lineWidth = 1; pctx.setLineDash([4, 3]);
|
||||
pctx.beginPath();
|
||||
pctx.moveTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
|
||||
pctx.lineTo(hx, hy); pctx.stroke();
|
||||
}
|
||||
pctx.restore();
|
||||
}
|
||||
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i], isFirst = i === 0;
|
||||
const closeable = isFirst && pts.length >= 3 && hx !== null && Math.hypot(hx - p.x, hy - p.y) <= VECTOR_CLOSE_R;
|
||||
pctx.beginPath();
|
||||
pctx.arc(p.x, p.y, closeable ? 7 : (isFirst ? 5 : 4), 0, Math.PI * 2);
|
||||
pctx.fillStyle = closeable ? "rgba(255,80,80,0.95)" : (isFirst ? "rgba(255,210,40,0.95)" : "rgba(255,210,40,0.8)");
|
||||
pctx.strokeStyle = "rgba(0,0,0,0.6)"; pctx.lineWidth = 1;
|
||||
pctx.fill(); pctx.stroke();
|
||||
}
|
||||
|
||||
if (hx !== null) {
|
||||
const onFirst = pts.length >= 3 && Math.hypot(hx - pts[0].x, hy - pts[0].y) <= VECTOR_CLOSE_R;
|
||||
if (!onFirst) {
|
||||
pctx.beginPath(); pctx.arc(hx, hy, 3, 0, Math.PI * 2);
|
||||
pctx.fillStyle = "rgba(255,255,255,0.6)"; pctx.strokeStyle = "rgba(200,200,200,0.5)"; pctx.lineWidth = 1;
|
||||
pctx.fill(); pctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_clearPreview() {
|
||||
if (!this._previewCanvas) return;
|
||||
const c = this._previewCanvas;
|
||||
c.getContext("2d").clearRect(0, 0, c.width, c.height);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shape Tool ───────────────────────────────────────────────────────────────
|
||||
// ドラッグで矩形または楕円をマスクとして描画する(Shift = 正方形/正円)。
|
||||
export class MaskShapeTool {
|
||||
constructor() {
|
||||
this.mode = "add";
|
||||
this.shape = "rect"; // "rect" | "ellipse"
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._previewCanvas = null;
|
||||
this._pCtx = null;
|
||||
this._startX = 0;
|
||||
this._startY = 0;
|
||||
this._curX = 0;
|
||||
this._curY = 0;
|
||||
this._curShift = false;
|
||||
this._drawing = false;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) { this._canvas = canvas; this._ctx = canvas.getContext("2d"); }
|
||||
setPreviewCanvas(canvas) { this._previewCanvas = canvas; this._pCtx = canvas.getContext("2d"); }
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() { if (this._canvas) this._canvas.style.cursor = "crosshair"; }
|
||||
deactivate() { this._drawing = false; this._clearPreview(); if (this._canvas) this._canvas.style.cursor = ""; }
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._startX = x; this._startY = y;
|
||||
this._curX = x; this._curY = y; this._curShift = false;
|
||||
this._drawing = true;
|
||||
this._drawPreview(x, y, false);
|
||||
}
|
||||
|
||||
onMouseMove(x, y, e) {
|
||||
if (!this._drawing) return;
|
||||
this._curX = x; this._curY = y; this._curShift = e?.shiftKey ?? false;
|
||||
this._drawPreview(x, y, this._curShift);
|
||||
}
|
||||
|
||||
// x/y を省略した場合は最後に記録した座標を使用(mouseupイベント非経由での呼び出し対応)
|
||||
onMouseUp(x, y, e) {
|
||||
if (!this._drawing) return;
|
||||
const cx = x ?? this._curX;
|
||||
const cy = y ?? this._curY;
|
||||
const shift = e?.shiftKey ?? this._curShift;
|
||||
this._drawing = false;
|
||||
this._clearPreview();
|
||||
this._commit(cx, cy, shift);
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
|
||||
onMouseLeave() {}
|
||||
|
||||
_getRect(x, y, shift) {
|
||||
const sx = this._startX, sy = this._startY;
|
||||
if (shift) {
|
||||
const half = Math.max(Math.abs(x - sx), Math.abs(y - sy));
|
||||
return { x: sx - half, y: sy - half, w: half * 2, h: half * 2 };
|
||||
}
|
||||
return { x: Math.min(sx, x), y: Math.min(sy, y), w: Math.abs(x - sx), h: Math.abs(y - sy) };
|
||||
}
|
||||
|
||||
_drawPreview(x, y, shift) {
|
||||
if (!this._pCtx) return;
|
||||
const pc = this._previewCanvas;
|
||||
this._pCtx.clearRect(0, 0, pc.width, pc.height);
|
||||
const { x: rx, y: ry, w, h } = this._getRect(x, y, shift);
|
||||
if (w < 1 || h < 1) return;
|
||||
this._pCtx.save();
|
||||
this._pCtx.strokeStyle = "rgba(0,180,255,0.9)"; this._pCtx.lineWidth = 1.5; this._pCtx.setLineDash([5, 3]);
|
||||
this._pCtx.beginPath();
|
||||
if (this.shape === "rect") this._pCtx.rect(rx, ry, w, h);
|
||||
else this._pCtx.ellipse(rx + w / 2, ry + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
this._pCtx.stroke(); this._pCtx.restore();
|
||||
}
|
||||
|
||||
_clearPreview() {
|
||||
if (!this._pCtx) return;
|
||||
this._pCtx.clearRect(0, 0, this._previewCanvas.width, this._previewCanvas.height);
|
||||
}
|
||||
|
||||
_commit(x, y, shift) {
|
||||
const { x: rx, y: ry, w, h } = this._getRect(x, y, shift);
|
||||
if (w < 1 || h < 1) return;
|
||||
const ctx = this._ctx;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = this.mode === "erase" ? "destination-out" : "source-over";
|
||||
ctx.fillStyle = "white";
|
||||
ctx.beginPath();
|
||||
if (this.shape === "rect") ctx.rect(rx, ry, w, h);
|
||||
else ctx.ellipse(rx + w / 2, ry + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Image Edit Tab - Mask Tool
|
||||
* White-paint / erase brush for mask layers.
|
||||
* paint mode: draws white (opaque) = mask present
|
||||
* erase mode: destination-out (transparent) = mask absent
|
||||
*
|
||||
* Supports circular soft brush (built-in) and image brushes loaded
|
||||
* from Mask Editor One's ABR brush library (optional).
|
||||
*/
|
||||
|
||||
export class MaskTool {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas?.getContext("2d");
|
||||
this.brushSize = 30;
|
||||
this.hardness = 0.85;
|
||||
this.spacing = 0.25; // stamp interval as fraction of brushSize (image brush only)
|
||||
this.mode = "paint"; // "paint" | "erase"
|
||||
|
||||
// Image brush from ABR library (null = default circle brush)
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
this.angle = 0; // degrees [0, 359]
|
||||
this.sizeJitter = false;
|
||||
this.sizeJitterAmount = 0.5; // 0–1: fraction of brushSize that can be removed
|
||||
this.rotationJitter = false;
|
||||
|
||||
this._drawing = false;
|
||||
this._lastX = 0;
|
||||
this._lastY = 0;
|
||||
|
||||
// Cached circle stamp
|
||||
this._stamp = null;
|
||||
this._stampSize = 0;
|
||||
this._stampHard = 0;
|
||||
this._stampMode = null;
|
||||
|
||||
// Cached image stamp
|
||||
this._imgStamp = null;
|
||||
this._imgStampSize = 0;
|
||||
this._imgStampImg = null;
|
||||
|
||||
// Stroke buffer: prevents intra-stroke opacity accumulation (image brush only)
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d");
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
setImageBrush(img, name) {
|
||||
this.brushImage = img;
|
||||
this.brushName = name;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
clearImageBrush() {
|
||||
this.brushImage = null;
|
||||
this.brushName = "Circle";
|
||||
this._stamp = null;
|
||||
this._imgStamp = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this.canvas) this.canvas.style.cursor = "none";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
if (this.brushImage) {
|
||||
this._initStrokeBuffer();
|
||||
this._paintToStroke(x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paint(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._drawing) return;
|
||||
if (this.brushImage) {
|
||||
this._paintLineToStroke(this._lastX, this._lastY, x, y);
|
||||
this._mergeStroke();
|
||||
} else {
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
}
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this._drawing) {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (this._drawing) this.onMouseUp();
|
||||
}
|
||||
|
||||
// ── Stroke buffer (image brush only) ─────────────────────────────
|
||||
|
||||
_initStrokeBuffer() {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
this._baseCanvas = document.createElement("canvas");
|
||||
this._baseCanvas.width = w;
|
||||
this._baseCanvas.height = h;
|
||||
this._baseCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
|
||||
this._strokeCanvas = document.createElement("canvas");
|
||||
this._strokeCanvas.width = w;
|
||||
this._strokeCanvas.height = h;
|
||||
this._strokeCtx = this._strokeCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
_clearStrokeBuffer() {
|
||||
this._baseCanvas = null;
|
||||
this._strokeCanvas = null;
|
||||
this._strokeCtx = null;
|
||||
}
|
||||
|
||||
_mergeStroke() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(this._baseCanvas, 0, 0);
|
||||
ctx.globalCompositeOperation = this.mode === "erase" ? "destination-out" : "lighten";
|
||||
ctx.drawImage(this._strokeCanvas, 0, 0);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
_paintToStroke(x, y) {
|
||||
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;
|
||||
} else {
|
||||
this._paintImageBrush(x, y);
|
||||
}
|
||||
|
||||
this.ctx = saved;
|
||||
}
|
||||
|
||||
_paintLineToStroke(x0, y0, x1, y1) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle brush ─────────────────────────────────────────────────
|
||||
|
||||
_getStamp() {
|
||||
if (
|
||||
this._stamp &&
|
||||
this._stampSize === this.brushSize &&
|
||||
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;
|
||||
const sctx = sc.getContext("2d");
|
||||
const cx = size / 2, cy = size / 2, r = size / 2;
|
||||
|
||||
const color = this.mode === "paint" ? "#ffffff" : "#000000";
|
||||
const colorAlpha = this.mode === "paint" ? "rgba(255,255,255,0)" : "rgba(0,0,0,0)";
|
||||
|
||||
const innerR = r * (1 - Math.min(this.hardness, 0.99)) * 0.95;
|
||||
const grd = sctx.createRadialGradient(cx, cy, innerR, cx, cy, r);
|
||||
grd.addColorStop(0, color);
|
||||
grd.addColorStop(1, colorAlpha);
|
||||
sctx.fillStyle = grd;
|
||||
sctx.beginPath();
|
||||
sctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
sctx.fill();
|
||||
|
||||
this._stamp = sc;
|
||||
this._stampSize = size;
|
||||
this._stampHard = this.hardness;
|
||||
this._stampMode = this.mode;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paint(x, y) {
|
||||
const stamp = this._getStamp();
|
||||
const s = stamp.width;
|
||||
this.ctx.save();
|
||||
if (this.mode === "erase") {
|
||||
this.ctx.globalCompositeOperation = "destination-out";
|
||||
}
|
||||
this.ctx.drawImage(stamp, x - s / 2, y - s / 2);
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
_paintLine(x0, y0, x1, y1) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image brush ──────────────────────────────────────────────────
|
||||
|
||||
_getImageStamp(size) {
|
||||
size = Math.max(1, size);
|
||||
if (
|
||||
this._imgStamp &&
|
||||
this._imgStampSize === size &&
|
||||
this._imgStampImg === this.brushImage
|
||||
) return this._imgStamp;
|
||||
|
||||
const img = this.brushImage;
|
||||
const srcW = img.naturalWidth || img.width;
|
||||
const srcH = img.naturalHeight || img.height;
|
||||
const aspect = (srcW > 0 && srcH > 0) ? srcW / srcH : 1;
|
||||
|
||||
const stampH = size;
|
||||
const stampW = Math.max(1, Math.round(size * aspect));
|
||||
|
||||
const sc = document.createElement("canvas");
|
||||
sc.width = stampW;
|
||||
sc.height = stampH;
|
||||
const stx = sc.getContext("2d");
|
||||
stx.drawImage(img, 0, 0, stampW, stampH);
|
||||
|
||||
const imgData = stx.getImageData(0, 0, stampW, stampH);
|
||||
const d = imgData.data;
|
||||
|
||||
// Detect alpha channel (ABR-exported PNGs carry alpha = brush density)
|
||||
let hasAlpha = false;
|
||||
for (let i = 3; i < d.length; i += 4) {
|
||||
if (d[i] < 250) { hasAlpha = true; break; }
|
||||
}
|
||||
|
||||
let invertLum = false;
|
||||
if (!hasAlpha) {
|
||||
const corners = [0, stampW - 1, stampW * (stampH - 1), stampW * stampH - 1];
|
||||
let bgLum = 0;
|
||||
for (const ci of corners) {
|
||||
const ii = ci * 4;
|
||||
bgLum += (d[ii] * 0.299 + d[ii + 1] * 0.587 + d[ii + 2] * 0.114) / 255;
|
||||
}
|
||||
invertLum = (bgLum / corners.length) > 0.5;
|
||||
}
|
||||
|
||||
// Convert to white pixels; alpha = brush density
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let alpha;
|
||||
if (hasAlpha) {
|
||||
alpha = d[i + 3] / 255;
|
||||
} else {
|
||||
const lum = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255;
|
||||
alpha = invertLum ? 1 - lum : lum;
|
||||
}
|
||||
d[i] = d[i + 1] = d[i + 2] = 255;
|
||||
d[i + 3] = Math.round(alpha * 255);
|
||||
}
|
||||
stx.putImageData(imgData, 0, 0);
|
||||
|
||||
this._imgStamp = sc;
|
||||
this._imgStampSize = size;
|
||||
this._imgStampImg = this.brushImage;
|
||||
return sc;
|
||||
}
|
||||
|
||||
_paintImageBrush(x, y) {
|
||||
const stamp = this._getImageStamp(Math.round(this.brushSize));
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
if (this.angle) {
|
||||
this.ctx.save();
|
||||
this.ctx.translate(Math.round(x), Math.round(y));
|
||||
this.ctx.rotate((this.angle * Math.PI) / 180);
|
||||
this.ctx.drawImage(stamp, -stamp.width / 2, -stamp.height / 2);
|
||||
this.ctx.restore();
|
||||
} else {
|
||||
this.ctx.drawImage(stamp, Math.round(x - stamp.width / 2), Math.round(y - stamp.height / 2));
|
||||
}
|
||||
this.ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Image Edit Tab - Select Tool
|
||||
* Handles object selection, move, resize, rotate, flip on the overlay canvas.
|
||||
*/
|
||||
|
||||
const HANDLE_R = 5; // ハンドル半径(px)
|
||||
const ROTATE_DIST = 32; // 回転ハンドルまでの距離(px)
|
||||
const MIN_SIZE = 10; // 最小表示サイズ(px)
|
||||
|
||||
export class SelectTool {
|
||||
constructor() {
|
||||
this._overlay = null;
|
||||
this._octx = null;
|
||||
this._layer = null; // 選択中レイヤー
|
||||
this._dragMode = null; // "move"|"rotate"|"resize-tl"|"resize-tr"|"resize-br"|"resize-bl"
|
||||
this._dragStart = null;
|
||||
this._origTransform = null;
|
||||
this._onChange = null;
|
||||
this._cursor = "default";
|
||||
}
|
||||
|
||||
setCanvas(overlayCanvas) {
|
||||
this._overlay = overlayCanvas;
|
||||
this._octx = overlayCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() {
|
||||
if (this._overlay) this._overlay.style.pointerEvents = "auto";
|
||||
this._drawOverlay();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._clearOverlay();
|
||||
if (this._overlay) this._overlay.style.pointerEvents = "none";
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this._layer = null;
|
||||
this._dragMode = null;
|
||||
this._clearOverlay();
|
||||
}
|
||||
|
||||
setLayer(layer) {
|
||||
this._layer = layer;
|
||||
this._drawOverlay();
|
||||
}
|
||||
|
||||
getSelectedLayer() { return this._layer; }
|
||||
|
||||
// ── マウスイベント ───────────────────────────
|
||||
|
||||
onMouseDown(x, y, layerMgr) {
|
||||
if (this._layer) {
|
||||
const hit = this._hitHandle(x, y);
|
||||
// ロック中はハンドル操作を禁止(選択済み表示は維持)
|
||||
if (hit && !this._layer.locked) {
|
||||
this._startDrag(hit, x, y);
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
// レイヤーヒットテスト(前面から)
|
||||
for (let i = 0; i < layerMgr.layers.length; i++) {
|
||||
const layer = layerMgr.layers[i];
|
||||
if (!layer.visible) continue;
|
||||
if (this._isPointInLayer(x, y, layer)) {
|
||||
this._layer = layer;
|
||||
this._drawOverlay();
|
||||
// ロック中は選択のみ(移動ドラッグは開始しない)
|
||||
if (!layer.locked) this._startDrag("move", x, y);
|
||||
return "select";
|
||||
}
|
||||
}
|
||||
|
||||
// 空白クリック → 選択解除
|
||||
this._layer = null;
|
||||
this._clearOverlay();
|
||||
return null;
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._dragMode || !this._layer) {
|
||||
// カーソル変更のみ
|
||||
this._updateCursor(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = x - this._dragStart.x;
|
||||
const dy = y - this._dragStart.y;
|
||||
const ot = this._origTransform;
|
||||
|
||||
if (this._dragMode === "move") {
|
||||
this._layer.x = ot.x + dx;
|
||||
this._layer.y = ot.y + dy;
|
||||
} else if (this._dragMode === "rotate") {
|
||||
const cx = ot.x + ot.displayW / 2;
|
||||
const cy = ot.y + ot.displayH / 2;
|
||||
const a0 = Math.atan2(this._dragStart.y - cy, this._dragStart.x - cx);
|
||||
const a1 = Math.atan2(y - cy, x - cx);
|
||||
this._layer.rotation = ot.rotation + (a1 - a0) * 180 / Math.PI;
|
||||
} else if (this._dragMode.startsWith("resize-")) {
|
||||
this._applyResize(dx, dy);
|
||||
}
|
||||
|
||||
this._drawOverlay();
|
||||
if (this._onChange) this._onChange("transforming");
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this._dragMode) {
|
||||
this._dragMode = null;
|
||||
this._dragStart = null;
|
||||
this._origTransform = null;
|
||||
if (this._onChange) this._onChange("transformEnd");
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() { this.onMouseUp(); }
|
||||
|
||||
// ── 反転 ─────────────────────────────────────
|
||||
|
||||
flipH() {
|
||||
if (!this._layer) return;
|
||||
this._layer.flipX = !this._layer.flipX;
|
||||
this._drawOverlay();
|
||||
if (this._onChange) this._onChange("transformEnd");
|
||||
}
|
||||
|
||||
flipV() {
|
||||
if (!this._layer) return;
|
||||
this._layer.flipY = !this._layer.flipY;
|
||||
this._drawOverlay();
|
||||
if (this._onChange) this._onChange("transformEnd");
|
||||
}
|
||||
|
||||
// ── 内部ユーティリティ ─────────────────────────
|
||||
|
||||
_startDrag(mode, x, y) {
|
||||
this._dragMode = mode;
|
||||
this._dragStart = { x, y };
|
||||
this._origTransform = {
|
||||
x: this._layer.x, y: this._layer.y,
|
||||
displayW: this._layer.displayW, displayH: this._layer.displayH,
|
||||
rotation: this._layer.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
// 回転を考慮した4頂点を返す(name: "tl"|"tr"|"br"|"bl")
|
||||
_getCorners(layer) {
|
||||
const hw = layer.displayW / 2;
|
||||
const hh = layer.displayH / 2;
|
||||
const cx = layer.x + hw;
|
||||
const cy = layer.y + hh;
|
||||
const r = (layer.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
return [
|
||||
{ name: "tl", lx: -hw, ly: -hh },
|
||||
{ name: "tr", lx: hw, ly: -hh },
|
||||
{ name: "br", lx: hw, ly: hh },
|
||||
{ name: "bl", lx: -hw, ly: hh },
|
||||
].map(p => ({
|
||||
name: p.name,
|
||||
x: cx + p.lx * cosR - p.ly * sinR,
|
||||
y: cy + p.lx * sinR + p.ly * cosR,
|
||||
}));
|
||||
}
|
||||
|
||||
// 上辺中央から ROTATE_DIST だけ上に出た回転ハンドル
|
||||
_getRotateHandle(layer) {
|
||||
const hw = layer.displayW / 2;
|
||||
const hh = layer.displayH / 2;
|
||||
const cx = layer.x + hw;
|
||||
const cy = layer.y + hh;
|
||||
const r = (layer.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
const dist = hh + ROTATE_DIST;
|
||||
// ローカル座標 (0, -dist) を回転: x' = dist*sin(r), y' = -dist*cos(r)
|
||||
return {
|
||||
x: cx + sinR * dist,
|
||||
y: cy - cosR * dist,
|
||||
};
|
||||
}
|
||||
|
||||
_hitHandle(x, y) {
|
||||
if (!this._layer) return null;
|
||||
const rh = this._getRotateHandle(this._layer);
|
||||
if (Math.hypot(x - rh.x, y - rh.y) <= HANDLE_R * 2.5) return "rotate";
|
||||
|
||||
const corners = this._getCorners(this._layer);
|
||||
for (const c of corners) {
|
||||
if (Math.hypot(x - c.x, y - c.y) <= HANDLE_R * 2.5) return `resize-${c.name}`;
|
||||
}
|
||||
|
||||
if (this._isPointInLayer(x, y, this._layer)) return "move";
|
||||
return null;
|
||||
}
|
||||
|
||||
_isPointInLayer(x, y, layer) {
|
||||
const hw = layer.displayW / 2;
|
||||
const hh = layer.displayH / 2;
|
||||
const cx = layer.x + hw;
|
||||
const cy = layer.y + hh;
|
||||
// 逆回転で点をローカル座標に変換
|
||||
const r = -(layer.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
const lx = (x - cx) * cosR - (y - cy) * sinR;
|
||||
const ly = (x - cx) * sinR + (y - cy) * cosR;
|
||||
return Math.abs(lx) <= hw && Math.abs(ly) <= hh;
|
||||
}
|
||||
|
||||
_applyResize(dx, dy) {
|
||||
const ot = this._origTransform;
|
||||
const mode = this._dragMode;
|
||||
// 回転の逆行列でdx/dyをローカル座標に変換
|
||||
const r = -(ot.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR = Math.cos(r), sinR = Math.sin(r);
|
||||
const ldx = dx * cosR - dy * sinR;
|
||||
const ldy = dx * sinR + dy * cosR;
|
||||
|
||||
let { x, y, displayW, displayH } = ot;
|
||||
|
||||
if (mode === "resize-tl") { x += ldx; y += ldy; displayW -= ldx; displayH -= ldy; }
|
||||
else if (mode === "resize-tr") { y += ldy; displayW += ldx; displayH -= ldy; }
|
||||
else if (mode === "resize-br") { displayW += ldx; displayH += ldy; }
|
||||
else if (mode === "resize-bl") { x += ldx; displayW -= ldx; displayH += ldy; }
|
||||
|
||||
if (displayW < MIN_SIZE) { if (mode.includes("l")) x -= MIN_SIZE - displayW; displayW = MIN_SIZE; }
|
||||
if (displayH < MIN_SIZE) { if (mode.includes("t")) y -= MIN_SIZE - displayH; displayH = MIN_SIZE; }
|
||||
|
||||
// 回転あり: x/yのずれを回転軸中心に合わせて再計算
|
||||
// ローカル左上の変位を逆回転して戻す
|
||||
const r2 = (ot.rotation ?? 0) * Math.PI / 180;
|
||||
const cosR2 = Math.cos(r2), sinR2 = Math.sin(r2);
|
||||
const ox = ot.x + ot.displayW / 2;
|
||||
const oy = ot.y + ot.displayH / 2;
|
||||
// 新しい左上 (ローカル -hw2, -hh2 を回転して絶対座標)
|
||||
const hw2 = displayW / 2;
|
||||
const hh2 = displayH / 2;
|
||||
const dx2 = -hw2 * cosR2 + hh2 * sinR2; // ローカル(-hw2, -hh2)を回転
|
||||
const dy2 = -hw2 * sinR2 - hh2 * cosR2;
|
||||
// ... でも回転付きリサイズは複雑なので、回転がほぼ0のときのみシンプル版を使用
|
||||
if (Math.abs(ot.rotation ?? 0) < 1) {
|
||||
this._layer.x = x; this._layer.y = y;
|
||||
}
|
||||
// 回転あり時は displayW/H のみ更新(位置は維持)
|
||||
this._layer.displayW = displayW;
|
||||
this._layer.displayH = displayH;
|
||||
}
|
||||
|
||||
_updateCursor(x, y) {
|
||||
if (!this._overlay) return;
|
||||
const hit = this._layer ? this._hitHandle(x, y) : null;
|
||||
const cursorMap = {
|
||||
"move": "move", "rotate": "crosshair",
|
||||
"resize-tl": "nwse-resize", "resize-br": "nwse-resize",
|
||||
"resize-tr": "nesw-resize", "resize-bl": "nesw-resize",
|
||||
};
|
||||
this._overlay.style.cursor = hit ? (cursorMap[hit] ?? "default") : "default";
|
||||
}
|
||||
|
||||
// ── オーバーレイ描画 ───────────────────────────
|
||||
|
||||
_drawOverlay() {
|
||||
this._clearOverlay();
|
||||
if (!this._layer || !this._octx) return;
|
||||
|
||||
const ctx = this._octx;
|
||||
const layer = this._layer;
|
||||
const locked = !!layer.locked;
|
||||
const color = locked ? "#e2a04a" : "#4a90e2"; // ロック中はオレンジ
|
||||
const corners = this._getCorners(layer);
|
||||
const rh = this._getRotateHandle(layer);
|
||||
const tl = corners[0], tr = corners[1];
|
||||
const topMid = { x: (tl.x + tr.x) / 2, y: (tl.y + tr.y) / 2 };
|
||||
|
||||
ctx.save();
|
||||
|
||||
// バウンディングボックス
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
corners.forEach((c, i) => i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y));
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
if (!locked) {
|
||||
// 回転ライン+ハンドル(ロック中は非表示)
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(topMid.x, topMid.y);
|
||||
ctx.lineTo(rh.x, rh.y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rh.x, rh.y, HANDLE_R, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// コーナーハンドル
|
||||
for (const c of corners) {
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.rect(c.x - HANDLE_R, c.y - HANDLE_R, HANDLE_R * 2, HANDLE_R * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// ロック中: 中央に鍵アイコン
|
||||
const cx = layer.x + layer.displayW / 2;
|
||||
const cy = layer.y + layer.displayH / 2;
|
||||
ctx.font = "18px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText("🔒", cx, cy);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_clearOverlay() {
|
||||
if (this._octx && this._overlay) {
|
||||
this._octx.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* ShapeTool — rect / ellipse / line / freeline drawing tool for Image Edit tab.
|
||||
* Preview is drawn on the overlay canvas; committed shapes become new draw layers.
|
||||
*/
|
||||
export class ShapeTool {
|
||||
constructor() {
|
||||
// shape settings
|
||||
this.shape = "rect";
|
||||
this.rounded = false;
|
||||
this.fillColor = "#ff0000";
|
||||
this.fillNone = false;
|
||||
this.strokeColor = "#000000";
|
||||
this.strokeNone = true;
|
||||
this.strokeWidth = 5;
|
||||
this.opacity = 1.0;
|
||||
|
||||
// internal state
|
||||
this._active = false;
|
||||
this._canvas = null; // overlay canvas (preview)
|
||||
this._ctx = null;
|
||||
this._dragging = false;
|
||||
this._dragStart = null;
|
||||
this._curPt = null;
|
||||
this._points = []; // freeline points
|
||||
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
// overlay canvas used for live preview
|
||||
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;
|
||||
this._dragging = false;
|
||||
this._clearOverlay();
|
||||
if (this._canvas) this._canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (!this._active) return;
|
||||
this._dragging = true;
|
||||
this._dragStart = { x, y };
|
||||
this._curPt = { x, y };
|
||||
this._points = this.shape === "freeline" ? [{ x, y }] : [];
|
||||
}
|
||||
|
||||
onMouseMove(x, y) {
|
||||
if (!this._dragging) return;
|
||||
this._curPt = { x, y };
|
||||
if (this.shape === "freeline") this._points.push({ x, y });
|
||||
this._drawPreview(x, y);
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._clearOverlay();
|
||||
this._commit();
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._clearOverlay();
|
||||
this._commit();
|
||||
}
|
||||
|
||||
// ── private ───────────────────────────────────────
|
||||
|
||||
_clearOverlay() {
|
||||
if (this._ctx && this._canvas) {
|
||||
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
_drawPreview(curX, curY) {
|
||||
if (!this._ctx || !this._canvas) return;
|
||||
const ctx = this._ctx;
|
||||
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
|
||||
const s = this._dragStart;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = this.opacity;
|
||||
|
||||
if (this.shape === "freeline") {
|
||||
// freeline: always draw with stroke color (strokeNone has no meaning here)
|
||||
if (this._points.length >= 2) {
|
||||
ctx.strokeStyle = this.strokeColor;
|
||||
ctx.lineWidth = this.strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this._points[0].x, this._points[0].y);
|
||||
for (let i = 1; i < this._points.length; i++) ctx.lineTo(this._points[i].x, this._points[i].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// dashed blue outline for rect / ellipse / line
|
||||
ctx.strokeStyle = "#0077ff";
|
||||
ctx.lineWidth = Math.max(1, this.strokeWidth || 1);
|
||||
ctx.setLineDash([4, 2]);
|
||||
|
||||
const x1 = Math.min(s.x, curX), y1 = Math.min(s.y, curY);
|
||||
const w = Math.abs(curX - s.x), h = Math.abs(curY - s.y);
|
||||
|
||||
if (this.shape === "ellipse") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x1 + w / 2, y1 + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (this.shape === "line") {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(curX, curY);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// rect
|
||||
if (this.rounded) {
|
||||
const r = Math.min(w, h) * 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x1, y1, w, h, r);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.strokeRect(x1, y1, w, h);
|
||||
}
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_commit() {
|
||||
const s = this._dragStart, c = this._curPt;
|
||||
if (!s || !c) return;
|
||||
|
||||
const isFreehand = this.shape === "freeline";
|
||||
const dx = Math.abs(c.x - s.x), dy = Math.abs(c.y - s.y);
|
||||
if (!isFreehand && dx < 2 && dy < 2) return;
|
||||
if (isFreehand && this._points.length < 2) return;
|
||||
|
||||
const isStrokeMandatory = ["line", "freeline"].includes(this.shape);
|
||||
const shapeObj = {
|
||||
kind: this.shape,
|
||||
s: { ...s },
|
||||
c: { ...c },
|
||||
points: isFreehand ? this._points.map(p => ({ ...p })) : [],
|
||||
fillColor: this.fillNone ? null : this.fillColor,
|
||||
// line / freeline always use stroke; ignore strokeNone
|
||||
strokeColor: (isStrokeMandatory || !this.strokeNone) ? this.strokeColor : null,
|
||||
strokeWidth: (isStrokeMandatory || !this.strokeNone) ? this.strokeWidth : 0,
|
||||
opacity: this.opacity,
|
||||
rounded: this.rounded,
|
||||
};
|
||||
|
||||
this._onChange?.(shapeObj);
|
||||
}
|
||||
|
||||
// ── static renderer (used by onChange callback to bake shape into layer) ──
|
||||
|
||||
static drawShape(ctx, sh) {
|
||||
const { kind, s, c, points, fillColor, strokeColor, strokeWidth, opacity, rounded } = sh;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity ?? 1;
|
||||
|
||||
if (kind === "freeline") {
|
||||
if (strokeColor && strokeWidth > 0 && points.length >= 2) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (kind === "ellipse") {
|
||||
const x1 = Math.min(s.x, c.x), y1 = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x1 + w / 2, y1 + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||||
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
|
||||
if (strokeColor && strokeWidth > 0) { ctx.strokeStyle = strokeColor; ctx.lineWidth = strokeWidth; ctx.stroke(); }
|
||||
} else if (kind === "line") {
|
||||
if (strokeColor && strokeWidth > 0) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(c.x, c.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// rect
|
||||
const x1 = Math.min(s.x, c.x), y1 = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
if (rounded) {
|
||||
const r = Math.min(w, h) * 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x1, y1, w, h, r);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.rect(x1, y1, w, h);
|
||||
}
|
||||
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
|
||||
if (strokeColor && strokeWidth > 0) { ctx.strokeStyle = strokeColor; ctx.lineWidth = strokeWidth; ctx.stroke(); }
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// coordinate helper (same signature as DrawTool.getCanvasPos)
|
||||
static getCanvasPos(canvas, event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Image Edit Tab - Text Tool
|
||||
* Measures text bounding box, renders to an off-screen canvas,
|
||||
* and calls onChange(clickX, clickY) so the caller can create
|
||||
* a proper text layer with the exact content size.
|
||||
*/
|
||||
|
||||
export const TEXT_FONTS = [
|
||||
"Arial", "Arial Black", "Georgia", "Times New Roman",
|
||||
"Courier New", "Verdana", "Trebuchet MS", "Impact",
|
||||
"Comic Sans MS", "Tahoma",
|
||||
];
|
||||
|
||||
const TEXT_PADDING = 4; // テキスト描画時の余白(px)
|
||||
|
||||
export class TextTool {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.text = "Hello";
|
||||
this.fontFamily = "Arial";
|
||||
this.fontSize = 64;
|
||||
this.bold = false;
|
||||
this.italic = false;
|
||||
this.align = "left";
|
||||
this.color = "#ffffff";
|
||||
this._overlay = null;
|
||||
this._onChange = null;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this._closeOverlay();
|
||||
this.canvas = canvas;
|
||||
}
|
||||
|
||||
onChange(fn) { this._onChange = fn; }
|
||||
|
||||
activate() {
|
||||
if (this.canvas) this.canvas.style.cursor = "text";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._closeOverlay();
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
if (this._overlay) { this._closeOverlay(); return; }
|
||||
this._showOverlay(x, y);
|
||||
}
|
||||
|
||||
/** 既存テキスト再編集用: プロパティを外部でセット後に呼ぶ */
|
||||
openAt(canvasX, canvasY) {
|
||||
this._closeOverlay();
|
||||
this._showOverlay(canvasX, canvasY);
|
||||
}
|
||||
|
||||
onMouseMove() {}
|
||||
onMouseLeave() {}
|
||||
onMouseUp() {}
|
||||
|
||||
/** テキストのバウンディングボックスサイズのcanvasを返す */
|
||||
createLayerData(clickX, clickY) {
|
||||
const lines = this.text.split("\n");
|
||||
const lineH = this.fontSize * 1.2;
|
||||
const font = this._getCSSFont();
|
||||
|
||||
// measureText 用の仮canvas
|
||||
const tmp = document.createElement("canvas");
|
||||
const tmpCtx = tmp.getContext("2d");
|
||||
tmpCtx.font = font;
|
||||
|
||||
let maxW = 0;
|
||||
for (const line of lines) {
|
||||
const w = line.length > 0 ? tmpCtx.measureText(line).width : this.fontSize * 0.3;
|
||||
if (w > maxW) maxW = w;
|
||||
}
|
||||
|
||||
const tw = Math.max(1, Math.ceil(maxW + TEXT_PADDING * 2));
|
||||
const th = Math.max(1, Math.ceil(lines.length * lineH + TEXT_PADDING * 2));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = tw;
|
||||
canvas.height = th;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.textBaseline = "top";
|
||||
ctx.textAlign = this.align;
|
||||
|
||||
let drawX = TEXT_PADDING;
|
||||
if (this.align === "center") drawX = tw / 2;
|
||||
else if (this.align === "right") drawX = tw - TEXT_PADDING;
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, drawX, TEXT_PADDING + i * lineH);
|
||||
});
|
||||
|
||||
return {
|
||||
canvas,
|
||||
width: tw,
|
||||
height: th,
|
||||
x: clickX,
|
||||
y: clickY,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
_getCSSFont() {
|
||||
const parts = [];
|
||||
if (this.italic) parts.push("italic");
|
||||
if (this.bold) parts.push("bold");
|
||||
parts.push(`${this.fontSize}px`);
|
||||
parts.push(`"${this.fontFamily}", sans-serif`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
_showOverlay(canvasX, canvasY) {
|
||||
const cv = this.canvas;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = rect.width / cv.width;
|
||||
const scaleY = rect.height / cv.height;
|
||||
const cssX = Math.round(canvasX * scaleX);
|
||||
const cssY = Math.round(canvasY * scaleY);
|
||||
|
||||
const container = cv.parentElement;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "ie-text-overlay";
|
||||
overlay.style.left = cssX + "px";
|
||||
overlay.style.top = cssY + "px";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "ie-text-textarea";
|
||||
textarea.value = this.text;
|
||||
textarea.rows = 3;
|
||||
textarea.placeholder = "Enter text…";
|
||||
overlay.appendChild(textarea);
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.className = "ie-text-btn-row";
|
||||
|
||||
const okBtn = document.createElement("button");
|
||||
okBtn.className = "wfm-btn wfm-btn-sm wfm-btn-primary";
|
||||
okBtn.textContent = "OK";
|
||||
okBtn.onclick = () => {
|
||||
this.text = textarea.value;
|
||||
this._closeOverlay();
|
||||
// drawCanvasには描画せず、呼び出し側でレイヤーとして処理する
|
||||
if (this._onChange) this._onChange(canvasX, canvasY);
|
||||
};
|
||||
btnRow.appendChild(okBtn);
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "wfm-btn wfm-btn-sm";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.onclick = () => this._closeOverlay();
|
||||
btnRow.appendChild(cancelBtn);
|
||||
|
||||
overlay.appendChild(btnRow);
|
||||
container.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
textarea.focus();
|
||||
|
||||
textarea.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); okBtn.click(); }
|
||||
else if (e.key === "Escape") this._closeOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
_closeOverlay() {
|
||||
if (this._overlay) { this._overlay.remove(); this._overlay = null; }
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
|
||||
// ── File size limit ───────────────────────────────────────────
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
@@ -504,7 +505,6 @@ async function extractAllMetadata(file) {
|
||||
}
|
||||
|
||||
// ── UI helpers ────────────────────────────────────────────────
|
||||
function escapeHtml(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
|
||||
function buildModelItem(label) {
|
||||
const el = document.createElement("div");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { showToast, openModal, closeModal } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { escapeHtml, readJsonStorage, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────
|
||||
const RESERVED_GROUPS = ["Batch", "Stack"];
|
||||
@@ -76,12 +77,13 @@ const TYPE_LABELS = {
|
||||
|
||||
// Mapping from models-tab type → comfyui-editor key + inputKey
|
||||
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" },
|
||||
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" },
|
||||
};
|
||||
|
||||
function applyToGenUI(modelName, modelType) {
|
||||
@@ -128,7 +130,7 @@ function applyToGenUI(modelName, modelType) {
|
||||
};
|
||||
comfyUI.currentWorkflow[nodeId].inputs.text = `<lora:${stem}:1:1>`;
|
||||
} else {
|
||||
comfyUI.currentWorkflow[nodeId].inputs.lora_name = modelName;
|
||||
comfyUI.currentWorkflow[nodeId].inputs.lora_name = comfyEditor.resolveLoraName(modelName);
|
||||
}
|
||||
|
||||
// Disable all Stack models and switch to Single tab
|
||||
@@ -155,17 +157,26 @@ function applyToGenUI(modelName, modelType) {
|
||||
} else {
|
||||
const { key, inputKey } = mapping;
|
||||
|
||||
const nodeId = Object.keys(comfyUI.currentWorkflow).find((id) => {
|
||||
const node = comfyUI.currentWorkflow[id];
|
||||
return node.inputs && inputKey in node.inputs;
|
||||
});
|
||||
// unet タイプは LoaderGGUF (gguf_name)、textencoder タイプは CLIPLoader (clip_name) も対応
|
||||
const searchKeys = modelType === "unet" ? [inputKey, "gguf_name"]
|
||||
: modelType === "textencoder" ? [inputKey, "clip_name"]
|
||||
: [inputKey];
|
||||
let nodeId = null;
|
||||
let actualInputKey = inputKey;
|
||||
for (const ik of searchKeys) {
|
||||
nodeId = Object.keys(comfyUI.currentWorkflow).find((id) => {
|
||||
const node = comfyUI.currentWorkflow[id];
|
||||
return node.inputs && ik in node.inputs;
|
||||
});
|
||||
if (nodeId) { actualInputKey = ik; break; }
|
||||
}
|
||||
|
||||
if (!nodeId) {
|
||||
showToast(t("modelsGenUINoNode", TYPE_LABELS[modelType] || modelType), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
comfyUI.currentWorkflow[nodeId].inputs[inputKey] = modelName;
|
||||
comfyUI.currentWorkflow[nodeId].inputs[actualInputKey] = modelName;
|
||||
selectEl = document.getElementById(`wfm-model-${key}`);
|
||||
}
|
||||
|
||||
@@ -187,12 +198,21 @@ function applyToGenUI(modelName, modelType) {
|
||||
showToast(`${TYPE_LABELS[modelType]}: ${modelName.split("/").pop().split("\\").pop()}`, "success");
|
||||
}
|
||||
|
||||
function applyEmbeddingToPrompt(modelName, promptType) {
|
||||
if (!comfyUI.currentWorkflow) {
|
||||
showToast(t("modelsGenUINoWorkflow"), "warning");
|
||||
return;
|
||||
}
|
||||
const stem = modelName.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
|
||||
const syntax = `(embedding:${stem}:1.0)`;
|
||||
comfyEditor.appendEmbeddingToPrompt(syntax, promptType);
|
||||
showToast(`Embedding → ${promptType === "positive" ? "PP" : "NP"}: ${stem}`, "success");
|
||||
}
|
||||
|
||||
// User-defined badge colors (stored in metadata per model)
|
||||
// Global badge palette: label → color, stored in localStorage
|
||||
function getBadgePalette() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}");
|
||||
} catch { return {}; }
|
||||
return readJsonStorage("wfm_models_badge_palette");
|
||||
}
|
||||
|
||||
function saveBadgePalette(palette) {
|
||||
@@ -201,13 +221,6 @@ function saveBadgePalette(palette) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function parseModelPath(fullName) {
|
||||
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
|
||||
@@ -322,6 +335,12 @@ function clearSelection() {
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
filterModels().forEach(m => state.selectedModels.add(m));
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
function renderBulkActionBar() {
|
||||
const bar = document.getElementById("wfm-models-bulk-bar");
|
||||
if (!bar) return;
|
||||
@@ -348,6 +367,7 @@ function renderBulkActionBar() {
|
||||
<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>
|
||||
@@ -385,6 +405,7 @@ function renderBulkActionBar() {
|
||||
`;
|
||||
|
||||
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", () => {
|
||||
@@ -439,7 +460,8 @@ async function bulkRemoveFromGroup(groupName) {
|
||||
const before = groups[groupName].length;
|
||||
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
|
||||
const removed = before - groups[groupName].length;
|
||||
if (groups[groupName].length === 0) delete groups[groupName];
|
||||
// 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();
|
||||
@@ -556,6 +578,10 @@ async function bulkMoveModels(destSubdir) {
|
||||
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;
|
||||
@@ -635,9 +661,10 @@ async function toggleGroupEnable(groupName, enable) {
|
||||
throw new Error(err.error || "group toggle failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
const members = state.modelGroups[groupName] || [];
|
||||
const okSet = new Set(data.ok || []);
|
||||
const s = state.disabledModels[state.activeModelType] || new Set();
|
||||
members.forEach((m) => { if (enable) s.delete(m); else s.add(m); });
|
||||
// 成功したメンバーのみ更新(部分失敗時のサーバー/クライアント乖離を防ぐ)
|
||||
for (const m of okSet) { if (enable) s.delete(m); else s.add(m); }
|
||||
state.disabledModels[state.activeModelType] = s;
|
||||
const errCount = data.errors?.length || 0;
|
||||
if (errCount > 0) showToast(`${errCount} ${t("modelToggleError")}`, "warning");
|
||||
@@ -670,7 +697,7 @@ async function saveModelMetadata(modelName, updates) {
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
showToast("Error saving metadata: " + err.message, "error");
|
||||
showToast(t("saveFailed", err.message), "error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -742,50 +769,44 @@ function filterModels() {
|
||||
return sortModels(models);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function sortModels(models) {
|
||||
if (!state.sortColumn) return models;
|
||||
return [...models].sort((a, b) => {
|
||||
const metaA = state.modelMetadata[a] || {};
|
||||
const metaB = state.modelMetadata[b] || {};
|
||||
let va, vb;
|
||||
switch (state.sortColumn) {
|
||||
case "fav":
|
||||
va = metaA.favorite ? 1 : 0; vb = metaB.favorite ? 1 : 0; break;
|
||||
case "filename": {
|
||||
const pa = parseModelPath(a); const pb = parseModelPath(b);
|
||||
va = pa.name.toLowerCase(); vb = pb.name.toLowerCase(); break;
|
||||
}
|
||||
case "subdir": {
|
||||
const pa = parseModelPath(a); const pb = parseModelPath(b);
|
||||
va = pa.dir.toLowerCase(); vb = pb.dir.toLowerCase(); break;
|
||||
}
|
||||
case "civtype": {
|
||||
const civA = metaA.sha256 && state.civitaiCache[metaA.sha256];
|
||||
const civB = metaB.sha256 && state.civitaiCache[metaB.sha256];
|
||||
va = (civA?.type || "").toLowerCase(); vb = (civB?.type || "").toLowerCase(); break;
|
||||
}
|
||||
case "basemodel": {
|
||||
const civA = metaA.sha256 && state.civitaiCache[metaA.sha256];
|
||||
const civB = metaB.sha256 && state.civitaiCache[metaB.sha256];
|
||||
va = (civA?.baseModel || "").toLowerCase(); vb = (civB?.baseModel || "").toLowerCase(); break;
|
||||
}
|
||||
case "ext": {
|
||||
const pa = parseModelPath(a); const pb = parseModelPath(b);
|
||||
va = getExtension(pa.name).toLowerCase(); vb = getExtension(pb.name).toLowerCase(); break;
|
||||
}
|
||||
case "tags":
|
||||
va = (metaA.tags || []).join(", ").toLowerCase();
|
||||
vb = (metaB.tags || []).join(", ").toLowerCase(); break;
|
||||
case "memo":
|
||||
va = (metaA.memo || "").toLowerCase(); vb = (metaB.memo || "").toLowerCase(); break;
|
||||
case "enabled":
|
||||
va = isModelDisabled(a) ? 1 : 0; vb = isModelDisabled(b) ? 1 : 0; break;
|
||||
default: return 0;
|
||||
}
|
||||
if (va < vb) return state.sortDir === "asc" ? -1 : 1;
|
||||
if (va > vb) return state.sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
// ソートキーを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]);
|
||||
}
|
||||
|
||||
function getAllTags() {
|
||||
@@ -937,6 +958,11 @@ function renderGroupFilter() {
|
||||
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}`
|
||||
: "";
|
||||
@@ -1065,17 +1091,19 @@ function renderThumbView(grid, models) {
|
||||
|
||||
card.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleBatch(modelName);
|
||||
if (state.showBatchOnly) {
|
||||
renderModelGrid();
|
||||
} else {
|
||||
e.currentTarget.classList.toggle("active", isInBatch(modelName));
|
||||
btn.classList.toggle("active", isInBatch(modelName));
|
||||
}
|
||||
});
|
||||
card.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleStack(modelName);
|
||||
e.currentTarget.classList.toggle("active", isInStack(modelName));
|
||||
btn.classList.toggle("active", isInStack(modelName));
|
||||
});
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1283,7 +1311,7 @@ async function clearStackGroup() {
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
renderModelGrid();
|
||||
showToast("Stack cleared", "success");
|
||||
showToast(t("stackCleared"), "success");
|
||||
}
|
||||
|
||||
// ── Favorite toggle ───────────────────────────────────────
|
||||
@@ -1351,10 +1379,14 @@ function openDetailModal(modelName) {
|
||||
</section>
|
||||
<div class="wfm-modal-actions">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-modal-model-save">${t("modelsSave")}</button>
|
||||
${GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-model" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</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>`;
|
||||
@@ -1404,10 +1436,21 @@ function openDetailModal(modelName) {
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// GenUI Model button
|
||||
// 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 () => {
|
||||
@@ -1502,6 +1545,29 @@ function showSidePanel(modelName) {
|
||||
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);
|
||||
@@ -1514,6 +1580,10 @@ function closeSidePanel() {
|
||||
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 ──────────────────────────────────
|
||||
@@ -1563,9 +1633,12 @@ function renderSideInfo(modelName) {
|
||||
</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>
|
||||
${GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button id="wfm-models-side-genui-btn" class="wfm-btn wfm-btn-sm" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</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
|
||||
@@ -1620,10 +1693,16 @@ function renderSideInfo(modelName) {
|
||||
});
|
||||
});
|
||||
|
||||
// GenUI Model button
|
||||
// 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 ─────────────────────────────────
|
||||
@@ -2089,7 +2168,7 @@ async function loadModelsForCurrentType() {
|
||||
} catch (err) {
|
||||
console.error("Failed to load models:", err);
|
||||
if (placeholder) placeholder.textContent = t("modelsLoadError");
|
||||
showToast("Error: " + err.message, "error");
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2246,6 +2325,11 @@ export function initModelsTab() {
|
||||
renderModelGrid();
|
||||
});
|
||||
}
|
||||
setupSearchClearBtn("wfm-models-search", "wfm-models-search-clear-btn", () => {
|
||||
state.searchText = "";
|
||||
state.currentPage = 0;
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// Tag filter
|
||||
document.getElementById("wfm-models-tag-filter")?.addEventListener("change", (e) => {
|
||||
@@ -2331,6 +2415,37 @@ export function initModelsTab() {
|
||||
clearStackGroup();
|
||||
});
|
||||
|
||||
// Clear filters button
|
||||
const modelsClearBtn = document.getElementById("wfm-models-clear-filters-btn");
|
||||
if (modelsClearBtn) {
|
||||
modelsClearBtn.textContent = t("clearFilters");
|
||||
modelsClearBtn.addEventListener("click", () => {
|
||||
state.searchText = "";
|
||||
state.tagFilter = "";
|
||||
state.dirFilter = "";
|
||||
state.groupFilter = "";
|
||||
state.statusFilter = "all";
|
||||
state.showFavoritesOnly = false;
|
||||
state.showBatchOnly = false;
|
||||
state.currentPage = 0;
|
||||
const searchInput = document.getElementById("wfm-models-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
const tagFilter = document.getElementById("wfm-models-tag-filter");
|
||||
if (tagFilter) tagFilter.value = "";
|
||||
const dirFilter = document.getElementById("wfm-models-dir-filter");
|
||||
if (dirFilter) dirFilter.value = "";
|
||||
const groupFilter = document.getElementById("wfm-models-group-filter");
|
||||
if (groupFilter) groupFilter.value = "";
|
||||
const statusFilter = document.getElementById("wfm-models-status-filter");
|
||||
if (statusFilter) statusFilter.value = "all";
|
||||
const favBtn = document.getElementById("wfm-models-fav-btn");
|
||||
if (favBtn) favBtn.classList.remove("active");
|
||||
const batchFilterBtn = document.getElementById("wfm-models-batch-filter-btn");
|
||||
if (batchFilterBtn) batchFilterBtn.classList.remove("active");
|
||||
renderModelGrid();
|
||||
});
|
||||
}
|
||||
|
||||
// View mode (thumb / card / table)
|
||||
document.querySelectorAll("[data-models-view]").forEach((btn) => {
|
||||
if (btn.dataset.modelsView === state.viewMode) {
|
||||
@@ -2380,6 +2495,19 @@ export function initModelsTab() {
|
||||
});
|
||||
});
|
||||
|
||||
// GenUI Model / Embedding buttons in side tab nav
|
||||
document.getElementById("wfm-side-genui-nav-btn")?.addEventListener("click", () => {
|
||||
if (!state.selectedModel) return;
|
||||
if (state.activeModelType === "embedding") {
|
||||
applyEmbeddingToPrompt(state.selectedModel, "positive");
|
||||
} else {
|
||||
applyToGenUI(state.selectedModel, state.activeModelType);
|
||||
}
|
||||
});
|
||||
document.getElementById("wfm-side-genui-np-btn")?.addEventListener("click", () => {
|
||||
if (state.selectedModel) applyEmbeddingToPrompt(state.selectedModel, "negative");
|
||||
});
|
||||
|
||||
// Initialize badge filter bar
|
||||
renderBadgeFilter();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
|
||||
@@ -25,6 +26,8 @@ const state = {
|
||||
activeSubView: "browser",
|
||||
loaded: false,
|
||||
currentPage: 0,
|
||||
selectedNodes: new Set(),
|
||||
lastSelectionIndex: -1,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
@@ -50,9 +53,6 @@ function categoryBadgeHtml(cat) {
|
||||
return `<span class="wfm-badge wfm-badge-category">${cat}</span>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────────────
|
||||
|
||||
@@ -275,6 +275,9 @@ function createNodeCard(node) {
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
card.classList.add("wfm-card-selected");
|
||||
}
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
card.classList.add("multi-selected");
|
||||
}
|
||||
card.dataset.nodeName = node.name;
|
||||
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
|
||||
|
||||
@@ -296,7 +299,32 @@ function createNodeCard(node) {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
card.addEventListener("click", () => showNodeSidePanel(node));
|
||||
card.addEventListener("click", (e) => {
|
||||
const filtered = filterNodes();
|
||||
const idx = filtered.findIndex(n => n.name === node.name);
|
||||
if (e.shiftKey && state.lastSelectionIndex !== -1) {
|
||||
const from = Math.min(state.lastSelectionIndex, idx);
|
||||
const to = Math.max(state.lastSelectionIndex, idx);
|
||||
for (let i = from; i <= to; i++) {
|
||||
state.selectedNodes.add(filtered[i].name);
|
||||
}
|
||||
_applyNodeSelectionToDOM();
|
||||
updateNodeBulkBar();
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
state.selectedNodes.delete(node.name);
|
||||
card.classList.remove("multi-selected");
|
||||
} else {
|
||||
state.selectedNodes.add(node.name);
|
||||
card.classList.add("multi-selected");
|
||||
}
|
||||
state.lastSelectionIndex = idx;
|
||||
updateNodeBulkBar();
|
||||
} else {
|
||||
state.lastSelectionIndex = idx;
|
||||
showNodeSidePanel(node);
|
||||
}
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -321,6 +349,9 @@ function renderNodeTableView(grid, filtered) {
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
tr.classList.add("wfm-card-selected");
|
||||
}
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
tr.classList.add("multi-selected");
|
||||
}
|
||||
const inputCount = Object.keys(node.input.required || {}).length +
|
||||
Object.keys(node.input.optional || {}).length;
|
||||
const outputCount = (node.output || []).length;
|
||||
@@ -334,7 +365,31 @@ function renderNodeTableView(grid, filtered) {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
tr.addEventListener("click", () => showNodeSidePanel(node));
|
||||
tr.addEventListener("click", (e) => {
|
||||
const idx = filtered.findIndex(n => n.name === node.name);
|
||||
if (e.shiftKey && state.lastSelectionIndex !== -1) {
|
||||
const from = Math.min(state.lastSelectionIndex, idx);
|
||||
const to = Math.max(state.lastSelectionIndex, idx);
|
||||
for (let i = from; i <= to; i++) {
|
||||
state.selectedNodes.add(filtered[i].name);
|
||||
}
|
||||
_applyNodeSelectionToDOM();
|
||||
updateNodeBulkBar();
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (state.selectedNodes.has(node.name)) {
|
||||
state.selectedNodes.delete(node.name);
|
||||
tr.classList.remove("multi-selected");
|
||||
} else {
|
||||
state.selectedNodes.add(node.name);
|
||||
tr.classList.add("multi-selected");
|
||||
}
|
||||
state.lastSelectionIndex = idx;
|
||||
updateNodeBulkBar();
|
||||
} else {
|
||||
state.lastSelectionIndex = idx;
|
||||
showNodeSidePanel(node);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
@@ -353,6 +408,100 @@ async function toggleFavorite(nodeName, btnEl) {
|
||||
btnEl.classList.toggle("active", newFav);
|
||||
}
|
||||
|
||||
// ── 複数選択ユーティリティ ─────────────────────────────────
|
||||
|
||||
function _applyNodeSelectionToDOM() {
|
||||
const grid = document.getElementById("wfm-nodes-grid");
|
||||
if (!grid) return;
|
||||
grid.querySelectorAll("[data-node-name]").forEach(el => {
|
||||
el.classList.toggle("multi-selected", state.selectedNodes.has(el.dataset.nodeName));
|
||||
});
|
||||
}
|
||||
|
||||
function updateNodeBulkBar() {
|
||||
const bar = document.getElementById("wfm-nodes-bulk-bar");
|
||||
const countEl = document.getElementById("wfm-nodes-bulk-count");
|
||||
if (!bar) return;
|
||||
const count = state.selectedNodes.size;
|
||||
if (count > 0) {
|
||||
bar.style.display = "";
|
||||
if (countEl) countEl.textContent = `${count} ${t("nodesBulkSelected")}`;
|
||||
} else {
|
||||
bar.style.display = "none";
|
||||
}
|
||||
const groupSel = document.getElementById("wfm-nodes-bulk-group-select");
|
||||
if (groupSel) {
|
||||
const current = groupSel.value;
|
||||
const grpNames = Object.keys(state.nodeGroups).sort();
|
||||
groupSel.innerHTML = `<option value="">${t("nodesBulkAddToGroup")}</option>` +
|
||||
grpNames.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
|
||||
if (grpNames.includes(current)) groupSel.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 一括操作 ──────────────────────────────────────────────
|
||||
|
||||
async function bulkNodeAddToGroup(groupName) {
|
||||
if (!groupName || state.selectedNodes.size === 0) return;
|
||||
const nodeNames = [...state.selectedNodes];
|
||||
if (!state.nodeGroups[groupName]) state.nodeGroups[groupName] = [];
|
||||
let added = 0;
|
||||
nodeNames.forEach(name => {
|
||||
if (!state.nodeGroups[groupName].includes(name)) {
|
||||
state.nodeGroups[groupName].push(name);
|
||||
added++;
|
||||
}
|
||||
});
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
renderFilters();
|
||||
if (state.selectedNode) renderSideGroups(state.selectedNode);
|
||||
showToast(t("addedNImagesToGroup", added, groupName), "success");
|
||||
}
|
||||
|
||||
async function bulkNodeRemoveFromGroup(groupName) {
|
||||
if (!groupName || state.selectedNodes.size === 0) return;
|
||||
if (!state.nodeGroups[groupName]) return;
|
||||
const nodeNames = new Set(state.selectedNodes);
|
||||
const before = state.nodeGroups[groupName].length;
|
||||
state.nodeGroups[groupName] = state.nodeGroups[groupName].filter(n => !nodeNames.has(n));
|
||||
const removed = before - state.nodeGroups[groupName].length;
|
||||
if (state.nodeGroups[groupName].length === 0) delete state.nodeGroups[groupName];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
renderFilters();
|
||||
if (state.selectedNode) renderSideGroups(state.selectedNode);
|
||||
showToast(t("removedNImagesFromGroup", removed, groupName), "success");
|
||||
}
|
||||
|
||||
async function bulkNodeCreateAndAddToGroup(name) {
|
||||
if (!name || state.selectedNodes.size === 0) return;
|
||||
if (state.nodeGroups[name] !== undefined) {
|
||||
showToast(t("groupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[name] = [...state.selectedNodes];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
renderFilters();
|
||||
if (state.selectedNode) renderSideGroups(state.selectedNode);
|
||||
updateNodeBulkBar();
|
||||
showToast(t("groupCreated", name), "success");
|
||||
}
|
||||
|
||||
async function bulkNodeSetFavorite(value) {
|
||||
if (state.selectedNodes.size === 0) return;
|
||||
const nodeNames = [...state.selectedNodes].filter(name => {
|
||||
const meta = state.nodeMetadata[name];
|
||||
return (meta?.favorite ?? false) !== value;
|
||||
});
|
||||
if (nodeNames.length === 0) return;
|
||||
await Promise.all(nodeNames.map(name => saveNodeMetadata(name, { favorite: value })));
|
||||
nodeNames.forEach(name => {
|
||||
if (!state.nodeMetadata[name]) state.nodeMetadata[name] = {};
|
||||
state.nodeMetadata[name].favorite = value;
|
||||
});
|
||||
renderNodeGrid();
|
||||
showToast(value ? t("favoritedNImages", nodeNames.length) : t("unfavoritedNImages", nodeNames.length), "success");
|
||||
}
|
||||
|
||||
// ── Side Panel ────────────────────────────────────────────
|
||||
|
||||
function showNodeSidePanel(node) {
|
||||
@@ -586,7 +735,7 @@ function renderSideGroups(node) {
|
||||
const name = input?.value?.trim();
|
||||
if (!name) return;
|
||||
if (state.nodeGroups[name]) {
|
||||
showToast("Group already exists", "warning");
|
||||
showToast(t("groupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[name] = [node.name];
|
||||
@@ -604,7 +753,7 @@ function renderSideGroups(node) {
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (state.nodeGroups[newName] !== undefined) {
|
||||
showToast("Group name already exists", "warning");
|
||||
showToast(t("groupNameExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[newName] = state.nodeGroups[oldName];
|
||||
@@ -613,7 +762,7 @@ function renderSideGroups(node) {
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(`Renamed to "${newName}"`, "success");
|
||||
showToast(t("renamedTo", newName), "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
@@ -627,7 +776,7 @@ function renderSideGroups(node) {
|
||||
if (state.groupFilter === name) state.groupFilter = "";
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
showToast(t("groupDeleted", name), "success");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1046,6 +1195,11 @@ export function initNodesTab() {
|
||||
renderNodeGrid();
|
||||
});
|
||||
}
|
||||
setupSearchClearBtn("wfm-nodes-search", "wfm-nodes-search-clear-btn", () => {
|
||||
state.searchText = "";
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
|
||||
// Filters
|
||||
document.getElementById("wfm-nodes-category-filter")?.addEventListener("change", e => {
|
||||
@@ -1111,9 +1265,88 @@ export function initNodesTab() {
|
||||
});
|
||||
});
|
||||
|
||||
// 一括操作バー: テキストをi18nで設定
|
||||
const bulkDeselBtn = document.getElementById("wfm-nodes-bulk-deselect");
|
||||
if (bulkDeselBtn) bulkDeselBtn.textContent = t("nodesBulkDeselect");
|
||||
const bulkGroupAddBtn = document.getElementById("wfm-nodes-bulk-group-add");
|
||||
if (bulkGroupAddBtn) bulkGroupAddBtn.textContent = t("nodesBulkGroupAdd");
|
||||
const bulkGroupRemBtn = document.getElementById("wfm-nodes-bulk-group-remove");
|
||||
if (bulkGroupRemBtn) bulkGroupRemBtn.textContent = t("nodesBulkGroupRemove");
|
||||
const bulkCreateAddBtn = document.getElementById("wfm-nodes-bulk-create-add");
|
||||
if (bulkCreateAddBtn) bulkCreateAddBtn.textContent = t("nodesBulkCreateAdd");
|
||||
const bulkFavBtn = document.getElementById("wfm-nodes-bulk-fav");
|
||||
if (bulkFavBtn) bulkFavBtn.textContent = t("nodesBulkFavAll");
|
||||
const bulkUnfavBtn = document.getElementById("wfm-nodes-bulk-unfav");
|
||||
if (bulkUnfavBtn) bulkUnfavBtn.textContent = t("nodesBulkUnfavAll");
|
||||
const bulkNewGroupInput = document.getElementById("wfm-nodes-bulk-new-group");
|
||||
if (bulkNewGroupInput) bulkNewGroupInput.placeholder = t("nodesBulkNewGroup");
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-deselect")?.addEventListener("click", () => {
|
||||
state.selectedNodes.clear();
|
||||
state.lastSelectionIndex = -1;
|
||||
updateNodeBulkBar();
|
||||
document.querySelectorAll("#wfm-nodes-grid .multi-selected").forEach(el => el.classList.remove("multi-selected"));
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-group-add")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-nodes-bulk-group-select");
|
||||
if (sel?.value) {
|
||||
bulkNodeAddToGroup(sel.value);
|
||||
} else {
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-group-remove")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-nodes-bulk-group-select");
|
||||
if (sel?.value) {
|
||||
bulkNodeRemoveFromGroup(sel.value);
|
||||
} else {
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-create-add")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-nodes-bulk-new-group");
|
||||
const name = input?.value?.trim();
|
||||
if (!name) return;
|
||||
bulkNodeCreateAndAddToGroup(name).then(() => { if (input) input.value = ""; });
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-bulk-fav")?.addEventListener("click", () => bulkNodeSetFavorite(true));
|
||||
document.getElementById("wfm-nodes-bulk-unfav")?.addEventListener("click", () => bulkNodeSetFavorite(false));
|
||||
|
||||
// Create node set button
|
||||
document.getElementById("wfm-node-set-create-btn")?.addEventListener("click", showCreateSetModal);
|
||||
|
||||
// Clear filters button
|
||||
const nodesClearBtn = document.getElementById("wfm-nodes-clear-filters-btn");
|
||||
if (nodesClearBtn) {
|
||||
nodesClearBtn.textContent = t("clearFilters");
|
||||
nodesClearBtn.addEventListener("click", () => {
|
||||
state.searchText = "";
|
||||
state.categoryFilter = "";
|
||||
state.packageFilter = "";
|
||||
state.tagFilter = "";
|
||||
state.groupFilter = "";
|
||||
state.showFavoritesOnly = false;
|
||||
state.currentPage = 0;
|
||||
const searchInput = document.getElementById("wfm-nodes-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
const catFilter = document.getElementById("wfm-nodes-category-filter");
|
||||
if (catFilter) catFilter.value = "";
|
||||
const pkgFilter = document.getElementById("wfm-nodes-package-filter");
|
||||
if (pkgFilter) pkgFilter.value = "";
|
||||
const tagFilter = document.getElementById("wfm-nodes-tag-filter");
|
||||
if (tagFilter) tagFilter.value = "";
|
||||
const grpFilter = document.getElementById("wfm-nodes-group-filter");
|
||||
if (grpFilter) grpFilter.value = "";
|
||||
const favBtn = document.getElementById("wfm-nodes-fav-btn");
|
||||
if (favBtn) favBtn.classList.remove("active");
|
||||
renderNodeGrid();
|
||||
});
|
||||
}
|
||||
|
||||
// Lazy load on first tab click
|
||||
document.querySelector('[data-tab="nodes"]')?.addEventListener("click", () => {
|
||||
if (!state.loaded) loadNodesData();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -92,15 +93,11 @@ async function migrateLocalStoragePresets() {
|
||||
await apiCreatePreset({
|
||||
name: p.name || "Untitled",
|
||||
text: p.posText || p.text || "",
|
||||
negText: p.negText || "",
|
||||
category: "",
|
||||
tags: [],
|
||||
favorite: false,
|
||||
});
|
||||
// Note: negText is stored separately - we combine it
|
||||
if (p.negText) {
|
||||
// Create a separate entry for negative prompt reference
|
||||
// or store in the text field with marker
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(PRESETS_KEY);
|
||||
} catch { /* ignore migration errors */ }
|
||||
@@ -115,9 +112,7 @@ async function loadAllPresets() {
|
||||
promptPresets = await fetchPresets();
|
||||
|
||||
// Load groups from localStorage
|
||||
try {
|
||||
pmGroups = JSON.parse(localStorage.getItem(PM_GROUPS_KEY) || "{}");
|
||||
} catch { pmGroups = {}; }
|
||||
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));
|
||||
@@ -365,7 +360,8 @@ function renderPmGroups(container) {
|
||||
removeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
|
||||
if (pmGroups[groupName].length === 0) delete pmGroups[groupName];
|
||||
// 予約グループ (Batch) は空になってもキーを保持する
|
||||
if (pmGroups[groupName].length === 0 && !PROMPT_RESERVED_GROUPS.includes(groupName)) delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
});
|
||||
@@ -379,26 +375,70 @@ function renderPmGroups(container) {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Ollama API helpers
|
||||
// AI Assistant helpers (Ollama / LM Studio)
|
||||
// ============================================
|
||||
|
||||
async function ollamaModels() {
|
||||
const res = await fetch("/api/wfm/ollama/models");
|
||||
return await res.json();
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
|
||||
function loadPromptAiSettings() {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
}
|
||||
|
||||
async function ollamaTest() {
|
||||
const res = await fetch("/api/wfm/ollama/test", { method: "POST" });
|
||||
return await res.json();
|
||||
// バックエンド変更検知用: モデル一覧を取得した時点のバックエンド名を記録
|
||||
let _promptAiBackendForModels = null;
|
||||
|
||||
function getPromptAiConfig() {
|
||||
const s = loadPromptAiSettings();
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return { backend, url };
|
||||
}
|
||||
|
||||
async function ollamaChat(model, messages) {
|
||||
const res = await fetch("/api/wfm/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages }),
|
||||
});
|
||||
return await res.json();
|
||||
async function fetchAiModels() {
|
||||
const { backend, url } = getPromptAiConfig();
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).models?.map(m => m.name) || [];
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ((await res.json()).data || []).map(m => m.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function chatWithAi(model, messages) {
|
||||
const { backend, url } = getPromptAiConfig();
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
} else {
|
||||
// LM Studio / Lemonade (OpenAI-compatible): convert Ollama-style messages (with .images) to OpenAI content arrays
|
||||
const openAiMessages = messages.map(msg => {
|
||||
if (msg.images?.length) {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: [
|
||||
{ type: "text", text: msg.content },
|
||||
...msg.images.map(b64 => ({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${b64}` } })),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { role: msg.role, content: msg.content };
|
||||
});
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages: openAiMessages, stream: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -463,7 +503,7 @@ function clearChat() {
|
||||
if (chat) {
|
||||
chat.innerHTML = `
|
||||
<div class="wfm-ollama-welcome">
|
||||
<p>AI Assistant (Ollama)</p>
|
||||
<p>AI Assistant</p>
|
||||
<p>${t("assistantWelcome")}</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -527,6 +567,24 @@ async function sendMessage() {
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
// バックエンドが変更されていたらモデル一覧を自動更新
|
||||
const { backend } = getPromptAiConfig();
|
||||
if (backend !== _promptAiBackendForModels) {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = `Switching to ${backend}...`; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
modelSelect.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) { status.textContent = `${models.length} models [${backend}]`; status.className = "wfm-ollama-status connected"; }
|
||||
} catch {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
}
|
||||
|
||||
const model = modelSelect.value;
|
||||
if (!model) {
|
||||
showToast(t("selectModelFirst"), "error");
|
||||
@@ -546,16 +604,11 @@ async function sendMessage() {
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await ollamaChat(model, ollamaState.chatHistory);
|
||||
if (data.status === "success" && data.message) {
|
||||
const reply = data.message.content;
|
||||
addChatMessage("assistant", reply);
|
||||
ollamaState.chatHistory.push({ role: "assistant", content: reply });
|
||||
ollamaState.attachedImage = null;
|
||||
updateAttachmentDisplay();
|
||||
} else {
|
||||
showToast(t("error") + ": " + (data.error || data.message || "No response"), "error");
|
||||
}
|
||||
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 {
|
||||
@@ -598,12 +651,8 @@ async function sendTranslate(direction) {
|
||||
};
|
||||
const prompt = prompts[direction];
|
||||
|
||||
const data = await ollamaChat(model, [{ role: "user", content: prompt }]);
|
||||
if (data.status === "success" && data.message) {
|
||||
addChatMessage("assistant", data.message.content);
|
||||
} else {
|
||||
showToast(t("error") + ": No response", "error");
|
||||
}
|
||||
const reply = await chatWithAi(model, [{ role: "user", content: prompt }]);
|
||||
addChatMessage("assistant", reply);
|
||||
} catch (err) {
|
||||
showToast(t("error") + ": " + err.message, "error");
|
||||
} finally {
|
||||
@@ -859,20 +908,23 @@ function wcUpdateFilePicker() {
|
||||
// ============================================
|
||||
|
||||
export function initPromptTab() {
|
||||
// Ollama model refresh
|
||||
// AI model refresh
|
||||
async function refreshModels() {
|
||||
const select = document.getElementById("wfm-ollama-model");
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
const { backend } = getPromptAiConfig();
|
||||
try {
|
||||
const data = await ollamaModels();
|
||||
if (data.status === "success" && data.models) {
|
||||
select.innerHTML = data.models
|
||||
.map((m) => `<option value="${m.name}">${m.name}</option>`)
|
||||
.join("");
|
||||
if (status) {
|
||||
status.textContent = `${data.models.length} models`;
|
||||
status.className = "wfm-ollama-status connected";
|
||||
}
|
||||
const models = await fetchAiModels();
|
||||
const savedModel = loadPromptAiSettings().model || "";
|
||||
if (select) {
|
||||
select.innerHTML = models.length
|
||||
? models.map(name => `<option value="${name}" ${name === savedModel ? "selected" : ""}>${name}</option>`).join("")
|
||||
: `<option value="">${t("noModelsFound")}</option>`;
|
||||
}
|
||||
_promptAiBackendForModels = backend;
|
||||
if (status) {
|
||||
status.textContent = `${models.length} models [${backend}]`;
|
||||
status.className = "wfm-ollama-status connected";
|
||||
}
|
||||
} catch {
|
||||
if (status) {
|
||||
@@ -892,15 +944,11 @@ export function initPromptTab() {
|
||||
const status = document.getElementById("wfm-ollama-status");
|
||||
if (status) { status.textContent = "Testing..."; status.className = "wfm-ollama-status"; }
|
||||
try {
|
||||
const data = await ollamaTest();
|
||||
if (data.connected) {
|
||||
if (status) { status.textContent = t("connected"); status.className = "wfm-ollama-status connected"; }
|
||||
await refreshModels();
|
||||
} else {
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
await fetchAiModels();
|
||||
if (status) { status.textContent = t("connectedCheck"); status.className = "wfm-ollama-status connected"; }
|
||||
await refreshModels();
|
||||
} catch {
|
||||
if (status) { status.textContent = t("error"); status.className = "wfm-ollama-status error"; }
|
||||
if (status) { status.textContent = t("failedConnect"); status.className = "wfm-ollama-status error"; }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1050,43 +1098,43 @@ export function initPromptTab() {
|
||||
if (!name || !name.trim()) return;
|
||||
const key = name.trim();
|
||||
if (pmGroups[key]) {
|
||||
showToast("Group already exists", "error");
|
||||
showToast(t("groupExists"), "error");
|
||||
return;
|
||||
}
|
||||
pmGroups[key] = [];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(`Group "${key}" created`, "success");
|
||||
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("Select a group first", "error");
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
return;
|
||||
}
|
||||
const id = presetSelect?.value;
|
||||
if (!id) {
|
||||
showToast("Select a preset first", "error");
|
||||
showToast(t("selectPresetFirst"), "error");
|
||||
return;
|
||||
}
|
||||
if (!pmGroups[groupName]) pmGroups[groupName] = [];
|
||||
if (pmGroups[groupName].includes(id)) {
|
||||
showToast("Already in this group", "info");
|
||||
showToast(t("alreadyInGroup"), "info");
|
||||
return;
|
||||
}
|
||||
pmGroups[groupName].push(id);
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast("Added to group", "success");
|
||||
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("Select a group first", "error");
|
||||
showToast(t("selectGroupFirst"), "error");
|
||||
return;
|
||||
}
|
||||
if (PROMPT_RESERVED_GROUPS.includes(groupName)) {
|
||||
@@ -1097,7 +1145,7 @@ export function initPromptTab() {
|
||||
delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(`Group "${groupName}" deleted`, "success");
|
||||
showToast(t("groupDeleted", groupName), "success");
|
||||
});
|
||||
|
||||
// --- Preset Manager ---
|
||||
@@ -1160,7 +1208,7 @@ export function initPromptTab() {
|
||||
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("Enter a positive integer", "error"); return; }
|
||||
if (isNaN(num) || num < 1) { showToast(t("enterPositiveInt"), "error"); return; }
|
||||
const template = "{" + num + "$$|}";
|
||||
const start = ta.selectionStart;
|
||||
wcInsertAtCursor(ta, template);
|
||||
@@ -1198,7 +1246,7 @@ export function initPromptTab() {
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast("Sent to Positive Prompt", "success");
|
||||
showToast(t("sentToPositive"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
|
||||
@@ -1207,7 +1255,7 @@ export function initPromptTab() {
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast("Sent to Negative Prompt", "success");
|
||||
showToast(t("sentToNegative"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
|
||||
@@ -1235,22 +1283,22 @@ export function initPromptTab() {
|
||||
const ext = extSelect?.value || "txt";
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!rawName) { showToast("Enter a filename", "error"); return; }
|
||||
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("Invalid path. Use folder/name format, each part: letters, numbers, -, _, . only", "error");
|
||||
showToast(t("invalidPathFormat"), "error");
|
||||
return;
|
||||
}
|
||||
const name = rawName;
|
||||
const filename = `${name}.${ext}`;
|
||||
const saved = await wcSaveFile(filename, content);
|
||||
if (saved) {
|
||||
showToast(`Saved: ${filename}`, "success");
|
||||
showToast(t("savedAs", filename), "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
} else {
|
||||
showToast("Failed to save file", "error");
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1259,7 +1307,7 @@ export function initPromptTab() {
|
||||
if (!wcEditingFilename) return;
|
||||
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
|
||||
await wcDeleteFile(wcEditingFilename);
|
||||
showToast(`Deleted: ${wcEditingFilename}`, "success");
|
||||
showToast(t("deletedName", wcEditingFilename), "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
|
||||
|
||||
import { getSettings, readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
@@ -15,11 +17,7 @@ function rgbToHex(rgb) {
|
||||
}
|
||||
|
||||
function loadLocalSettings() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return readJsonStorage(SETTINGS_KEY);
|
||||
}
|
||||
|
||||
function saveLocalSettings(patch) {
|
||||
@@ -639,28 +637,65 @@ export async function initSettingsTab() {
|
||||
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- Ollama Settings -->
|
||||
<!-- Default Checkpoint -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("defaultCheckpointSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-settings-default-ckpt-enabled" ${serverSettings.default_checkpoint_enabled ? "checked" : ""}>
|
||||
<span id="wfm-settings-default-ckpt-enable-label">${t("defaultCheckpointEnable")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label id="wfm-settings-default-ckpt-model-label">${t("defaultCheckpointModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-default-ckpt-select" style="flex:1;">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-default-ckpt-refresh">${t("refresh")}</button>
|
||||
</div>
|
||||
<small id="wfm-settings-default-ckpt-hint" style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("defaultCheckpointHint")}
|
||||
</small>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-default-ckpt-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- AI Assistant Settings (Prompt Tab) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("aiSettingsBackend")}</label>
|
||||
<div class="wfm-ai-backend-row">
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="ollama"> Ollama
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaUrl")}</label>
|
||||
<input type="text" class="wfm-input" id="wfm-settings-ollama-url"
|
||||
value="${serverSettings.ollama_url || "http://localhost:11434"}"
|
||||
<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-ollama-model" style="flex:1;">
|
||||
<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-ollama-refresh">${t("refresh")}</button>
|
||||
<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-ollama-save">${t("saveOllama")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-test">${t("testConnection")}</button>
|
||||
<span id="wfm-settings-ollama-status" style="font-size:12px;line-height:28px;"></span>
|
||||
<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>
|
||||
|
||||
@@ -730,6 +765,24 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- G'MIC Integration -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">G'MIC-Qt Integration</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>G'MIC-Qt Executable Path</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-gmic-path"
|
||||
value="${serverSettings.gmic_qt_path || ""}"
|
||||
placeholder="C:\\path\\to\\gmic_qt.exe"
|
||||
style="flex:1;">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-gmic-path-save">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div id="wfm-settings-gmic-path-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Data Management -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
|
||||
@@ -960,7 +1013,7 @@ export async function initSettingsTab() {
|
||||
setLang(lang);
|
||||
saveLocalSettings({ ...loadLocalSettings(), uiLang: lang });
|
||||
// Re-render the entire page to apply new language
|
||||
showToast("Language changed. Reloading...", "success");
|
||||
showToast(t("languageChanged"), "success");
|
||||
setTimeout(() => location.reload(), 500);
|
||||
});
|
||||
|
||||
@@ -1056,54 +1109,90 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Ollama model list loader ---
|
||||
async function loadOllamaModels() {
|
||||
const select = document.getElementById("wfm-settings-ollama-model");
|
||||
// --- AI Assistant (Prompt Tab) settings ---
|
||||
const PROMPT_AI_KEY = "wfm_prompt_ai_settings";
|
||||
const loadPromptAiCfg = () => {
|
||||
try { return JSON.parse(localStorage.getItem(PROMPT_AI_KEY) || "{}"); } catch { return {}; }
|
||||
};
|
||||
const savePromptAiCfg = (patch) => {
|
||||
const data = { ...loadPromptAiCfg(), ...patch };
|
||||
localStorage.setItem(PROMPT_AI_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Restore saved values into UI
|
||||
const promptAiSaved = loadPromptAiCfg();
|
||||
const promptAiBackendSaved = promptAiSaved.backend || "ollama";
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.checked = r.value === promptAiBackendSaved;
|
||||
});
|
||||
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
|
||||
if (promptAiUrlEl) {
|
||||
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
|
||||
promptAiUrlEl.placeholder = getAiBackendDefaultUrl(promptAiBackendSaved);
|
||||
}
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
|
||||
});
|
||||
});
|
||||
|
||||
// Load models directly from the selected backend
|
||||
async function loadPromptAiModels() {
|
||||
const select = document.getElementById("wfm-settings-prompt-ai-model");
|
||||
if (!select) return;
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
try {
|
||||
const res = await fetch("/api/wfm/ollama/models");
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
const savedModel = serverSettings.ollama_model || "llava";
|
||||
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((m) => `<option value="${m.name}" ${m.name === savedModel ? "selected" : ""}>${m.name}</option>`).join("")
|
||||
? 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 loadOllamaModels();
|
||||
await loadPromptAiModels();
|
||||
|
||||
// Refresh Ollama models
|
||||
document.getElementById("wfm-settings-ollama-refresh")?.addEventListener("click", loadOllamaModels);
|
||||
document.getElementById("wfm-settings-prompt-ai-refresh")?.addEventListener("click", loadPromptAiModels);
|
||||
|
||||
// Test Ollama connection
|
||||
document.getElementById("wfm-settings-ollama-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-ollama-status");
|
||||
statusEl.textContent = "Testing...";
|
||||
statusEl.style.color = "var(--wfm-text-secondary)";
|
||||
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
|
||||
try {
|
||||
const res = await fetch("/api/wfm/ollama/test", { method: "POST" });
|
||||
const data = await res.json();
|
||||
statusEl.textContent = data.connected ? t("ollamaConnected") : `${t("ollamaFailed")}: ${data.message}`;
|
||||
statusEl.style.color = data.connected ? "var(--wfm-success)" : "var(--wfm-danger)";
|
||||
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) {
|
||||
statusEl.textContent = `${t("error")}: ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
if (statusEl) { statusEl.textContent = `${t("ollamaFailed")}: ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
});
|
||||
|
||||
// Save Ollama settings (to server)
|
||||
document.getElementById("wfm-settings-ollama-save")?.addEventListener("click", async () => {
|
||||
const ollamaUrl = document.getElementById("wfm-settings-ollama-url")?.value.trim() || "http://localhost:11434";
|
||||
const ollamaModel = document.getElementById("wfm-settings-ollama-model")?.value || "";
|
||||
try {
|
||||
await saveServerSettings({ ollama_url: ollamaUrl, ollama_model: ollamaModel });
|
||||
serverSettings.ollama_url = ollamaUrl;
|
||||
serverSettings.ollama_model = ollamaModel;
|
||||
showToast(t("ollamaSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("saveError")}: ${err.message}`, "error");
|
||||
}
|
||||
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
|
||||
@@ -1148,6 +1237,35 @@ export async function initSettingsTab() {
|
||||
statusEl.style.color = ok ? "var(--wfm-success)" : "var(--wfm-danger)";
|
||||
});
|
||||
|
||||
// Default Checkpoint
|
||||
const defaultCkptSelect = document.getElementById("wfm-settings-default-ckpt-select");
|
||||
|
||||
async function loadDefaultCkptOptions() {
|
||||
if (!defaultCkptSelect) return;
|
||||
try {
|
||||
const list = await comfyUI.fetchCheckpoints();
|
||||
const saved = serverSettings.default_checkpoint_name || "";
|
||||
defaultCkptSelect.innerHTML = `<option value="">${t("selectModel")}</option>` +
|
||||
(list || []).map((m) => `<option value="${m}" ${m === saved ? "selected" : ""}>${m}</option>`).join("");
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadDefaultCkptOptions();
|
||||
|
||||
document.getElementById("wfm-settings-default-ckpt-refresh")?.addEventListener("click", loadDefaultCkptOptions);
|
||||
|
||||
document.getElementById("wfm-settings-default-ckpt-save")?.addEventListener("click", async () => {
|
||||
const enabled = !!document.getElementById("wfm-settings-default-ckpt-enabled")?.checked;
|
||||
const ckptName = defaultCkptSelect?.value || "";
|
||||
try {
|
||||
await saveServerSettings({ default_checkpoint_enabled: enabled, default_checkpoint_name: ckptName });
|
||||
serverSettings.default_checkpoint_enabled = enabled;
|
||||
serverSettings.default_checkpoint_name = ckptName;
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("saveError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Test Eagle URL (via server proxy to avoid CORS)
|
||||
document.getElementById("wfm-settings-test-eagle")?.addEventListener("click", async () => {
|
||||
const urlInput = document.getElementById("wfm-settings-eagle-url");
|
||||
@@ -1176,7 +1294,7 @@ export async function initSettingsTab() {
|
||||
|
||||
// Clear default workflow
|
||||
document.getElementById("wfm-settings-clear-wf")?.addEventListener("click", () => {
|
||||
const s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
const s = getSettings();
|
||||
delete s.defaultWorkflow;
|
||||
delete s.defaultWorkflowData;
|
||||
localStorage.setItem("wfm_settings", JSON.stringify(s));
|
||||
@@ -1371,4 +1489,28 @@ export async function initSettingsTab() {
|
||||
// Reset file input so same file can be re-selected
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
// Save G'MIC path setting
|
||||
document.getElementById("wfm-settings-gmic-path-save")?.addEventListener("click", async () => {
|
||||
const path = document.getElementById("wfm-settings-gmic-path")?.value.trim() || "";
|
||||
const statusEl = document.getElementById("wfm-settings-gmic-path-status");
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Saving...";
|
||||
statusEl.style.color = "var(--wfm-text-secondary)";
|
||||
}
|
||||
try {
|
||||
await saveServerSettings({ gmic_qt_path: path });
|
||||
serverSettings.gmic_qt_path = path;
|
||||
showToast("G'MIC path saved successfully", "success");
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Saved successfully!";
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Error: " + err.message;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* Tagger Tab
|
||||
* Handles WD Tagger / DeepDanbooru + Ollama VLM single/batch tagging.
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./app.js";
|
||||
|
||||
// ── 状態 ────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
/** @type {string|null} 現在の画像 base64 (data URI prefix を除く) */
|
||||
imageB64: null,
|
||||
/** @type {string} 元のファイルパス (Galleryから来た場合) */
|
||||
imagePath: "",
|
||||
/** @type {string} 表示名 */
|
||||
imageName: "",
|
||||
/** @type {NodeJS.Timeout|null} バッチポーリングタイマー */
|
||||
batchPollTimer: null,
|
||||
/** @type {number|null} DB編集中の行ID */
|
||||
dbEditId: null,
|
||||
};
|
||||
|
||||
// ── 初期化 ──────────────────────────────────────────────────────
|
||||
|
||||
export function initTaggerTab() {
|
||||
_applyI18n();
|
||||
_setupSubtabs();
|
||||
_setupSliders();
|
||||
_setupOllamaToggle();
|
||||
_setupSingleActions();
|
||||
_setupBatchActions();
|
||||
_setupDbActions();
|
||||
_loadModels();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
/** GalleryタブからTaggerタブへ画像を渡す */
|
||||
export function openImageInTaggerTab(img) {
|
||||
// タブを切り替え
|
||||
document.querySelectorAll(".wfm-tab").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".wfm-tab-content").forEach(s => s.classList.remove("active"));
|
||||
const btn = document.querySelector('.wfm-tab[data-tab="tagger"]');
|
||||
const sec = document.getElementById("wfm-tab-tagger");
|
||||
if (btn) btn.classList.add("active");
|
||||
if (sec) sec.classList.add("active");
|
||||
|
||||
// 画像を読み込む (Gallery APIから取得)
|
||||
if (!img?.path) return;
|
||||
const url = `/wfm/gallery/image/serve?path=${encodeURIComponent(img.path)}`;
|
||||
fetch(url)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
_setPreviewFromDataUrl(e.target.result, img.filename || img.path.split(/[\\/]/).pop());
|
||||
state.imagePath = img.path;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch(() => showToast(t("taggerNoImage"), "error"));
|
||||
}
|
||||
|
||||
// ── i18n ─────────────────────────────────────────────────────────
|
||||
|
||||
function _applyI18n() {
|
||||
const map = {
|
||||
"wfm-tagger-from-gallery-btn": "taggerFromGallery",
|
||||
"wfm-tagger-upload-label": "taggerUpload",
|
||||
"wfm-tagger-threshold-label": "taggerThreshold",
|
||||
"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",
|
||||
"wfm-tagger-send-genui-btn": "taggerSendToGenUI",
|
||||
"wfm-tagger-send-prompt-btn": "taggerSendToPrompt",
|
||||
"wfm-tagger-save-gallery-btn": "taggerSaveToGallery",
|
||||
"wfm-tagger-write-file-btn": "taggerWriteToFile",
|
||||
"wfm-tagger-save-db-btn": "taggerSaveToDB",
|
||||
"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-prompt-label": "taggerOllamaPrompt",
|
||||
"wfm-tagger-batch-output-header": "taggerBatchOutput",
|
||||
"wfm-tagger-batch-save-db-label": "taggerSaveToDB",
|
||||
"wfm-tagger-batch-write-file-label": "taggerWriteToFile",
|
||||
"wfm-tagger-batch-write-txt-label": "taggerBatchWriteTxt",
|
||||
"wfm-tagger-batch-start-btn": "taggerBatchStart",
|
||||
"wfm-tagger-batch-stop-btn": "taggerBatchStop",
|
||||
"wfm-tagger-db-search-btn": "taggerDbSearch",
|
||||
"wfm-tagger-db-show-all-btn": "taggerDbShowAll",
|
||||
"wfm-tagger-db-export-btn": "taggerDbExport",
|
||||
"wfm-tagger-db-th-file": "taggerDbFile",
|
||||
"wfm-tagger-db-th-interrogator": "taggerDbInterrogator",
|
||||
"wfm-tagger-db-th-vlm": "taggerDbVlm",
|
||||
"wfm-tagger-db-th-date": "taggerDbDate",
|
||||
"wfm-tagger-db-edit-interr-label": "taggerDbInterrogator",
|
||||
"wfm-tagger-db-edit-vlm-label": "taggerDbVlm",
|
||||
"wfm-tagger-db-save-btn": "taggerDbSave",
|
||||
"wfm-tagger-db-delete-btn": "taggerDbDelete",
|
||||
};
|
||||
for (const [id, key] of Object.entries(map)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
|
||||
// サブタブボタン
|
||||
const subtabKeys = ["taggerSingleTab", "taggerBatchTab", "taggerDbTab"];
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach((btn, i) => {
|
||||
btn.textContent = t(subtabKeys[i] || "");
|
||||
});
|
||||
|
||||
// プレースホルダー
|
||||
const ph = document.getElementById("wfm-tagger-preview-placeholder");
|
||||
if (ph) ph.textContent = t("taggerNoImage");
|
||||
}
|
||||
|
||||
// ── サブタブ切り替え ─────────────────────────────────────────────
|
||||
|
||||
function _setupSubtabs() {
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".wfm-tagger-subtab-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const tab = btn.dataset.taggerTab;
|
||||
document.querySelectorAll(".wfm-tagger-subtab").forEach(s => {
|
||||
s.style.display = s.id === `wfm-tagger-${tab}` ? "" : "none";
|
||||
});
|
||||
if (tab === "db") _dbLoad();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── スライダー ───────────────────────────────────────────────────
|
||||
|
||||
function _setupSliders() {
|
||||
[
|
||||
["wfm-tagger-threshold", "wfm-tagger-threshold-val"],
|
||||
["wfm-tagger-char-threshold", "wfm-tagger-char-threshold-val"],
|
||||
["wfm-tagger-batch-threshold", "wfm-tagger-batch-threshold-val"],
|
||||
["wfm-tagger-batch-char-threshold", "wfm-tagger-batch-char-threshold-val"],
|
||||
].forEach(([sliderId, valId]) => {
|
||||
const slider = document.getElementById(sliderId);
|
||||
const val = document.getElementById(valId);
|
||||
if (!slider || !val) return;
|
||||
val.textContent = slider.value;
|
||||
slider.addEventListener("input", () => { val.textContent = slider.value; });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Ollama トグル ────────────────────────────────────────────────
|
||||
|
||||
function _setupOllamaToggle() {
|
||||
const chk = document.getElementById("wfm-tagger-ollama-enable");
|
||||
const wrap = document.getElementById("wfm-tagger-ollama-settings");
|
||||
if (chk && wrap) {
|
||||
chk.addEventListener("change", () => {
|
||||
wrap.style.display = chk.checked ? "" : "none";
|
||||
});
|
||||
wrap.style.display = chk.checked ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// ── モデル一覧ロード ─────────────────────────────────────────────
|
||||
|
||||
async function _loadModels() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/models");
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
["wfm-tagger-model", "wfm-tagger-batch-model"].forEach(selId => {
|
||||
const sel = document.getElementById(selId);
|
||||
if (!sel) return;
|
||||
sel.innerHTML = `<option value="">${t("taggerNoModel")}</option>` +
|
||||
models.map(m => `<option value="${m.name}">[${m.type}] ${m.name}</option>`).join("");
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("tagger: _loadModels error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
// ファイルアップロード
|
||||
document.getElementById("wfm-tagger-upload")?.addEventListener("change", e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
_loadImageFile(file);
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
// ドラッグ&ドロップ
|
||||
const dropZone = document.getElementById("wfm-tagger-drop-zone");
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("drag-over");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", e => {
|
||||
if (!dropZone.contains(e.relatedTarget)) {
|
||||
dropZone.classList.remove("drag-over");
|
||||
}
|
||||
});
|
||||
dropZone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
_loadImageFile(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// タグ生成
|
||||
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);
|
||||
document.getElementById("wfm-tagger-save-gallery-btn")?.addEventListener("click", _saveToGallery);
|
||||
document.getElementById("wfm-tagger-write-file-btn")?.addEventListener("click", _writeToFile);
|
||||
document.getElementById("wfm-tagger-save-db-btn")?.addEventListener("click", _saveToDb);
|
||||
}
|
||||
|
||||
function _loadImageFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
_setPreviewFromDataUrl(e.target.result, file.name);
|
||||
state.imagePath = "";
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function _setPreviewFromDataUrl(dataUrl, name) {
|
||||
state.imageB64 = dataUrl.replace(/^data:[^;]+;base64,/, "");
|
||||
state.imageName = name;
|
||||
const img = document.getElementById("wfm-tagger-preview");
|
||||
const ph = document.getElementById("wfm-tagger-preview-placeholder");
|
||||
const nameEl = document.getElementById("wfm-tagger-image-name");
|
||||
if (img) { img.src = dataUrl; img.style.display = ""; }
|
||||
if (ph) ph.style.display = "none";
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
}
|
||||
|
||||
async function _generateTags() {
|
||||
if (!state.imageB64) {
|
||||
showToast(t("taggerNoImage"), "warning");
|
||||
return;
|
||||
}
|
||||
const model = document.getElementById("wfm-tagger-model")?.value || "";
|
||||
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 ollamaPrompt = document.getElementById("wfm-tagger-ollama-prompt")?.value || "";
|
||||
const maxTags = parseInt(document.getElementById("wfm-tagger-ollama-max-tags")?.value || "40");
|
||||
|
||||
const generateBtn = document.getElementById("wfm-tagger-generate-btn");
|
||||
if (generateBtn) { generateBtn.disabled = true; generateBtn.textContent = "..."; }
|
||||
|
||||
try {
|
||||
let tags = "";
|
||||
|
||||
if (model) {
|
||||
const res = await fetch("/wfm/tagger/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_b64: state.imageB64, model, threshold, char_threshold: charThreshold }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
tags = data.tags || "";
|
||||
}
|
||||
|
||||
if (useOllama && ollamaModel) {
|
||||
const res2 = await fetch("/wfm/tagger/ollama/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
image_b64: state.imageB64, api_url: ollamaUrl,
|
||||
model: ollamaModel, prompt: ollamaPrompt, max_tags: maxTags,
|
||||
}),
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
if (data2.error) throw new Error(data2.error);
|
||||
const vlm = data2.tags || "";
|
||||
tags = [tags, vlm].filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
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 {
|
||||
if (generateBtn) { generateBtn.disabled = false; generateBtn.textContent = t("taggerGenerateTags"); }
|
||||
}
|
||||
}
|
||||
|
||||
function _sendToGenUI() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
const ta = document.getElementById("wfm-prompt-pos-text");
|
||||
if (!ta) { showToast(t("taggerNoGenUI"), "warning"); return; }
|
||||
const cur = ta.value;
|
||||
ta.value = cur ? `${cur}, ${tags}` : tags;
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
document.getElementById("wfm-prompt-pos-apply")?.click();
|
||||
showToast(t("taggerSentToGenUI"), "success");
|
||||
}
|
||||
|
||||
function _sendToPrompt() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
const ta = document.getElementById("wfm-preset-pos");
|
||||
if (!ta) { showToast(t("taggerNoGalleryImage"), "warning"); return; }
|
||||
const cur = ta.value;
|
||||
ta.value = cur ? `${cur}, ${tags}` : tags;
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast(t("taggerSentToPrompt"), "success");
|
||||
}
|
||||
|
||||
async function _saveToGallery() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags || !state.imagePath) {
|
||||
showToast(t("taggerNoGalleryImage"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tagsArray = tags.split(",").map(s => s.trim()).filter(Boolean);
|
||||
const res = await fetch("/wfm/gallery/image/meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.imagePath, tags: tagsArray }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerSavedToGallery"), "success");
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _writeToFile() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags || !state.imagePath) {
|
||||
showToast(t("taggerNoGalleryImage"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/write_meta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.imagePath, tags }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerWrittenToFile"), "success");
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveToDb() {
|
||||
const tags = document.getElementById("wfm-tagger-result")?.value || "";
|
||||
if (!tags) { showToast(t("taggerNoTags"), "warning"); return; }
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/db/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: state.imageName, interrogator_tags: tags, vlm_tags: "" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerSavedToDB"), "success");
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
async function _batchStart() {
|
||||
const folder = document.getElementById("wfm-tagger-batch-folder")?.value || "";
|
||||
if (!folder) { showToast(t("taggerBatchFolder"), "warning"); return; }
|
||||
|
||||
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 || "",
|
||||
ollama_prompt: document.getElementById("wfm-tagger-batch-ollama-prompt")?.value || "",
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/batch/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
document.getElementById("wfm-tagger-batch-start-btn").disabled = true;
|
||||
document.getElementById("wfm-tagger-batch-stop-btn").disabled = false;
|
||||
document.getElementById("wfm-tagger-batch-log").value = "";
|
||||
_startBatchPoll();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _batchStop() {
|
||||
await fetch("/wfm/tagger/batch/stop", { method: "POST" });
|
||||
}
|
||||
|
||||
function _startBatchPoll() {
|
||||
if (state.batchPollTimer) clearInterval(state.batchPollTimer);
|
||||
state.batchPollTimer = setInterval(_pollBatch, 1000);
|
||||
}
|
||||
|
||||
async function _pollBatch() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/batch/status");
|
||||
const data = await res.json();
|
||||
|
||||
const total = data.total || 0;
|
||||
const done = data.done || 0;
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
|
||||
const bar = document.getElementById("wfm-tagger-batch-progress-bar");
|
||||
const txt = document.getElementById("wfm-tagger-batch-progress-text");
|
||||
if (bar) bar.style.width = pct + "%";
|
||||
if (txt) txt.textContent = `${done} / ${total} (${pct}%)`;
|
||||
|
||||
const logEl = document.getElementById("wfm-tagger-batch-log");
|
||||
if (logEl && data.log?.length) {
|
||||
logEl.value = data.log.join("\n");
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
if (!data.running) {
|
||||
clearInterval(state.batchPollTimer);
|
||||
state.batchPollTimer = null;
|
||||
document.getElementById("wfm-tagger-batch-start-btn").disabled = false;
|
||||
document.getElementById("wfm-tagger-batch-stop-btn").disabled = true;
|
||||
showToast(t("taggerDone"), "success");
|
||||
}
|
||||
} catch {
|
||||
// 無視
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB サブタブ ──────────────────────────────────────────────────
|
||||
|
||||
function _setupDbActions() {
|
||||
document.getElementById("wfm-tagger-db-search-btn")?.addEventListener("click", _dbSearch);
|
||||
document.getElementById("wfm-tagger-db-show-all-btn")?.addEventListener("click", _dbLoad);
|
||||
document.getElementById("wfm-tagger-db-export-btn")?.addEventListener("click", _dbExport);
|
||||
document.getElementById("wfm-tagger-db-search-input")?.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") _dbSearch();
|
||||
});
|
||||
document.getElementById("wfm-tagger-db-save-btn")?.addEventListener("click", _dbSave);
|
||||
document.getElementById("wfm-tagger-db-delete-btn")?.addEventListener("click", _dbDelete);
|
||||
}
|
||||
|
||||
async function _dbLoad() {
|
||||
try {
|
||||
const res = await fetch("/wfm/tagger/db/list?limit=200&offset=0");
|
||||
const data = await res.json();
|
||||
_renderDbTable(data.rows || []);
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _dbSearch() {
|
||||
const q = document.getElementById("wfm-tagger-db-search-input")?.value || "";
|
||||
if (!q) { _dbLoad(); return; }
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/db/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
_renderDbTable(data.rows || []);
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
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>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows.map(row => `
|
||||
<tr data-id="${row.id}" title="${_esc(row.all_tags)}">
|
||||
<td>${row.id}</td>
|
||||
<td>${_esc(row.filename)}</td>
|
||||
<td>${_esc(_truncate(row.interrogator_tags, 60))}</td>
|
||||
<td>${_esc(_truncate(row.vlm_tags, 60))}</td>
|
||||
<td>${_esc(row.created_at?.split("T")[0] || row.created_at || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
tbody.querySelectorAll("tr").forEach(tr => {
|
||||
tr.addEventListener("click", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _openDbEdit(row) {
|
||||
state.dbEditId = row.id;
|
||||
const panel = document.getElementById("wfm-tagger-db-edit-panel");
|
||||
const header = document.getElementById("wfm-tagger-db-edit-header");
|
||||
const interrEl = document.getElementById("wfm-tagger-db-edit-interrogator");
|
||||
const vlmEl = document.getElementById("wfm-tagger-db-edit-vlm");
|
||||
if (!panel) return;
|
||||
if (header) header.textContent = `ID: ${row.id} ${row.filename}`;
|
||||
if (interrEl) interrEl.value = row.interrogator_tags;
|
||||
if (vlmEl) vlmEl.value = row.vlm_tags;
|
||||
panel.style.display = "";
|
||||
}
|
||||
|
||||
async function _dbSave() {
|
||||
if (state.dbEditId == null) return;
|
||||
const interrogator_tags = document.getElementById("wfm-tagger-db-edit-interrogator")?.value || "";
|
||||
const vlm_tags = document.getElementById("wfm-tagger-db-edit-vlm")?.value || "";
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/db/${state.dbEditId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ interrogator_tags, vlm_tags }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(t("taggerDbSaved"), "success");
|
||||
_dbLoad();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function _dbDelete() {
|
||||
if (state.dbEditId == null) return;
|
||||
if (!confirm(`Delete DB record #${state.dbEditId}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/wfm/tagger/db/${state.dbEditId}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
document.getElementById("wfm-tagger-db-edit-panel").style.display = "none";
|
||||
state.dbEditId = null;
|
||||
showToast(t("taggerDbDeleted"), "success");
|
||||
_dbLoad();
|
||||
} catch (e) {
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _dbExport() {
|
||||
const a = document.createElement("a");
|
||||
a.href = "/wfm/tagger/db/export";
|
||||
a.download = "tagger_tags.csv";
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ── ユーティリティ ───────────────────────────────────────────────
|
||||
|
||||
function _esc(str) {
|
||||
return String(str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function _truncate(str, len) {
|
||||
if (!str) return "";
|
||||
return str.length > len ? str.slice(0, len) + "…" : str;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Shared utilities
|
||||
*/
|
||||
|
||||
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// localStorage からJSONを安全に読む(不正JSON・未設定時はfallback)
|
||||
export function readJsonStorage(key, fallback = {}) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// プラグイン共通設定 (wfm_settings)
|
||||
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",
|
||||
};
|
||||
|
||||
export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索inputにオーバーレイXボタンを設定する。
|
||||
* @param {string} inputId - 検索inputのID
|
||||
* @param {string} clearBtnId - クリアボタンのID
|
||||
* @param {Function} onClear - クリア時に呼び出すコールバック(inputは既に空になった後で呼ばれる)
|
||||
*/
|
||||
export function setupSearchClearBtn(inputId, clearBtnId, onClear) {
|
||||
const input = document.getElementById(inputId);
|
||||
const btn = document.getElementById(clearBtnId);
|
||||
if (!input || !btn) return;
|
||||
|
||||
const sync = () => {
|
||||
btn.style.display = input.value ? "flex" : "none";
|
||||
};
|
||||
|
||||
input.addEventListener("input", sync);
|
||||
btn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
btn.style.display = "none";
|
||||
input.focus();
|
||||
onClear();
|
||||
});
|
||||
|
||||
// 初期値が入っている場合に備えて同期
|
||||
sync();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { t, getSummaryPrompt } from "./i18n.js";
|
||||
import { highlightJSON } from "./json-highlight.js";
|
||||
import { openBadgeEditModal } from "./models-tab.js";
|
||||
import { escapeHtml, getSettings, readJsonStorage, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
@@ -33,26 +34,39 @@ const state = {
|
||||
|
||||
|
||||
function getBadgePalette() {
|
||||
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
|
||||
catch { return {}; }
|
||||
return readJsonStorage("wfm_models_badge_palette");
|
||||
}
|
||||
|
||||
function saveBadgePalette(palette) {
|
||||
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Open in ComfyUI
|
||||
// Open in ComfyUI / Send to Canvas
|
||||
// ============================================
|
||||
|
||||
function sendToCanvas(workflowData) {
|
||||
try {
|
||||
// window.opener経由でComfyUIキャンバスに直接ロード(推奨)
|
||||
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
|
||||
window.opener.wfmReceiveWorkflow(workflowData);
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
return;
|
||||
}
|
||||
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
|
||||
const fmt = comfyWorkflow.detectFormat(workflowData);
|
||||
if (fmt === "api") {
|
||||
showToast(t("apiFormatCanvasNoOpener"), "error");
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("wfm_pending_workflow", JSON.stringify(workflowData));
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function openInComfyUI(workflowData) {
|
||||
// Open ComfyUI frontend in a new tab and load the workflow.
|
||||
// Same-origin, so we can access the child window's app object.
|
||||
@@ -94,7 +108,7 @@ const groups = {
|
||||
|
||||
load() {
|
||||
try {
|
||||
this._data = JSON.parse(localStorage.getItem("wfm_groups") || "{}");
|
||||
this._data = readJsonStorage("wfm_groups");
|
||||
} catch {
|
||||
this._data = {};
|
||||
}
|
||||
@@ -501,7 +515,7 @@ async function toggleFavorite(wf, btnEl) {
|
||||
btnEl.textContent = newVal ? "\u2605" : "\u2606";
|
||||
btnEl.classList.toggle("active", newVal);
|
||||
} catch (err) {
|
||||
showToast("Error: " + err.message, "error");
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +691,7 @@ function renderSideGroup(wf) {
|
||||
groups.remove(filename, btn.dataset.group);
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Removed from "${btn.dataset.group}"`, "success");
|
||||
showToast(t("removedFromGroupName", btn.dataset.group), "success");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -687,7 +701,7 @@ function renderSideGroup(wf) {
|
||||
if (!g) return;
|
||||
groups.assign(filename, g);
|
||||
renderSideGroup(wf);
|
||||
showToast(`Added to "${g}"`, "success");
|
||||
showToast(t("addedToGroupName", g), "success");
|
||||
});
|
||||
|
||||
// グループ作成(作成後に現在のWFに追加)
|
||||
@@ -696,14 +710,14 @@ function renderSideGroup(wf) {
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(`"${name}" already exists`, "warning");
|
||||
showToast(t("nameAlreadyExists", name), "warning");
|
||||
return;
|
||||
}
|
||||
groups.assign(filename, name);
|
||||
input.value = "";
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" created`, "success");
|
||||
showToast(t("groupCreated", name), "success");
|
||||
});
|
||||
|
||||
// グループ名変更
|
||||
@@ -717,13 +731,13 @@ function renderSideGroup(wf) {
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (!groups.renameGroup(oldName, newName)) {
|
||||
showToast("Rename failed", "error");
|
||||
showToast(t("renameFailed"), "error");
|
||||
return;
|
||||
}
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Renamed to "${newName}"`, "success");
|
||||
showToast(t("renamedTo", newName), "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
@@ -743,7 +757,7 @@ function renderSideGroup(wf) {
|
||||
}
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
showToast(t("groupDeleted", name), "success");
|
||||
});
|
||||
|
||||
_refreshGroupFilter();
|
||||
@@ -865,7 +879,7 @@ function openDetailModal(wf) {
|
||||
</section>
|
||||
<div class="wfm-modal-actions">
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-detail-load">${t("loadInGenerate")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-open-comfyui">${t("openInComfyUI")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-open-comfyui">${t("sendToCanvas")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-set-default">${t("setAsDefault")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-change-thumb">${t("changeThumbnail")}</button>
|
||||
<input type="file" id="wfm-detail-thumb-file" accept="image/png,image/webp,image/jpeg" style="display:none">
|
||||
@@ -903,7 +917,7 @@ function openDetailModal(wf) {
|
||||
favBtn.classList.toggle("active", newVal);
|
||||
renderGrid();
|
||||
} catch (err) {
|
||||
showToast("Error: " + err.message, "error");
|
||||
showToast(t("errorWithMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
titleEl.parentNode.insertBefore(favBtn, titleEl);
|
||||
@@ -1015,11 +1029,11 @@ function openDetailModal(wf) {
|
||||
}
|
||||
});
|
||||
|
||||
// Open in ComfyUI
|
||||
// Send to Canvas
|
||||
document.getElementById("wfm-detail-open-comfyui")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const wfData = await getRawWorkflow(wf.filename);
|
||||
openInComfyUI(wfData);
|
||||
sendToCanvas(wfData);
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
@@ -1036,7 +1050,7 @@ function openDetailModal(wf) {
|
||||
apiData = await comfyWorkflow.convertUiToApi(wfData);
|
||||
}
|
||||
// Save to localStorage
|
||||
const settings = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
|
||||
const settings = getSettings();
|
||||
settings.defaultWorkflow = wf.filename;
|
||||
settings.defaultWorkflowData = apiData;
|
||||
localStorage.setItem("wfm_settings", JSON.stringify(settings));
|
||||
@@ -1115,10 +1129,18 @@ function openDetailModal(wf) {
|
||||
try {
|
||||
const wfJson = await getRawWorkflow(wf.filename);
|
||||
const promptText = getSummaryPrompt() + JSON.stringify(wfJson).substring(0, 4000);
|
||||
const aiCfg = readJsonStorage("wfm_prompt_ai_settings", {});
|
||||
if (aiCfg.backend && aiCfg.backend !== "ollama") {
|
||||
throw new Error(t("summarizeOllamaOnly"));
|
||||
}
|
||||
const chatRes = await fetch("/api/wfm/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: promptText }] }),
|
||||
body: JSON.stringify({
|
||||
url: aiCfg.backendUrl || undefined,
|
||||
model: aiCfg.model || undefined,
|
||||
messages: [{ role: "user", content: promptText }],
|
||||
}),
|
||||
});
|
||||
const chatData = await chatRes.json();
|
||||
if (chatData.error) throw new Error(chatData.error);
|
||||
@@ -1186,7 +1208,7 @@ export function initWorkflowTab() {
|
||||
showToast(msg, ng.length ? "error" : "success");
|
||||
await loadWorkflows();
|
||||
} catch (err) {
|
||||
showToast("Import error: " + err.message, "error");
|
||||
showToast(t("importErrorMsg", err.message), "error");
|
||||
}
|
||||
e.target.value = "";
|
||||
});
|
||||
@@ -1213,6 +1235,11 @@ export function initWorkflowTab() {
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
setupSearchClearBtn("wfm-search", "wfm-search-clear-btn", () => {
|
||||
state.searchText = "";
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
// Drag & drop on workflow grid
|
||||
const grid = document.getElementById("wfm-workflow-grid");
|
||||
@@ -1229,10 +1256,10 @@ export function initWorkflowTab() {
|
||||
const data = await importFiles(files);
|
||||
const ok =
|
||||
data.results?.filter((r) => r.status === "success").length ?? 0;
|
||||
showToast(`${ok} file(s) imported via drag & drop`, "success");
|
||||
showToast(t("importedViaDrop", ok), "success");
|
||||
await loadWorkflows();
|
||||
} catch (err) {
|
||||
showToast("Import error: " + err.message, "error");
|
||||
showToast(t("importErrorMsg", err.message), "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1292,12 +1319,12 @@ export function initWorkflowTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar: Open in ComfyUI button
|
||||
// Toolbar: Send to Canvas button
|
||||
document.getElementById("wfm-list-open-comfyui-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedWf) return;
|
||||
try {
|
||||
const wfData = await getRawWorkflow(state.selectedWf.filename);
|
||||
openInComfyUI(wfData);
|
||||
sendToCanvas(wfData);
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
@@ -1306,7 +1333,7 @@ export function initWorkflowTab() {
|
||||
// Toolbar: Refresh button
|
||||
document.getElementById("wfm-refresh-btn")?.addEventListener("click", async () => {
|
||||
await loadWorkflows();
|
||||
showToast("Refreshed", "success");
|
||||
showToast(t("refreshed"), "success");
|
||||
});
|
||||
|
||||
// Toolbar: ⚙ settings button (view settings panel)
|
||||
@@ -1340,6 +1367,25 @@ export function initWorkflowTab() {
|
||||
clearBatch();
|
||||
});
|
||||
|
||||
// Toolbar: Clear filters button
|
||||
const wfClearBtn = document.getElementById("wfm-wf-clear-filters-btn");
|
||||
if (wfClearBtn) {
|
||||
wfClearBtn.textContent = t("clearFilters");
|
||||
wfClearBtn.addEventListener("click", () => {
|
||||
state.activeBadge = "ALL";
|
||||
state.showBatchOnly = false;
|
||||
state.searchText = "";
|
||||
state.groupFilter = "";
|
||||
state.currentPage = 0;
|
||||
const searchInput = document.getElementById("wfm-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
const groupFilter = document.getElementById("wfm-group-filter");
|
||||
if (groupFilter) groupFilter.value = "";
|
||||
renderModelFilters();
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadWorkflows();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* WFS_GalleryFeeder – ComfyUI canvas extension
|
||||
* Adds "After Gen", Run, and Stop controls directly on the node widget area.
|
||||
*/
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const FEEDER_GROUP = "__Feeder__";
|
||||
|
||||
// Per-node runtime state (keyed by node.id)
|
||||
const _ns = new Map();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchGroupImages(groupName) {
|
||||
try {
|
||||
const res = await fetch(`/wfm/gallery/groups/${encodeURIComponent(groupName)}/images`);
|
||||
if (!res.ok) return [];
|
||||
return (await res.json()).images || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function getOrCreate(nodeId) {
|
||||
if (!_ns.has(nodeId)) {
|
||||
_ns.set(nodeId, { running: false, stopFlag: false, idx: 0, images: [] });
|
||||
}
|
||||
return _ns.get(nodeId);
|
||||
}
|
||||
|
||||
function getInputWidget(node, name) {
|
||||
return node.widgets?.find(w => w.name === name);
|
||||
}
|
||||
|
||||
function setWidgetValue(node, name, value) {
|
||||
const w = getInputWidget(node, name);
|
||||
if (!w) return;
|
||||
w.value = value;
|
||||
if (w.callback) w.callback(value);
|
||||
app.canvas?.setDirty(true, true);
|
||||
}
|
||||
|
||||
/** Resolves when the current prompt finishes (executing=null, interrupted), rejects on error. */
|
||||
function waitForExecution() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
app.api.removeEventListener("executing", onExecuting);
|
||||
app.api.removeEventListener("execution_error", onError);
|
||||
app.api.removeEventListener("execution_interrupted", onInterrupted);
|
||||
};
|
||||
const onExecuting = ({ detail }) => { if (detail === null) { cleanup(); resolve(); } };
|
||||
const onError = ({ detail }) => { cleanup(); reject(new Error(detail?.exception_message || "Execution error")); };
|
||||
const onInterrupted = () => { cleanup(); resolve(); }; // treat interrupt as "done"
|
||||
|
||||
app.api.addEventListener("executing", onExecuting);
|
||||
app.api.addEventListener("execution_error", onError);
|
||||
app.api.addEventListener("execution_interrupted", onInterrupted);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRunBtn(node) {
|
||||
const st = getOrCreate(node.id);
|
||||
if (node._wfsRunWidget) node._wfsRunWidget.name = st.running ? "⏳ Running..." : "▶ Run";
|
||||
app.canvas?.setDirty(true, true);
|
||||
}
|
||||
|
||||
function wfsToast(msg, severity = "warn") {
|
||||
try {
|
||||
const tm = app.extensionManager?.toast;
|
||||
if (tm?.add) { tm.add({ severity, summary: "Gallery Feeder", detail: msg, life: 4000 }); return; }
|
||||
} catch {}
|
||||
console[severity === "error" ? "error" : "warn"]("[WFS_GalleryFeeder]", msg);
|
||||
}
|
||||
|
||||
// ── Run / Stop ────────────────────────────────────────────────────────
|
||||
|
||||
async function startLoop(node) {
|
||||
const st = getOrCreate(node.id);
|
||||
if (st.running) return;
|
||||
|
||||
// Re-fetch images for the current group each Run
|
||||
const groupName = getInputWidget(node, "group_name")?.value || FEEDER_GROUP;
|
||||
const images = await fetchGroupImages(groupName);
|
||||
if (images.length === 0) {
|
||||
wfsToast(`No images in group "${groupName}"`, "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
st.running = true;
|
||||
st.stopFlag = false;
|
||||
st.images = images;
|
||||
st.idx = Math.max(0, Math.min(
|
||||
parseInt(getInputWidget(node, "index")?.value) || 0,
|
||||
images.length - 1
|
||||
));
|
||||
|
||||
updateRunBtn(node);
|
||||
|
||||
// Queue first generation with current index
|
||||
setWidgetValue(node, "index", st.idx);
|
||||
app.queuePrompt(0, 1);
|
||||
|
||||
while (st.running && !st.stopFlag) {
|
||||
try {
|
||||
await waitForExecution();
|
||||
} catch (err) {
|
||||
if (st.running) wfsToast(err.message, "error");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!st.running || st.stopFlag) break;
|
||||
|
||||
const afterGen = node._wfsAfterGenWidget?.value || "loop";
|
||||
const total = st.images.length;
|
||||
|
||||
// Increment mode: stop after the last image
|
||||
if (afterGen === "increment" && st.idx >= total - 1) break;
|
||||
|
||||
if (afterGen !== "fixed") {
|
||||
st.idx = (st.idx + 1) % total;
|
||||
setWidgetValue(node, "index", st.idx);
|
||||
}
|
||||
|
||||
app.queuePrompt(0, 1);
|
||||
}
|
||||
|
||||
st.running = false;
|
||||
updateRunBtn(node);
|
||||
}
|
||||
|
||||
function stopLoop(node) {
|
||||
const st = getOrCreate(node.id);
|
||||
if (!st.running) return;
|
||||
st.stopFlag = true;
|
||||
st.running = false;
|
||||
try { app.api.interrupt(); } catch {}
|
||||
updateRunBtn(node);
|
||||
}
|
||||
|
||||
// ── Extension registration ────────────────────────────────────────────
|
||||
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.GalleryFeeder",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "WFS_GalleryFeeder") return;
|
||||
|
||||
const origCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
origCreated?.call(this);
|
||||
const node = this;
|
||||
|
||||
// ── After Gen selector ────────────────────────────────────
|
||||
// serialize:false → not included in widgets_values / prompt inputs
|
||||
const afterGenW = node.addWidget("combo", "wfs_after_gen", "loop", () => {}, {
|
||||
values: ["loop", "increment", "fixed"],
|
||||
serialize: false,
|
||||
});
|
||||
node._wfsAfterGenWidget = afterGenW;
|
||||
|
||||
// ── Run button ────────────────────────────────────────────
|
||||
const runW = node.addWidget("button", "▶ Run", null, () => startLoop(node));
|
||||
runW.serialize = false;
|
||||
node._wfsRunWidget = runW;
|
||||
|
||||
// ── Stop button ───────────────────────────────────────────
|
||||
const stopW = node.addWidget("button", "■ Stop", null, () => stopLoop(node));
|
||||
stopW.serialize = false;
|
||||
node._wfsStopWidget = stopW;
|
||||
};
|
||||
|
||||
// Clean up when node is removed from graph
|
||||
const origRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
stopLoop(this);
|
||||
_ns.delete(this.id);
|
||||
origRemoved?.call(this);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -61,7 +61,7 @@ const state = {
|
||||
infoSubTab: "info-model", // "info-model" | "info-lora" | "info-prompt"
|
||||
infoMeta: null, // parsed metadata from dropped file
|
||||
// AI tab
|
||||
aiSubTab: "ai-translate", // "ai-translate" | "ai-vlm" | "ai-settings"
|
||||
aiSubTab: "ai-translate", // "ai-translate" | "ai-chat" | "ai-vlm" | "ai-settings"
|
||||
};
|
||||
|
||||
// ============================================
|
||||
@@ -241,6 +241,52 @@ const fetchWorkflowRaw = async (filename) => {
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
// Returns true if the JSON is in ComfyUI API format (dict of nodeId→{class_type, inputs})
|
||||
// rather than the UI format (has nodes[] and links[] arrays).
|
||||
const isApiWorkflowFormat = (data) => {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return false;
|
||||
if (Array.isArray(data.nodes)) return false;
|
||||
const keys = Object.keys(data);
|
||||
return keys.length > 0 && keys.every((k) => data[k]?.class_type);
|
||||
};
|
||||
|
||||
// Minimal API→UI conversion so app.loadGraphData() can handle API-format workflows.
|
||||
// Nodes are arranged in a grid; link types use "*" since object_info is not queried here.
|
||||
const convertApiToUiWorkflow = (api) => {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
let linkId = 1;
|
||||
const sortedIds = Object.keys(api).sort((a, b) => Number(a) - Number(b));
|
||||
sortedIds.forEach((id, idx) => {
|
||||
const node = api[id];
|
||||
const col = idx % 5;
|
||||
const row = Math.floor(idx / 5);
|
||||
const uiNode = {
|
||||
id: Number(id),
|
||||
type: node.class_type,
|
||||
title: node._meta?.title || node.class_type,
|
||||
pos: [col * 300 + 50, row * 250 + 50],
|
||||
size: [250, 200],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets_values: [],
|
||||
mode: 0,
|
||||
};
|
||||
for (const [key, val] of Object.entries(node.inputs || {})) {
|
||||
if (Array.isArray(val) && val.length === 2 && typeof val[0] === "string" && typeof val[1] === "number") {
|
||||
uiNode.inputs.push({ name: key, type: "*", link: linkId });
|
||||
links.push([linkId, Number(val[0]), val[1], Number(id), uiNode.inputs.length - 1, "*"]);
|
||||
linkId++;
|
||||
} else {
|
||||
uiNode.widgets_values.push(val);
|
||||
}
|
||||
}
|
||||
nodes.push(uiNode);
|
||||
});
|
||||
const maxNodeId = Math.max(0, ...sortedIds.map(Number));
|
||||
return { nodes, links, groups: [], config: {}, extra: {}, version: 0.4, last_node_id: maxNodeId, last_link_id: linkId };
|
||||
};
|
||||
|
||||
const loadWfData = async () => {
|
||||
const workflows = await fetchWorkflows();
|
||||
state.wfList = workflows.filter(w => w.filename !== ".index.json");
|
||||
@@ -362,7 +408,7 @@ const placePromptNode = (posText, negText, promptName, pos) => {
|
||||
}
|
||||
|
||||
node.pos = pos || [100, 100];
|
||||
if (promptName) node.title = promptName;
|
||||
node.title = "Wfs Prompt";
|
||||
|
||||
graph.add(node);
|
||||
|
||||
@@ -513,6 +559,30 @@ const placeLoraMgrNode = (loras, pos) => {
|
||||
// Workflow canvas loading
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Load workflow JSON onto the ComfyUI canvas.
|
||||
* Delegates to app.handleFile() so ComfyUI's native path handles both UI and API
|
||||
* formats (identical to dragging a file from Explorer). Falls back to
|
||||
* convertApiToUiWorkflow() + loadGraphData() if handleFile is unavailable.
|
||||
*/
|
||||
const loadDataOnCanvas = async (data) => {
|
||||
if (typeof app?.handleFile === "function") {
|
||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||
const file = new File([blob], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
} else {
|
||||
if (isApiWorkflowFormat(data)) data = convertApiToUiWorkflow(data);
|
||||
await app.loadGraphData(data);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Cross-window receiver for SPA Send-to-Canvas
|
||||
// ============================================
|
||||
window.wfmReceiveWorkflow = async (data) => {
|
||||
await loadDataOnCanvas(data);
|
||||
};
|
||||
|
||||
const loadWorkflowOnCanvas = async (filename) => {
|
||||
const displayName = filename.replace(/\.json$/i, "");
|
||||
showToast(`Loading "${displayName}"...`, "info");
|
||||
@@ -522,7 +592,7 @@ const loadWorkflowOnCanvas = async (filename) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.loadGraphData(data);
|
||||
await loadDataOnCanvas(data);
|
||||
showToast(`Loaded: ${displayName}`, "success");
|
||||
} catch (err) {
|
||||
showToast("Failed to load: " + err.message, "error");
|
||||
@@ -548,7 +618,8 @@ const installCanvasDropHandler = () => {
|
||||
e.dataTransfer.types.includes("application/x-wfm-prompt") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-model") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-lora-multi") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-clip-text")) {
|
||||
e.dataTransfer.types.includes("application/x-wfm-clip-text") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-pending")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
@@ -595,6 +666,31 @@ const installCanvasDropHandler = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle pending workflow drop (from Gallery / Workflow tab "Send to Canvas")
|
||||
const pendingRaw = e.dataTransfer.getData("application/x-wfm-pending");
|
||||
if (pendingRaw) {
|
||||
e.preventDefault();
|
||||
(async () => {
|
||||
try {
|
||||
const wfData = JSON.parse(pendingRaw);
|
||||
await loadDataOnCanvas(wfData);
|
||||
localStorage.removeItem("wfm_pending_workflow");
|
||||
if (panelEl) {
|
||||
const titleEl = panelEl.querySelector(".wfm-nlp-title");
|
||||
if (titleEl) {
|
||||
titleEl.draggable = false;
|
||||
titleEl.classList.remove("wfm-nlp-title-pending");
|
||||
titleEl.title = "Workflow Studio Library";
|
||||
}
|
||||
}
|
||||
showToast("Workflow loaded on canvas", "success");
|
||||
} catch (err) {
|
||||
showToast("Failed to load workflow: " + err.message, "error");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle workflow drop
|
||||
const wfRaw = e.dataTransfer.getData("application/x-wfm-workflow");
|
||||
if (wfRaw) {
|
||||
@@ -763,6 +859,28 @@ const createPanel = () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Pending workflow drag-to-canvas
|
||||
const titleEl = panel.querySelector(".wfm-nlp-title");
|
||||
const updateTitlePendingState = () => {
|
||||
const hasPending = !!localStorage.getItem("wfm_pending_workflow");
|
||||
titleEl.draggable = hasPending;
|
||||
titleEl.classList.toggle("wfm-nlp-title-pending", hasPending);
|
||||
titleEl.title = hasPending
|
||||
? "Drag to canvas to load workflow"
|
||||
: "Workflow Studio Library";
|
||||
};
|
||||
updateTitlePendingState();
|
||||
panel.addEventListener("mouseenter", updateTitlePendingState);
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "wfm_pending_workflow") updateTitlePendingState();
|
||||
});
|
||||
titleEl.addEventListener("dragstart", (e) => {
|
||||
const pendingRaw = localStorage.getItem("wfm_pending_workflow");
|
||||
if (!pendingRaw) { e.preventDefault(); return; }
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-pending", pendingRaw);
|
||||
});
|
||||
|
||||
// Close
|
||||
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
|
||||
|
||||
@@ -833,6 +951,7 @@ const rebuildSubTabs = () => {
|
||||
if (state.topTab === "ai") {
|
||||
const row1Tabs = [
|
||||
{ key: "ai-translate", label: "Translation" },
|
||||
{ key: "ai-chat", label: "Chat" },
|
||||
{ key: "ai-vlm", label: "TOOLS" },
|
||||
{ key: "ai-settings", label: "Settings" },
|
||||
];
|
||||
@@ -2884,6 +3003,14 @@ const renderInfoTab = (container) => {
|
||||
|
||||
const AI_SETTINGS_KEY = "wfm_ai_settings";
|
||||
const AI_LANG_NAMES = { ja: "Japanese", en: "English", zh: "Chinese" };
|
||||
const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
};
|
||||
function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
function isValidAiUrl(url) {
|
||||
try {
|
||||
@@ -2959,6 +3086,24 @@ async function aiCallVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCallChat(url, backend, model, messages) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/chat`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).message?.content || "";
|
||||
} else {
|
||||
const r = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).choices?.[0]?.message?.content || "";
|
||||
}
|
||||
}
|
||||
|
||||
async function aiFetchModels(url, backend) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/tags`);
|
||||
@@ -3000,6 +3145,16 @@ const renderAiTab = (container) => {
|
||||
</div>
|
||||
<textarea id="wfm-nlp-ai-output" class="wfm-nlp-ai-textarea wfm-nlp-ai-output" placeholder="Translation result..." readonly></textarea>
|
||||
</div>
|
||||
<!-- Chat sub -->
|
||||
<div id="wfm-nlp-ai-chat" class="wfm-nlp-ai-pane" style="display:none;">
|
||||
<div class="wfm-nlp-ai-chat-msgs" id="wfm-nlp-ai-chat-msgs"></div>
|
||||
<textarea id="wfm-nlp-ai-chat-input" class="wfm-nlp-ai-textarea wfm-nlp-ai-chat-input" placeholder="Type a message..."></textarea>
|
||||
<div class="wfm-nlp-ai-actions">
|
||||
<button id="wfm-nlp-ai-chat-send" class="wfm-nlp-ai-btn wfm-nlp-ai-btn-primary">Send</button>
|
||||
<button id="wfm-nlp-ai-chat-clear" class="wfm-nlp-ai-btn">Clear</button>
|
||||
<span id="wfm-nlp-ai-chat-status" class="wfm-nlp-ai-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TOOLS sub -->
|
||||
<div id="wfm-nlp-ai-vlm" class="wfm-nlp-ai-pane" style="display:none;">
|
||||
<div class="wfm-nlp-ai-vlm-drop" id="wfm-nlp-ai-vlm-drop">
|
||||
@@ -3007,11 +3162,22 @@ const renderAiTab = (container) => {
|
||||
<span id="wfm-nlp-ai-vlm-label" class="wfm-nlp-ai-vlm-label">Drop PNG / JPG / WebP</span>
|
||||
<input type="file" id="wfm-nlp-ai-vlm-file" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
||||
</div>
|
||||
<div id="wfm-nlp-ai-wc-inputs" style="display:none;flex-direction:column;gap:5px;">
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<span class="wfm-nlp-ai-lbl">Name</span>
|
||||
<input type="text" id="wfm-nlp-ai-wc-name" class="wfm-nlp-ai-input" style="flex:1;" placeholder="e.g. sports">
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<span class="wfm-nlp-ai-lbl">Count</span>
|
||||
<input type="number" id="wfm-nlp-ai-wc-count" class="wfm-nlp-ai-input wfm-nlp-ai-wc-count" value="20" min="1" max="200">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<select id="wfm-nlp-ai-vlm-task" class="wfm-nlp-ai-sel" style="flex:1;">
|
||||
<option value="describe">Describe image</option>
|
||||
<option value="prompt">Create prompt</option>
|
||||
<option value="tags">Create tags</option>
|
||||
<option value="wildcard">Create wildcards</option>
|
||||
</select>
|
||||
<button id="wfm-nlp-ai-vlm-run" class="wfm-nlp-ai-btn wfm-nlp-ai-btn-primary">Run</button>
|
||||
</div>
|
||||
@@ -3026,6 +3192,7 @@ const renderAiTab = (container) => {
|
||||
<div class="wfm-nlp-ai-radio-row">
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="ollama"> Ollama</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lmstudio"> LM Studio</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lemonade"> Lemonade</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
@@ -3069,7 +3236,7 @@ const renderAiTab = (container) => {
|
||||
|
||||
const renderAiSubContent = () => {
|
||||
if (!panelEl) return;
|
||||
const panes = { "ai-translate": "wfm-nlp-ai-translate", "ai-vlm": "wfm-nlp-ai-vlm", "ai-settings": "wfm-nlp-ai-settings" };
|
||||
const panes = { "ai-translate": "wfm-nlp-ai-translate", "ai-chat": "wfm-nlp-ai-chat", "ai-vlm": "wfm-nlp-ai-vlm", "ai-settings": "wfm-nlp-ai-settings" };
|
||||
for (const [key, id] of Object.entries(panes)) {
|
||||
const el = panelEl.querySelector(`#${id}`);
|
||||
if (el) el.style.display = state.aiSubTab === key ? "flex" : "none";
|
||||
@@ -3086,6 +3253,13 @@ const setupAiHandlers = (container) => {
|
||||
if (urlInput && cfg.backendUrl) urlInput.value = cfg.backendUrl;
|
||||
else if (urlInput) urlInput.value = "http://localhost:11434";
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
backendRadios.forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
if (urlInput) urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
});
|
||||
});
|
||||
|
||||
const freeSrcInput = container.querySelector("#wfm-nlp-ai-free-src");
|
||||
const freeDstInput = container.querySelector("#wfm-nlp-ai-free-dst");
|
||||
if (freeSrcInput && cfg.freeSrcLang) freeSrcInput.value = cfg.freeSrcLang;
|
||||
@@ -3115,7 +3289,7 @@ const setupAiHandlers = (container) => {
|
||||
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
@@ -3224,6 +3398,67 @@ const setupAiHandlers = (container) => {
|
||||
// Auto-load models if settings exist
|
||||
if (cfg.backendUrl && cfg.backend) refreshModels().catch(() => {});
|
||||
|
||||
// ---- Chat handlers ----
|
||||
let chatHistory = [];
|
||||
const chatMsgsEl = container.querySelector("#wfm-nlp-ai-chat-msgs");
|
||||
const chatInputEl = container.querySelector("#wfm-nlp-ai-chat-input");
|
||||
const chatSendBtn = container.querySelector("#wfm-nlp-ai-chat-send");
|
||||
const chatClearBtn= container.querySelector("#wfm-nlp-ai-chat-clear");
|
||||
const chatStatusEl= container.querySelector("#wfm-nlp-ai-chat-status");
|
||||
|
||||
const appendChatBubble = (role, content) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-nlp-ai-chat-msg wfm-nlp-ai-chat-msg-${role}`;
|
||||
div.textContent = content;
|
||||
chatMsgsEl.appendChild(div);
|
||||
chatMsgsEl.scrollTop = chatMsgsEl.scrollHeight;
|
||||
};
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
const text = chatInputEl?.value?.trim();
|
||||
if (!text) return;
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
|
||||
chatInputEl.value = "";
|
||||
chatHistory.push({ role: "user", content: text });
|
||||
appendChatBubble("user", text);
|
||||
|
||||
chatSendBtn.disabled = true;
|
||||
chatStatusEl.textContent = "Thinking...";
|
||||
chatStatusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
|
||||
|
||||
try {
|
||||
const reply = await aiCallChat(url, backend, model, chatHistory);
|
||||
chatHistory.push({ role: "assistant", content: reply });
|
||||
appendChatBubble("assistant", reply);
|
||||
chatStatusEl.textContent = "";
|
||||
chatStatusEl.className = "wfm-nlp-ai-status";
|
||||
} catch (err) {
|
||||
chatHistory.pop();
|
||||
if (chatMsgsEl.lastChild) chatMsgsEl.removeChild(chatMsgsEl.lastChild);
|
||||
chatInputEl.value = text;
|
||||
chatStatusEl.textContent = `エラー: ${err.message}`;
|
||||
chatStatusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
|
||||
} finally {
|
||||
chatSendBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
chatSendBtn?.addEventListener("click", sendChatMessage);
|
||||
chatInputEl?.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
|
||||
});
|
||||
chatClearBtn?.addEventListener("click", () => {
|
||||
chatHistory = [];
|
||||
if (chatMsgsEl) chatMsgsEl.innerHTML = "";
|
||||
if (chatStatusEl) { chatStatusEl.textContent = ""; chatStatusEl.className = "wfm-nlp-ai-status"; }
|
||||
});
|
||||
|
||||
// ---- VLM handlers ----
|
||||
let vlmImage = null; // { base64, mimeType }
|
||||
|
||||
@@ -3231,10 +3466,22 @@ const setupAiHandlers = (container) => {
|
||||
const vlmPreview = container.querySelector("#wfm-nlp-ai-vlm-preview");
|
||||
const vlmLabel = container.querySelector("#wfm-nlp-ai-vlm-label");
|
||||
const vlmFile = container.querySelector("#wfm-nlp-ai-vlm-file");
|
||||
const vlmTaskSel = container.querySelector("#wfm-nlp-ai-vlm-task");
|
||||
const vlmRunBtn = container.querySelector("#wfm-nlp-ai-vlm-run");
|
||||
const vlmStatus = container.querySelector("#wfm-nlp-ai-vlm-status");
|
||||
const vlmResult = container.querySelector("#wfm-nlp-ai-vlm-result");
|
||||
const vlmCopy = container.querySelector("#wfm-nlp-ai-vlm-copy");
|
||||
const wcInputs = container.querySelector("#wfm-nlp-ai-wc-inputs");
|
||||
const wcNameEl = container.querySelector("#wfm-nlp-ai-wc-name");
|
||||
const wcCountEl = container.querySelector("#wfm-nlp-ai-wc-count");
|
||||
|
||||
const updateVlmTaskUI = () => {
|
||||
const isWc = vlmTaskSel?.value === "wildcard";
|
||||
if (vlmDrop) vlmDrop.style.display = isWc ? "none" : "";
|
||||
if (wcInputs) wcInputs.style.display = isWc ? "flex" : "none";
|
||||
};
|
||||
vlmTaskSel?.addEventListener("change", updateVlmTaskUI);
|
||||
updateVlmTaskUI();
|
||||
|
||||
const loadVlmImage = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
@@ -3263,15 +3510,45 @@ const setupAiHandlers = (container) => {
|
||||
};
|
||||
|
||||
vlmRunBtn?.addEventListener("click", async () => {
|
||||
if (!vlmImage) { showToast("Please drop an image", "error"); return; }
|
||||
const task = vlmTaskSel?.value || "describe";
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
|
||||
const task = container.querySelector("#wfm-nlp-ai-vlm-task")?.value || "describe";
|
||||
if (task === "wildcard") {
|
||||
const name = wcNameEl?.value?.trim() || "";
|
||||
const count = Math.max(1, parseInt(wcCountEl?.value) || 20);
|
||||
if (!name) { showToast("Please enter a wildcard name", "error"); return; }
|
||||
|
||||
vlmRunBtn.disabled = true;
|
||||
vlmStatus.textContent = "Running...";
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
|
||||
vlmResult.value = "";
|
||||
|
||||
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 aiCallLLM(url, backend, model, prompt);
|
||||
vlmResult.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
.filter(l => l.length > 0)
|
||||
.join("\n");
|
||||
vlmStatus.textContent = "完了";
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-ok";
|
||||
} catch (err) {
|
||||
vlmStatus.textContent = `エラー: ${err.message}`;
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
|
||||
} finally {
|
||||
vlmRunBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vlmImage) { showToast("Please drop an image", "error"); return; }
|
||||
|
||||
vlmRunBtn.disabled = true;
|
||||
vlmStatus.textContent = "Running...";
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-working";
|
||||
@@ -3427,6 +3704,16 @@ const injectStyles = () => {
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.wfm-nlp-title-pending {
|
||||
cursor: grab;
|
||||
color: #66aaff;
|
||||
}
|
||||
.wfm-nlp-title-pending::after {
|
||||
content: " ●";
|
||||
font-size: 7px;
|
||||
color: #4caf50;
|
||||
vertical-align: super;
|
||||
}
|
||||
.wfm-nlp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -4128,6 +4415,46 @@ const injectStyles = () => {
|
||||
color: var(--descrip-text, #999);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Chat */
|
||||
.wfm-nlp-ai-chat-msgs {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
.wfm-nlp-ai-chat-msg {
|
||||
max-width: 92%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.wfm-nlp-ai-chat-msg-user {
|
||||
align-self: flex-end;
|
||||
background: var(--p-button-background, #4a9eff);
|
||||
color: #fff;
|
||||
}
|
||||
.wfm-nlp-ai-chat-msg-assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #4e4e4e);
|
||||
color: var(--input-text, #ddd);
|
||||
}
|
||||
.wfm-nlp-ai-chat-input {
|
||||
min-height: 50px;
|
||||
max-height: 100px;
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Wildcard count */
|
||||
.wfm-nlp-ai-wc-count {
|
||||
width: 60px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
@@ -611,27 +611,55 @@ app.registerExtension({
|
||||
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
|
||||
];
|
||||
|
||||
const replaceButtonIcons = () => {
|
||||
const applyIconToButton = (btn, cfg) => {
|
||||
// Skip if the SVG icon is already in place
|
||||
if (btn.querySelector("svg")) return;
|
||||
btn.classList.add(cfg.className);
|
||||
btn.innerHTML = cfg.getIcon();
|
||||
btn.style.borderRadius = "4px";
|
||||
btn.style.padding = "6px";
|
||||
if (cfg.styles) Object.assign(btn.style, cfg.styles);
|
||||
const svg = btn.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
};
|
||||
|
||||
// Initial application — retry until buttons are rendered by Vue
|
||||
const pollUntilFound = () => {
|
||||
let foundAny = false;
|
||||
for (const cfg of buttonConfigs) {
|
||||
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
|
||||
foundAny = true;
|
||||
btn.classList.add(cfg.className);
|
||||
btn.innerHTML = cfg.getIcon();
|
||||
btn.style.borderRadius = "4px";
|
||||
btn.style.padding = "6px";
|
||||
if (cfg.styles) Object.assign(btn.style, cfg.styles);
|
||||
const svg = btn.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
applyIconToButton(btn, cfg);
|
||||
});
|
||||
}
|
||||
if (!foundAny) {
|
||||
requestAnimationFrame(replaceButtonIcons);
|
||||
requestAnimationFrame(pollUntilFound);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcons);
|
||||
requestAnimationFrame(pollUntilFound);
|
||||
|
||||
// MutationObserver: re-apply icons after Vue re-renders the action bar
|
||||
// (e.g. when the Properties Panel is toggled inside a subgraph)
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const cfg of buttonConfigs) {
|
||||
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
|
||||
// Only re-apply when Vue has reset innerHTML back to <i>
|
||||
if (btn.querySelector("i")) {
|
||||
applyIconToButton(btn, cfg);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const watchNode =
|
||||
document.querySelector("[data-testid='action-bar-buttons']") ||
|
||||
document.querySelector(".actionbar-container") ||
|
||||
document.body;
|
||||
observer.observe(watchNode, { childList: true, subtree: true });
|
||||
window.__wfmIconObserver = observer;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
{
|
||||
"id": "6d392e45-b20b-4dee-a422-0b4a18e645cd",
|
||||
"revision": 0,
|
||||
"last_node_id": 35,
|
||||
"last_link_id": 102,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1351.4570000000006,
|
||||
197.9979999999999
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 42
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 84
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
22
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
-35,
|
||||
161
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
80
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
81,
|
||||
82
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
84,
|
||||
90
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"anime\\merge\\hayochamixIDXL_v11+_blueberryPlus_v10-amanatsuIllustrious_v11_x0.1_trainDifference.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
330,
|
||||
369
|
||||
],
|
||||
"size": [
|
||||
412.2799987792969,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 82
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"w\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-20.402179424849777,
|
||||
348.01722821269084
|
||||
],
|
||||
"size": [
|
||||
282.798828125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "choose file to upload",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
101
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "MASK",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
102
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.8"
|
||||
},
|
||||
"image": "clipspace/clipspace-painted-masked-1784801792090.png [input]"
|
||||
},
|
||||
"widgets_values": [
|
||||
"clipspace/clipspace-painted-masked-1784801792090.png [input]",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
997,
|
||||
174
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 80
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 72
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "steps",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "sampler_name",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "scheduler",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "denoise",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
42
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
1008854893628621,
|
||||
"randomize",
|
||||
30,
|
||||
4,
|
||||
"ddim",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "VAEEncodeForInpaint",
|
||||
"pos": [
|
||||
759,
|
||||
169
|
||||
],
|
||||
"size": [
|
||||
226.8000030517578,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 101
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 90
|
||||
},
|
||||
{
|
||||
"localized_name": "mask",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 102
|
||||
},
|
||||
{
|
||||
"localized_name": "grow_mask_by",
|
||||
"name": "grow_mask_by",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "grow_mask_by"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
72
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncodeForInpaint",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1362.0710000000008,
|
||||
369.32099999999986
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
326.97,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
422.8500061035156,
|
||||
164.30999755859375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 81
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"smile"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
42,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
72,
|
||||
26,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
80,
|
||||
29,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
81,
|
||||
29,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
82,
|
||||
29,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
84,
|
||||
29,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
90,
|
||||
29,
|
||||
2,
|
||||
26,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
101,
|
||||
32,
|
||||
0,
|
||||
26,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
102,
|
||||
32,
|
||||
1,
|
||||
26,
|
||||
2,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9229599817706418,
|
||||
"offset": [
|
||||
365.6203139679832,
|
||||
118.10373545614928
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": []
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 250 KiB |